Skip to content
ioob.dev
Go back

DDD 5편 — Aggregate와 Repository

· 13분 읽기
DDD 시리즈 (5/7)
  1. DDD 1편 — 도메인 중심 설계가 필요한 이유
  2. DDD 2편 — 유비쿼터스 언어와 Bounded Context
  3. DDD 3편 — Context Mapping
  4. DDD 4편 — Entity와 Value Object
  5. DDD 5편 — Aggregate와 Repository
  6. DDD 6편 — Domain Service와 Application Service
  7. DDD 7편 — Domain Event와 Anti-Corruption Layer
Table of contents

Table of contents

모든 걸 한 트랜잭션에 넣고 싶은 유혹

주문 시스템을 만든다고 해보자. 주문이 생성되면 주문 항목이 추가되고, 재고가 차감되고, 결제가 요청되고, 알림이 발송된다. 이 모든 과정이 하나의 트랜잭션 안에서 처리되면 데이터 불일치는 없을 것이다. 하지만 테이블 네다섯 개를 하나의 트랜잭션으로 묶는 순간, 잠금(lock) 범위가 넓어지고 동시 처리량은 바닥을 친다.

그렇다고 각 테이블을 개별 트랜잭션으로 쪼개면 어떻게 될까. 주문은 생성됐는데 재고 차감이 실패한 상태가 발생한다. 일관성(Consistency)과 성능 사이에서 어디에 선을 그을 것인가 — 이 질문에 대한 DDD의 답이 Aggregate다.

Aggregate란

Aggregate는 관련된 객체들을 하나의 단위로 묶어, 그 안에서만 강한 일관성을 보장하는 설계 패턴이다. Eric Evans의 표현을 빌리면, 데이터 변경의 단위다. Aggregate 내부의 객체들은 항상 함께 유효한 상태를 유지해야 하며, 외부에서는 이 묶음의 진입점을 통해서만 접근한다.

핵심은 경계라는 개념이다. Aggregate 경계 안쪽에서는 즉시 일관성(Strong Consistency)을 보장하고, 경계 바깥 — 즉 다른 Aggregate와의 관계 — 에서는 결과적 일관성(Eventual Consistency)을 허용한다. 이 구분이 트랜잭션의 범위를 결정하고, 시스템의 확장성을 좌우한다.

Aggregate Root — 유일한 진입점

Aggregate 안에는 여러 Entity와 Value Object가 공존할 수 있다. 이때 외부와 소통하는 유일한 창구가 Aggregate Root다.

flowchart TB
    subgraph OrderAggregate["주문 Aggregate"]
        direction TB
        Order["Order<br/>(Aggregate Root)"]
        OI1["OrderItem"]
        OI2["OrderItem"]
        OI3["OrderItem"]
        SA["ShippingAddress<br/>(Value Object)"]

        Order --> OI1
        Order --> OI2
        Order --> OI3
        Order --> SA
    end

    Client["외부 코드"] -->|"Order를 통해서만 접근"| Order
    Client -.->|"직접 접근 금지"| OI1

    style Order fill:#4a90d9

외부 코드가 OrderItem을 직접 생성하거나 수정하는 것은 허용되지 않는다. 반드시 Order를 통해야 한다. 이유는 분명하다. Order가 전체 상태의 유효성을 보장하는 책임을 지기 때문이다. 예를 들어 주문 항목이 최소 1개 이상이어야 한다는 규칙을 OrderItem 스스로 지킬 방법은 없다. Order만이 자신의 항목 목록을 보고 이 규칙을 검증할 수 있다.

Aggregate Root가 유일한 진입점이라는 원칙은 세 가지를 함의한다.

  1. 외부 객체는 Aggregate Root만 참조한다. 내부 Entity의 레퍼런스를 밖으로 꺼내지 않는다.
  2. 모든 상태 변경은 Aggregate Root의 메서드를 통한다. 내부 객체를 직접 조작하면 불변식(invariant)이 깨질 수 있다.
  3. 저장과 조회도 Aggregate Root 단위다. OrderItemRepository 같은 것은 존재하지 않는다.

트랜잭션 경계 = Aggregate 경계

