DDD · Tactical Design

Aggregate 설계하기:
트랜잭션 경계와 불변식

Aggregate는 관련 데이터를 모아둔 폴더가 아니다 — 그것은 트랜잭션의 경계이자 불변식(invariant)의 소유자다. 경계를 잘못 그으면, 서로를 몰라도 될 모델들이 저장할 때마다 협상을 벌이게 된다.

Bounded Context의 경계를 정했다면, 다음 질문은 그 안에서 무슨 일이 벌어지는가다. 여기가 바로 전술적 설계(tactical design)의 영역이다: Aggregate, Entity, Value Object, Domain Event. 이 중에서도 Aggregate Root를 어떻게 정할지가 가장 중요한데, 트랜잭션 크기, 락 경합, 그리고 저장 한 번에 알아야 하는 다른 객체가 몇 개인지를 조용히 결정짓는 요소이기 때문이다.

Aggregate Root의 역할

Aggregate Root는 비즈니스 규칙과 불변식을 캡슐화한다. 외부의 그 무엇도 그 내부 상태를 직접 바꾸지 못한다 — 변경은 항상 자신의 도메인 메서드를 거쳐야 하고, 불변식이 깨지면 나중에 어딘가 다른 곳이 아니라 바로 그 메서드 안에서 즉시 예외가 발생해야 한다.

// domain/OrderException.kt
sealed class OrderException(message: String) : RuntimeException(message)
class OrderMustHaveItemsException : OrderException("An order must have at least one item.")
class OrderAlreadyCancelledException : OrderException("This order has already been cancelled.")
class PaidOrderCannotBeCancelledException : OrderException("A paid order cannot be cancelled.")

// domain/Order.kt — private constructor() + companion object factory, no framework import.
enum class OrderStatus { PENDING, PAID, CANCELLED }

class Order private constructor() {
    var orderId: String = ""
        private set

    var userId: String = ""
        private set

    var items: List<OrderItem> = emptyList()
        private set

    var status: OrderStatus = OrderStatus.PENDING
        private set

    private val domainEvents: MutableList<Any> = mutableListOf()

    companion object {
        fun create(orderId: String, userId: String, items: List<OrderItem>, status: OrderStatus): Order {
            if (items.isEmpty()) throw OrderMustHaveItemsException()
            return Order().apply {
                this.orderId = orderId
                this.userId = userId
                this.items = items
                this.status = status
            }
        }
    }

    fun pullDomainEvents(): List<Any> = domainEvents.toList().also { domainEvents.clear() }

    fun cancel(reason: String) {
        if (status == OrderStatus.CANCELLED) throw OrderAlreadyCancelledException()
        if (status == OrderStatus.PAID) throw PaidOrderCannotBeCancelledException()
        status = OrderStatus.CANCELLED
        domainEvents += OrderCancelledEvent(orderId, reason, LocalDateTime.now())
    }
}

Application Service는 비즈니스 로직을 스스로 수행하지 않는다 — Aggregate의 메서드에 위임할 뿐, 그 이상은 아니다. Command Service 안에서 도메인에 관한 if 문을 쓰고 있다면, 그 로직은 거의 확실히 한 계층 아래로 내려가야 한다.

다른 Aggregate는 객체가 아니라 반드시 ID로 참조하라

트랜잭션 경계는 Aggregate Root 단위로 설정된다 — 트랜잭션 하나당 오직 하나의 Aggregate만 변경된다. 이는 Aggregate들이 서로를 직접 객체 참조로 붙들고 있지 않을 때만 가능하다. OrderuserId: String을 가질 뿐, User 객체를 갖지 않는다. 객체를 직접 참조하면 ID로 참조할 때는 생기지 않을 결합이 생긴다: Aggregate 하나를 로드할 때 단지 타입을 맞추기 위해 다른 객체들의 그래프까지 줄줄이 로드되는 일이 없어야 한다.

Entity와 Value Object는 같은 계층에 살지만, 계약이 다르다

Entity의 동일성은 고유 식별자로 판단한다 — ID가 같으면 다른 모든 필드가 다르더라도 같은 객체이며, 생성·수정·삭제라는 생명주기를 갖는다. OrderItem처럼 Aggregate 내부의 자식 Entity는 그것을 소유한 Aggregate Root를 통해서만 접근하고 수정할 수 있다.

Value Object는 식별자를 아예 갖지 않는다 — 동일성은 값들의 조합으로 판단하며, 불변(immutable)이다.

// domain/MoneyException.kt
sealed class MoneyException(message: String) : RuntimeException(message)
class InvalidMoneyAmountException : MoneyException("The amount must be 0 or greater.")
class CurrencyMismatchException : MoneyException("The currencies are different.")

// domain/Money.kt — a data class gets equals()/hashCode()/copy() for free, no manual equals() needed.
enum class Currency { KRW, USD }

data class Money(val amount: Long, val currency: Currency) {

    init {
        if (amount < 0) throw InvalidMoneyAmountException()
    }

