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들이 서로를 직접 객체 참조로 붙들고 있지 않을 때만 가능하다. Order는 userId: 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로 묶는다 — Order와 OrderItem이 그렇다. 아이템이 하나도 없는 주문은 유효한 주문이 아니기 때문이다. 독립적으로 조회되고 수정되며, 한쪽의 변경이 다른 쪽의 불변식에 전혀 영향을 주지 않는다면 별도의 Aggregate로 분리한다 — Order와 User가 그렇다. 주문을 취소해도 사용자 정보에는 아무 영향이 없기 때문이다.
저장 메서드 하나가 수십 개의 행(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 처리