
1. 들어가며
DDD를 이론으로 접한 적은 있었지만 실제 프로젝트에서 적용해 본 경험은 많지 않았다. 그래서 개념 자체는 알고 있다고 생각했지만 막상 실무 코드 안에서 도메인 개념이 어떤 형태로 드러나는지, 그리고 어디까지를 하나의 도메인으로 봐야 하는지는 쉽게 와닿지 않았다.
새로운 회사에 합류한 뒤 프로젝트 구조를 더 잘 이해하고 싶다는 생각이 들었고, 이를 계기로 DDD 이론을 다시 정리해 보면서 실제 프로젝트에서는 어떤 방식으로 적용되고 있는지 함께 살펴보게 되었다.
이번 글에서는 먼저 DDD의 기본 개념을 간단히 정리한 뒤, 현재 프로젝트에서는 어떤 방식으로 원칙이 적용되어 있는지, 그리고 공통 회원 모델과 역할 모델의 경계를 어떻게 나누고 있는지 살펴보려고 한다.
참고로 글에서 등장하는 도메인 이름과 코드 예시는 실제 프로젝트 구현을 그대로 옮긴 것이 아니라, 설명을 위해 단순화한 예시다.
2. DDD 이해하기
DDD에는 다양한 개념이 있지만, 이번 글에서는 실제 프로젝트 구조를 이해하는 과정에서 특히 중요하다고 느낀 개념만 정리해보려고 한다.
1) Domain
도메인은 소프트웨어로 해결하고자 하는 문제 영역이다. 예를 들어 온라인 교육 서비스를 하나의 큰 도메인으로 본다면, 그 안에는 회원, 인증, 수업, 강사, 학습자, 결제 같은 여러 하위 도메인이 존재할 수 있다.

중요한 점은 같은 단어라도 도메인에 따라 의미가 달라질 수 있다는 것이다. 예를 들어 language라는 단어도 어떤 도메인에서 사용하는지에 따라 전혀 다른 의미를 가질 수 있다.

따라서 모델을 설계할 때는 단순히 같은 이름을 사용하는 것이 아니라, 해당 도메인에서 그 단어가 어떤 의미를 가지는지를 명확하게 표현하는 것이 중요하다.
DDD에서 도메인 모델은 단순히 데이터를 담는 객체가 아니라 도메인의 비즈니스 규칙도 함께 표현해야 한다. 예를 들어 수업 예약 취소의 경우, 아무 때나 취소할 수 있는 것이 아니라는 규칙이 있다고 하자. 이때는 수업 상태나 시작 시간 같은 조건을 만족해야만 취소가 가능하다.
이 규칙을 간단히 표현하면 다음과 같다.
class Lesson(
private var status: LessonStatus,
private var scheduledAt: LocalDateTime,
) {
fun cancel(now: LocalDateTime) {
if (!isCancelable(now)) {
throw IllegalStateException("취소할 수 없는 수업입니다.")
}
status = LessonStatus.CANCELED
}
private fun isCancelable(now: LocalDateTime): Boolean {
return status == LessonStatus.RESERVED &&
scheduledAt.isAfter(now.plusHours(24))
}
}
enum class LessonStatus {
RESERVED,
COMPLETED,
CANCELED,
}
이처럼 수업 취소 가능 여부 같은 규칙을 단순히 Service의 if문으로 흩어놓기보다, 가능하면 Lesson이라는 도메인 모델 안에서 표현하는 것이 DDD가 지향하는 방식이다.
2) Entity
엔티티는 고유한 식별자를 가진 객체다. 객체의 상태가 바뀌더라도 식별자가 같다면 동일한 엔티티로 본다.
예를 들어 회원의 이름이나 프로필 이미지는 변경될 수 있지만 memberId가 같다면 여전히 같은 회원이다.
class Member(
val id: Long,
private var name: String,
private var profileImageUrl: String?,
) {
fun updateProfileImage(profileImageUrl: String) {
this.profileImageUrl = profileImageUrl
}
}
엔티티는 단순히 데이터를 보관하는 역할만 하는 게 아니라, 해당 데이터와 관련된 기능도 함께 제공해야 한다.
예를 들어 프로필 이미지를 변경할 수 있다면 외부에서 필드를 직접 수정하기보다 updateProfileImage()처럼 변경 의도를 드러내는 메서드를 통해 상태를 변경하는 편이 더 자연스럽다.
3) Value Object
Value Object는 식별자가 아니라 값 자체로 의미를 가지는 객체다. 하나의 의미 있는 값을 표현하기 위해 사용한다.
전화번호를 단순히 String으로만 표현하면 이 값이 어떤 형식이어야 하는지, 국가 번호와 전화번호를 어떻게 조합해야 하는지 같은 의미가 코드에 잘 드러나지 않는다.
class PhoneNumber(
val countryCode: String,
val number: String,
) {
fun fullNumber(): String {
return "$countryCode$number"
}
}
그래서 이렇게 PhoneNumber라는 타입을 만들면 단순한 문자열 두 개가 아니라 전화번호라는 개념을 코드에 표현할 수 있다. 또한 전화번호 포맷팅이나 검증 같은 기능도 함께 관리할 수 있다.
이런 장점은 다른 값에도 동일하게 적용된다. 금액을 단순히 Long이나 Int로 표현하면 이것이 포인트인지, 원화인지, 크레딧인지 알기 어렵다. 반면 Money, Credit, TicketCount처럼 의미를 가진 타입을 만들면 코드만 봐도 어떤 값을 다루는지 훨씬 명확해진다.
data class Credit(
val amount: Long,
) {
fun deduct(value: Long): Credit {
require(value > 0)
require(amount >= value)
return Credit(amount - value)
}
}
4) Aggregate
애그리거트는 서로 관련된 엔티티와 Value Object를 하나의 단위로 묶은 것이다. 애그리거트에는 루트 엔티티(Aggregate Root)가 있고, 외부에서는 이 루트를 통해서만 내부 객체에 접근한다.
예를 들어 수업 예약을 하나의 애그리거트로 본다면 다음과 같은 객체들을 함께 묶을 수 있다.

