이전 회고에 이어 포텐업에서의 2개월 LXP 프로젝트를 새로운 팀원들과 시작하게 되었습니다.
이번 프로젝트에서는 처음으로 DDD(Domain-Driven Design)를 적극적으로 도입해 봤습니다.
그 과정에서 무엇을 배웠고, 무엇이 아쉬웠고, 다음에는 무엇을 바꾸고 싶은지를 정리했습니다.
회고를 시작하기 앞서 먼저 프로젝트를 소개드리겠습니다.

Notion과 GitHub 등을 활용했지만, 핵심은 도구 자체가 아닌 "서로의 성장을 위한 기록과 리뷰 문화"였습니다.
기능 구현에 바로 들어가기 전, 이벤트 스토밍을 통해 도메인 이벤트를 도출하고 이를 기준으로 도메인을 나눴습니다.
이후 각 도메인을 적절히 분배하여 담당자를 정하고, DDD 스타일의 패키지 구조와 레이어를 적용해 구현을 진행했습니다.

| 구분 | LMS (이전 프로젝트) | LXP (이번 프로젝트) |
|---|---|---|
| 핵심 | 관리자 중심 (CRUD) | 사용자 경험 중심 (Customizing) |
| 목표 | Spring MVC 경험 | 도메인 주도 설계 (DDD) |
| 레퍼런스 | 기존 강의 사이트 | roadmap.sh |
단순히 화면을 그리는 것이 아니라, "사용자가 학습 경로를 스스로 커스터마이징한다"는 도메인적 특성을 살리기 위해 DDD를 선택했습니다.
DDD는 “Domain Driven Design”의 약자로, 복잡한 도메인 모델을 설계하고 구현하기 위한 방법론이다. 도메인 전문가와 개발자가 협력해 도메인 모델을 정의하고, 이를 기반으로 소프트웨어를 설계하는 것을 목표로 한다.
처음에는 DDD가 MVC와 완전히 다른 “어떤 패턴”이라고 생각했습니다.
실제로 써보니, DDD는 특정 프레임워크나 패턴이라기보다 도메인 중심으로 사고하고 설계하는 방법론에 가까웠습니다.
그래서
MVC 위에 DDD의 관점을 얹어서, “코드를 어떤 책임 단위로 나눌지 도메인을 어디까지 보호할지”를 고민해 보는 쪽에 더 가깝다고 느꼈습니다.
그렇다면 왜 DDD여야 했을까요 🤔?
이번 프로젝트에서 “왜 굳이 DDD여야 했는가?” 를 계속 질문하게 됐습니다.
단순히 유행하는 개념을 써보고 싶어서가 아니라,
LXP라는 도메인의 특성과 팀의 협업 방식, 그리고 개인적인 학습 목표가 맞물린 선택에 가까웠습니다.
이번 프로젝트의 목표는 단순히 강좌와 강의를 CRUD로 관리하는 LMS가 아니라, 사용자 중심으로 학습 경험을 설계하는 LXP였습니다.
그래서,
가 더 중요해진다고 느꼈습니다.
이 의미를 생각하다 보니 이런 질문들을 던지게 됐습니다.
이 지점에서 “도메인부터 먼저 잡고 가는 설계 방식”인 DDD를 떠올리게 되었고,
단순히 MVC를 써보는 수준을 넘어 도메인 중심 설계를 직접 경험해 보고 싶다는 생각이 들었습니다.
💡 요약
LXP라는 도메인 자체가 “학습 경험”에 초점이 있다 보니,
자연스럽게 화면이 아닌 도메인 모델부터 설계하고 싶은 마음이 들었습니다.

