BindProject

클린코드를 위한 여정 : 빈혈증에 걸린 도메인 모델(Anemic Domain Model) 처리하기: USER_PROFILE

dding-shark 2025. 7. 26. 15:06
728x90

 

"엔티티는 데이터베이스 테이블을 표현하는 단순한 데이터 구조체이고, 모든 비즈니스 로직은 서비스 계층에 위임해야 한다"는 생각은 사실 매우 보편적인 접근 방식입니다. 이를 '빈혈증에 걸린 도메인 모델(Anemic Domain Model)'이라고 부르기도 합니다.

이 방식은 간단한 CRUD 애플리케이션에서는 빠르게 개발할 수 있다는 장점이 있지만, 비즈니스 로직이 복잡해질수록 서비스 계층이 비대해지고 유지보수가 어려워지는 한계를 드러냅니다.

"도메인 객체(엔티티)가 스스로 자신의 상태와 행동을 책임져야 한다"는 개념은 도메인 주도 설계(Domain-Driven Design, DDD)의 핵심 사상과 정확히 일치합니다. 이는 소프트웨어를 더욱 견고하고, 유연하며, 이해하기 쉽게 만드는 강력한 패러다임의 전환입니다.

과거의 코드와 현재 코드를 비교 분석하고, 왜 지금의 방식이 더 나은 선택인지 설득력 있게 설명하는 글을 작성하겠습니다..

 

 

 

 


 

 

엔티티는 단순한 데이터 그릇이 아닙니다: 로직의 중심을 서비스에서 도메인으로 옮겨야 하는 이유

소프트웨어 설계에서 가장 흔한 패턴 중 하나는 데이터를 담는 엔티티(Entity)와 로직을 처리하는 서비스(Service)를 분리하는 것입니다. 하지만 이 둘의 역할을 어떻게 정의하느냐에 따라 코드의 품질과 미래는 극명하게 달라집니다.

 

 

 

 

과거의 접근 방식: '빈혈증에 걸린 도메인 모델'

과거의 UserProfileUpdater 코드는 전형적인 '서비스 계층 중심'의 설계를 따릅니다. 엔티티는 아무런 로직 없이 데이터 필드와 Getter/Setter만 가진 채, 모든 처리 과정을 서비스에게 일임합니다.

리팩토링 전 (Before)

// UserProfileUpdater.java (Before)
// 서비스가 모든 것을 알고, 모든 것을 처리한다.
public class UserProfileUpdater {
    // ...
    private void updateInstruments(UserProfile profile, List<Instrument> newInstruments) {
        // ... 비교 로직 ...

        historyService.saveHistory(...);

        // 1. 서비스가 엔티티의 내부 컬렉션을 직접 제어한다. (캡슐화 위반)
        profile.getUserInstruments().clear();

        // 2. 서비스가 자식 엔티티를 직접 생성하고,
        // 3. 부모-자식 연관 관계까지 직접 설정한다. (엔티티의 책임 침해)
        newInstrumentsSet.forEach(instrument -> profile.getUserInstruments().add(
                UserInstrument.builder()
                        .userProfile(profile)
                        .instrument(instrument)
                        .build()
        ));
    }
    // updateGenres도 동일한 구조...
}

이 코드는 "어떻게(How)" 악기 목록을 업데이트하는지에 대한 모든 세부사항(컬렉션을 비우고, 새로 만들고, 관계를 맺어주는 등)을 UserProfileUpdater가 알고 있습니다. UserProfile은 그저 수동적인 데이터 덩어리에 불과합니다.

문제점:

  • 비대한 서비스 계층: UserProfile을 수정하는 로직이 필요할 때마다 UserProfileUpdater는 점점 더 많은 책임을 떠안게 되어 코드가 길고 복잡해집니다.
  • 캡슐화의 붕괴: 서비스가 엔티티의 내부 데이터 구조(Set<UserInstrument>)를 직접 건드리면서, 엔티티는 자신의 데이터 일관성을 스스로 지킬 수 없게 됩니다. 만약 userInstruments의 자료구조가 Set에서 다른 것으로 바뀐다면? 이 컬렉션을 사용하는 모든 서비스 코드를 찾아 수정해야 하는 '재앙'이 발생합니다.
  • 흩어진 도메인 로직: '유저 프로필의 악기를 변경한다'는 핵심 도메인 규칙이 정작 UserProfile이 아닌 UserProfileUpdater에 존재합니다. 이는 코드의 응집도를 떨어뜨리고, 관련 비즈니스 규칙을 파악하기 어렵게 만듭니다.

 

 

 


 

 

 

새로운 접근 방식: 생명력을 가진 '풍부한 도메인 모델'