외부에서는 내부 객체를 직접 수정하기보다 루트 엔티티가 제공하는 기능을 사용한다.
class Lesson(
val id: Long,
private val learnerId: Long,
private val instructorId: Long,
private var status: LessonStatus,
private var schedule: LessonSchedule,
) {
fun reschedule(newSchedule: LessonSchedule) {
if (status != LessonStatus.RESERVED) {
throw IllegalStateException("예약된 수업만 일정을 변경할 수 있습니다.")
}
schedule = newSchedule
}
}
애그리거트를 이해하면서 가장 중요하게 느낀 부분은 독립성이다. 하나의 애그리거트는 자신의 상태와 규칙만 책임질 뿐, 다른 애그리거트의 상태를 직접 변경하지 않는다.
예를 들어 Lesson은 자신의 일정을 변경할 수는 있지만, 그 안에서 강사의 프로필이나 학습자의 마케팅 동의 여부를 변경하면 안 된다. 이런 상태는 각각 Instructor, Learner, Member 애그리거트가 책임져야 한다.
@Transactional
fun reserveLesson(command: ReserveLessonCommand) {
val learner = learnerService.getLearner(command.learnerId)
val instructor = instructorService.getInstructor(command.instructorId)
val subscription = subscriptionService.getActiveSubscription(command.learnerId)
learner.validateCanReserveLesson()
instructor.validateCanTeachAt(command.scheduledAt)
subscription.useTicket()
lessonService.createLesson(
learnerId = learner.id,
instructorId = instructor.id,
scheduledAt = command.scheduledAt,
)
}
위 코드로 알 수 있듯이 Learner는 Instructor를 직접 수정하지 않고, Instructor가 Subscription을 직접 수정하지도 않는다. 이처럼 하나의 기능을 수행하다 보면 여러 애그리거트를 한 트랜잭션에서 함께 수정해야 하는 경우가 생기는데, UseCase가 필요한 애그리거트를 가져와 각각의 규칙을 실행하고 전체 흐름을 조율하는 방식으로 해결하면 된다.
이 부분은 뒤에서 살펴볼 역할 모델의 경계 고민를 이해하는 데도 중요한 기준이 된다.
5) Repository
Repository는 도메인 객체를 저장하고 조회하는 역할을 한다. DDD에서 보통 애그리거트 단위로 저장하고 조회하며, 응용 서비스는 DB 구조를 직접 알지 않고 Repository를 통해 도메인 객체를 가져온다.
interface LessonRepository {
fun findById(id: Long): Lesson?
fun save(lesson: Lesson): Lesson
}
Repository를 사용하면 응용 서비스는 Lesson이 어떤 테이블에 저장되는지, 어떤 ORM이나 어떤 쿼리 기술을 사용하는지 신경 쓰지 않아도 된다. 그렇기 때문에 비즈니스 흐름 자체에만 집중할 수 있다.
다만 실제 프로젝트에서는 책에서 설명하는 형태 그대로 Repository가 존재하는 경우는 많지 않고, 사용하는 프레임워크나 ORM에 따라 구현 방식이 달라질 수도 있다.
6) Bounded Context
Bounded Context는 하나의 모델이 유효한 범위를 의미한다.
중요한 점은 같은 단어라도 컨텍스트가 다르면 의미가 달라질 수 있다는 것이다. 예를 들어 Member라는 개념도 인증 컨텍스트, 수업 컨텍스트, 결제 컨텍스트에서는 각각 다른 관점으로 바라보게 된다.

