Skip to content
메모장
Go back

DDD 전술적 설계 팁

DDD Tactical Design Tips

1. Aggregate / Entity / Value Object 구분

판별 기준

구분식별자동등성예시
Aggregate Root / Entity있음식별자 기준User, ChatRoom, ChatMessage
Value Object없음값 기준UserProfile, Location, Participant

예시

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. 트랜잭션 경계

원칙

어디에 붙이나

위치@Transactional
Application Service (UseCase 구현체)✅
Domain Service❌
Domain Model (Aggregate)❌
Repository interface❌
Controller❌
@Service
@Transactional
class SubmitUserProfileService(...) : SubmitUserProfileUseCase { ... }

@Service
@Transactional(readOnly = true)  // 조회 전용 최적화
class GetUserProfileService(...) { ... }

여러 Aggregate를 변경해야 할 때


3. 도메인 이벤트

흐름

도메인 객체 → registerEvent()로 내부 목록에 쌓는다 (Spring 의존 없음)
     ↓
Application Service → 저장 성공 후 pullEvents()로 꺼내고 비운다
     ↓
이벤트 발행의 경우 상황에 맞게 Application 내부 event 혹은 외부 kafka, 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, updatedAtJPA 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 분리 이유

// 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 + 검증이 실무적으로 더 많이 쓰이는 이유


6. HTTP 상태코드와 예외 체계

기준

상태코드의미
400요청 자체에 오류가 있다 (형식, 비즈니스 규칙 위반, 입력값 오류)
401인증 실패다 (로그인 필요, 토큰 무효)
403인가 실패다 (권한 없음)
404Path/리소스 자체가 없다
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)

7. Domain Service vs Application Service

구분역할Spring 의존
Domain Service어느 Entity에도 자연스럽게 속하지 않는 도메인 규칙을 다룬다 (보통 외부 조회 필요)없다 (순수 Kotlin)
Application ServiceUseCase를 구현하고, 트랜잭션을 관리하고, 조율한다있다 (@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 설계


10. Bounded Context 분리

같은 단어가 BC마다 다른 의미/속성을 가지면 분리 신호다

변경 이유가 다른가

워크플로우/상태전이의 성격이 다른가

주체(Actor)가 다른가

데이터의 폴리모픽/대상 비결정성

한 도메인이 여러 종류의 다른 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)으로 충분한 영역

의존성 방향이 한쪽으로만 흐르는가

분리했을 때 의존성이 자연스럽게 단방향이 되는지 확인한다

분리했는데도 서로가 서로를 양방향으로 알아야 한다면 -> 잘못 자른 것일 수 있다 (응집되어야 할 것을 나눈 경우)

팀/조직 경계와 일치하는가 (Conway’s Law 관점)

이 영역을 누가 소유하고 책임지는가

서로 다른 팀이 독립적으로 배포/결정해야 하는 영역이라면 -> BC로 분리해야 조직 구조와 코드 구조가 어긋나지 않는다

분리하지 않아도 되는 신호 (반대 기준)

분리 이유

ReviewReport
대상Help 완료 후 매칭된 유저 간 평가User/Help/Review/ChatMessage 등 폴리모픽 대상
주체일반 유저일반 유저 + 관리자
워크플로우생성 후 끝대기 → 검토 → 조치 (상태 머신)
성격핵심 비즈니스 도메인운영/Trust & Safety 영역

분리하면 Report → Review 단방향 참조만 존재하고, Review가 Report를 알 필요 없다.

같은 BC 안에서 다른 Aggregate인 경우 (review BC: Review, Report)


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()

결론 (모놀리스 기준)

쓰기 → ACL 포트로 도메인을 보호하면서 다른 BC에 접근한다
읽기 → BC 경계를 넘어 직접 JOIN을 허용한다 (CQRS 관점에서 읽기는 덜 엄격해도 된다)

참고 — 실제 MSA에서는

방법설명
API 호출 (HTTP/gRPC)항상 최신이지만, 네트워크 지연·장애 전파 위험이 있다
이벤트 기반 데이터 복제 (Kafka 등)장애를 격리하지만, 동기화 지연이 있다
BFF/API Gateway 조합클라이언트단에서 여러 서비스 응답을 조합한다

12. Aggregate 설계 예시 — Chat 모듈

ChatRoom / ChatMessage — 별도 Aggregate인 이유

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에 두지 않는 이유

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()

ChatMessage ↔ ChatRoom 연관관계 — @ManyToOne보다 단순 UUID 컬럼을 사용한다

@Entity
class ChatMessageJpaEntity(
    @Id val id: UUID,
    val roomId: UUID,   // @ManyToOne 없이 단순 FK 컬럼
    val senderId: UUID,
    ...
) : BaseJpaEntity()

13. 한눈에 보는 원칙 요약


Share this post:

Previous Post
Database summary
Next Post
DDD 전술적 설계 2(Tactical Design)