DDD Tactical Design Tips
1. Aggregate / Entity / Value Object 구분
판별 기준
| 구분 | 식별자 | 동등성 | 예시 |
|---|---|---|---|
| Aggregate Root / Entity | 있음 | 식별자 기준 | User, ChatRoom, ChatMessage |
| Value Object | 없음 | 값 기준 | UserProfile, Location, Participant |
예시
userId자체는 다른 Aggregate(User)의 식별자를 참조하는 것일 뿐,Participant자체의 식별자가 아님roomId도 갖지 않음 →ChatRoom내부 컬렉션의 원소이므로 컨텍스트상 자명- 상태(
lastReadAt,leftAt) 변경 시copy()로 새 값을 만들어 “교체”하는 방식 → VO 특성 유지 - Entity의 속성을 ValueObject로 할지 별도의 Entity로 할지는 식별자 여부 및 속성을 개념적으로 묶을 수 있는지, 데이터의 생명주기가 어떠한지를 고려
- 데이터의 생명주기가 다르면 별도의 Aggregate로 분리.
data class Participant(
val userId: UserId,
val lastReadAt: Instant? = null,
val leftAt: Instant? = null,
)
// 변경은 교체 방식
fun markAsRead(userId: UserId, at: Instant) {
val index = _participants.indexOfFirst { it.userId == userId }
_participants[index] = _participants[index].copy(lastReadAt = at)
}
Aggregate 간 참조는 ID로만
class ChatMessage private constructor(
val id: ChatMessageId,
val roomId: ChatRoomId, // ← ChatRoom 전체가 아닌 ID만 참조
val senderId: UserId, // ← User 전체가 아닌 ID만 참조
...
)
2. 트랜잭션 경계
원칙
- 하나의 트랜잭션 = 하나의 Aggregate
@org.springframework.transaction.annotation.Transactional사용 (jakarta 아님)- Spring 전용이라
readOnly,rollbackFor,propagation등 옵션이 풍부함
- Spring 전용이라
- Transactional 어노테이션이
Aggregate에 있어야 한다는 뜻이 아님. Applicaiton Service 에서 여러 Aggregate를 수정할 경우 설계를 다시 확인해야 할 필요가 있음.
어디에 붙이나
| 위치 | @Transactional |
|---|---|
| Application Service (UseCase 구현체) | ✅ |
| Domain Service | ❌ |
| Domain Model (Aggregate) | ❌ |
| Repository interface | ❌ |
| Controller | ❌ |
@Service
@Transactional
class SubmitUserProfileService(...) : SubmitUserProfileUseCase { ... }
@Service
@Transactional(readOnly = true) // 조회 전용 최적화
class GetUserProfileService(...) { ... }
여러 Aggregate를 변경해야 할 때
- 같은 트랜잭션에 묶지 않고 도메인 이벤트로 분리
- 단, 같은 BC 내에서 강한 일관성이 필요하면 실용적으로 허용하는 경우도 있음 (트레이드오프)
3. 도메인 이벤트
흐름
도메인 객체 → registerEvent() 로 내부 목록에 쌓기 (Spring 의존 없음)
↓
Application Service → 저장 성공 후 pullEvents() 로 꺼내고 비우기
↓
이벤트 발행의 경우 상황에 맞게 Application 내부 event 혹은 외부 kafaka, message queue 등 선택하여 발행
↓
이벤트 발행 구현에 맞춰서 구독 처리.
내부 이벤트의 경우 @EventListener / @TransactionalEventListener 에서 처리
AggregateRoot 베이스 클래스
abstract class AggregateRoot {
private val domainEvents = mutableListOf<DomainEvent>()
protected fun registerEvent(event: DomainEvent) {
domainEvents.add(event)
}
fun pullEvents(): List<DomainEvent> {
val events = domainEvents.toList()
domainEvents.clear()
return events
}
}
DomainEvent — occurredAt은 생성 시점에 고정
// ❌ getter로 매번 다른 시간이 나옴
interface DomainEvent {
val occurredAt: Instant
get() = Instant.now()
}
// ✅ 생성 시점 값으로 고정
interface DomainEvent {
val occurredAt: Instant
}
data class UserCreated(
val userId: UserId,
override val occurredAt: Instant = Instant.now(),
) : DomainEvent
왜 저장 후에 발행하는가
// ✅ 저장 성공 후 발행해야 데이터 정합성 보장
user.updateProfile(...)
userRepository.save(user)
user.pullEvents().forEach { eventPublisher.publishEvent(it) }
리스너 종류
| 애노테이션 | 트랜잭션 | 용도 |
|---|---|---|
@EventListener | 같은 트랜잭션 | 실패 시 전체 롤백되어야 하는 연장선상 로직 |
@TransactionalEventListener(AFTER_COMMIT) | 커밋 후 | 외부 알림, 이메일 등 |
@TransactionalEventListener(AFTER_COMMIT) + @Transactional(REQUIRES_NEW) | 별도 트랜잭션 | 다른 Aggregate 생성/수정 |
@Component
class ReportEventListener(
private val userRepository: UserRepository,
) {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun handle(event: ReportResolved) {
// 별도 트랜잭션으로 User 조치
}
}
4. createdAt / updatedAt 관리 위치
원칙
| 시간 필드 | 관리 주체 | 이유 |
|---|---|---|
createdAt, updatedAt | JPA Auditing (@CreatedDate, @LastModifiedDate) | 비즈니스 규칙이 아닌 기술적 메타데이터 |
sentAt, resolvedAt 등 비즈니스 의미 있는 시간 | 도메인이 직접 관리 | 정렬·표시 등에 쓰이는 도메인 개념 |
// 공통 Auditing 베이스
@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
abstract class BaseJpaEntity {
@CreatedDate
@Column(updatable = false)
var createdAt: Instant? = null
@LastModifiedDate
var updatedAt: Instant? = null
}
@Configuration
@EnableJpaAuditing
class JpaAuditingConfig
// 도메인은 비즈니스 의미 있는 시간만 보유, Instant.now() 직접 호출보다 파라미터로 받는 게 테스트하기 좋음
fun delete(now: Instant = Instant.now()) {
deleted = true
deletedAt = now
}
5. 검증(Validation)의 계층별 책임
| 계층 | 검증 내용 | 예시 |
|---|---|---|
| Presentation (DTO) | 형식, 길이, null 여부 | @field:NotBlank, @field:Size |
| Domain (Aggregate / VO) | 외부 의존 없는 비즈니스 규칙 | 닉네임 길이, 좌표 범위 |
| Domain Service | 외부 조회가 필요한 비즈니스 규칙 | 닉네임 중복 검사 (NicknameValidator) |
// Presentation — 형식 검증
data class SubmitUserProfileRequest(
@field:Size(min = 2, max = 10, message = "닉네임은 2~10자여야 합니다")
val nickname: String,
)
// Domain VO — 자체 검증
class UserProfile(val nickname: String, ...) {
init {
require(nickname.length in 2..10)
}
}
// Domain Service — 외부 의존 필요한 검증
class NicknameValidator(private val userRepository: UserRepository) {
fun validate(nickname: String) {
check(!userRepository.existsByNickname(nickname))
}
}
RequestBody DTO vs Command 분리 이유
- DTO는 presentation 관심사 (Jackson 역직렬화 대상)
- Command는 application 관심사 (UseCase 입력)
- 같이 쓰면 Command가 Spring/Jackson에 오염됨
// presentation/dto/SubmitUserProfileRequest.kt
data class SubmitUserProfileRequest(val nickname: String, val profileImageUrl: String?)
// app/dto/SubmitUserProfileCommand.kt
data class SubmitUserProfileCommand(val userId: UserId, val nickname: String, val profileImageUrl: String?)
URL 타입 필드 — String + 검증이 실무적으로 더 많이 쓰이는 이유
java.net.URL은 Jackson 역직렬화 실패 시 메시지를 우리가 제어할 수 없음- DNS 조회 등 성능 이슈 가능성
- S3 URL만 허용 등 비즈니스 규칙 추가가
@Pattern으로 더 유연함 - 팀 컨벤션 일관성 (모든 필드를 String + Bean Validation으로 통일)
6. HTTP 상태코드와 예외 체계
기준
| 상태코드 | 의미 |
|---|---|
| 400 | 요청 자체 오류 (형식, 비즈니스 규칙 위반, 입력값 오류) |
| 401 | 인증 실패 (로그인 필요, 토큰 무효) |
| 403 | 인가 실패 (권한 없음) |
| 404 | Path/리소스 자체가 없음 |
| 409 | 상태 충돌 (이미 존재, 이미 처리됨) |
Path Variable로 받은 ID가 없을 때
GET /users/{userId} → 해당 userId 없음 → 404
JWT에서 꺼낸 userId로 조회했는데 없을 때
이미 인증된 토큰인데 DB에 없음 = 데이터 불일치
→ 404보다 401(Unauthorized)이 더 자연스러움
Request Body로 받은 id로 연관 리소스를 조회했는데 없을 때
보통 400으로 처리 (path variable이 아니라 body 값이므로)
예외 클래스 구조 (shared/exception/)
ErrorCode (interface)
BaseException (sealed class, RuntimeException 상속, Spring 무관)
├── BadRequestException (400)
├── UnauthorizedException (401)
├── ForbiddenException (403)
├── NotFoundException (404)
├── ConflictException (409)
└── InternalServerException (500, writableStackTrace=true)
- 4xx는
writableStackTrace=false로 성능/로그 최적화 - 5xx만 스택트레이스 포함
7. Domain Service vs Application Service
| 구분 | 역할 | Spring 의존 |
|---|---|---|
| Domain Service | 어느 Entity에도 자연스럽게 속하지 않는 도메인 규칙 (보통 외부 조회 필요) | 없음 (순수 Kotlin) |
| Application Service | UseCase 구현, 트랜잭션 관리, 조율 | 있음 (@Service, @Transactional) |
// Domain Service — 닉네임 중복 검사 (다른 User 조회 필요)
class NicknameValidator(
private val userRepository: UserRepository,
) {
fun validate(nickname: String) {
require(nickname.length in 2..10)
check(!userRepository.existsByNickname(nickname))
}
}
8. UseCase 파라미터 — Command가 항상 필요한 건 아님
| 파라미터 개수 | 권장 |
|---|---|
| 1~2개 | 직접 파라미터 |
| 3개 이상 / 확장 가능성 있음 | Command 클래스 |
// 단순하면 직접 파라미터
interface LogOutUseCase {
fun execute(userId: UserId)
}
// 필드가 여럿이면 Command
data class SubmitUserProfileCommand(
val userId: UserId,
val nickname: String,
val profileImageUrl: String?,
)
9. Controller 설계
- 하나의 Controller가 같은 도메인의 여러 UseCase를 가지는 것은 자연스러움
- 같은 path(
/users등)를 여러 Controller로 나누는 것도 가능 (CQRS 스타일)UserQueryController(/users, GET만)UserCommandController(/users, POST/PUT/DELETE만)
- UseCase가 5개 이상으로 늘어나면 Controller 분리를 고려
10. Bounded Context 분리
같은 단어가 BC마다 다른 의미/속성을 가지면 분리 신호
User라는 사용자- 인증 에서는 “로그인 자격을 가진 주체”
- 주문 에서는 “배송지를 가진 구매자”
- 고객 지원 에서는 “문의 이력을 가진 상담 대상”
변경 이유가 다른가
- 한 모듈이 여러 이유로 변경된다면 분리 신호
- 기능 요구사항이 바뀔 때 같이 수정되야 하는 관심사의 경우 같은 Bounded Context로 묶여 있음.
- 서로 다른 팀/이해관계자의 요구로 변경이 되는 경우 Bounded Context를 분리해야함
워크플로우/상태전이의 성격이 다른가
- 워크플로우 패턴 자체가 이질적이면 -> 하나의 BC안에 욱여넣으면 상태/로직이 뒤섞임 -> 분리해서 각자의 라이프사이클을 명확히 포현해야함.
주체(Actor)가 다른가
- 일반 사용자가 다루는 영역 vs 관리자/운영자가 다루는 영역
- 권한 체계, 접근 경로, UI/API 노출 방식이 본질적으로 다름
- 도메인 모델도 그에 맞춰 분리되는 경우가 많음
데이터의 폴리모픽/대상 비결정성
한 도메인이 여러 종류의 다른 Aggregate를 “대상”으로 가리켜야 하는 경우
예시 예를 들어 신고(Report)는 User를 신고할 수도, Help를 신고할 수도, Review를 신고할 수도, ChatMessage를 신고할 수도 있음
잘못된 접근
class Report(
val targetUser: User?,
val targetHelp: Help?,
val targetReview: Review?,
val targetChatMessage: ChatMessage?,
)
/**
* 이러한 문제가 생김
* 1. Report가 User, Help, Review, ChatMessage 4개 BC를 전부 알아야 함
* → Report의 의존성이 폭발적으로 늘어남
*
* 2. 새로운 신고 대상(예: Comment)이 추가될 때마다
* → Report 도메인 자체를 수정해야 함
* → Report가 "닫혀있지 않은" 구조가 됨 (OCP 위반)
*
* 3. 4개 BC 중 하나라도 변경되면
* → Report도 영향을 받을 가능성이 생김
* → 본래 무관해야 할 도메인끼리 결합됨
*/
해결
class Report(
val targetType: ReportTargetType, // USER, HELP, REVIEW, CHAT_MESSAGE
val targetId: UUID, // 타입 무관하게 ID만
)
/**
* Report는 USER인지 HELP인지 "타입"만 알 뿐
* 실제 User 도메인, Help 도메인의 구조는 전혀 모름
*
* → Report가 다른 BC를 참조하는 게 아니라
* "느슨한 포인터(targetType + targetId)"만 들고 있음
*/
한 도메인이 “내가 어떤 타입을 다루게 될지 미리 알 수 없는” 패턴을 가진다는 것 자체가
- 그 도메인이 다루는 대상들과 본질적으로 다른 레벨의 추상화에 있다는 뜻
- Report는 “신고라는 행위/프로세스”를 다루는 도메인이고 User, Help, Review는 “실제 도메인 콘텐츠”를 다루는 도메인
- 둘은 관심사의 층위가 다름 -> 분리되어야 함 반대로 생각해보면 만약 Report가 Help만 신고할 수 있다면 (다른 대상이 없다면)
- 굳이 폴리모픽하게 만들 필요가 없음
- Help BC 안의 Aggregate로 둬도 무방했을 수 있음
폴리모픽 참조 패턴을 쓰면 자연스럽게 의존성이 역전됨(의존성 역전)
Before (직접 참조 시도) Report → User, Help, Review, ChatMessage (전부 알아야 함)
After (ID + 타입 패턴) Report → 아무것도 모름 (target_type, target_id만 보유)
Help/Review가 신고 생성을 요청할 때 Help → Report (CreateReportUseCase 호출, snapshot 함께 전달) Review → Report
- 의존 방향이 “콘텐츠 도메인 → Report” 단방향으로 정리됨
- Report는 누구에게도 의존하지 않는 독립적인 BC가 됨
“여러 종류의 대상을 무차별적으로 가리켜야 한다”는 요구사항 자체가 이미 Report를 독립된 개념으로 분리해야 한다는 증거
정리
특정 도메인이 자신의 대상 타입을 미리 알 수 없고 범용적으로 참조해야 한다면 (target_type + target_id 패턴)
- 그 도메인을 참조 대상이 되는 도메인들과 분리
- 대상 도메인들이 거꾸로 이 도메인을 알 필요 없게 만듦 (의존성 역전)
트랜잭션/일관성 요구 수준이 다른가
강한 일관성(즉시 일관)이 필요한 영역 vs 최종적 일관성(eventually consistent)으로 충분한 영역
- 즉시 일관성이 필요 없는 관계라면 분리해서 도메인 이벤트로 느슨하게 연결하는 게 자연스러움
의존성 방향이 한쪽으로만 흐르는가
분리했을 때 의존성이 자연스럽게 단방향이 되는지 확인
- A → B만 참조하고 B는 A를 모르는 구조가 만들어지면 -> 분리가 적절했다는 신호
분리했는데도 서로가 서로를 양방향으로 알아야 한다면 -> 잘못 자른 것일 수 있음 (응집되어야 할 것을 나눈 경우)
팀/조직 경계와 일치하는가 (Conway’s Law 관점)
이 영역을 누가 소유하고 책임지는가
서로 다른 팀이 독립적으로 배포/결정해야 하는 영역이라면 -> BC로 분리해야 조직 구조와 코드 구조가 어긋나지 않음
- 작은 팀이라면 이 기준의 무게는 작아지고, 위 1~7번 기준이 더 중요해짐
분리하지 않아도 되는 신호 (반대 기준)
- 항상 같은 트랜잭션에서 일관되게 변경되어야 함 (강한 결합 필요)
- 같은 용어, 같은 의미로 일관되게 쓰임
- 변경 이유가 항상 같음 (하나가 바뀌면 다른 하나도 항상 같이 바뀜)
- 분리하면 오히려 양방향 참조나 중복 로직이 생김
분리 이유
| Review | Report | |
|---|---|---|
| 대상 | Help 완료 후 매칭된 유저 간 평가 | User/Help/Review/ChatMessage 등 폴리모픽 대상 |
| 주체 | 일반 유저 | 일반 유저 + 관리자 |
| 워크플로우 | 생성 후 끝 | 대기 → 검토 → 조치 (상태 머신) |
| 성격 | 핵심 비즈니스 도메인 | 운영/Trust & Safety 영역 |
분리하면 Report → Review 단방향 참조만 존재하고, Review가 Report를 알 필요 없음.
같은 BC 안에서 다른 Aggregate인 경우 (review BC: Review, Report)
- 각자 독립적인 Repository
- 트랜잭션은 여전히 Aggregate 단위로 분리 (Review UseCase ≠ Report UseCase)
- Controller도 권한 단위로 분리
11. 다른 BC의 정보 조회 (같은 서버/모놀리스 기준)
비교
| 방법 | 결합도 | 데이터 신선도 | 적합한 경우 |
|---|---|---|---|
| 도메인 이벤트 + 데이터 복제 | 낮음 | 지연 가능 | 자주 안 바뀌는 데이터 (닉네임 등) |
| ACL 포트 (인터페이스 + Adapter) | 중간 | 실시간 | 쓰기(Command) 흐름에서 다른 BC 데이터 필요 시 |
| 쿼리 레벨 직접 JOIN | 높음 | 실시간 | 읽기 전용(Query) 조회, 성능 중요할 때 |
ACL 포트 패턴
// chat BC가 정의한 포트
interface UserPort {
fun findById(userId: UserId): ChatUserInfo?
}
// infra에서 구현 — 같은 서버라 iam의 Repository 직접 참조
@Component
class UserAdapter(
private val userRepository: UserRepository,
) : UserPort {
override fun findById(userId: UserId) =
userRepository.findById(userId)?.toChatUserInfo()
}
읽기 전용 — Querydsl/Native Query로 직접 JOIN
queryFactory
.select(Projections.constructor(ChatRoomSummary::class.java, ...))
.from(chatRoomJpaEntity)
.join(userJpaEntity) // ← iam BC 테이블 직접 JOIN
.on(userJpaEntity.id.eq(participantEmbeddable.userId))
.fetch()
Projections.constructor로 충분,@QueryProjection+ Q클래스는 빌드 복잡도만 늘어남 (필요할 때만 사용)
결론 (모놀리스 기준)
쓰기 → ACL 포트로 도메인 보호하면서 다른 BC 접근
읽기 → BC 경계 넘어 직접 JOIN 허용 (CQRS 관점에서 읽기는 덜 엄격해도 됨)
참고 — 실제 MSA에서는
| 방법 | 설명 |
|---|---|
| API 호출 (HTTP/gRPC) | 항상 최신, but 네트워크 지연·장애 전파 위험 |
| 이벤트 기반 데이터 복제 (Kafka 등) | 장애 격리, but 동기화 지연 |
| BFF/API Gateway 조합 | 클라이언트단에서 여러 서비스 응답 조합 |
12. Aggregate 설계 예시 — Chat 모듈
ChatRoom / ChatMessage — 별도 Aggregate인 이유
- 생명주기 독립 (메시지는 무한히 생성됨)
ChatRoom이 메시지 컬렉션을 직접 보유하면 안 됨 (메모리 폭탄)ChatMessage는roomId로만ChatRoom을 참조
class ChatRoom private constructor(
val id: ChatRoomId,
val helpId: HelpId,
private val _participants: MutableList<Participant>, // VO 컬렉션
var status: ChatRoomStatus,
) {
fun isParticipant(userId: UserId): Boolean =
_participants.any { it.userId == userId }
}
class ChatMessage private constructor(
val id: ChatMessageId,
val roomId: ChatRoomId, // ID만 참조
val senderId: UserId,
val type: MessageType,
val content: String,
val sentAt: Instant, // 비즈니스 의미 있는 시간 → 도메인이 관리
)
lastMessageAt을 ChatRoom에 두지 않는 이유
- 두면 ChatMessage 저장 + ChatRoom 갱신이 한 트랜잭션에 묶여 “하나의 트랜잭션 = 하나의 Aggregate” 원칙 위반
- 대안: 채팅방 목록 조회 시
findLastByRoomId()같은 쿼리로 조회 시점에 가져옴 (트랜잭션 경계 문제 자체가 발생하지 않음)
JPA 매핑 — @ElementCollection
Participant가 VO이고 참여자 수가 적다면(1:1 채팅) @ElementCollection + @Embeddable로 충분
@Embeddable
data class ParticipantEmbeddable(
val userId: UUID,
var lastReadAt: Instant? = null,
var leftAt: Instant? = null,
)
@Entity
class ChatRoomJpaEntity(
@Id val id: UUID,
@ElementCollection
@CollectionTable(name = "chat_room_participant", joinColumns = [JoinColumn(name = "room_id")])
val participants: MutableList<ParticipantEmbeddable> = mutableListOf(),
) : BaseJpaEntity()
- 단점: Hibernate가 컬렉션 변경 시 delete-all-reinsert 방식으로 동작 → 참여자 수 적고 변경 빈도 낮으면 무방
- 참여자 많고 변경 빈도 높으면 기술적 PK를 가진 별도
@Entity로 분리 (단, 도메인Participant는 여전히 VO로 유지)
ChatMessage ↔ ChatRoom 연관관계 — @ManyToOne보다 단순 UUID 컬럼
@Entity
class ChatMessageJpaEntity(
@Id val id: UUID,
val roomId: UUID, // @ManyToOne 없이 단순 FK 컬럼
val senderId: UUID,
...
) : BaseJpaEntity()
@OneToMany(ChatRoom → Messages)는 절대 금지 — 메시지가 무한 증가하는 구조에서 메모리 문제- 단순 UUID 컬럼이어도 Querydsl/Native Query로 JOIN 가능 —
@ManyToOne이 JOIN의 필수조건은 아님 - 단, 단순 UUID 컬럼은 DB 레벨 외래키 제약조건이 생기지 않음 → 참조 무결성은 애플리케이션(UseCase에서 사전 조회)이 책임
13. 한눈에 보는 원칙 요약
- 1 트랜잭션 = 1 Aggregate
- Aggregate 간 참조 = ID 값 객체로만
- 도메인 이벤트 = registerEvent → pullEvents → publishEvent (저장 성공 후)
- createdAt/updatedAt = JPA Auditing
- 비즈니스 의미 있는 시간 = 도메인이 직접 관리 (파라미터로 주입 권장)
- 검증 책임 = Presentation(형식) / Domain(자체 규칙) / Domain Service(외부 조회 필요 규칙)
- 같은 BC, 다른 Aggregate = Repository/UseCase 독립 유지
- BC 분리 기준 = 생명주기, 워크플로우, 주체(유저 vs 관리자)가 다르면 분리
- 다른 BC 데이터 조회 = 쓰기는 ACL 포트, 읽기는 직접 JOIN 허용