이벤트 스토밍은 실제로 이렇게 포스트잇을 잔뜩 붙여가며 진행했어요 🙌
roadmap.sh를 분석한 뒤 이벤트 스토밍을 진행하면서,
같은 화면을 보고도 팀원마다 쓰는 언어가 다르다는 걸 체감했습니다.
이 경험을 통해 유비쿼터스 언어의 필요성을 느꼈습니다.
이벤트 스토밍에서 도출한 도메인 이벤트를 기반으로 용어를 정리하면서,
바운디드 컨텍스트와 어그리게이트를 나눌 때 DDD의 관점을 적극적으로 활용하게 되었습니다.
- 바운디드 컨텍스트(Bounded Context) : “이 말은 여기 안에서만 이 의미로 쓴다”라고 경계를 그어놓은 도메인 구역.
- 어그리게이트(Aggregate) : “여기까지는 한 번에 함께 일관성을 지켜야 하는 덩어리”를 정의한 것.
이전 프로젝트에서도 요구사항 정의와 ERD 설계는 했지만,
이번에는 여기에 더해
까지 포함해 설계 단계에 더 많은 시간을 투자했습니다.
동시에
을 세우면서 단순 스타일이나 구현 디테일이 아니라,
“이 도메인 규칙을 이 위치에서 책임지는 게 맞는가?”라는 관점으로 이야기를 나눌 수 있었습니다.
💡 요약
설계–구현–코드 리뷰를 하나의 흐름으로 엮어 본 첫 실험이었고,
“도메인 관점에서 코드를 보는 연습”이 많이 되었습니다.
강의와 글로만 접하던 DDD를 “알고 있다” 수준에 두기보다는, 실제 프로젝트에 적용해 보면서 어디까지 도움이 되고 어디서부터는 과한지 직접 느껴보고 싶었습니다.
이번 프로젝트에서는 DDD를 이론이 아니라 코드와 협업 경험으로 이해해보고 싶었고, 덕분에 도메인 중심 사고와 설계가 무엇인지 조금은 느낄 수 있었습니다.
이제부터는, 이렇게 선택한 DDD 관점 아래에서 실제 구현을 하며 부딪혔던 고민들을 정리해보려 합니다.
발표 이후 강사님께 아래와 같은 피드백을 들었습니다.
MVP 단계에서는 SubTopic이 Topic 하위에 포함되는 구조로 가도 괜찮지만,
서비스가 확장되면 강사라는 액터가 등장해 콘텐츠를 직접 업로드하게 될 수 있다.
그런 경우를 미리 염두에 뒀다면 SubTopic을 루트 어그리게이트로 설계하는 선택도 가능했을 것.
이 피드백을 듣고, 제가 맡았던 도메인에서의 고민 포인트와 연결점이 있지 않을까 생각하게 되었고,
“도메인을 어디까지 확장 가능성을 염두에 두고 설계할 것인가”를 다시 돌아보는 계기가 되었습니다.
제가 맡았던 도메인은 Category(카테고리)와 Star(별점)이었습니다.
둘 다 Roadmap과 분리된 별도의 어그리게이트로 설계했습니다.
으로 보고, 각자 독립된 도메인 모델과 패키지로 가져갔습니다.
이미 어그리게이트로 분리한 상태였지만, 구현을 진행할수록
“어디까지 확장 가능성을 설계에 녹여 둘 것인가” 에 대한 고민이 깊어졌습니다.
Star는 처음부터 Roadmap 내부의 필드가 아닌 독립 어그리게이트였습니다.
user와 roadmap을 연결하는 평가이고,value는 1~5점 사이의 값입니다.그래서 처음에는 아래처럼, Star 테이블을 떠올렸습니다.
// "한 유저가 한 로드맵에 한 번만 평가할 수 있다" 불변식을 가진 초기 설계
@Entity
@Table(
name = "star_road_map",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_star_user_roadmap",
columnNames = {"user_id", "road_map_id"}
)
}
)
public class StarRoadMap {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "value", nullable = false)
private int value; // 1~5점
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "road_map_id", nullable = false)
private Long roadMapId;
private LocalDateTime createdAt;
private LocalDateTime modifiedAt;
// 생성자/검증 로직 등 생략
}
이 코드만 보면 도메인 규칙이 꽤 명확하게 드러납니다.
하지만 구현을 진행할수록 이런 시나리오들이 떠올랐습니다.
이 지점에서 “독립된 애그리게이트로 분리했다는 사실만으로 충분한가?” 라는 질문이 생겼습니다.
Roadmap에서만 쓸 생각으로 road_map_id에 고정해 버리면,
나중에 다른 도메인에 재사용하려 할 때 모델 자체를 갈아엎어야 하는 구조가 되기 때문입니다.
하나의 유저는 하나의 타깃에 대해 한 번만 평가할 수 있다.
→ (user_id + target_id) 유니크 불변식
Star는 Roadmap에만 붙는 개념이 아니라, Topic / SubTopic / Instructor 등
여러 타깃에 붙을 수 있는 "범용 평가 도메인"이 될 수 있다.
지금은 Roadmap에만 사용하더라도,
나중을 생각하면 (target_type + target_id) 구조로 여지를 열어두는 편이 확장에 유리하다.
// 여러 도메인에 붙을 수 있는 "평가 타깃" 값 객체
@Embeddable
public class StarTarget {
@Enumerated(EnumType.STRING)
@Column(name = "target_type", nullable = false, length = 50)
private StarTargetType type; // ex) ROADMAP, TOPIC, SUB_TOPIC
@Column(name = "target_id", nullable = false)
private Long id;
...
}
@Entity
@Table(
name = "stars",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_star_user_target",
columnNames = {"user_id", "target_type", "target_id"}
)
}
)
public class Star {
...
}
지금 프로젝트에서는 여기까지 구현하진 않았지만,
를 나란히 두고 보면서
“우리는 이미 Star를 애그리게이트로 분리했지만, 도메인 관점에서도 ‘확장’을 염두에 둔 설계였는가?”
를 다시 한 번 질문해 볼 수 있었습니다.
Category도 Roadmap과 분리된 독립 어그리게이트로 가져갔습니다.
이라는 단순한 관계로 출발했고, 이에 맞게 엔티티도 아주 단순한 형태입니다.
@Entity
@Table(name = "category")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Category extends IdEntity {
@Enumerated(EnumType.STRING)
private CategoryType categoryType; // 예: ROLE, SKILL로 고정된 분류 타입
@Column(nullable = false)
private String name;
}
이 코드만 놓고 보면 Category는
관리자가 미리 정의해 둔 카테고리 집합에서 로드맵이 하나를 선택해서 매핑된다는 설계에 잘 맞는 구현입니다.
지금 단계(LXP MVP)에서는 “최소한의 Category 어그리게이트”로 적절한 선택이었습니다.
LXP의 특성상, 구현 이후 이런 생각들이 따라왔습니다.
이미 Category를 어그리게이트로 분리해 두었기 때문에
“나중에 이 도메인의 라이프사이클이 이렇게까지 바뀔 수도 있겠다”를 더 고민해 볼 수 있었습니다.
카테고리 도메인을 정리하면서 스스로 세운 기준은 대략 이런 느낌이었습니다.
@Entity
@Table(name = "category")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Category extends IdEntity {
@Enumerated(EnumType.STRING)
private CategoryType categoryType;
@Column(nullable = false)
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Category parent; // 상위 카테고리
@OneToMany(mappedBy = "parent")
private List<Category> children = new ArrayList<>(); // 하위 카테고리 리스트
...
}
그리고 유저 커스텀 카테고리까지 상상한다면,
전역 Category와는 별도 어그리게이트로 이렇게 분리하는 선택도 가능할 것 같습니다.
// 전역 Category는 그대로 두고, 사용자 전용 커스텀 카테고리는 별도 애그리게이트로 분리
@Entity
@Table(name = "user_category")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class UserCategory extends IdEntity {
@Column(nullable = false)
private Long userId; // 카테고리를 만든 사용자
@Column(nullable = false)
private String name; // 사용자가 지정한 커스텀 이름
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "base_category_id")
private Category base;
}
지금 프로젝트에서 여기까지 구현한 것은 아니지만,
“지금은 리스트처럼 보이는 도메인도, 나중에는 구조와 책임이 달라질 수 있다.”는 걸 더 민감하게 느끼게 해 준 도메인이었습니다.
그래서 다음에는 설계 단계에서부터
를 먼저 적어두고 시작해 보자는 기준을 갖게 되었습니다.
도메인 자체에 대한 고민과 함께, 이번 프로젝트에서는 협업 과정에서의 저 자신의 태도에 대해서도 돌아보게 되었습니다.
프로젝트를 진행하면서 아래 네 가지가 계속 마음에 남았습니다.
이걸 Before / After로 나눠 보니 더 선명해졌습니다.
| 항목 | Before | After |
|---|---|---|
| 질문 | “조금만 더 고민해보고 그래도 안 되면 물어보자” | 설계 단계에서 이해 안 되는 부분은 바로 질문하고, 논의에 적극적으로 참여하기 |
| PR | 한 번에 몰아서, 덩치 큰 PR | 작은 단위로 자주 올리고, 중간 진행 상황 공유용 도구로 PR을 인식 |
| 테스트 | “일단 통과하는 테스트” 위주, 목데이터는 대충 | 도메인 시나리오를 설명하는 문서에 가깝게 보고, 실제 도메인을 대표하는 목데이터를 고민 |
그리고 중간중간 받았던 팀원분들의 이해도 체크와 조언들이 큰 도움이 되었습니다 🙇🏻♀️
지금 이 설계를 어떻게 이해하고 있는지와 같은 팀원들의 질문이 오히려 제 사고를 정리하게 만든 계기가 되었습니다.
다시 한 번 팀원분들에게 너무 감사드립니다 😊
도메인별 모듈이 생기면서, 모듈 간 통신 방식도 함께 고민하게 되었습니다.
presentation → application → domain → infra 방향의 의존을 유지하고,제가 맡았던 Category/Star 도메인은 Roadmap 도메인의 상태 변화에 영향을 받는 쪽(consumer)에 더 가까웠습니다.
예를 들어,
기존이라면 그냥 Roadmap 서비스를 참조해 와서,
하지만 모듈별로 나뉜 구조와 DDD 관점을 함께 보면서, 한 번 더 이런 질문을 던지게 되었습니다.
Star/Category가 정말 Roadmap 서비스에 직접 의존해야 하는가?
아니면 “로드맵이 생성되었다”, “로드맵이 비공개로 전환되었다” 같은
로드맵 도메인 이벤트를 수신하는 쪽으로 두는 게 더 자연스러운가?
이번 프로젝트에서는 모든 흐름을 완전히 이벤트 기반으로 만들지는 못했지만,
실제로 Star 모듈이 다른 도메인의 이벤트를 구독해서 반응하는 구조는 일부 구현해 볼 수 있었습니다.
예를 들어, Star 모듈에서는 다음과 같이
UserAccountStatusUpdated)RoadMapEventOccurred)를 구독해서 별점 정보를 정리합니다.
@Slf4j
@Component
@RequiredArgsConstructor
public class StarRoadMapEventHandler {
private final StarRoadMapCommandService starRoadMapCommandService;
private final RoadMapQueryService roadMapQueryService;
// 유저 상태 변경 이벤트는 별도 정리 (예: BLOCKED → 별점 삭제)
// ...
/**
* 로드맵 도메인에서 "삭제됨" 이벤트가 발생하면
* → 그 로드맵에 달린 별점들을 정리한다.
*/
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handlerRoadMapEventOccurred(RoadMapEventOccurred event) {
if (event.eventType() == EventType.DELETED) {
log.info("RoadMap deleted → delete stars, roadmapId={}", event.roadMapId());
// 실제 코드에서는 삭제 가능 상태 체크를 위해 RoadMapView 를 한 번 더 확인
RoadMapView roadMapView = roadMapQueryService.findById(event.roadMapId());
starRoadMapCommandService.deleteAllStarByRoadMapId(roadMapView.id());
}
}
}
이 코드 덕분에 Star 모듈은
UserService 나 RoadMapService 에 직접 의존하지 않고,아직 전체를 이벤트 기반으로 재구성한 단계는 아니지만,
“무슨 일이 일어났는가(What happened?)”를 기준으로 모듈을 연결하는 연습을 해볼 수 있었다는 점이 의미 있었습니다.
RoadMapDeleted 나 RoadMapVisibilityChanged 처럼 의미가 더 분명한 이벤트로 분리하고, 그 안에 “삭제 가능한 상태인지”, “어떤 사유로 삭제/비공개가 되었는지” 등의 정보를 함께 담는 방향도 고려해 볼 수 있겠다고 느꼈습니다.👀 도메인 이벤트 흐름 보기

