Table of contents
- Aggregate 사이의 간극
- Domain Event란
- 이벤트의 구성 요소
- 이벤트 발행 — Spring의 ApplicationEventPublisher
- 이벤트 발행/구독 흐름
- @TransactionalEventListener — 트랜잭션과 이벤트의 관계
- Aggregate 내부에서 이벤트 수집하기
- Anti-Corruption Layer — 외부로부터 도메인을 보호하기
- ACL 구현 — 외부 결제 API 통합
- ACL이 없으면 어떻게 되는가
- Domain Event + ACL — 결합의 실전 패턴
- 시리즈를 마치며
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에는 최소한 다음 정보가 포함되어야 한다.
- 이벤트 타입: 클래스 이름 자체가 타입을 나타낸다 (
OrderPlacedEvent) - 발생 시각: 언제 이 사건이 일어났는지
- 관련 식별자: 어떤 Aggregate에서 발생했는지 (주문 ID, 결제 ID 등)
- 필요한 데이터: 이벤트를 처리하는 쪽이 원본 Aggregate를 다시 조회하지 않아도 되는 수준의 정보
이벤트에 너무 많은 데이터를 담으면 이벤트가 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사 특유의 에러 코드가 도메인에 등장
}
}
}
이 코드의 문제점은 세 가지다.
- 도메인에 외부 모델이 침투한다.
DONE,ALREADY_PROCESSED_PAYMENT같은 PG사 고유 문자열이 도메인 로직에 박혀 있다. - PG사 변경이 도메인 변경을 유발한다. 다른 PG사는 상태값을
SUCCESS로 쓸 수 있고, 에러 코드 체계도 다르다. - 테스트가 어렵다. 외부 API를 모킹해야 하고, 모킹 대상이 PG사마다 달라진다.
ACL을 두면 이 세 가지가 모두 해소된다. 도메인은 PaymentResult.Success와 PaymentResult.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을 참고하면 좋다.




Loading comments...