DDD에서 하나의 트랜잭션은 하나의 Aggregate만 수정하는 것이 원칙이다. 이 규칙은 처음 들으면 과하게 느껴질 수 있다. 주문을 생성하면서 동시에 재고를 차감해야 하는데, 둘을 하나의 트랜잭션에서 처리하면 안 된다고?

맞다. 주문 Aggregate와 재고 Aggregate는 별도의 트랜잭션으로 처리한다. 재고 차감은 주문 생성 이벤트를 받아 비동기로 처리하거나, 별도의 트랜잭션에서 실행한다. 이렇게 하면 주문 생성이 재고 테이블의 락을 잡지 않으므로 동시성이 크게 개선된다.

물론 주문은 됐는데 재고가 안 줄었다는 시간차가 생긴다. 이것이 결과적 일관성이다. 짧은 시간 동안의 불일치를 허용하되, 최종적으로는 일관된 상태에 도달하도록 설계한다. 보상 트랜잭션(Compensation Transaction)이나 Saga 패턴이 이 간극을 메우는 데 쓰인다.

이 구조의 기반에 있는 것이 Domain Event인데, 이는 7편에서 자세히 다룬다.

Aggregate 설계 규칙

Aggregate를 잘 설계하기 위한 실전 규칙을 정리한다. Vaughn Vernon의 Implementing Domain-Driven Design에서 제시한 규칙들이 실무에서도 잘 들어맞는다.

규칙 1: Aggregate는 작게 유지한다

Aggregate가 크면 트랜잭션의 범위도 커진다. 주문, 배송, 결제, 리뷰를 모두 하나의 Aggregate에 넣으면 리뷰 하나 수정할 때마다 주문 전체에 락이 걸린다.

작은 Aggregate는 동시성 충돌을 줄이고, 메모리 사용량도 적다. 하나의 Aggregate에 포함되는 Entity 수가 셋을 넘기면 설계를 다시 점검해볼 필요가 있다.

규칙 2: 다른 Aggregate는 ID로만 참조한다

Aggregate 사이에 직접 객체 참조를 두면 경계가 무너진다. OrderProduct 객체를 직접 들고 있으면, Order를 로딩할 때 Product도 함께 올라오고, Product를 수정하면 Order 트랜잭션에도 영향을 미친다.

ID 참조를 쓰면 이 문제가 사라진다.

// 나쁜 예 — 다른 Aggregate를 직접 참조
class Order(
    val id: OrderId,
    val product: Product  // Product Aggregate를 직접 참조
)

// 좋은 예 — ID로만 참조
class Order(
    val id: OrderId,
    val productId: ProductId  // ID만 보관
)

Product의 상세 정보가 필요하면 Application Service에서 ProductRepository를 통해 별도로 조회한다. Aggregate 간의 결합도가 낮아지고, 각각 독립적으로 배포·확장할 수 있는 기반이 된다.

규칙 3: 결과적 일관성을 적극 활용한다

모든 것을 즉시 일관적으로 만들려는 욕심을 버려야 한다. 주문이 완료되면 포인트가 적립된다는 규칙에서, 주문 완료와 포인트 적립이 밀리초 단위로 동시에 일어나야 할 비즈니스 이유가 있는가? 대부분의 경우 없다.

결과적 일관성을 적용할 수 있는지 판단하는 기준은 이렇다.

규칙 4: 불변식을 기준으로 경계를 정한다

Aggregate의 경계를 결정하는 가장 중요한 기준은 불변식(invariant)이다. 불변식이란 항상 참이어야 하는 비즈니스 규칙을 말한다.

주문 총액은 주문 항목들의 합계와 같아야 한다는 불변식이 있다면, OrderOrderItem은 같은 Aggregate에 있어야 한다. OrderItem 하나가 변경될 때 Order의 총액도 함께 갱신되어야 하기 때문이다. 반면 주문 후 리뷰를 작성할 수 있다는 불변식이 아니다. 주문과 리뷰 사이에 즉시 일관성이 필요하지 않으므로 별도의 Aggregate로 분리한다.

주문 Aggregate 구현

이론을 코드로 옮겨보자. 주문 Aggregate는 Order(Root)와 OrderItem(내부 Entity), ShippingAddress(Value Object)로 구성된다.

먼저 Value Object부터 정의한다. Kotlin의 data class는 Value Object를 표현하기에 적합하다.