이번 프로젝트를 하면서 DTO / Projection / Read Model의 역할을 한 번 정리하게 되었습니다.
📌 자세히 보기: DTO / Projection / Read Model 간단 정리
조회 요구사항이 다양하다 보니,
를 고민하게 되었고, 그 과정에서 조회 전용 모델도 도메인 관점에서 설계할 수 있다는 관점을 조금이나마 갖게 되었습니다.
질문을 적극적으로 하지 못했던 부분 때문에,
이해하지 못한 상태로 개발을 시작해 막막함을 느끼는 순간이 있었습니다.
정한 기간 안에 PR 리뷰와 머지를 지키지 못한 부분이
팀 전체 흐름에 작게나마 영향을 주었다고 느꼈습니다.
테스트를 급하게 작성하면서 목데이터를 신경 쓰지 못한 부분은
“테스트를 신뢰할 수 있는가?”라는 관점에서 아쉬움으로 남았습니다.
설계 단계에서 더 적극적으로 참여했다면,
나중에 구현 단계에서 느꼈던 막막함을 줄일 수 있었을 것 같다는 생각도 들었습니다.
LXP라는 도메인 위에서 처음으로 DDD를 적극적으로 적용해 보면서,
도메인 중심으로 설계하고 코드를 바라보는 경험을 했습니다.
Notion, GitHub Discussion, 코드 리뷰를 통해
서로가 성장할 수 있도록 기록하고 리뷰하는 문화를 함께 만들어 갔다는 점이 좋았습니다.
바운디드 컨텍스트, 어그리게이트, 도메인 이벤트, 도메인 기반 패키지 구조,
DTO/Projection/Read Model 등 책에서만 보던 개념들을 실제 코드에 녹여 보면서
“아 이래서 이런 개념이 필요하구나”를 몸으로 이해하기 시작했습니다.
이전 프로젝트 회고에서 다음과 같은 세 가지 목표를 적어둔 적이 있습니다.
이번 팀 프로젝트에서 이 세 가지 목표를 실제로 얼마나 실천해 봤는지 점검해보았습니다.
| 목표 | 달성도 | 회고 |
|---|---|---|
| 코드 리뷰 문화 | ✅ | PR 단위 축소 및 리뷰 사이클 정착 성공. (규칙 구체화 필요) |
| 논의 본질 유지 | 🟡 | Notion 기록으로 논의 발산은 막았으나, 타임박싱은 미흡. |
| 성장 분위기 | ✅ | "비난"이 아닌 "제안"하는 리뷰 문화 정착. |
이번 프로젝트를 계기로, 다음과 같은 목표를 갖게 되었습니다.
DDD를 한 번 써봤다고 해서, “이제 DDD를 안다”고 말할 수 있는 단계는 아니라고 생각합니다.
하지만 이번 경험을 통해,
DDD는 정답이 정해진 규칙이 아니라, 복잡성과 싸우기 위한 하나의 사고방식이다.
라는 문장에는 조금 더 자신 있게 동의하게 되었습니다.
긴 글 읽어주셔서 감사합니다 🙌