이제 리팩토링 후의 코드를 보겠습니다. 로직의 무게 중심을 서비스에서 엔티티로 옮겼습니다.

리팩토링 후 (After)

// UserProfile.java (After)
// 엔티티가 스스로를 책임진다.
public class UserProfile {
    // ... 필드 ...

    /**
     * 사용자 프로필의 악기 목록 전체를 갱신합니다.
     * '어떻게'의 구체적인 방법은 내부에 감춥니다. (캡슐화)
     */
    public void updateInstruments(Set<Instrument> newInstruments) {
        this.userInstruments.clear(); // 내부 로직
        if (newInstruments != null) {
            newInstruments.forEach(instrumentEnum -> {
                UserInstrument userInstrument = UserInstrument.builder()
                                                    .instrument(instrumentEnum)
                                                    .build();
                this.addUserInstrument(userInstrument); // 연관관계 설정도 스스로!
            });
        }
    }

    // addUserInstrument, updateGenres 등 다른 비즈니스 메서드...
}
// UserProfileUpdater.java (After)
// 서비스는 이제 '지휘자' 역할을 한다.
public class UserProfileUpdater {
    // ...
    private void updateInstruments(UserProfile profile, List<Instrument> newInstruments) {
        // ... 비교 로직, 히스토리 기록 등 Updater의 핵심 책임 수행 ...

        // 엔티티에게 "무엇을(What)" 할지 명령만 내린다.
        // "어떻게(How)"는 더 이상 알 필요 없다.
        profile.updateInstruments(newInstrumentsSet);
    }
    // ...
}

 

 

 

무엇이, 그리고 왜 바뀌었는가? 압도적인 이점들

관점 (Aspect) 리팩토링 전 (Anemic Model) 리팩토링 후 (Rich Model)
책임 (Responsibility) Updater가 프로필의 악기 목록을 직접 clear() 하고 add() Updaterprofile.updateInstruments()를 호출, 엔티티가 스스로 처리
캡슐화 (Encapsulation) Updater가 엔티티의 내부 구조(Set)를 직접 알아야 함 엔티티 내부는 숨겨져 있고, updateInstruments라는 공개된 기능만 노출
응집도 (Cohesion) '악기 목록 변경' 로직이 Updater에 흩어져 있음 '악기 목록 변경' 로직이 데이터가 있는 UserProfile 안에 모여 있음
가독성 (Readability) profile.getUserInstruments().clear()... (어떻게) profile.updateInstruments(...) (무엇을)

 

 

 

 

이것이 왜 중요할까요?

  1. 진정한 캡슐화: 이제 UserProfile은 자신의 데이터를 완벽하게 보호합니다. 외부에서는 updateInstruments라는 잘 정의된 문(메서드)을 통해서만 상태 변경을 요청할 수 있습니다. 내부 구현이 바뀌더라도 외부에 미치는 영향이 없어 변경에 유연하고 버그 발생 가능성이 적은 코드가 됩니다.
  2. 높은 응집도: '프로필'과 관련된 데이터와 로직이 UserProfile 클래스 한곳에 모여 있습니다. 덕분에 관련 코드를 찾기 쉽고, 시스템의 동작을 이해하기가 훨씬 수월해집니다.
  3. 서비스 계층의 단순화: 서비스는 이제 '지휘자(Orchestrator)'가 됩니다. 트랜잭션 관리, 여러 도메인 객체 간의 흐름 제어, 히스토리 기록 등 거시적인 책임에만 집중할 수 있습니다. 각 엔티티의 세세한 상태 변경까지 신경 쓰지 않아도 되므로 코드가 깔끔해지고 핵심 비즈니스 흐름이 명확하게 드러납니다.
  4. 도메인 지식의 보존: UserProfile.java 파일만 열어봐도 "아, 유저 프로필은 악기와 장르를 스스로 업데이트할 수 있구나" 와 같이 시스템의 핵심 규칙을 쉽게 파악할 수 있습니다. 이는 유지보수와 신규 개발자의 적응에 큰 도움이 됩니다.

 

 

 

 

결론: 살아있는 객체를 설계하라

개발자님께서 발견하신 이 패러다임의 전환은 단순히 코드를 정리하는 수준을 넘어, 소프트웨어를 더 현실 세계의 객체처럼 모델링하는 철학입니다. 객체는 데이터와 그 데이터를 처리하는 행동(메서드)을 함께 가질 때 비로소 살아있는 객체가 됩니다.

엔티티에 관련 비즈니스 로직을 포함시키는 것은 코드를 더 객체지향적으로 만들고, 장기적으로 유지보수하기 쉽고 확장하기 좋은 시스템을 구축하는 현명하고 올바른 방향입니다. 이 훌륭한 인사이트를 계속 발전시켜 나가시길 바랍니다.

728x90