data class Money(
    val amount: BigDecimal,
    val currency: String = "KRW"
) {
    init {
        require(amount >= BigDecimal.ZERO) { "금액은 0 이상이어야 한다" }
    }

    operator fun plus(other: Money): Money {
        require(currency == other.currency) { "통화가 다르면 더할 수 없다" }
        return Money(amount + other.amount, currency)
    }

    operator fun times(quantity: Int): Money {
        return Money(amount * quantity.toBigDecimal(), currency)
    }
}

data class ShippingAddress(
    val city: String,
    val street: String,
    val zipCode: String
)

다음으로 내부 Entity인 OrderItem을 정의한다. OrderItem은 자체 식별자를 가지지만, Order 밖에서는 독립적으로 존재하지 않는다.

class OrderItem(
    val id: Long,
    val productId: Long,
    val productName: String,
    val price: Money,
    val quantity: Int
) {
    init {
        require(quantity > 0) { "수량은 1 이상이어야 한다" }
    }

    fun totalPrice(): Money = price * quantity
}

이제 Aggregate Root인 Order를 구현한다. 핵심은 모든 상태 변경이 Order의 메서드를 통해서만 이루어진다는 점이다.

class Order private constructor(
    val id: Long,
    val customerId: Long,
    private val _items: MutableList<OrderItem> = mutableListOf(),
    private var _shippingAddress: ShippingAddress? = null,
    private var _status: OrderStatus = OrderStatus.CREATED
) {
    val items: List<OrderItem> get() = _items.toList()
    val shippingAddress: ShippingAddress? get() = _shippingAddress
    val status: OrderStatus get() = _status

    companion object {
        fun create(id: Long, customerId: Long): Order {
            return Order(id = id, customerId = customerId)
        }
    }

    fun addItem(item: OrderItem) {
        require(_status == OrderStatus.CREATED) {
            "생성 상태에서만 항목을 추가할 수 있다"
        }
        require(_items.size < 20) {
            "주문 항목은 최대 20개까지 가능하다"
        }
        _items.add(item)
    }

    fun removeItem(itemId: Long) {
        require(_status == OrderStatus.CREATED) {
            "생성 상태에서만 항목을 제거할 수 있다"
        }
        val removed = _items.removeIf { it.id == itemId }
        require(removed) { "해당 항목이 존재하지 않는다: $itemId" }
    }

    fun assignShippingAddress(address: ShippingAddress) {
        _shippingAddress = address
    }

    fun place() {
        require(_items.isNotEmpty()) { "주문 항목이 비어있으면 주문할 수 없다" }
        requireNotNull(_shippingAddress) { "배송 주소가 필요하다" }
        _status = OrderStatus.PLACED
    }

    fun totalAmount(): Money {
        return _items.fold(Money(BigDecimal.ZERO)) { acc, item ->
            acc + item.totalPrice()
        }
    }
}

enum class OrderStatus {
    CREATED, PLACED, PAID, SHIPPED, DELIVERED, CANCELLED
}

Order.place()를 보면, 불변식 두 가지를 검증하고 있다. 항목이 비어있지 않아야 하고, 배송 주소가 있어야 한다. 이런 검증이 Aggregate Root에 집중되어 있기 때문에, 외부 코드가 어떤 순서로 메서드를 호출하든 유효하지 않은 상태로 전이되는 것을 막을 수 있다.

Repository — Aggregate 단위의 영속화

Aggregate가 일관성의 경계라면, Repository는 그 경계 단위로 저장과 조회를 담당하는 추상화다. Repository라는 이름은 Eric Evans가 DDD에서 정의한 용어로, 도메인 객체의 컬렉션처럼 동작하는 인터페이스를 뜻한다.

핵심 규칙은 단순하다. Aggregate Root 하나당 Repository 하나. OrderRepository는 있지만 OrderItemRepository는 없다. OrderItemOrder의 일부이므로, Order를 저장하면 OrderItem도 함께 저장되고, Order를 조회하면 OrderItem도 함께 올라온다.

flowchart LR
    subgraph Domain["도메인 계층"]
        OR["OrderRepository<br/>(인터페이스)"]
    end

    subgraph Infra["인프라 계층"]
        JPA["JpaOrderRepository<br/>(구현체)"]
        DB[("Database")]
    end

    App["Application Service"] --> OR
    OR -.->|"구현"| JPA
    JPA --> DB

    style Domain fill:#5ca45c
    style Infra fill:#d4943a
    style App fill:#4a90d9