그래서 모든 곳에서 하나의 Member 모델로 모든 의미를 표현하려고 하면 모델이 점점 커지고 모호해질 수 있다. 바운디드 컨텍스트는 이런 문제를 줄이기 위해 “이 모델은 어느 문맥에서 유효한가?”를 정하는 경계라고 볼 수 있다.
물론 바운디드 컨텍스트를 무조건 잘게 나누는 것이 항상 좋은 것은 아니다. 비즈니스 경계가 아직 명확하지 않거나 팀과 배포 단위가 분리되어 있지 않은 상황에서 형식적으로만 컨텍스트를 나누면 오히려 구조가 복잡해질 수도 있다.
결국 바운디드 컨텍스트는 코드만 보고 결정하는 것이 아니라, 비즈니스 규칙, 용어의 의미, 변경 주기, 팀 구조, 배포 단위 등을 함께 고려해서 결정해야 한다.
7) 계층 구조와 DDD
DDD를 적용한다고 해서 반드시 특정 아키텍처 하나만 사용해야 하는 것은 아니다. 많은 백엔드 프로젝트에서는 표현(Presentation), 응용(Application), 도메인(Domain), 인프라스트럭처(Infrastructure) 계층으로 나누는 구조를 사용한다.

| 계층 | 역할 |
| 표현 | HTTP 요청 및 응답 처리 |
| 응용 | 하나의 유스케이스를 수행하기 위한 흐름 조율 |
| 도메인 | 핵심 비즈니스 규칙 |
| 인프라스트럭처 | DB, Redis, 외부 API 등의 기술적인 구현 |
도메인의 복잡도가 높지 않다면 응용 계층과 도메인 계층의 역할이 어느 정도 합쳐질 수도 있다. 반대로 도메인 규칙이 복잡해질수록 비즈니스 규칙을 도메인 모델에 모으고, 응용 계층은 흐름만 조율하도록 역할을 분리하는 것이 중요해진다.
결국 DDD의 목적은 계층을 많이 만드는 것이 아니라, 비즈니스 규칙이 어디에 있어야 하는지를 명확하게 만드는 것이라고 생각한다.
3. 우리 프로젝트의 도메인 구조
책을 통해 DDD 개념을 다시 정리한 뒤 실제 프로젝트를 살펴보니, 코드가 정석적인 DDD의 형태를 그대로 따르고 있다기보다는 Spring 기반 계층형 아키텍처 안에서 DDD의 일부 원칙을 실용적으로 적용하고 있다는 느낌을 받았다.
DDD에서는 애그리거트의 경계를 명확히 나누고, 도메인 모델을 인프라 기술로부터 최대한 분리하며, Repository 역시 도메인이나 애플리케이션 계층의 인터페이스로 두는 구조를 많이 설명한다. 하지만 실제 서비스에서는 기존 코드, 데이터베이스 구조, 사용하는 프레임워크, 개발 속도, 팀이 익숙한 방식까지 함께 고려해야 한다.
현재 프로젝트에서는 이런 현실적인 조건 안에서 다음과 같은 방식으로 DDD의 아이디어가 적용되고 있었다.
1. 도메인별로 코드의 위치를 나누는 구조
2. Entity, Value Object, enum을 활용한 도메인 모델 표현
3. Entity가 자신의 상태를 변경하는 메서드를 가지는 방식
4. UseCase를 통해 여러 도메인이 필요한 흐름을 조율하는 방식
5. Repository를 통해 영속성 접근을 한 곳으로 모으는 방식
즉 순수한 DDD라기보다는, DDD가 중요하게 보는 도메인의 경계와 책임 분리를 현실적인 Spring 프로젝트 안에 적용한 구조에 가까웠다.
예를 들어 도메인별로 패키지나 모듈을 나누고 있었지만, 이것만으로 각각을 별도의 Bounded Context라고 볼 수는 없다. Bounded Context는 단순히 코드가 위치한 경계가 아니라, 특정 비즈니스 모델과 용어가 일관된 의미를 가지는 범위이기 때문이다.
그럼에도 코드를 도메인별로 나누는 것만으로 기능이 어느 비즈니스 영역에 속하는지 파악하기 쉬워지고, 변경이 발생했을 때 영향 범위를 추적하는 데도 도움이 된다.
도메인 모델과 영속성 모델 역시 완전히 분리되어 있지는 않았다. 일부 영역에서는 JPA Entity가 도메인 모델의 역할을 함께 하고 있었다. 이론적으로는 두 모델을 분리하면 인프라 기술에 대한 의존성을 줄일 수 있지만, 모든 영역에서 이를 적용하면 매핑 코드와 관리해야 할 객체도 함께 늘어난다. 영속성 기술을 자주 교체하지 않는 서비스라면 ORM Entity에 도메인 행위를 함께 두는 방식도 충분히 현실적인 선택이라고 생각했다.
Repository와 UseCase도 비슷했다. 정석적인 DDD의 형태를 그대로 따르기보다는, Repository를 통해 DB 접근을 한 곳으로 모으고 필요한 흐름에서는 UseCase가 여러 도메인의 작업을 조율하고 있었다.
결국 현재 프로젝트는 하나의 DDD 패턴을 엄격하게 따르기보다는, 필요한 영역에서 DDD의 개념을 선택적으로 사용하고 있었다. 물론 이런 구조에서는 영역마다 패턴이 달라지면서 UseCase와 Service의 책임이 모호해지거나, 공통 모델에 여러 책임이 모일 가능성도 있다. 하지만 그렇다고 이를 단순히 '정석 DDD가 아니다'라고 볼 필요는 없다고 생각했다. 실무에서는 기존 코드와 데이터 구조, 변경 비용까지 함께 고려해야 하기 때문이다.
이런 관점으로 프로젝트를 다시 보니 어떤 영역에서는 도메인의 경계가 잘 드러나고 있고, 어떤 영역에서는 그 경계가 흐려질 가능성이 있는지 계속 살펴보는 것이 중요하다고 느껴졌다.
4. 사례 분석: 회원의 역할에 대한 경계
이 고민은 기존 프로젝트 구조를 개선하기 위해 팀 안에서 도메인 모델에 대한 의견을 나누는 과정에서 시작되었다. 그중에서도 가장 고민이 되었던 부분은 공통 회원 정보와 역할별 정보의 경계를 어디까지 나눌 것인가였다. 앞서 말했듯이 아래에 등장하는 도메인 모델과 코드는 실제 프로젝트의 구현을 그대로 옮긴 것이 아니라, 논의를 설명하기 위한 예시다.
일반적으로 회원에게 여러 역할이 존재한다면 이름, 이메일, 전화번호, 계정 상태처럼 모든 회원에게 필요한 정보는 공통 회원 모델에 두고, 학습자나 강사에게만 필요한 정보는 각각의 역할 모델로 분리하는 방식을 생각해 볼 수 있다.
하지만 실제 서비스의 경계가 처음부터 명확하게 나뉘어 있는 경우는 많지 않다. 초기에는 공통 회원 모델만으로 충분했던 정보가 기능이 늘어나면서 역할별 의미를 가지게 되기도 하고, 기존 데이터 구조나 구현 방식 때문에 공통 정보와 역할별 정보가 하나의 모델 주변에 함께 남아 있기도 한다.
우리 프로젝트에서는 공통 회원 모델을 중심으로 학습자와 강사 역할을 표현하고 있었고, 역할별 모델이 공통 모델을 상속하는 구조에 가까웠다.
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "member_type")
open class MemberEntity(...)
class LearnerEntity(...) : MemberEntity(...)
class InstructorEntity(...) : MemberEntity(...)
이 구조에는 분명한 장점이 있다. 학습자와 강사는 모두 서비스를 사용하는 회원이기 때문에 이름, 이메일, 전화번호, 프로필, 계정 상태 같은 공통 정보를 한 곳에서 관리할 수 있다. 기존 DB 역시 하나의 회원 테이블 중심으로 설계되어 있다면 JPA의 SINGLE_TABLE 상속 전략은 자연스러운 선택이 될 수 있다.
다만 역할별 정책이 점점 많아질수록 공통 회원과 역할 모델의 책임을 어디까지로 봐야 하는가라는 문제가 생긴다.
패키지가 나뉘어 있거나 클래스 이름이 다르다고 해서 반드시 애그리거트의 경계까지 나뉘었다고 볼 수는 없다. 애그리거트를 나눌 때는 각각의 객체가 어떤 일관성을 책임지는지, 어떤 이유로 함께 변경되는지를 함께 살펴봐야 한다.
이 구조를 보면서 내가 크게 고민했던 부분은 두 가지였다.
- Entity의 이름과 실제 책임의 경계가 모호해질 수 있다는 점
- 상속으로 인해 공통 정보와 역할별 정보의 경계가 흐려질 수 있다는 점
1) 애그리거트 독립성 관점에서 보기
앞에서 학습한 애그리거트 특성을 다시 보자.
1. DDD에서는 각각의 애그리거트가 가능한 한 자신의 상태와 규칙을 스스로 관리하도록 한다. 한 애그리거트가 다른 애그리거트의 내부 상태까지 직접 변경하기 시작하면 결합도가 높아지고, 어느 한쪽의 변경이 다른 쪽까지 영향을 미칠 가능성도 커진다.
2. 여러 애그리거트를 하나의 UseCase에서 함께 변경해야 한다면, 애그리거트끼리 직접 서로를 수정하기보다 Application Service나 UseCase가 각각의 작업을 조율하는 방식으로 풀 수 있다.
이 관점에서 기존의 상속 구조를 바라보면 Member, Learner, Instructor의 사이의 책임을 명확하게 구분하기 어려운 부분이 있었다. LearnerEntity와 InstructorEntity가 클래스상으로는 분리되어 있지만, 실제로는 MemberEntity의 하위 타입이기 때문이다.
역할별 기능이 많지 않은 상황에서는 큰 문제가 되지 않을 수 있다. 하지만 학습자에게만 필요한 학습 정책, 강사에게만 필요한 강의 정책, 공통 회원 정보와 인증 관련 기능이 계속 같은 모델 주변으로 모이기 시작하면 Member가 여러 도메인의 변경 이유를 동시에 가지게 될 가능성이 있다.
예를 들어 학습자 프로필 정책을 변경할 때도 공통 회원 모델의 영향을 확인해야 하고, 강사에게만 필요한 정책을 수정할 때도 같은 모델과의 관계를 계속 신경 써야 하는 식이다. 역할별 정책이 커질수록 상속으로 얻었던 공통화의 장점보다 모델 사이의 결합으로 인한 비용이 더 커질 수도 있다.
2) DDD 관점에서의 개선 방향
처음 내가 생각했던 방향은 Member를 공통 회원 정보를 담당하는 모델로 두고, Learner와 Instructor를 각각 별도의 역할 모델로 바라보는 것이었다. 이 경우 역할 모델이 Member를 상속하거나 객체 전체를 포함하기보다 memberId를 통해 관계를 맺는다.

