본문 바로가기

Architecture/DDD

[DDD] 바운디드 컨텍스트 매핑 패턴 - OHS/PL과 ACL 중심으로

1. BC 간 통합 패턴 전체 목록

Bounded Context 간 통합 방식을 결정하는 것이 컨텍스트 매핑(Context Mapping)입니다.


IDDD(Implementing Domain-Driven Design)에서는 다음과 같은 패턴들을 제시합니다.
각 패턴은 두 BC 사이의 팀 관계기술적 통합 방식을 함께 표현합니다.

패턴 핵심 Upstream/Downstream
파트너십 (Partnership) 두 팀이 대등하게 협력, 함께 일정 조율 없음 (대등)
공유 커널 (Shared Kernel) 일부 도메인 모델을 두 BC가 공유 없음 (공유)
고객-공급자 (Customer-Supplier) Upstream이 Downstream 요구사항을 수용 U/D 있음
순응주의자 (Conformist) Downstream이 Upstream 모델을 그냥 따름 U/D 있음
OHS/PL Upstream이 공개 API + 공용 언어로 노출 U/D 있음
ACL (Anti-Corruption Layer) Downstream이 번역 레이어로 오염 방지 U/D 있음
분리된 방법 (Separate Ways) 통합 포기, 각자 독립 구현 없음
큰 진흙공 (Big Ball of Mud) 레거시 혼돈 시스템, ACL로 격리 -

OHS/PL과 ACL은 나머지 패턴들과 추상화 수준이 다릅니다.

고객-공급자, 순응주의자 등은 팀 관계를 기술하고,
OHS/PL과 ACL은 기술적 통합 방식을 기술합니다. 따라서 함께 조합해서 사용합니다.

 

분리된 방법(Separate Ways) 은 두 BC 간에 아예 통합하지 않기로 결정하는 패턴입니다.
언뜻 나쁜 설계처럼 보이지만, 핵심은 "통합 안 한다"는 걸 명시적으로 결정하는 것입니다.
무의식적으로 방치하는 것과, 컨텍스트 맵에 Separate Ways로 표기하는 것은 완전히 다릅니다.

 


2. 고객-공급자 vs 순응주의자

  고객-공급자 순응주의자
Downstream의 힘 있음 (요구사항 협상 가능) 없음
Upstream 팀 관계 협력적 일방적
도메인 순수성 지킬 수 있음 오염 위험
선택 가능 조건 같은 조직, 협력 문화 -

이상적으로는 고객-공급자가 낫지만, 순응주의자가 강제되는 상황도 있습니다.

  • Upstream이 외부 서비스라 협상 자체가 불가한 경우 (결제 PG사, 공공 API 등)
  • Upstream 팀이 다른 조직이라 요구사항을 전달할 채널이 없는 경우
  • Upstream이 레거시 시스템이라 변경 비용이 너무 큰 경우

이 경우엔 순응주의자를 택하되, ACL을 반드시 붙여서 외부 모델이 내 도메인으로 스며드는 걸 막는 것이 현실적인 전략입니다.

 


3. OHS / PL 상세

OHS (Open Host Service)

Upstream BC가 자신의 기능을 공개된 서비스 형태로 제공하는 패턴입니다.
핵심은 특정 Downstream만을 위한 전용 API가 아니라, 누구나 사용할 수 있는 범용 인터페이스를 제공한다는 점입니다.

// 특정 팀만을 위한 전용 API (OHS가 아님)
@GetMapping("/orders/for-inventory-team")
public InventoryTeamSpecificResponse getForInventory(...) { ... }

// OHS: 범용적으로 설계된 공개 인터페이스
@GetMapping("/orders/{orderId}")
public OrderResponse getOrder(@PathVariable String orderId) { ... }
  OHS 전용 API
대상 불특정 다수 Downstream 특정 팀
변경 시 하위 호환성 유지 필요 해당 팀과 협의
설계 기준 범용성 요청자 맞춤

 

PL (Published Language)

OHS가 "어떻게 노출할지"라면, PL은 "어떤 언어(포맷/스펙)로 노출할지" 입니다.
OHS와 항상 함께 다닙니다.

OHS  →  "REST API로 공개한다"
PL   →  "이 OpenAPI 스펙 문서가 계약이다"

 

