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 혹은 외부 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, 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)항상 최신, but 네트워크 지연·장애 전파 위험
이벤트 기반 데이터 복제 (Kafka 등)장애 격리, but 동기화 지연
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)