이때 각 모델은 자신이 소유한 상태와 규칙만 관리하고, 여러 모델이 필요한 작업은 UseCase나 Application Service에서 조율한다. 예를 들어 강사 등록이라는 유스케이스가 있다면 다음과 같이 표현할 수 있다.
@Transactional
fun registerInstructor(command: RegisterInstructorCommand) {
val member = memberService.getMember(command.memberId)
member.verifyCanRegisterAsInstructor()
instructorService.createInstructorProfile(
memberId = member.id,
teachingLanguage = command.teachingLanguage,
introduction = command.introduction,
)
}
여기서 중요한 것은 Member가 InstructorProfile의 상태까지 직접 관리하지 않는다는 것이다. 반대로 InstructorProfile 역시 회원의 이메일이나 계정 상태 같은 공통 정보를 직접 변경하지 않는다. 두 모델이 함께 필요한 흐름은 UseCase에서 조율하고, 각각의 모델은 자신에게 속한 규칙만 책임진다.
다만 이런 방향이 반드시 DB 테이블까지 바로 분리해야 한다는 의미는 아니다. DDD에서 모델의 경계를 고민할 때 중요한 것은 먼저 어떤 상태와 규칙을 누가 소유하는 가다. 기존 데이터 구조를 한 번에 변경하기 어렵다면 같은 테이블의 데이터를 사용하더라도 코드 안에서 공통 회원과 역할별 책임을 구분하는 방식으로 점진적으로 개선할 수도 있다.
이 경우 Repository나 매핑 계층에서 기존 데이터를 각 역할에 필요한 모델로 변환할 수도 있고, 상태 변경을 위한 모델과 화면 조회를 위한 모델을 다르게 가져가는 방법도 있다.