PL의 실제 형태는 OpenAPI 스펙, Protobuf, Avro 스키마 등이 있습니다.

# OpenAPI 스펙 (PL의 전형적인 예)
components:
  schemas:
    OrderResponse:
      properties:
        orderId:
          type: string
        status:
          type: string
          enum: [PENDING, CONFIRMED, CANCELLED]  # 이 용어 정의 자체가 PL

 

PL이 중요한 이유는 용어의 표준화 때문입니다.


status가 어떤 값을 가질 수 있는지, orderId가 어떤 형식인지를 문서화된 계약으로 명시해야
Downstream이 안정적으로 사용할 수 있습니다.

 


4. ACL (Anti-Corruption Layer) 상세

왜 필요한가

ACL 없이 Upstream의 모델을 그대로 사용하면 외부 용어가 내 도메인까지 침투합니다.

// ACL 없이 외부 모델이 도메인까지 침투한 상태
public class Order {
    private String externalInventoryItemCode;  // 외부 용어가 내 도메인에
    private int remainingCount;                // 외부 용어가 내 도메인에
}

외부 시스템이 itemCodeproductCode로 바꾸는 순간, 내 도메인 모델까지 수정해야 합니다.
ACL은 이 의존성 전파를 차단합니다.

 

ACL의 세 가지 책임

1. 모델 번역

외부 필드명/타입을 내 도메인 모델의 언어로 바꿉니다.

private Stock translate(ExternalInventoryResponse response) {
    // 외부 용어(remainingCount) → 내 도메인 모델(Stock)
    return new Stock(response.getRemainingCount());
}

 

2. 개념 매핑

외부 시스템과 내 도메인의 개념 자체가 다를 때 더 중요해집니다.
단순한 필드 변환이 아니라, 의미의 재해석이 필요합니다.

private OrderStatus translateStatus(ExternalOrderStatus externalStatus) {
    // 외부 시스템: 숫자 코드로 상태 표현
    // 내 도메인: 명확한 의미를 가진 열거형으로 표현
    return switch (externalStatus.getCode()) {
        case 1 -> OrderStatus.PENDING;
        case 2 -> OrderStatus.CONFIRMED;
        case 3, 4 -> OrderStatus.CANCELLED; // 외부의 2가지 상태 → 내 도메인의 1가지로 통합
        default -> throw new UnknownStatusException(externalStatus.getCode());
    };
}

 

3. 외부 모델 캡슐화

외부 응답 객체(ExternalInventoryResponse)가 Adapter 밖으로 새어나가지 않도록 막습니다.

@Component
public class ExternalInventoryAdapter implements InventoryPort {

    @Override
    public Stock checkStock(ProductId productId) {
        ExternalInventoryResponse response = externalClient.getInventory(...);
        return translate(response);
        // ExternalInventoryResponse는 이 클래스 밖으로 절대 나가지 않음
    }
}

ExternalInventoryResponse가 Adapter 밖으로 나가는 순간 ACL이 깨집니다.
Application이나 Domain이 외부 모델 타입을 직접 참조하게 되면, 외부 변경이 내부로 전파됩니다.

 

ACL을 별도 클래스로 분리할 때

번역 로직이 복잡해지면 ACL을 별도 클래스로 명시적으로 분리하기도 합니다.

// 번역 책임만 담당하는 ACL 클래스
@Component
public class InventoryAcl {

    public Stock toStock(ExternalInventoryResponse response) {
        return new Stock(response.getRemainingCount());
    }

    public ProductId toProductId(String externalItemCode) {
        // 외부 코드 형식 → 내 도메인 ID 형식으로 변환
        return new ProductId(externalItemCode.replace("EXT-", ""));
    }
}

// Adapter는 외부 호출만 담당하고, 번역은 ACL에 위임
@Component
public class ExternalInventoryAdapter implements InventoryPort {

    private final ExternalInventoryClient externalClient;
    private final InventoryAcl inventoryAcl;

    @Override
    public Stock checkStock(ProductId productId) {
        ExternalInventoryResponse response = externalClient.getInventory(...);
        return inventoryAcl.toStock(response);
    }
}

번역 로직이 단순하면 Adapter 안의 private 메서드로 두고,
복잡해지면 별도 클래스로 분리하는 것이 적절한 기준입니다.


5. 헥사고날 아키텍처와의 관계

