Skip to content
ioob.dev
Go back

DDD 7편 — Domain Event와 Anti-Corruption Layer

· 11분 읽기
DDD 시리즈 (7/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

Aggregate 사이의 간극

5편에서 하나의 트랜잭션은 하나의 Aggregate만 수정해야 한다는 원칙을 다뤘다. 그런데 실제 비즈니스는 한 Aggregate의 변화가 다른 Aggregate에 영향을 미치는 경우가 대부분이다. 주문이 접수되면 재고가 줄어야 하고, 결제가 완료되면 포인트가 적립되어야 한다.

이 간극을 메우는 것이 Domain Event다. 이런 일이 일어났다를 도메인 수준에서 표현하고, 관심 있는 쪽이 그 사실을 받아서 자신의 Aggregate를 갱신하는 구조다.

Domain Event란

Domain Event는 도메인에서 일어난 사실(Fact)을 나타내는 객체다. 주문이 접수되었다, 결제가 완료되었다, 회원 등급이 변경되었다 — 이미 일어난 사실을 기록하는 것이기 때문에 불변(Immutable)이고, 과거형으로 이름을 짓는다.

data class OrderPlacedEvent(
    val orderId: Long,
    val customerId: Long,
    val totalAmount: BigDecimal,
    val occurredAt: Instant = Instant.now()
)

data class PaymentCompletedEvent(
    val paymentId: Long,
    val orderId: Long,
    val amount: BigDecimal,
    val occurredAt: Instant = Instant.now()
)

네이밍 규칙이 중요하다. OrderPlaced, PaymentCompleted, MemberGradeChanged — 모두 과거형이다. 현재형(OrderPlacing)이나 명령형(PlaceOrder)과 구분해야 한다. 명령(Command)은 이것을 해달라는 요청이고, 이벤트(Event)는 이것이 일어났다는 통보다. 명령은 거부될 수 있지만, 이벤트는 이미 발생한 사실이므로 거부할 수 없다.

이벤트의 구성 요소

Domain Event에는 최소한 다음 정보가 포함되어야 한다.

이벤트에 너무 많은 데이터를 담으면 이벤트가 Aggregate의 스냅샷이 되어버린다. 반대로 너무 적으면 수신 측이 매번 원본을 조회해야 하므로 결합도가 높아진다. 적절한 균형을 찾는 것은 설계 판단의 영역이다.

이벤트 발행 — Spring의 ApplicationEventPublisher

Spring Framework는 ApplicationEventPublisher를 통해 애플리케이션 내부의 이벤트 발행/구독을 지원한다. 별도의 메시지 브로커 없이도 이벤트 기반 설계를 구현할 수 있어, DDD의 Domain Event를 적용하기에 적합하다.

이벤트를 발행하는 코드는 Application Service에 둔다.

@Service
@Transactional
class PlaceOrderService(
    private val orderRepository: OrderRepository,
    private val eventPublisher: ApplicationEventPublisher
) {
    fun execute(command: PlaceOrderCommand) {
        val order = orderRepository.findById(command.orderId)
            ?: throw OrderNotFoundException(command.orderId)

        order.place()
        orderRepository.save(order)

        eventPublisher.publishEvent(
            OrderPlacedEvent(
                orderId = order.id,
                customerId = order.customerId,
                totalAmount = order.totalAmount().amount
            )
        )
    }
}

이벤트를 구독하는 쪽은 @EventListener 또는 @TransactionalEventListener를 사용한다.

@Component
class InventoryEventHandler(
    private val inventoryService: DeductInventoryService
) {
    @EventListener
    fun handle(event: OrderPlacedEvent) {
        inventoryService.deduct(event.orderId)
    }
}

PlaceOrderService는 재고 차감의 존재를 모른다. 주문을 접수하고 이벤트를 발행하는 것이 전부다. 재고 차감은 이벤트를 수신한 InventoryEventHandler가 독립적으로 처리한다. 이 구조 덕분에 새로운 요구사항 — 예를 들어 주문 접수 시 알림을 보낸다 — 이 생겨도 PlaceOrderService를 수정할 필요가 없다. 새 핸들러를 추가하기만 하면 된다.

이벤트 발행/구독 흐름

전체 흐름을 다이어그램으로 정리하면 이렇다.

sequenceDiagram
    participant AS as PlaceOrderService
    participant OR as OrderRepository
    participant EP as EventPublisher
    participant IH as InventoryHandler
    participant PH as PointHandler
    participant NH as NotificationHandler

    AS->>OR: findById / save
    AS->>EP: publish(OrderPlacedEvent)
    EP->>IH: handle(event)
    EP->>PH: handle(event)
    EP->>NH: handle(event)

    Note over IH: 재고 차감
    Note over PH: 포인트 적립
    Note over NH: 알림 발송

하나의 이벤트에 여러 핸들러가 반응할 수 있다. 각 핸들러는 서로의 존재를 모르고, 독립적으로 실행된다. 이것이 이벤트 기반 아키텍처의 핵심적인 장점이다. 발행자와 구독자 사이의 결합이 사라진다.

@TransactionalEventListener — 트랜잭션과 이벤트의 관계

@EventListener는 이벤트가 발행되는 즉시 핸들러를 실행한다. 문제는 이벤트 발행 시점이 트랜잭션 커밋 전이라는 것이다. 만약 order.place() 이후에 이벤트를 발행했는데, 이후 로직에서 예외가 발생하여 트랜잭션이 롤백되면 어떻게 될까? 주문은 접수되지 않았는데 재고는 이미 차감된 상태가 된다.

이 문제를 해결하는 것이 @TransactionalEventListener다.

@Component
class InventoryEventHandler(
    private val inventoryService: DeductInventoryService
) {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    fun handle(event: OrderPlacedEvent) {
        inventoryService.deduct(event.orderId)
    }
}

AFTER_COMMIT 페이즈를 지정하면, 트랜잭션이 성공적으로 커밋된 후에만 핸들러가 실행된다. 트랜잭션이 롤백되면 핸들러는 아예 실행되지 않는다. 사용 가능한 페이즈는 네 가지다.

페이즈실행 시점용도
AFTER_COMMIT커밋 성공 후가장 일반적. 부수 효과 실행
AFTER_ROLLBACK롤백 후실패 시 보상 작업
AFTER_COMPLETION커밋/롤백 상관없이리소스 정리
BEFORE_COMMIT커밋 직전같은 트랜잭션 내 추가 작업

주의할 점이 있다. AFTER_COMMIT으로 실행되는 핸들러는 원래 트랜잭션 바깥에서 실행된다. 핸들러 내부에서 DB를 수정하려면 새로운 트랜잭션을 시작해야 하며, 이때 핸들러가 실패하면 원래 트랜잭션은 이미 커밋된 상태이므로 자동 롤백이 되지 않는다. 이 간극이 바로 결과적 일관성이 필요한 지점이다.

핸들러 실패에 대비하는 전략은 여러 가지가 있다.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Retryable(maxAttempts = 3, backoff = Backoff(delay = 1000))
fun handle(event: OrderPlacedEvent) {
    inventoryService.deduct(event.orderId)
}

Spring Retry의 @Retryable을 사용하면 일시적인 실패에 대해 재시도할 수 있다. 재시도에도 실패하면 Dead Letter Queue나 실패 로그 테이블에 기록하고, 운영자가 수동으로 처리하거나 배치 작업이 재시도하는 구조를 갖추는 것이 일반적이다.

Aggregate 내부에서 이벤트 수집하기

지금까지는 Application Service에서 직접 이벤트를 발행했다. 하지만 이벤트의 발생 원인은 도메인 로직에 있다. order.place()가 실행되었기 때문에 OrderPlacedEvent가 발생하는 것이다. 이 인과 관계를 코드에 드러내고 싶다면, Aggregate 내부에서 이벤트를 수집하는 패턴을 사용할 수 있다.

Spring Data는 @DomainEvents@AfterDomainEventPublication 어노테이션을 제공한다. 또는 직접 구현하는 방식도 있다.

abstract class AggregateRoot {
    @Transient
    private val _domainEvents: MutableList<Any> = mutableListOf()

    val domainEvents: List<Any> get() = _domainEvents.toList()

    protected fun registerEvent(event: Any) {
        _domainEvents.add(event)
    }

    fun clearEvents() {
        _domainEvents.clear()
    }
}

Order가 이 기반 클래스를 상속한다.

class Order private constructor(
    val id: Long,
    val customerId: Long,
    private val _items: MutableList<OrderItem>,
    private var _status: OrderStatus
) : AggregateRoot() {

    fun place() {
        require(_items.isNotEmpty()) { "주문 항목이 비어있으면 주문할 수 없다" }
        _status = OrderStatus.PLACED

        registerEvent(
            OrderPlacedEvent(
                orderId = id,
                customerId = customerId,
                totalAmount = totalAmount().amount
            )
        )
    }
}

Application Service는 Aggregate에 쌓인 이벤트를 꺼내어 발행한다.

@Service
@Transactional
class PlaceOrderService(
    private val orderRepository: OrderRepository,
    private val eventPublisher: ApplicationEventPublisher
) {
    fun execute(command: PlaceOrderCommand) {
        val order = orderRepository.findById(command.orderId)
            ?: throw OrderNotFoundException(command.orderId)

        order.place()
        orderRepository.save(order)

        order.domainEvents.forEach { eventPublisher.publishEvent(it) }
        order.clearEvents()
    }
}

이 구조의 장점은 어떤 상태 변경이 어떤 이벤트를 유발하는가가 도메인 코드에 명시된다는 것이다. Application Service가 어떤 이벤트를 발행해야 하는지 고민할 필요가 없고, Entity의 상태 전이와 이벤트 발행이 한 곳에 묶여 있어 누락의 가능성도 줄어든다.

Anti-Corruption Layer — 외부로부터 도메인을 보호하기

여기서 주제를 전환한다. Domain Event가 내부 Aggregate 간의 소통이라면, Anti-Corruption Layer(ACL)는 외부 시스템과의 소통에서 도메인을 보호하는 패턴이다.

ACL은 2편에서 다룬 Context Mapping 패턴 중 하나다. 외부 시스템 — 레거시 모놀리스, 서드파티 API, 다른 팀의 서비스 — 의 모델이 우리 도메인 모델과 다를 때, 그 차이를 흡수하는 번역 계층을 두는 것이다.

왜 필요한가? 외부 결제 API의 응답 포맷이 우리 도메인 모델과 다르다고 해서, 도메인 객체를 외부 API에 맞춰 바꾸면 안 된다. 외부 시스템이 변경될 때마다 도메인 모델이 흔들리기 때문이다. ACL은 이 충격을 흡수하는 완충 지대다.

flowchart LR
    subgraph Our["우리 도메인"]
        PS["PaymentService<br/>(Domain)"]
    end

    subgraph ACL["Anti-Corruption Layer"]
        Adapter["PaymentGatewayAdapter"]
        Translator["ResponseTranslator"]
    end

    subgraph External["외부 결제 시스템"]
        API["PG사 REST API"]
    end

    PS -->|"도메인 언어"| Adapter
    Adapter -->|"외부 프로토콜"| API
    API -->|"외부 응답"| Translator
    Translator -->|"도메인 객체"| PS

    style Our fill:#5ca45c,stroke:#2e7d32,color:#fff
    style ACL fill:#d4943a,stroke:#f9a825,color:#fff
    style External fill:#c0392b,stroke:#c62828,color:#fff

ACL의 책임은 두 가지다. 변환(Translation)격리(Isolation). 외부 모델을 우리 도메인 모델로 변환하고, 외부 시스템의 변경이 도메인 계층으로 전파되지 않도록 격리한다.

ACL 구현 — 외부 결제 API 통합

실무에서 가장 흔한 ACL 사례인 외부 결제 API 통합을 구현해보자. PG사(Payment Gateway)마다 API 스펙이 다르고, 응답 형식도 제각각이다. 이 다양성을 도메인이 직접 상대하면 안 된다.

먼저 도메인 계층에 결제 인터페이스를 정의한다.

// 도메인 계층 — 외부 시스템의 존재를 모른다
interface PaymentGateway {
    fun requestPayment(request: PaymentRequest): PaymentResult
    fun cancelPayment(paymentId: PaymentId): CancelResult
}

data class PaymentRequest(
    val orderId: Long,
    val amount: Money,
    val method: PaymentMethod
)

sealed class PaymentResult {
    data class Success(val paymentId: PaymentId, val approvedAt: Instant) : PaymentResult()
    data class Failure(val reason: String, val code: String) : PaymentResult()
}

도메인은 PaymentGateway라는 인터페이스만 안다. 어떤 PG사를 쓰는지, REST인지 gRPC인지 전혀 모른다.

다음으로 외부 PG사의 API 응답 모델을 정의한다. 이것은 인프라 계층에 위치한다.

// 인프라 계층 — 외부 PG사의 응답 형식 그대로
data class TossPaymentResponse(
    val paymentKey: String,
    val status: String,       // "DONE", "CANCELED", "ABORTED" 등
    val approvedAt: String?,  // ISO 8601
    val totalAmount: Int,
    val method: String,       // "카드", "가상계좌", "간편결제" 등
    val failure: TossFailure?
)

data class TossFailure(
    val code: String,
    val message: String
)

ACL의 핵심인 Adapter를 구현한다. 외부 API를 호출하고, 응답을 도메인 모델로 변환하는 역할이다.

@Component
class TossPaymentAdapter(
    private val restClient: RestClient
) : PaymentGateway {

    override fun requestPayment(request: PaymentRequest): PaymentResult {
        val tossRequest = toTossRequest(request)

        val response = restClient.post()
            .uri("/v1/payments/confirm")
            .body(tossRequest)
            .retrieve()
            .body(TossPaymentResponse::class.java)
            ?: return PaymentResult.Failure("응답 없음", "NO_RESPONSE")

        return toPaymentResult(response)
    }

    override fun cancelPayment(paymentId: PaymentId): CancelResult {
        // 취소 API 호출 및 변환
    }

    // --- 변환 로직 ---

    private fun toTossRequest(request: PaymentRequest): Map<String, Any> {
        return mapOf(
            "orderId" to request.orderId.toString(),
            "amount" to request.amount.amount.toInt()
        )
    }

    private fun toPaymentResult(response: TossPaymentResponse): PaymentResult {
        return when (response.status) {
            "DONE" -> PaymentResult.Success(
                paymentId = PaymentId(response.paymentKey),
                approvedAt = Instant.parse(response.approvedAt)
            )
            else -> PaymentResult.Failure(
                reason = response.failure?.message ?: "알 수 없는 오류",
                code = response.failure?.code ?: "UNKNOWN"
            )
        }
    }
}

이 구조에서 PG사를 교체하는 상황을 상상해보자. Toss에서 다른 PG사로 바꿔야 한다면, TossPaymentAdapter 대신 새로운 Adapter를 만들면 된다. 도메인 계층의 PaymentGateway 인터페이스와 PaymentResult는 그대로 유지된다. 도메인 코드를 한 줄도 수정하지 않고 PG사를 교체할 수 있다는 것 — 이것이 ACL의 가치다.

ACL이 없으면 어떻게 되는가

ACL 없이 외부 API를 직접 사용하면 어떤 일이 벌어지는지 구체적으로 살펴보자.

// ACL 없이 외부 응답을 직접 사용하는 코드 (나쁜 예)
@Service
class OrderPaymentService(
    private val restClient: RestClient,
    private val orderRepository: OrderRepository
) {
    fun pay(orderId: Long) {
        val order = orderRepository.findById(orderId)!!

        val response = restClient.post()
            .uri("https://api.tosspayments.com/v1/payments/confirm")
            .body(mapOf("orderId" to orderId, "amount" to order.totalAmount()))
            .retrieve()
            .body(TossPaymentResponse::class.java)!!

        // 도메인 로직에 외부 모델이 침투
        if (response.status == "DONE") {
            order.markAsPaid(response.paymentKey)
        } else if (response.failure?.code == "ALREADY_PROCESSED_PAYMENT") {
            // PG사 특유의 에러 코드가 도메인에 등장
        }
    }
}

이 코드의 문제점은 세 가지다.

  1. 도메인에 외부 모델이 침투한다. DONE, ALREADY_PROCESSED_PAYMENT 같은 PG사 고유 문자열이 도메인 로직에 박혀 있다.
  2. PG사 변경이 도메인 변경을 유발한다. 다른 PG사는 상태값을 SUCCESS로 쓸 수 있고, 에러 코드 체계도 다르다.
  3. 테스트가 어렵다. 외부 API를 모킹해야 하고, 모킹 대상이 PG사마다 달라진다.

ACL을 두면 이 세 가지가 모두 해소된다. 도메인은 PaymentResult.SuccessPaymentResult.Failure만 알면 되고, PG사 교체는 Adapter만 바꾸면 된다.

Domain Event + ACL — 결합의 실전 패턴

Domain Event와 ACL은 독립적인 개념이지만, 실무에서는 함께 쓰이는 경우가 많다. 외부 시스템에서 발생한 이벤트(Webhook)를 ACL을 통해 도메인 이벤트로 변환하는 패턴이 대표적이다.

예를 들어 PG사가 결제 상태 변경을 Webhook으로 알려주는 경우를 보자.

@RestController
class PaymentWebhookController(
    private val webhookTranslator: PaymentWebhookTranslator,
    private val eventPublisher: ApplicationEventPublisher
) {
    @PostMapping("/webhook/payment")
    fun handleWebhook(@RequestBody payload: Map<String, Any>) {
        // ACL이 외부 Webhook 페이로드를 도메인 이벤트로 변환
        val domainEvent = webhookTranslator.translate(payload)

        if (domainEvent != null) {
            eventPublisher.publishEvent(domainEvent)
        }
    }
}

@Component
class PaymentWebhookTranslator {
    fun translate(payload: Map<String, Any>): Any? {
        val status = payload["status"] as? String ?: return null
        val paymentKey = payload["paymentKey"] as? String ?: return null

        return when (status) {
            "DONE" -> PaymentCompletedEvent(
                paymentId = PaymentId(paymentKey),
                occurredAt = Instant.now()
            )
            "CANCELED" -> PaymentCancelledEvent(
                paymentId = PaymentId(paymentKey),
                occurredAt = Instant.now()
            )
            else -> null  // 알 수 없는 상태는 무시
        }
    }
}

외부 Webhook의 포맷이 바뀌어도 PaymentWebhookTranslator만 수정하면 된다. 도메인 이벤트의 형태는 그대로 유지되므로, 이벤트를 수신하는 핸들러들은 영향을 받지 않는다.

시리즈를 마치며

7편에 걸쳐 DDD의 핵심 개념을 살펴봤다. 1편의 전략적 설계에서 출발해, 유비쿼터스 언어와 Bounded Context로 모델의 경계를 잡고, Context Mapping으로 경계 간 관계를 정의했다. 4편부터는 전술적 설계로 들어와 Entity와 Value Object로 모델을 구체화하고, Aggregate로 일관성의 경계를 정했으며, Domain Service와 Application Service로 로직의 배치를 다뤘다. 마지막으로 Domain Event와 ACL로 Aggregate 간, 그리고 외부 시스템과의 느슨한 연결을 구현했다.

이 개념들을 관통하는 핵심은 결국 경계다. Bounded Context는 모델의 경계, Aggregate는 일관성의 경계, ACL은 외부 시스템과의 경계를 정의한다. DDD의 대부분의 설계 결정은 어디에 경계를 그을 것인가라는 질문으로 귀결된다.

DDD는 모든 프로젝트에 적용해야 하는 만능 도구가 아니다. 도메인이 단순한 CRUD 애플리케이션에 Aggregate와 Domain Event를 도입하면 과한 복잡성만 더해진다. DDD가 빛을 발하는 곳은 비즈니스 규칙이 복잡하고, 도메인 전문가와의 소통이 중요하며, 시스템이 장기간 유지보수되어야 하는 프로젝트다.

여기서 다루지 않은 주제로는 CQRS, Event Sourcing, Saga 패턴 등이 있다. 이들은 DDD의 전술적 패턴을 더 정교하게 활용하기 위한 아키텍처 패턴으로, 각각의 공식 문서와 Vaughn Vernon의 Implementing Domain-Driven Design을 참고하면 좋다.


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
DDD 6편 — Domain Service와 Application Service