상태를 변경하는 흐름에서는 각 모델의 책임과 경계를 지키고, 여러 영역의 정보가 한 번에 필요한 목록이나 상세 조회에서는 Projection 같은 별도의 조회 모델을 사용하는 방식이다.
여기까지가 처음 내가 DDD 관점에서 생각했던 방향이었다. 공통 회원 정보와 역할별 정보를 분리하고, 각각의 모델이 자신의 상태와 규칙만 책임 하도록 만드는 쪽이 장기적으로는 더 명확한 구조라고 생각했다.
하지만 곧바로 한 가지 현실적인 문제가 생긴다.
현재 프로젝트에는 이미 공통 회원 테이블과 모델을 기준으로 동작하는 코드가 많다. 역할별 모델을 완전히 독립시키려면 기존 조회뿐만 아니라 상태 변경, 테스트, API 응답 구조까지 함께 영향을 받을 가능성이 높았다. 그래서 논의 과정에서도 역할별 책임을 분리할 필요가 있다는 의견과 함께, 현재 구조에서 완전히 분리하는 것은 변경 비용이 클 수 있고 중간 단계의 절충안도 충분히 가능하다고 전달했다.

.
팀에서도 비슷한 관점에서 기존 코드의 의존성과 변경 비용을 함께 고려했다. 그리고 여기서 한 가지 더 중요한 질문이 나왔다.
현재 비즈니스 자체가 Member, Learner, Instructor를 별도의 Bounded Context로 나눌 만큼 강하게 분리되어 있는가?
모델의 책임을 나누는 것과 Bounded Context를 나누는 것은 같은 문제가 아니다. 역할별 모델을 별도의 Entity나 애그리거트로 분리하더라도, 현재 비즈니스가 대부분 하나의 회원 맥락 안에서 움직이고 있다면 반드시 별도의 Bounded Context까지 만들 필요는 없다. 오히려 실제 비즈니스 경계가 충분히 나뉘지 않은 상태에서 Context부터 형식적으로 분리하면 공통 정보 하나를 가져오기 위해서도 불필요하게 복잡한 구조를 거쳐야 할 수 있다.
결국 논의의 핵심은 '분리할 것인가, 말 것인가'가 아니라 현재 프로젝트에서는 어느 수준까지 분리하는 것이 적절한가에 가까웠다.
3) 도메인 모델 분리의 목적
DDD 관점에서 공통 회원, 학습자, 강사를 각각 완전히 독립된 Context로 분리하면 모델의 경계가 더 명확해 보일 수 있다. 하지만 현재 비즈니스에서 세 개념의 라이프사이클이 완전히 독립되어 있지 않고, 대부분의 기능이 하나의 회원이라는 맥락 안에서 함께 움직인다면 별도의 Bounded Context까지 나누는 것은 아직 이른 선택일 수 있다.
예를 들어 강사의 수업 가능 시간을 생성하기 위해 공통 회원이 가지고 있는 시간대 정보가 필요하다고 해보자. 시간대가 Member가 소유한 공통 정보라면 강사 기능을 수행하는 과정에서 Member를 조회하는 것 자체는 자연스럽다.
@Transactional
fun createInstructorTimeTable(command: CreateInstructorTimeTableCommand) {
val instructor = instructorRepository.getById(command.instructorId)
val member = memberRepository.getById(instructor.memberId)
val startAt = command.localStartAt
.atZone(member.zoneId)
.toInstant()
instructorTimeTableRepository.save(
InstructorTimeTable.create(
instructorId = instructor.id,
startAt = startAt,
)
)
}
여기서 중요한 것은 Instructor가 Member를 조회하느냐가 아니다. Instructor가 Member의 상태까지 직접 변경하는지, 혹은 조회 편의를 위해 두 모델의 책임을 하나로 섞고 있는지를 살펴봐야 한다. 위 예시에서는 Member가 소유한 시간대 정보는 MemberRepository를 통해 가져오고, InstructorTimeTable은 자신의 생성에 필요한 값만 전달받는다. 즉 다른 모델의 정보가 필요하다는 이유만으로 그 모델의 상태 변경 권한까지 함께 가져오지는 않는다.
4) 최종 결정
팀원들의 의견을 모두 모아 의논한 결과, 현재 상황에서는 공통 회원, 학습자, 강사를 완전히 별도의 Bounded Context로 분리하기보다는 하나의 상위 회원 맥락 안에 두되 Entity와 Repository의 책임을 각각 분리하는 방향이 더 현실적이라고 정리되었다.
이 구조에서는 공통 정보는 Member가 소유하고, 학습자와 강사 역할 모델은 참조를 통해 Member와 연결된다. 그리고 각 Repository는 자신이 담당하는 Entity를 반환하고, 상태 변경 역시 해당 상태를 소유한 모델의 도메인 메서드를 통해 이루어진다.

