“사용자가 카테고리를 선택하면, 매일 ‘키워드 + 설명 + 관련 뉴스들 + 퀴즈’를 한 세트로 공부한다.
소프트웨어 개발을 시작할 때 습관적으로 데이터베이스 스키마부터 그린다.
우리 프로젝트에서 요구를 듣고, 나와 팀원은 단순히 여러 테이블의 조합이 아니라 애그리거트들의 조합으로 시스템을 설계하기로 했다.
우리 프로젝트의 애그리거트 중 하나인 DailyContent 애그리거트의 설계 과정을 기록한다.
이 글은 “코드 설명”이 아니라 도메인 설계 의도를 기록하는 글이다. (코드 설명은 다음 글부터)
- 왜 이 프로젝트에 DDD(도메인 주도 설계) 를 썼는지
- 왜 DailyContent를 애그리거트 루트로 잡았는지
- 왜 News / Quiz는 엔티티, 나머지는 값 객체(VO) 로 두었는지
1. 이 프로젝트에 DDD를 쓰기로 한 이유
프로젝트에서 사용자에게 제공되는 '하루치 공부'는 단순히 텍스트 본문 하나가 아니다.
"오늘의 경제 용어(키워드) + 설명 본문 + 관련 뉴스 기사들 + 복습 퀴즈"가 하나의 세트로 묶여서 제공된다.
처음에는 이걸 전형적인 관계형 DB 관점으로만 바라봤다.
daily_content테이블news테이블quiz테이블
그리고 서비스 레이어에서 이 세 테이블을 각각 조회해서 조립하는 방식으로 구현하려고 했다.
그런데 곧 여러 도메인 규칙들이 눈에 들어오기 시작했다.
- 뉴스가 없는 콘텐츠는 발행될 수 없다.
- 퀴즈의 순서는 콘텐츠 내에서 유일해야 한다.
- 콘텐츠가 삭제되면, 그날의 뉴스나 퀴즈만 덩그러니 남아서는 안 된다.
- …
이 규칙들을 서비스 레이어의 수많은 if 문으로만 관리하다 보면
서비스 레이어는 점점 “조립 + 검증 + 예외처리”가 뒤섞인 거대한 절차 코드가 될 것이 뻔했다.
그래서 “서비스에서 데이터 조합을 관리하는 구조” 대신,
도메인 객체 스스로가 자신의 상태를 검증하고, 규칙을 보장하는 구조로 바꾸기로 했다.
즉, daily_content, news, quiz를 느슨하게 조립하는 것이 아니라
“하루치 공부”라는 개념 자체를 하나의 애그리거트(DailyContent)로 묶고,
그 안에서 “뉴스가 최소 1개 이상인지, 퀴즈 순번이 중복되지 않는지” 같은 규칙을 책임지도록 설계했다.
2. DailyContent 애그리거트의 큰 그림
먼저 도메인 구조를 그림으로 보면 이렇게 생겼다.