Repository 인터페이스는 도메인 계층에 위치하고, 구현체는 인프라 계층에 둔다. 이 분리 덕분에 도메인 로직은 JPA든 MyBatis든, 심지어 인메모리 Map이든 영속화 기술에 의존하지 않게 된다.

Repository 인터페이스 설계

Repository 인터페이스는 컬렉션의 메타포를 따른다. 도메인 객체를 넣고, 꺼내고, 찾는 동작을 표현한다.

interface OrderRepository {
    fun save(order: Order): Order
    fun findById(id: Long): Order?
    fun findByCustomerId(customerId: Long): List<Order>
    fun delete(order: Order)
}

여기서 중요한 것은 이 인터페이스가 도메인 계층에 정의된다는 점이다. savefindById는 SQL이나 JPA의 개념이 아니라, 컬렉션에 넣다, 컬렉션에서 찾다라는 도메인 언어다.

Repository vs DAO

Repository와 DAO(Data Access Object)는 비슷해 보이지만 추상화 수준이 다르다.

DAO는 테이블 단위의 CRUD를 추상화한다. OrderDaoorders 테이블에 대한 insert, select, update, delete를 제공하고, OrderItemDaoorder_items 테이블에 대한 같은 동작을 제공한다. 테이블과 DAO가 1:1로 대응하는 구조다.

Repository는 Aggregate 단위의 영속화를 추상화한다. OrderRepository.save(order)를 호출하면 orders 테이블과 order_items 테이블, shipping_addresses 테이블이 한 번에 처리된다. 호출자는 테이블이 몇 개인지, 어떤 순서로 저장되는지 알 필요가 없다.

구분DAORepository
추상화 단위테이블Aggregate
메서드 네이밍insertOrder, selectOrderByIdsave, findById
반환 타입DB 행 / DTO도메인 객체
개수테이블마다 하나Aggregate Root마다 하나
소속 계층인프라도메인 (인터페이스)

DAO는 이 테이블에 이 행을 넣어라고 말하고, Repository는 이 주문을 보관해라고 말한다. 추상화 수준의 차이가 곧 설계 사고의 차이를 만든다.

Spring Data JPA에서 Repository 패턴

Spring Data JPA는 DDD의 Repository 패턴과 궁합이 좋다. JpaRepository를 상속하면 기본적인 CRUD 메서드가 자동으로 생성되고, 메서드 이름 규칙에 따라 쿼리도 자동으로 만들어진다.

다만, Spring Data JPA의 JpaRepository를 도메인 계층에서 직접 사용하면 인프라 의존이 생긴다. 이를 해결하는 방법은 도메인 인터페이스와 인프라 구현체를 분리하는 것이다.

먼저 JPA Entity를 정의한다. 도메인 객체와 JPA Entity를 별도로 유지하면 도메인 모델이 JPA 어노테이션에 오염되지 않는다.

@Entity
@Table(name = "orders")
class OrderJpaEntity(
    @Id
    val id: Long,

    @Column(name = "customer_id")
    val customerId: Long,

    @OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true)
    @JoinColumn(name = "order_id")
    val items: MutableList<OrderItemJpaEntity> = mutableListOf(),

    @Embedded
    var shippingAddress: ShippingAddressEmbeddable? = null,

    @Enumerated(EnumType.STRING)
    var status: OrderStatus = OrderStatus.CREATED
)

@Entity
@Table(name = "order_items")
class OrderItemJpaEntity(
    @Id
    val id: Long,

    @Column(name = "product_id")
    val productId: Long,

    @Column(name = "product_name")
    val productName: String,

    @Column(name = "price")
    val price: BigDecimal,

    @Column(name = "currency")
    val currency: String,

    val quantity: Int
)

@Embeddable
data class ShippingAddressEmbeddable(
    val city: String,
    val street: String,
    val zipCode: String
)

Spring Data JPA 인터페이스는 인프라 계층에 둔다.

interface OrderJpaRepository : JpaRepository<OrderJpaEntity, Long> {
    fun findByCustomerId(customerId: Long): List<OrderJpaEntity>
}

