"엔티티는 데이터베이스 테이블을 표현하는 단순한 데이터 구조체이고, 모든 비즈니스 로직은 서비스 계층에 위임해야 한다"는 생각은 사실 매우 보편적인 접근 방식입니다. 이를 '빈혈증에 걸린 도메인 모델(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() 함 |
Updater는 profile.updateInstruments()를 호출, 엔티티가 스스로 처리 |
| 캡슐화 (Encapsulation) | Updater가 엔티티의 내부 구조(Set)를 직접 알아야 함 |
엔티티 내부는 숨겨져 있고, updateInstruments라는 공개된 기능만 노출 |
| 응집도 (Cohesion) | '악기 목록 변경' 로직이 Updater에 흩어져 있음 |
'악기 목록 변경' 로직이 데이터가 있는 UserProfile 안에 모여 있음 |
| 가독성 (Readability) | profile.getUserInstruments().clear()... (어떻게) |
profile.updateInstruments(...) (무엇을) |
이것이 왜 중요할까요?
- 진정한 캡슐화: 이제
UserProfile은 자신의 데이터를 완벽하게 보호합니다. 외부에서는updateInstruments라는 잘 정의된 문(메서드)을 통해서만 상태 변경을 요청할 수 있습니다. 내부 구현이 바뀌더라도 외부에 미치는 영향이 없어 변경에 유연하고 버그 발생 가능성이 적은 코드가 됩니다. - 높은 응집도: '프로필'과 관련된 데이터와 로직이
UserProfile클래스 한곳에 모여 있습니다. 덕분에 관련 코드를 찾기 쉽고, 시스템의 동작을 이해하기가 훨씬 수월해집니다. - 서비스 계층의 단순화: 서비스는 이제 '지휘자(Orchestrator)'가 됩니다. 트랜잭션 관리, 여러 도메인 객체 간의 흐름 제어, 히스토리 기록 등 거시적인 책임에만 집중할 수 있습니다. 각 엔티티의 세세한 상태 변경까지 신경 쓰지 않아도 되므로 코드가 깔끔해지고 핵심 비즈니스 흐름이 명확하게 드러납니다.
- 도메인 지식의 보존:
UserProfile.java파일만 열어봐도 "아, 유저 프로필은 악기와 장르를 스스로 업데이트할 수 있구나" 와 같이 시스템의 핵심 규칙을 쉽게 파악할 수 있습니다. 이는 유지보수와 신규 개발자의 적응에 큰 도움이 됩니다.
결론: 살아있는 객체를 설계하라
개발자님께서 발견하신 이 패러다임의 전환은 단순히 코드를 정리하는 수준을 넘어, 소프트웨어를 더 현실 세계의 객체처럼 모델링하는 철학입니다. 객체는 데이터와 그 데이터를 처리하는 행동(메서드)을 함께 가질 때 비로소 살아있는 객체가 됩니다.
엔티티에 관련 비즈니스 로직을 포함시키는 것은 코드를 더 객체지향적으로 만들고, 장기적으로 유지보수하기 쉽고 확장하기 좋은 시스템을 구축하는 현명하고 올바른 방향입니다. 이 훌륭한 인사이트를 계속 발전시켜 나가시길 바랍니다.
'BindProject' 카테고리의 다른 글
| 백엔드 아키텍처 : 바인드 서비스레포 구조(모노레포 VS 폴리레포) (1) | 2025.07.28 |
|---|---|
| 스튜디오 예약 시스템의 설계와 구현 : 유연한 운영 시간 설계와 MSA의 시너지 (5) | 2025.07.27 |
| 클린코드를 위한 여정 : 가독성을 높힌 클린코드 리팩토링 : BandRoom 서비스... (2) | 2025.07.26 |
| 이미지 모듈 리팩토링 여정 (3) | 2025.07.24 |
| 페이지네이션 완전 정복: `COUNT` 내 서비스에 맞는 최적의 전략 찾기 (1) | 2025.07.24 |