반면 여러 테이블의 데이터가 필요한 목록, 검색, 상세 조회는 변경 모델을 억지로 확장하지 않고 조회 전용 Projection을 사용한다.

예를 들어 조회 편의를 위해 InstructorRepository가 Member까지 포함한 변경 가능한 객체를 반환한다면 Instructor를 조회했다가 공통 회원 정보까지 수정할 수 있는 경로가 생길 수 있다. 반면 Projection은 화면이나 조회에 필요한 값만 조합하기 때문에 모델의 상태 변경 책임까지 함께 노출하지 않는다.
정리하면 팀에서 선택한 방향은 다음 세 가지로 볼 수 있었다.
1. 공통 정보와 역할별 정보의 소유권을 구분한다.
2. 상태 변경은 해당 상태를 소유한 모델의 책임 안에서 수행한다.
3. 여러 영역의 정보가 필요한 조회는 변경 모델을 확장하지 않고 별도의 조회 모델로 해결한다.
처음 내가 생각했던 방향은 역할별 모델을 보다 독립적으로 만드는 장기적인 구조에 가까웠고, 팀에서 정리한 방향은 현재 구조와 변경 비용을 고려하면서도 책임의 경계를 조금 더 명확하게 만드는 선택에 가까웠다. 결국 이 논의를 통해 DDD의 원칙을 아는 것만큼 중요한 것은, 현재 비즈니스가 실제로 어디에서 나뉘어 있는지를 파악하고 그에 맞는 수준으로 적용하는 것이라는 점을 배울 수 있었다.
5. 마무리
책에서는 애그리거트, Bounded Context, Repository, Entity, Value Object 같은 개념을 비교적 명확한 형태로 설명한다. 하지만 실제 프로젝트에서는 기존 코드와 데이터 구조, 팀의 개발 방식, 변경 비용, 현재 비즈니스의 복잡도까지 함께 고려해야 한다. 그래서 정석에 가까운 구조가 항상 지금 당장 가장 적절한 구조라고 보기는 어렵다.
실제 프로젝트를 살펴보면서도 비슷한 생각이 들었다. 모든 영역의 경계가 DDD에서 설명하는 형태로 명확하게 나뉘어 있지는 않았지만, 도메인별로 책임을 나누고 Entity에 행위를 두거나 UseCase를 통해 여러 도메인의 흐름을 조율하는 등 필요한 부분에서는 DDD의 원칙을 활용하고 있었기 때문이다.
그래서 지금은 프로젝트를 보면서 “DDD를 잘 적용하고 있는가?”보다 다음과 같은 질문을 더 많이 하게 되었다.
- 이 규칙은 어느 도메인의 책임인가?
- 이 모델은 어떤 이유로 변경되는가?
- 서로 다른 책임이 하나의 모델에 섞여 있지는 않은가?
- 지금 이 경계를 나누는 것이 실제로 복잡도를 줄여주는가?
DDD를 다시 공부하고 실제 프로젝트에 비춰보면서, 정석을 이해하는 것만큼 현재 코드와 비즈니스의 맥락을 이해하는 것도 중요하다는 것을 배웠다. 앞으로도 DDD라는 형태 자체를 맞추는 것보다는, 도메인의 경계와 책임을 조금 더 명확하게 만들 수 있는 방향인지 고민하면서 코드를 바라보려고 한다.
참고
- 도메인 주도 개발 시작하기(DDD 핵심 개념 정리부터 구현까지) 최범균