강의 애그리거트에는 강사, 소개, 공개 상태가 들어 있다. 다음은 실제 학습 내용이다. 강의는 여러 수업으로 이뤄지고, 수업이 많으니 섹션으로 다시 묶는다. 수업과 섹션은 강의에 속한 것처럼 보이니 Course 아래에 매달고 싶어진다. 이 글의 커리큘럼 애그리거트 트리 설계는 그 선택지를 버리는 데서 시작한다
나는 Curriculum이라는 새 애그리거트를 두고, 그 안을 섹션 → 수업의 트리로 만들었다. 수업을 순서대로 따라가는 탐색은 트리를 한 줄로 펴서 처리한다. 이 글은 거기에 이른 판단 두 개를 다룬다. 경계를 어디에 긋는가, 그리고 경계 안을 어떤 모양으로 만드는가다
이 글에서 자주 나오는 용어
- 애그리거트(Aggregate): 함께 바뀌어야 하는 객체 묶음. 바깥에서는 대표 엔티티인 애그리거트 루트를 통해서만 안을 바꾼다
- 불변식(Invariant): 애그리거트가 언제나 지켜야 하는 조건. 예를 들어 “섹션은 최소 하나 있어야 한다”
- 보편 언어(Ubiquitous Language): 개발자와 도메인 전문가가 함께 쓰는 용어. 클래스와 메서드 이름에 그대로 들어간다
- 평탄화(Flattening): 리스트 안의 리스트를 한 줄짜리 리스트로 펴는 것. 자바 스트림의
flatMap이 이 일을 한다
섹션과 수업을 둘 자리는 세 군데다
먼저 규칙을 적어 본다. 강의에는 섹션이 하나 이상 있어야 한다. 섹션마다 수업이 하나 이상 있어야 한다. 강의 전체의 수업 수와 총 시간도 관리해야 한다. 이 규칙을 누가 지키는지가 경계를 정한다
| 후보 | 구조 | 문제 |
|---|---|---|
| A | Section, Lesson을 각각 애그리거트로 | “섹션은 최소 하나” 같은 규칙을 지킬 주인이 없다 |
| B | Course를 루트로, 그 아래에 섹션과 수업 | 강의 제목만 바꿔도 구성 전체가 한 경계에 묶인다 |
| C | 새 루트를 두고 섹션과 수업을 그 아래로 | 새 루트의 이름과 의미가 필요하다 |
A는 규칙이 여러 애그리거트에 흩어진다. 섹션 하나를 지울 때 “마지막 섹션인가”를 확인하려면 다른 섹션들을 모두 알아야 하는데, 섹션 애그리거트는 자기 자신만 안다
B는 규칙을 한곳에 모으지만 경계가 너무 크다. 강의 소개 문구를 고치는 일과 수업 순서를 바꾸는 일은 서로 상관이 없다. 그런데 한 애그리거트로 묶으면 둘이 같은 트랜잭션 단위, 같은 로딩 단위가 된다
C가 남는다. 남은 문제는 이름이다
새 단어가 새 경계를 만든다
에릭 에반스의 DDD Reference는 보편 언어를 용어집으로 끝내지 않는다. 모델을 설명하기 어려운 자리에서 새 표현을 찾고, 그 표현으로 모델을 바꾸는 과정까지 포함한다. 강의도 같은 흐름으로 커리큘럼이라는 단어를 들였다. “이 강의를 어떻게 구성할 것인가”를 가리키는 말이다
단어가 생기자 책임이 갈렸다
Course (강의) Curriculum (커리큘럼) ┌─────────────────────┐ 1 1 ┌───────────────────────────┐ │ 강사 │◀─────────│ course │ │ 제목, 소개 │ │ sections: List<Section> │ │ 상태(DRAFT → ...) │ │ └ lessons: List<Lesson> │ └─────────────────────┘ └───────────────────────────┘ 무엇을 누가 파는가 어떤 순서로 무엇을 배우는가
Course는 “무엇을 누가 파는가”를 맡는다. Curriculum은 “어떤 순서로 무엇을 배우는가”를 맡는다. 두 애그리거트는 1:1이다
화살표 방향이 중요하다. 커리큘럼이 강의를 가리키고, 강의는 커리큘럼을 모른다. 커리큘럼은 강의 없이 존재할 수 없지만, 강의는 구성이 비어 있어도 초안(DRAFT) 상태로 존재할 수 있다. 의존 방향을 이렇게 잡아 두면 강의 쪽 코드는 커리큘럼이 바뀌어도 영향을 받지 않는다. 이 방향은 5편에서 한 번 흔들린다
애그리거트 경계를 정하는 기준 자체는 애그리거트 도메인 상태 전이 설계로 잘못된 호출 막기에서 다뤘다
편집과 탐색은 서로 다른 모양을 원한다
경계를 정했으니 안쪽 모양을 정할 차례다. 강의는 여기서 경쟁 서비스인 인프런의 강의 편집 화면을 직접 시연했다. 화면에서 확인된 동작은 이렇다
- 강의를 만들면 섹션이 하나 생긴다. 섹션이 있어야 수업을 넣을 수 있다
- 섹션과 수업은 제목만으로 만든다. 영상과 자료는 나중에 붙인다
- 수업 번호는 섹션과 상관없이 처음부터 1, 2, 3, 4로 이어진다
- 수업은 드래그로 같은 섹션 안에서도, 다른 섹션으로도 옮길 수 있다
- 섹션을 지우면 그 안의 수업은 사라지지 않고 앞 섹션으로 들어간다
- 마지막 남은 섹션은 지울 수 없다
이 목록에서 커리큘럼을 쓰는 방식이 두 가지로 갈린다
- 구조 편집: 섹션 추가·삽입, 수업 추가, 제목 수정, 수업 이동, 삭제. 강사가 강의를 만들 때 쓴다
- 순차 탐색: 첫 수업 찾기, 다음 수업 찾기, 마지막인지 판단하기. 수강생이 학습할 때 쓴다
탐색에서는 섹션이 거의 의미가 없다. “다음 섹션을 시작합니다”라고 안내하지 않는다. 수업 단위로 다음, 다음으로 넘어갈 뿐이다. 반대로 편집에서는 섹션이 중심이다. 수업은 항상 어느 섹션의 몇 번째 자리에 들어간다
두 용도에 맞는 모델이 각각 있다
모델 1: 탐색에 맞춘 평면 목록 모델 2: 편집에 맞춘 트리
Curriculum Curriculum
└ lessons ├ S0
├ L0 (section = S0) │ ├ L0
├ L1 (section = S0) │ └ L1
├ L2 (section = S1) └ S1
└ L3 (section = S1) ├ L2
└ L3
모델 1은 다음 수업이 곧 리스트의 다음 원소다. 탐색이 한 줄로 끝난다. 섹션은 수업의 속성처럼 붙는다
모델 2는 전형적인 트리다. 수업을 추가할 때 어느 섹션의 리스트에 넣을지만 정하면 된다. 대신 탐색하려면 섹션의 끝에서 다음 섹션의 처음으로 건너가는 코드가 필요하다
강의는 처음에 모델 1을 쓰려다가 모델 2로 바꿨다. 이유는 수업 이동이다. L1을 S1의 맨 앞으로 옮기는 경우를 보자. 모델 1에서는 전체 순서가 그대로다. 바뀌는 것은 L1의 섹션 속성뿐이다. 그런데 L0을 S1의 맨 뒤로 옮기면 전체 순서도 바뀌고 섹션 속성도 바뀐다. 이동할 때마다 “같은 섹션 안인가, 앞 섹션인가, 뒤 섹션인가, 경계를 넘는가”를 나눠 따져야 한다. 한 섹션의 수업이 목록 안에서 붙어 있어야 한다는 조건도 코드가 계속 지켜야 한다
모델 2에서 이동은 두 동작이다. 원래 섹션의 리스트에서 빼고, 대상 섹션의 리스트에 넣는다. 경계를 넘는지 따질 필요가 없다. 섹션 삭제도 같다. 지운 섹션의 리스트를 통째로 옆 섹션 리스트에 붙이면 된다
탐색을 쉽게 만드는 쪽과 편집을 쉽게 만드는 쪽 중 하나를 골라야 했다. 편집 쪽 규칙이 훨씬 많고 틀리기 쉬우므로 편집을 기준으로 골랐다. 탐색은 트리를 펴서 해결한다
탐색은 편 목록 위에서 한다
트리를 한 줄로 펴는 메서드는 한 줄이다
public List<Lesson> allLessons() {
return this.getSections().stream().flatMap(section -> section.getLessons().stream())
.toList();
}
섹션 스트림의 각 원소를 그 섹션의 수업 스트림으로 바꾸고, 그 스트림들을 이어 붙인다. Stream.flatMap의 정의 그대로다. 결과는 섹션 순서, 그 안의 수업 순서대로 선 수업 목록이다. 앞에서 본 “섹션과 상관없이 이어지는 수업 번호”가 이 목록의 인덱스다
마지막의 toList()는 Java 16에 추가된 Stream.toList()다. 문서는 돌려주는 리스트가 수정 불가이고, 변경 메서드를 부르면 항상 UnsupportedOperationException이 난다고 적는다. 그래서 allLessons()로 받은 목록에 수업을 넣어 구조를 바꾸는 일은 처음부터 막힌다. collect(Collectors.toList())와는 다르다. Collectors.toList()는 반환 리스트의 타입과 수정 가능 여부를 보장하지 않는다
첫 수업과 다음 수업은 이 목록 위에서 찾는다
public Optional<Lesson> firstLesson() {
return this.allLessons().stream().findFirst();
}
public Optional<Lesson> nextLesson(Lesson lesson) {
List<Lesson> lessons = allLessons();
int index = lessons.indexOf(lesson);
isTrue(index >= 0, "커리큘럼에 포함된 수업이 아닙니다");
if (index + 1 >= lessons.size())
return Optional.empty();
return Optional.of(lessons.get(index + 1));
}
반환 타입이 Optional인 이유는 “없음”이 정상 결과이기 때문이다. 수업이 하나도 없는 커리큘럼에는 첫 수업이 없다. 마지막 수업 다음에는 수업이 없다. 반면 커리큘럼에 없는 수업을 넘기면 호출한 쪽의 잘못이므로 예외를 던진다
설계 문서에는 “빈 섹션은 첫/다음 수업 탐색 시 건너뛴다”는 규칙이 있었다. 이 규칙을 위한 코드는 따로 없다. 빈 섹션은 flatMap에서 빈 스트림이 되어 결과에 아무것도 보태지 않는다. 트리를 펴는 방식이 규칙을 공짜로 지킨다
비용은 있다. nextLesson은 호출할 때마다 전체 수업 목록을 새로 만들고 indexOf로 처음부터 찾는다. 수업 수에 비례하는 일을 매번 한다. 수업이 수십 개인 강의에서는 문제가 되지 않는다고 판단했지만, 수업이 수천 개일 때의 시간은 재지 않았다
아이디로 찾는 탐색은 테스트에서 아이디를 심어야 한다
API로 들어오는 요청은 수업 객체가 아니라 수업 아이디를 들고 온다. 그래서 아이디를 받는 버전도 있다
public Optional<Lesson> nextLesson(Long lessonId) {
Lesson lesson = allLessons().stream().filter(
candidate -> lessonId.equals(candidate.getId()))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("레슨을 찾을 수 없습니다. ID: " + lessonId));
return nextLesson(lesson);
}
도메인 테스트에는 DB가 없으므로 아이디가 모두 null이다. 아이디는 리포지토리가 저장할 때 채운다. 테스트에서는 스프링의 ReflectionTestUtils.setField로 필드에 값을 직접 넣었다
Lesson l0 = curriculum.addLesson(0, "L0");
assignIn(l0, 10L);
Lesson l1 = curriculum.addLesson(0, "L1");
assignIn(l1, 11L);
assertThat(curriculum.nextLesson(10L).orElseThrow()).isEqualTo(l1);
private void assignIn(Lesson lesson, Long id) {
ReflectionTestUtils.setField(lesson, "id", id);
}
setField는 setter가 없는 private 필드에도 값을 넣는다. 제품 코드에 테스트용 setter를 만들지 않으려고 쓰는 도구다
여기서 강의에서 다루지 않은 함정이 하나 보인다. 이 프로젝트의 AbstractEntity.equals는 아이디로 같음을 판단하고, 아이디가 null이면 같은 인스턴스일 때만 같다고 본다
return getId() != null && Objects.equals(getId(), that.getId());
nextLesson(Lesson)의 indexOf는 이 equals를 쓴다. 도메인 테스트에서 통과하는 것은 커리큘럼 안의 바로 그 인스턴스를 넘기기 때문이다. 아이디가 없는 다른 인스턴스를 넘기면 제목이 같아도 “포함된 수업이 아니다”라는 예외가 난다. 운영에서는 수업이 항상 DB에서 읽혀 아이디를 가지므로 이 경로를 타지 않는다
규칙은 코드보다 설계 문서에 먼저 적었다
구현 전에 저장소의 도메인모델.md에 커리큘럼 애그리거트를 적었다
#### 규칙 - 섹션 삽입 인덱스는 `[0, 섹션 수]` 범위여야 한다(맨 끝 삽입 허용) - 마지막 남은 섹션은 삭제할 수 없다 - 섹션 삭제 시 수업은 이전 섹션 끝으로 이동한다(첫 섹션이면 다음 섹션 맨 앞) - 수업의 전체 순서는 섹션 순서 + 섹션 내 순서로 결정된다 - 빈 섹션은 첫/다음 수업 탐색 시 건너뛴다 - 최종 확인 조건은. 최소 1개 섹션과 1개 수업이 있어야 한다. 수업이 없는 빈 섹션은 존재하면 안 된다.
셋째 규칙의 괄호 부분은 인프런 화면에는 없던 동작이다. 강의에서 따로 확인한 바로는, 경쟁 서비스는 섹션이 여러 개 남아 있어도 첫 섹션을 지우지 못했다. 이 모델은 첫 섹션을 지우면 그 수업을 다음 섹션 맨 앞에 붙인다. 수업의 전체 순서는 그대로 유지된다
다섯째와 여섯째 규칙은 얼핏 부딪힌다. 탐색은 빈 섹션을 허용하는데, 최종 확인은 빈 섹션을 금지한다. 두 규칙은 서로 다른 시점에 적용된다. 편집 중인 커리큘럼은 불완전해도 된다. 강사가 섹션을 먼저 만들고 수업을 나중에 채우기 때문이다. 검수를 신청하거나 공개하기 직전에만 완전해야 한다. 그 시점의 검사가 validate()다
public void validate() {
if (this.sections.isEmpty())
throw new InvalidCurriculumException("최소한 하나의 섹션이 필요합니다");
this.sections.forEach(section -> {
if (section.getLessons().isEmpty())
throw new InvalidCurriculumException("수업이 없는 섹션은 허용되지 않습니다");
});
}
“섹션은 항상 하나 이상”이 편집 중에는 지켜지지 않는다는 점에 주의해야 한다. 새로 만든 커리큘럼은 섹션이 0개다. 편집 중에 막는 것은 “마지막 하나 남은 섹션을 지우는 일”뿐이다. 이 차이는 2편의 섹션 삭제에서 다시 나온다
엔티티 초안에서 뒤집힌 값 하나
첫 구현 엔티티 뼈대는 이렇다.
Curriculum (루트) course : Course @OneToOne, 필수 sections : List<Section> @OneToMany(mappedBy = "curriculum") Section (내부 엔티티) curriculum : Curriculum @ManyToOne, 필수 title : String lessons : List<Lesson> @OneToMany(mappedBy = "section") Lesson (내부 엔티티) section : Section @ManyToOne, 필수 title : String
Section과 Curriculum, Lesson과 Section은 서로를 가리키는 양방향 관계다. 애그리거트 사이의 양방향 참조는 피해야 하지만 애그리거트 안에서는 괜찮다. 바깥에서는 루트로만 들어오므로, 양쪽을 맞추는 책임을 애그리거트가 혼자 진다. 수업이 섹션을 알아야 “이 수업이 어느 섹션, 어느 커리큘럼, 어느 강의 것인가”를 거슬러 올라갈 수 있다
강의에서는 처음에 Curriculum에도 title을 넣었다가 뺐다. 제목은 이미 Course에 있다. 섹션 제목과 헷갈린 것이다
내 초안에서 뒤집힌 값도 하나 있다. 섹션과 수업 제목에 @Column(length = 256)을 적었다. 강의는 200을 썼다. 나중에 매핑을 orm.xml로 옮기면서 200으로 맞췄다. 256에는 근거가 없었다. 2의 거듭제곱이라 익숙했을 뿐이다. DB 컬럼 길이는 화면에서 받을 제목 길이에서 와야 하는데, 그 기준은 아직 기획에 없다. 200도 강의 값을 따른 것이지 검증한 값이 아니다
편집 규칙이 많은 쪽에 모델을 맞추고, 탐색처럼 단순한 쪽은 변환으로 풀면 모델 하나로 두 용도를 버틴다
출처
커리큘럼이라는 용어와 두 모델의 비교, 경쟁 서비스 시연은 토비의 클린 스프링 – 도메인 모델 패턴과 헥사고날 아키텍처 Part 2 강의에서 가져왔다
커리큘럼 애그리거트 시리즈
- 커리큘럼 애그리거트는 탐색이 아니라 편집을 기준으로 트리로 설계한다 (이 글)
- 리스트 인덱스로 애그리거트 구조를 편집하면 삭제가 인덱스를 먼저 바꾼다
- JPA OrderColumn과 orphanRemoval은 수업 이동에서 충돌한다
- 도메인에 위임만 하는 애플리케이션 서비스도 테스트해야 버그가 드러난다
- DIP로 컴포넌트 순환 의존을 끊으려면 시그니처의 타입까지 옮겨야 한다
참고 자료
- DDD Reference — Eric Evans
Stream.flatMap(Java 21 API)Stream.toList(Java 21 API)ReflectionTestUtils(Spring Framework javadoc)- kyhyeok/splearn 커밋 6520eab — 커리큘럼 도메인 설계
- kyhyeok/splearn 커밋 783eba0 — 커리큘럼 도메인 설계 (1)