관리 메뉴

오늘도 더 나은 코드를 작성하였습니까?

도메인 주도 설계 (Domain Driven Design - DDD)의 기초 개념정리2 본문

카테고리 없음

도메인 주도 설계 (Domain Driven Design - DDD)의 기초 개념정리2

hik14 2026. 5. 12. 03:27

전술적 설계 (Tactical Design)

 

1. Entity (엔티티)

- 고유한 식별자(ID)로 구별되는 객체입니다. 속성이 바뀌어도 같은 것으로 취급됩니다.

 

핵심 특징:

  • ID가 같으면 같은 객체로 간주
  • 시간에 따라 상태가 변함 (가변)
  • 생명주기를 가짐 (생성 → 변경 → 삭제)
class User(
    val id: UserId,           // 식별자
    var name: String,          // 바뀔 수 있는 속성
    var email: Email
) {
    override fun equals(other: Any?): Boolean {
        if (other !is User) return false
        return this.id == other.id   // ID로만 동등성 판단
    }
    
    override fun hashCode(): Int = id.hashCode()
}

 

2. Value Object (값 객체)

- 식별자 없이 값 자체로 동등성이 판단되는 불변 객체입니다.

 

핵심 특징:

  • 모든 속성이 같으면 같은 객체
  • 불변(Immutable) — 한번 만들면 못 바꿈, 바꾸려면 새로 생성
  • ID 없음
  • 생명주기 없음
data class Money(
    val amount: BigDecimal,
    val currency: Currency
) {
    operator fun plus(other: Money): Money {
        require(currency == other.currency) { "통화가 달라요" }
        return Money(amount + other.amount, currency)  // 새 객체 반환
    }
}

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

 

*도메인 모델링에서는 가능한 한 Value Object를 많이 쓰는 게 좋다. 원시 타입(String, Int)을 그대로 쓰지 말고 의미를 담은 Value Object로 감싸서 의미를 전달한다. 

// 나쁨 - 그냥 String
fun sendEmail(email: String)

// 좋음 - Email Value Object  
@JvmInline value class Email(val value: String) {
    init { require(value.contains("@")) }
}
fun sendEmail(email: Email)

 

Aggregate (애그리거트)

- 함께 변경되어야 하는 엔티티·값 객체의 묶음이며, 트랜잭션과 일관성의 단위입니다.

 

핵심 특징:

  • 외부에서는 Aggregate Root를 통해서만 접근
  • 1개의Root Entity를 가짐
  • 내부 객체에 직접 접근 금지
  • 하나의 트랜잭션에서 변경되는 단위
  • 불변식(invariant)을 보장하는 책임
// Aggregate Root
class Order(
    val id: OrderId,
    private val items: MutableList<OrderItem> = mutableListOf(),  // 내부 엔티티
    private var status: OrderStatus = OrderStatus.DRAFT,
    val shippingAddress: Address                                   // 값 객체
) {
    // 외부는 Order를 통해서만 OrderItem 조작 가능
    fun addItem(product: Product, quantity: Int) {
        require(status == OrderStatus.DRAFT) { "확정된 주문은 수정 불가" }
        require(items.size < 100) { "한 주문에 100개 초과 불가" }
        items.add(OrderItem(product.id, quantity, product.price))
    }
    
    fun totalAmount(): Money = items
        .map { it.subtotal() }
        .reduce { acc, money -> acc + money }
    
    fun confirm() {
        require(items.isNotEmpty()) { "빈 주문은 확정 불가" }
        status = OrderStatus.CONFIRMED
    }
    
    // 외부에 노출할 땐 읽기 전용
    fun items(): List<OrderItem> = items.toList()
}

// Aggregate 내부 엔티티 (외부에서 직접 생성/접근 X)
class OrderItem(
    val productId: ProductId,
    var quantity: Int,
    val unitPrice: Money
) {
    fun subtotal(): Money = unitPrice * quantity
}

 

*Aggregate 설계 원칙

  • 작게 유지: 큰 애그리거트는 동시성·성능 문제를 일으킴
  • ID로 다른 애그리거트 참조: Order가 User 객체를 직접 들고 있지 말고 userId: UserId로 참조
  • 한 트랜잭션엔 하나의 애그리거트만 변경: 여러 애그리거트를 한 번에 바꿔야 한다면 도메인 이벤트로 비동기 처리

4. Repository (리포지토리)

- 애그리거트를 저장하고 조회하는 추상화입니다. 도메인 입장에서는 컬렉션처럼 보이고, 실제 DB 구현은 인프라 계층에 숨깁니다.

// 도메인 계층 - 인터페이스만 정의
interface OrderRepository {
    fun findById(id: OrderId): Order?
    fun save(order: Order)
    fun findByUserId(userId: UserId): List<Order>
}

// 인프라 계층 - 실제 구현 (Room, Retrofit 등)
class OrderRepositoryImpl(
    private val orderDao: OrderDao,
    private val mapper: OrderMapper
) : OrderRepository {
    override fun findById(id: OrderId): Order? {
        return orderDao.findById(id.value)?.let { mapper.toDomain(it) }
    }
    
    override fun save(order: Order) {
        orderDao.insertOrUpdate(mapper.toEntity(order))
    }
    
    override fun findByUserId(userId: UserId): List<Order> {
        return orderDao.findByUserId(userId.value).map { mapper.toDomain(it) }
    }
}

 

