DDD · Integration
Bounded Context 사이의
대화법
Bounded Context 경계를 제대로 그었다면 곧바로 다음 질문이 나온다. 한 BC가 다른 BC에 뭔가를 요청하거나 무슨 일이 있었다고 알리려면 어떻게 해야 할까? 솔직한 답은 둘뿐이다. 상황에 안 맞는 쪽을 고르면 분산 시스템은 티 나지 않게 분산 모놀리스가 된다.
같은 프로세스 안에서 한 Bounded Context가 다른 BC를 써야 할 때 방법은 두 가지다. Adapter로 동기 호출을 하거나, Integration Event로 비동기 처리를 한다. 메시지 브로커, Saga, 코레오그래피와 오케스트레이션 같은 나머지 이야기는 모두 이 선택을 변형한 것이다. 유스케이스마다 이 선택을 제대로 해야 BC들이 공유 트랜잭션으로 몰래 엮이지 않고, 저마다 따로 배포할 수 있는 상태로 남는다.
질문 네 개로 고르기
먼저, 지금 요청의 응답에 외부 BC의 데이터가 당장 필요한가? 그렇다면 동기 호출을 써야 한다. 다음으로, 호출받는 BC가 상태를 바꾸는가, 읽히기만 하는가? 동기 호출로 상태를 바꾼다면 두 BC를 한 트랜잭션으로 묶기 한 걸음 앞까지 온 셈이다. 대개는 설계를 다시 보라는 신호다. 외부 호출이 실패했을 때 지금 트랜잭션을 롤백해야 하는가? 그럴 필요가 없다면, 즉 최종적 일관성(eventual consistency)으로 충분하다면 비동기 쪽으로 기우는 게 맞다. 마지막으로, 호출이 "듣는 쪽이 누구든 알린다"처럼 원래 한 방향인가? 그렇다면 그건 요청이 아니고 이벤트다.
외부 BC에 필요한 게 읽기가 아니라 상태 변경이라면, 동기 호출로 두 BC를 한 트랜잭션에 묶지 않는다. Integration Event를 보내서 각 BC가 따로 처리하게 한다.
동기 호출은 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의 쓰기 메서드를 부르지 않는다. 쓰기가 꼭 필요하다면 Integration Event로 바꿀 때가 된 것이다.
이 정도로 분명한 규칙이면 기계로 검사할 수 있다. 같은 백엔드 설계를 5개 언어로 나란히 구현해 둔 내 예제 프로젝트에서는 언어마다 아키텍처 검사에 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)도 똑같은 반응 로직을 그대로 쓴다. Go 구현에서는 두 쪽이 다른 곳에 있다. internal/application/event/payment_cancelled_event_handler.go는 Payment 쪽으로, 도메인 이벤트를 internal/application/integration-event/에 정의한 PaymentCancelledV1 Integration Event로 바꿔 payment.cancelled.v1로 발행한다. Account 쪽 반응은 cmd/server/main.go의 구독으로, 그 이벤트를 입금 명령으로 옮긴다.
한 유스케이스에서 두 방식 함께 쓰기
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(Payment 쪽이 payment.cancelled.v1을 발행하는 곳) · cmd/server/main.go(보상 입금을 실행하는 Account 쪽 구독)