마지막으로 도메인 Repository 인터페이스의 구현체를 작성한다. 이 구현체가 도메인 객체와 JPA Entity 사이의 변환을 담당한다.

@Repository
class JpaOrderRepositoryAdapter(
    private val jpaRepository: OrderJpaRepository
) : OrderRepository {

    override fun save(order: Order): Order {
        val entity = toEntity(order)
        val saved = jpaRepository.save(entity)
        return toDomain(saved)
    }

    override fun findById(id: Long): Order? {
        return jpaRepository.findById(id)
            .map { toDomain(it) }
            .orElse(null)
    }

    override fun findByCustomerId(customerId: Long): List<Order> {
        return jpaRepository.findByCustomerId(customerId)
            .map { toDomain(it) }
    }

    override fun delete(order: Order) {
        jpaRepository.deleteById(order.id)
    }

    private fun toEntity(order: Order): OrderJpaEntity { /* 변환 로직 */ }
    private fun toDomain(entity: OrderJpaEntity): Order { /* 변환 로직 */ }
}

이 구조에서 도메인 계층의 OrderRepository는 JPA를 전혀 모르고, JPA 관련 코드는 모두 인프라 계층에 격리된다. 도메인 모델을 테스트할 때는 인메모리 구현체를 끼우면 된다.

class InMemoryOrderRepository : OrderRepository {
    private val store = mutableMapOf<Long, Order>()

    override fun save(order: Order): Order {
        store[order.id] = order
        return order
    }

    override fun findById(id: Long): Order? = store[id]

    override fun findByCustomerId(customerId: Long): List<Order> {
        return store.values.filter { it.customerId == customerId }
    }

    override fun delete(order: Order) {
        store.remove(order.id)
    }
}

테스트에서 DB 없이 도메인 로직을 검증할 수 있다는 것은 큰 장점이다. 테스트가 빠르고, 외부 의존 없이 실행되므로 CI 환경에서도 안정적이다.

Aggregate 설계의 흔한 실수

실무에서 자주 보이는 Aggregate 설계 실수 세 가지를 짚고 넘어간다.

실수 1: 거대한 Aggregate

주문에 결제 정보, 배송 추적, 리뷰, 환불 이력까지 전부 넣는 경우다. 이렇게 하면 주문 하나를 조회할 때 관련 테이블이 모두 조인되어 올라오고, 리뷰 하나 수정하는 데도 주문 전체가 잠긴다. 각각의 생명주기가 다르면 별도의 Aggregate로 분리하는 것이 맞다.

실수 2: 모든 관계를 즉시 일관성으로 처리

주문이 취소되면 포인트가 즉시 복원되어야 한다는 비즈니스 규칙이 정말로 즉시 일관성을 요구하는지 따져봐야 한다. 포인트 복원이 1초 뒤에 일어나도 사용자 경험에 영향이 없다면, 이벤트 기반의 결과적 일관성이 더 적절하다.

실수 3: Aggregate Root를 우회하는 코드

Repository가 Aggregate Root 단위로 잘 설계되어 있더라도, 서비스 계층에서 내부 Entity를 직접 조작하면 불변식이 깨진다.

// 잘못된 예 — Aggregate Root를 우회
val order = orderRepository.findById(orderId)!!
order.items.first().quantity = 5  // 직접 수정 — 불변식 검증 불가

// 올바른 예 — Aggregate Root를 통해 변경
order.updateItemQuantity(itemId = 1, newQuantity = 5)  // Order가 검증 수행
orderRepository.save(order)

Aggregate Root의 메서드를 통해서만 상태를 변경해야 한다는 원칙은, 단순히 규칙을 지키자는 이야기가 아니다. 불변식 검증 로직이 한 곳에 모여 있어야 유지보수가 가능하기 때문이다.

핵심 정리

이 편에서 다룬 내용을 정리하면 이렇다.


다음 편에서는 도메인 로직을 어디에 배치할 것인가에 대해 다룬다. Entity에 넣기 어색한 로직은 Domain Service로, 유스케이스 조율은 Application Service로 — 이 둘의 차이와 판단 기준을 살펴본다.

6편: Domain Service, Application Service


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
DDD 4편 — Entity와 Value Object
Next Post
DDD 6편 — Domain Service와 Application Service