중요 원칙:

  • 애그리거트 단위로만 조작: OrderItemRepository 같은 건 만들지 않음. Order를 통해 접근
  • 인터페이스는 도메인 계층, 구현체는 인프라 계층: 의존성 역전(DIP)
  • 컬렉션처럼 설계: findAll(), save(), remove() 같은 메서드. SQL 냄새 풍기지 않게

안드로이드의 Repository 패턴과 비슷하지만 DDD의 Repository는 반드시 애그리거트만 다룬다는 점이 다릅니다.

안드로이드 Repository는 보통 DTO나 단순 모델까지 다 다루죠.

 

5. Domain Service (도메인 서비스)

- 특정 엔티티나 값 객체에 자연스럽게 속하지 않는 도메인 로직을 담는 곳입니다.

 

언제 쓰나?

  • 여러 애그리거트를 협력시켜야 할 때
  • 어느 한 객체에 책임을 두기 어색할 때
  • 외부 시스템과 협력해야 하는 도메인 로직
// 두 계좌 사이 송금 - Account 하나에 두기 어색함
class TransferService(
    private val accountRepository: AccountRepository
) {
    fun transfer(fromId: AccountId, toId: AccountId, amount: Money) {
        val from = accountRepository.findById(fromId) ?: error("계좌 없음")
        val to = accountRepository.findById(toId) ?: error("계좌 없음")
        
        from.withdraw(amount)   // 도메인 로직은 여전히 Account 안에
        to.deposit(amount)
        
        accountRepository.save(from)
        accountRepository.save(to)
    }
}

 

주의: 도메인 서비스로 도망치는 게 너무 쉬워서 남발하면 빈혈 도메인 모델(Anemic Domain Model) 이 됩니다. 엔티티는 데이터만 들고 서비스가 모든 로직을 갖는 안티패턴이에요. 먼저 엔티티/값 객체에 책임을 두려고 시도하고, 정말 어색할 때만 도메인 서비스로 빼야 합니다.

 

6. Domain Event (도메인 이벤트)

- 도메인에서 의미 있는 일이 일어났음을 알리는 객체입니다. 과거형으로 이름 짓습니다.

sealed class DomainEvent {
    abstract val occurredAt: Instant
}

data class OrderPlaced(
    val orderId: OrderId,
    val userId: UserId,
    val totalAmount: Money,
    override val occurredAt: Instant = Instant.now()
) : DomainEvent()

data class PaymentCompleted(
    val orderId: OrderId,
    val paymentId: PaymentId,
    override val occurredAt: Instant = Instant.now()
) : DomainEvent()

 

왜 쓰나?

  1. 애그리거트 간 결합도 낮춤: Order가 Payment를 직접 호출하지 않고, "주문됨" 이벤트만 발행. 결제 모듈이 이벤트를 구독해서 알아서 처리.
  2. 컨텍스트 간 통합: 주문 컨텍스트의 이벤트를 배송·알림 컨텍스트가 구독.
  3. 이벤트 소싱(Event Sourcing)의 기반: 상태가 아닌 이벤트의 흐름으로 시스템을 구성.
class Order(/* ... */) {
    private val events = mutableListOf<DomainEvent>()
    
    fun confirm() {
        require(items.isNotEmpty())
        status = OrderStatus.CONFIRMED
        events.add(OrderPlaced(id, userId, totalAmount()))  // 이벤트 발생
    }
    
    fun pullEvents(): List<DomainEvent> {
        val pulled = events.toList()
        events.clear()
        return pulled
    }
}

 

안드로이드로 비유하면 LiveData/Flow로 상태 변화를 알리는 것과 발상이 비슷해요. 다만 도메인 이벤트는 비즈니스 사실의 기록이라는 점에서 의미가 더 무겁습니다.

 

7. Factory (팩토리)

- 복잡한 애그리거트 생성 로직을 캡슐화하는 객체입니다.

 

언제 쓰나?

  • 생성 자체에 복잡한 규칙이 있을 때
  • 여러 객체를 조합해야 할 때
  • 다양한 형태로 생성될 수 있을 때
class OrderFactory(
    private val pricingPolicy: PricingPolicy,
    private val discountService: DiscountService
) {
    fun createFromCart(cart: Cart, user: User): Order {
        require(cart.items.isNotEmpty()) { "빈 장바구니로 주문 불가" }
        
        val orderItems = cart.items.map { cartItem ->
            val price = pricingPolicy.calculate(cartItem.product, user)
            val discounted = discountService.applyDiscount(price, user)
            OrderItem(cartItem.product.id, cartItem.quantity, discounted)
        }
        
        return Order(
            id = OrderId.generate(),
            userId = user.id,
            items = orderItems.toMutableList(),
            shippingAddress = user.defaultAddress
        )
    }
}

 

: 생성 로직이 단순하면 굳이 Factory를 만들지 말고 생성자나 정적 팩토리 메서드로 충분합니다.

class Order private constructor(...) {
    companion object {
        fun create(userId: UserId, items: List<OrderItem>): Order {
            require(items.isNotEmpty())
            return Order(OrderId.generate(), userId, items.toMutableList())
        }
    }
}
 

DDD 개념안드로이드 일반적 위치

Entity, Value Object domain/model/ 안의 클래스
Aggregate 동일하게 domain/model/
Repository (인터페이스) domain/repository/
Repository (구현체) data/repository/
Domain Service domain/service/ 또는 UseCase로 흡수되기도 함
Domain Event Flow/Channel로 발행하거나 EventBus 활용
Factory domain/factory/ 또는 companion object