OHS/ACL은 DDD 전략 패턴이고, 포트/어댑터는 전술적 구현 수단입니다.
두 개념은 추상화 수준이 다르며, OHS/ACL을 구현하는 데 헥사고날 아키텍처를 사용하는 관계입니다.

 

DDD 전략 패턴 헥사고날 대응 개념 방향
OHS Incoming Adapter + Incoming Port 외부 → 내 BC
ACL Outgoing Adapter 내부 내 BC → 외부

 

전체 흐름 코드

Incoming Adapter (OHS의 구현체)

@RestController
@RequestMapping("/orders")
public class OrderController {
    private final CreateOrderUseCase createOrderUseCase;

    @PostMapping
    public ResponseEntity<CreateOrderResponse> createOrder(
            @RequestBody CreateOrderRequest request) {
        CreateOrderCommand command = new CreateOrderCommand(
                new CustomerId(request.getCustomerId()),
                new ProductId(request.getProductId()),
                request.getQuantity()
        );
        OrderId orderId = createOrderUseCase.createOrder(command);
        return ResponseEntity.ok(new CreateOrderResponse(orderId.getValue()));
    }
}

 

Incoming Port

public interface CreateOrderUseCase {
    OrderId createOrder(CreateOrderCommand command);
}

 

Application Service

@Service
@Transactional
public class CreateOrderService implements CreateOrderUseCase {
    private final OrderRepository orderRepository;
    private final InventoryPort inventoryPort;

    @Override
    public OrderId createOrder(CreateOrderCommand command) {
        // Outgoing Port를 통해 재고 확인 — 외부 시스템이 어떻게 생겼는지 모름
        Stock stock = inventoryPort.checkStock(command.getProductId());
        if (!stock.isAvailable(command.getQuantity())) {
            throw new InsufficientStockException();
        }
        Order order = Order.create(
                command.getCustomerId(),
                command.getProductId(),
                command.getQuantity()
        );
        orderRepository.save(order);
        return order.getId();
    }
}

 

Outgoing Port (내 도메인 언어로만 정의)

// 외부 재고 시스템이 어떻게 생겼는지 전혀 모른다
// 오직 내 도메인 언어(Stock, ProductId)만 사용
public interface InventoryPort {
    Stock checkStock(ProductId productId);
}

 

Outgoing Adapter (ACL의 실체)

@Component
public class ExternalInventoryAdapter implements InventoryPort {
    private final ExternalInventoryClient externalClient;

    @Override
    public Stock checkStock(ProductId productId) {
        ExternalInventoryResponse response =
                externalClient.getInventory(productId.getValue());
        return translate(response); // ACL의 핵심: 외부 모델 → 내 도메인 모델
    }

    private Stock translate(ExternalInventoryResponse response) {
        return new Stock(response.getRemainingCount());
    }
}

 


6. 고객-공급자와 OHS/ACL의 공존

세 패턴은 서로 다른 관점을 다루기 때문에 함께 사용할 수 있습니다.

패턴 관점 역할
고객-공급자 조직 두 팀이 어떻게 협력할 것인가
OHS/PL 인터페이스 Upstream이 무엇을 어떻게 노출할 것인가
ACL 번역 Downstream이 어떻게 자신을 보호할 것인가

 

  • 고객-공급자: 주문 팀이 요구사항을 전달하고, 재고 팀이 수용해서 OHS를 설계하는 팀 간 협력 방식
  • OHS/PL: 재고 팀이 실제로 REST API와 OpenAPI 스펙을 만들어 공개하는 방식
  • ACL: 주문 팀이 재고 API의 모델이 자신의 도메인을 오염시키지 않도록 번역하는 방식

세 패턴은 같은 통합을 각각 다른 레이어에서 바라보기 때문에 충돌 없이 함께 사용됩니다.

 


7. 정리

OHS/PL과 ACL은 통합의 양쪽 끝을 담당합니다.

  • OHS/PL: Upstream이 노출을 표준화
  • ACL: Downstream이 수신을 보호

두 패턴은 서로를 전제하는 관계입니다.


OHS/PL이 잘 정의되어 있을수록 ACL의 번역 로직이 단순해지고,
ACL이 잘 구현되어 있을수록 Upstream의 변경이 내 도메인에 미치는 영향이 줄어듭니다.