DDD · Integration
Bounded Context 사이의
대화법
Bounded Context 경계가 실제로 자리 잡고 나면, 다음 질문은 피할 수 없다. 한 BC가 다른 BC에게 뭔가를 요청하거나, 무슨 일이 일어났다고 알려주려면 어떻게 해야 할까? 정직한 답은 정확히 둘뿐이고, 상황에 맞지 않는 쪽을 고르는 순간 분산 시스템은 조용히 분산 모놀리스로 변한다.
같은 프로세스 안에서 한 Bounded Context가 다른 BC를 필요로 할 때, 방법은 두 가지다: Adapter를 통한 동기 호출, 아니면 Integration Event를 통한 비동기 처리. 메시지 브로커, Saga, 코레오그래피 대 오케스트레이션 같은 나머지 모든 이야기는 결국 이 선택의 변주일 뿐이다. 유스케이스마다 이 선택을 제대로 하는 것이야말로 BC들을 하나의 공유 트랜잭션으로 몰래 결합시키지 않고 독립적으로 배포 가능한 상태로 유지하는 방법이다.
네 가지 질문으로 정리하는 판단 기준
지금 이 요청의 응답이 외부 BC의 데이터를 당장 필요로 하는가? 그렇다면 동기 호출이 필요하다. 호출당하는 BC가 실제로 상태를 변경하는가, 아니면 단순히 읽히기만 하는가? 동기 호출을 통한 상태 변경이라면, 사실상 두 BC를 하나의 트랜잭션으로 묶는 것이나 다름없다 — 대개는 다시 생각해봐야 한다는 신호다. 외부 호출이 실패하면 현재 트랜잭션이 롤백되어야 하는가? 그렇지 않다면 — 즉 최종적 일관성(eventual consistency)으로 충분하다면 — 그것은 비동기 쪽으로 강하게 기우는 신호다. 그리고 호출 방향이 본질적으로 "듣고 있는 누구에게든 알린다" 같은 일방향인가? 그것은 요청이 아니라 이벤트다.
외부 BC에서 필요한 것이 읽기가 아니라 상태 변경이라면, 동기 호출로 두 BC를 하나의 트랜잭션에 묶지 마라. 각 BC가 Integration Event를 통해 독립적으로 처리하게 하라.
동기 방식: Adapter 패턴
현재 요청 안에서, 외부 BC의 서비스로부터 즉시 뭔가를 조회해야 할 때 사용한다. 사용자 이름을 포함해야 하는 주문 상세 응답이나, 결제를 처리하기 전의 잔액 확인 — 둘 다 현재 요청이 끝나기 전에 답을 필요로 한다.
[Order BC Application] → UserAdapter (interface) → UserAdapterImpl → [User BC Service]
(my application/adapter/) (my infrastructure/)Adapter는 Anticorruption Layer 역할을 한다. 외부 BC의 모델이나 인터페이스가 형태를 바꾸더라도, 이쪽의 내부 도메인 모델은 영향을 받지 않는다 — 그 변환이 일어나는 곳이 바로 Adapter이며, 모든 호출 지점에 흩어지는 대신 한 곳에서 딱 한 번 처리된다. 주의해야 할 두 가지가 있다: 외부 BC의 Repository나 Service를 Application 계층에 직접 주입하지 말 것 — 항상 Adapter 인터페이스를 거칠 것 — 그리고 Adapter를 통해 외부 BC의 쓰기(write) 메서드를 호출하지 말 것. 정말로 쓰기가 필요하다면, 그것이 바로 Integration Event로 전환해야 한다는 신호다.
이건 기계적으로 검사할 수 있을 만큼 정확히 정의된 규칙이다: 모든 언어의 harness는 no-cross-bc-repository-in-application 규칙을 갖고 있으며, Application 계층 파일이 다른 BC의 Repository를 직접 import하면 이를 잡아낸다 — 같은 도메인 안에서 Repository를 import하는 정상적인 패턴은 대상이 아니다.
비동기 방식: Integration Event
이 BC 자신의 도메인 작업이 끝난 뒤, 외부 BC가 반응해서 자기 자신의 상태를 바꿔야 할 때 사용한다 — 주문이 취소된 뒤 Payment BC가 환불을 처리해야 하는 경우, 주문이 완료된 뒤 Notification BC가 이메일을 보내야 하는 경우가 그렇다. 둘 다 원래 요청을 막아설 필요가 없다.
[Order BC] → Domain Event → Application EventHandler → Integration Event → Outbox → message queue
↓
[Payment BC] ← IntegrationEventControllerIntegration Event는 내부 Domain Event를 있는 그대로 외부에 노출하지 않는다 — Application EventHandler가 바로 그 변환 지점이며, Adapter와 같은 anticorruption 개념을 반대 방향으로 적용한 것이다. 그리고 수신 측은 at-least-once 전달을 전제해야 하므로, 신뢰할 수 있는 이벤트 기반 설계 전반에서 일반적으로 요구하는 것과 똑같이 처리를 멱등(idempotent)하게 구현한다.
실제 보상 트랜잭션 사례
Payment BC는 동기 Adapter를 통해 계정의 활성 상태와 잔액을 확인한 다음, 결제를 완료 처리한다(payment.completed.v1 발행). Account BC는 그 이벤트를 구독해 실제 차감을 수행한다 — 동기 확인과 비동기 차감 사이에 짧은 최종적 일관성 구간이 존재하는데, 이 간극은 실수가 아니라 명시적으로 받아들인 설계 결정이다.
결제가 나중에 취소되면(payment.cancelled.v1) Account BC는 같은 방식으로 구독해, 이미 차감된 금액을 되돌리는 보상 입금(compensating credit)을 실행한다 — 트랜잭션 롤백이 아니라, 이전 상태 변경을 제자리에서 되돌리는 대신 그것을 상쇄하는 새로운 비동기 이벤트라는 점에서 전형적인 크로스 BC 보상 트랜잭션이다. 환불 승인(refund.approved.v1) 역시 정확히 같은 반응 로직을 재사용한다. 실제 구현은 implementations/go/examples/internal/application/event/payment_cancelled_event_handler.go에 있으며, internal/application/integration-event/에 정의된 PaymentCancelledV1 Integration Event에 반응한다.
하나의 유스케이스에서 두 방식을 함께 쓰기
하나의 Command Handler가 작업의 서로 다른 부분에 두 패턴을 함께 쓰는 일은 흔하다 — 응답이 지금 당장 필요로 하는 것에는 동기 조회를, 그렇지 않은 후속 반응에는 비동기 후속 처리를:
func (h *CancelOrderHandler) Handle(ctx context.Context, cmd CancelOrderCommand) error {
// 1. A synchronous cross-BC lookup via an Adapter (needed for the response)
user, err := h.userAdapter.FindUser(ctx, cmd.UserID)
if err != nil {
return fmt.Errorf("cancel order: %w", err)
}
if user == nil {
return order.ErrUserNotFound
}
o, err := order.FindOne(ctx, h.orderRepository, cmd.OrderID, cmd.UserID)
if err != nil {
return fmt.Errorf("cancel order: %w", err)
}
if err := o.Cancel(cmd.Reason); err != nil {
return err
}
// 2. save → Domain Event → Integration Event (requesting a refund from the Payment BC is asynchronous)
return h.orderRepository.SaveOrder(ctx, o)
}고전적인 Context Map 패턴과의 대응 관계
Context Mapping 용어를 이미 알고 있다면, 위의 두 패턴은 그 구체적인 구현체다. Anticorruption Layer는 곧 Adapter로, 외부 모델로부터의 오염을 막는다. Published Language를 갖춘 Open Host Service는 order.cancelled.v1처럼 명시적인 버전과 함께 Integration Event를 발행하는 것에 해당한다. Conformist는 Adapter 없이 외부 BC의 모델을 그대로 사용하는 것으로 — 권장되지 않으며, 대개 경계가 서둘러 그어졌다는 신호다. Customer-Supplier는 흔히 두 패턴이 함께 조합된 형태로 나타나는데, 위의 보상 트랜잭션 예시가 정확히 그런 경우다.
이 중 어느 것도 시작 단계부터 메시지 브로커를 필요로 하지 않는다. 단일 배포 단위 안에서도 같은 원칙을 지키는 것 — 조회를 위한 진짜 Adapter 인터페이스, 반응을 위한 진짜 이벤트 계약 — 이 나중에 BC를 별도 서비스로 분리할 때 그 작업을 재작성이 아니라 리팩터링으로 만들어준다.
docs/architecture/cross-domain-communication.md — 전체 판단 기준표와 Context Map 대응 관계 · payment_cancelled_event_handler.go — 실제 보상 입금(compensating credit) 반응 로직