이 구조를 한 줄로 설명하면:
DailyContent(AR)가 “카테고리별 N일차”를 대표하고,
그 아래에 NewsItem/Quiz 엔티티와 여러 값 객체들이 매달려 있는 형태.
이다.
3. 왜 DailyContent가 애그리거트 루트인가?
3.1. 도메인 상의 한 덩어리: 하루치 학습 경험
기획/운영 관점에서 보면, 이 서비스의 세계는 이렇게 보인다.
- 카테고리 “돈의 흐름”은 14일짜리 코스
- 그중 “3일차”에는
- 오늘의 키워드 1개
- 설명(제목, 요약, 본문)
- 관련 뉴스 2~3개
- 퀴즈 1~3개
- 운영자가 하는 일:
- “돈의 흐름 3일차 콘텐츠 작성/수정/배포”
- 잘못 올라갔으면 “3일차 전체 롤백”
즉, 비즈니스가 바라보는 일관성의 최소 단위는
“카테고리 X의 N일차”
이다.
데이터베이스 입장에서는 여러 테이블이지만, 사람 입장에서는 이것이 하나의 세트다.
그래서 이 세트를 대표하는 루트로 DailyContent 를 세웠다.
- 트랜잭션 단위: “3일차 DailyContent + 그 하위의 News/Quiz 포함” 한 번에 저장
- API 단위:
/api/daily-contents/{categoryId}/{daySequence}로 한 번에 조회/제공할 수도 있음
DDD에서 말하는 “애그리거트”의 정의를 가져오면:
“항상 함께 일관성을 유지해야 하는 도메인 객체들의 묶음”
이 프로젝트에서는 그게 바로 DailyContent와 그 내부 구성 요소들이었다.
3.2. 유일성 규칙과 경계
DailyContent에는 이런 규칙이 있다.
- 같은 카테고리에서 같은 DaySequence는 하나만 존재해야 한다.
이 규칙을 코드/DB에선 이렇게 녹였다.
- 도메인 규칙: “CategoryId + DaySequence 조합은 유일”
- DB 제약:
unique(category_id, day_sequence)
이 조합 자체가 바로 “애그리거트 식별자”에 해당한다.
즉,
- “이 DailyContent가 누구인가?” →
CategoryId + DaySequence로 설명 가능 - “이 세트를 하나로 보고 관리한다” → 애그리거트 루트로서 DailyContent가 적합
4. 왜 NewsItem / Quiz는 루트가 아니라 엔티티인가?
4.1. 독립성 vs 종속성
NewsItem과 Quiz는 둘 다 엔티티다.
ID도 있고, 각각 수정/삭제가 가능하다. 그런데도 애그리거트 루트로 올리지 않았다.
이유는 단순하다.
- NewsItem/Quiz는 DailyContent 없이는 비즈니스적으로 의미가 거의 없다.
- “돈의 흐름 3일차 2번 뉴스”라는 문맥이 있어야만 의미가 생김
- “혼자 떠다니는 뉴스 엔티티”를 외부에 직접 노출할 필요가 없다
- 라이프사이클도 DailyContent에 종속된다.
- 3일차를 삭제하면 그 아래 뉴스/퀴즈도 같이 사라지는 것이 자연스럽다.
- “퀴즈만 남아있는 3일차 없는 데이터” 같은 상태는 원하지 않는다.
그래서 NewsItem / Quiz는:
- 개별적으로 식별 가능한 엔티티이긴 하지만
- 트랜잭션/일관성/수명 관점에서는 DailyContent 내부의 구성 요소
로 보는 것이 더 자연스러웠다.
4.2. 만약 News/Quiz를 각각 애그리거트 루트로 만들었다면?
반대로 설계했다면 이런 형태가 된다.
NewsItem이 독립 애그리거트 →/news/{id}로 직접 관리Quiz가 독립 애그리거트 → “퀴즈 관리 시스템”처럼 동작
그럼 이런 문제가 바로 튀어나온다.
- “3일차 전체 롤백”이 어려워진다.
- DailyContent, 관련 NewsItem, Quiz를 서로 다른 애그리거트로 나눠버리면
- 롤백/수정 시에 여러 애그리거트를 동시에 건드리는 복잡한 트랜잭션이 필요
- “3일차를 추가한다” 같은 간단한 요구도
- DailyContent 생성
- 관련 NewsItem 애그리거트 여러 개 생성
- 관련 Quiz 애그리거트 여러 개 생성
- 이들을 서로 참조 연결
- 같이 분산된 작업이 된다.
그래서 초반 설계 단계에서:
- “3일차 전체”가 하나의 트랜잭션 단위
- 그 아래 News/Quiz는 그냥 종속 엔티티
로 두는 것이 맞다고 판단했다.
5. 왜 나머지는 값 객체(VO)로 만들었는가?
DailyContent 애그리거트 안에는 많은 VO가 있다.
- CategoryId
- DaySequence
- Keyword
- ContentDescription
- ImageFile
- WebLink
- NewsItemOrder, QuestionOrder
- AnswerIndex, QuizOptions, QuizOption …
이걸 그냥 다 String, Long, int 로 두고 진행할 수도 있었지만, 의도적으로 값 객체로 빼냈다.
5.1. “의미 있는 타입”으로 만들기
예를 들어 CategoryId를 Long 대신 VO로 만든 이유는:
Long타입만 보면 “이 숫자가 뭔데?”가 전혀 안 보인다.- 실수로 memberId를 넣어도 컴파일이 된다.
- null, 0, 음수 같은 값도 아무렇지 않게 들어간다.
CategoryId VO로 만들면:
- 타입 이름만 봐도 “카테고리 식별자”라는 의미가 코드에 드러난다.
- 생성자에서 “양수만 허용” 같은 비즈니스 규칙을 강제할 수 있다.
- 잘못된 타입이 섞이는 것을 컴파일 단계에서 막을 수 있다.
DDD의 좋은 값 객체는:
- “이것이 무엇을 뜻하는지” 를 타입 이름에 담고
- “어떤 값이 허용/금지되는지” 를 생성 시점에 검증한다.
5.2. 검증 로직을 도메인 내부로 밀어넣기
Keyword, ContentDescription, WebLink, ImageFile, AnswerIndex, QuizOptions 등은
모두 “검증을 어디에 둘 것인가?”라는 문제와 직결되어 있다.
- Controller/Service마다
if (title.length() > 200)같은 코드를 반복할 수도 있지만, - 그러면 중복 + 누락 + 일관성 깨짐의 가능성이 커진다.
대신 VO에 넣으면:
- 한 곳에서만 규칙을 정의하면 된다.
- 규칙이 바뀌면 그 값 타입만 바꾸면 된다.
- 엔티티는 “유효성이 보장된 값 타입들만 조합”하도록 만들 수 있다.
즉, 엔티티는 조립, VO는 검증이라는 역할 분리가 생긴다.
6. 정리: 이 설계가 말하고 싶은 것
이 글에서 하고 싶은 말은 사실 딱 하나다.
“우리는 DB 테이블을 설계한 게 아니라,
‘하루치 학습 경험’이라는 도메인 개념을 코드로 옮겼다.”
그래서
- DailyContent를 애그리거트 루트로 잡았고
- News/Quiz를 그 아래 엔티티로 두었고
- 나머지는 값 객체로 잘게 쪼개서 의미와 규칙을 타입에 담았다.
다음 글들에서는 이 안에서:
- 코드를 통해서 애그리거트 분석하기
- AbstractSequence를 써서 DaySequence / QuestionOrder / NewsItemOrder를 어떻게 통합했는지
- QuizOptions를 JSON으로 매핑하면서도 도메인 모델을 “JSON 냄새”로부터 어떻게 지켜냈는지
같은 조금 더 디테일한 주제들을 풀어볼 수 있다.
[ONECO DDD 도메인 설계 시리즈 Part 1] DailyContent 설계 스토리
“사용자가 카테고리를 선택하면, 매일 ‘키워드 + 설명 + 관련 뉴스들 + 퀴즈’를 한 세트로 공부한다. 소프트웨어 개발을 시작할 때 습관적으로 데이터베이스 스키마부터 그린다.우리 프로젝
gimini.tistory.com
[ONECO DDD 도메인 설계 시리즈 Part 2] DailyContent 애그리거트 뜯어보기
Part 1에서는 “왜 DailyContent를 애그리거트 루트로 두었는가”를 개념적으로 정리했다면,이번 Part 2는 실제 코드 한 파일(DailyContent.java)(애그리거트)을 기준으로 설계 의도와 동작 방식을 해부하는
gimini.tistory.com
[ONECO DDD 도메인 설계 시리즈 Part 3] 값 객체는 어디서 만들고, 엔티티는 누가 만들어야 할까?
이번 글은 ONECO 프로젝트에서 DailyContent 애그리거트를 설계하면서 부딪힌 고민들을 바탕으로,“값 객체(Value Object)는 어디서 만들고, 엔티티(Entity)는 누가 만들게 할 것인가?” 를 정리해보는 글
gimini.tistory.com
[ONECO DDD 도메인 설계 시리즈 Part 4] 왜 DailyContent → News/Quiz(Entity)는 단방향으로만 묶었을까?
0. 이 글에서 이야기할 것oneco 콘텐츠의 도메인은 이렇게 생겼다.하루 단위 묶음: DailyContent (애그리거트 루트)그날 보여줄 뉴스들: NewsItem (엔티티 리스트)그날 풀게 될 퀴즈들: Quiz (엔티티 리스트
gimini.tistory.com
[ONECO DDD 도메인 설계 시리즈 Part 5] AbstractSequence와 정렬 전략
0. 이 글에서 다룰 것이 글은 “하루치 학습(DailyContent)” 애그리거트에서 순서/일차/문항 번호를 어떻게 다뤘는지에 대한 설계 기록이다.내가 실제로 겪은 고민은 대략 이런 거였다.“카테고리
gimini.tistory.com
[ONCEO DDD 도메인 설계 시리즈 Part 6] 퀴즈 보기(QuizOption)는 왜 VO + JSON으로 설계했을까?
0. 들어가며 – “퀴즈 보기, 어디까지 쪼갤 건데?”내 서비스의 하루 학습 경험은 이렇게 생겼다.“키워드 설명을 읽고, 관련 뉴스를 보고, 마지막에 퀴즈 1문제를 푼다.그 퀴즈에는 여러 개의
gimini.tistory.com