    fun add(other: Money): Money {
        if (currency != other.currency) throw CurrencyMismatchException()
        return Money(amount + other.amount, currency)
    }
}

객체의 속성만으로 그 의미가 온전히 전달되고 식별자가 필요 없을 때 — 금액, 주소, 좌표 쌍처럼 — 그리고 불변성이 보장돼야 할 때는 Value Object를 선택하라.

경계를 어디에 그을지 정하기

함께 생성되고 함께 삭제되는 객체들, 그리고 불변식을 유지하기 위해 반드시 함께 변경돼야 하는 객체들은 같은 Aggregate로 묶는다 — OrderOrderItem이 그렇다. 아이템이 하나도 없는 주문은 유효한 주문이 아니기 때문이다. 독립적으로 조회되고 수정되며, 한쪽의 변경이 다른 쪽의 불변식에 전혀 영향을 주지 않는다면 별도의 Aggregate로 분리한다 — OrderUser가 그렇다. 주문을 취소해도 사용자 정보에는 아무 영향이 없기 때문이다.

Aggregate가 너무 커졌다는 신호

저장 메서드 하나가 수십 개의 행(row)을 변경한다. 다른 Aggregate를 ID가 아니라 객체로 직접 품고 있다. 낙관적 락(optimistic-lock) 충돌이 자주 발생하기 시작한다. 이 중 하나라도 해당한다면 인덱스를 더 추가할 게 아니라 이음매(seam)를 찾아야 한다는 신호다.

경계가 정말로 애매하다면 작게 시작하라. 두 Aggregate가 프로덕션에서 실제로 어떻게 변화하는지 지켜본 뒤 나중에 합치는 편이, 이미 비대해진 것을 부하가 걸린 상태에서 억지로 쪼개려 애쓰는 것보다 비용이 훨씬 적게 든다.

Aggregate 스스로 자신의 ID를 생성하기

ID는 Domain 계층에서 — Aggregate 자신의 create() 팩토리 안에서 — 생성되며, 항상 서버가 생성할 뿐 클라이언트가 넘겨준 값을 쓰지 않는다. 형식은 하이픈을 제거한 UUID v4, 즉 32자리 16진수 문자열이며 auto-increment 번호가 아니다: 증가하는 ID는 레코드 개수와 생성 순서를 외부에 노출하고, 서비스나 샤드 사이에서 충돌할 수 있으며, DB가 부여하기 전까지는 값이 정해지지 않으므로 Domain 계층이 필요로 하는 시점에 미리 생성해 둘 수가 없다.

// common/GenerateId.kt
import java.util.UUID

fun generateId(): String = UUID.randomUUID().toString().replace("-", "")

// domain/Order.kt
class Order private constructor() {
    var orderId: String = ""
        private set

    var userId: String = ""
        private set

    companion object {
        // Called for a brand-new Order — the ID is generated here.
        fun create(userId: String): Order =
            Order().apply {
                this.orderId = generateId()
                this.userId = userId
            }

        // Called by the Repository implementation when restoring from the DB — the existing ID is passed straight through.
        fun reconstitute(orderId: String, userId: String): Order =
            Order().apply {
                this.orderId = orderId
                this.userId = userId
            }
    }
}

새로 생성할 때는 create()가 스스로 ID를 생성한다. DB에서 복원할 때는 Repository 구현체가 reconstitute()를 호출하며 기존 ID를 그대로 전달한다. 어느 경우든 Repository가 새 ID를 스스로 발급하는 일은 없다 — Aggregate가 이미 갖고 있는 ID를 그대로 사용할 뿐이다.

경계를 점검하는 체크리스트

  • 이 Aggregate를 통한 저장 한 번이 실제 불변식과 무관한 여러 테이블까지 건드리고 있지 않은가?
  • 비즈니스 규칙 하나가, 함께 로드되는 일이 없는 두 Aggregate에 걸쳐 나뉘어 있지 않은가?
  • 이 Aggregate가 다른 Aggregate를 ID가 아니라 객체 참조로 갖고 있지 않은가?
  • 이 Aggregate에서 낙관적 락 충돌이 반복적으로 불평거리가 되고 있지 않은가?
  • 두 Aggregate를 합치면 더 많은 불변식을 하나의 트랜잭션 안에서 증명 가능하게 만들 수 있지 않은가?

이 모든 것은 유일하게 올바른 다이어그램 하나를 찾는 일이 아니다. 규칙을 지키는 단위를 그 규칙이 요구하는 만큼만 정확히 유지하는 일이다 — 관련 없는 데이터를 매번 락에 끌어들이지 않도록 너무 크지 않게, 그리고 그 규칙이 지켜야 할 대상이 우연히 처음 호출한 Service로나 새어나가지 않도록 너무 작지도 않게.

저장소 내 추가 자료

docs/architecture/tactical-ddd.md — Aggregate/Entity/Value Object 설계와 경계 기준 전체 · docs/architecture/aggregate-id.md — ID 생성 규칙과 Repository 처리