결제 한 건으로 DDD 와 헥사고날을 다시 그려본 이야기 — 정책은 도메인 안쪽, 외부 시스템은 어댑터 바깥쪽
DDD 와 헥사고날 아키텍처를 글로만 읽으면 "그래서 결국 폴더 어떻게 가르라는 거지?" 에서 한 번 멈춥니다. 이번 글은 결제 한 건이라는 단순한 예제를 가지고, 도메인 정책은 안쪽에, 외부 시스템은 어댑터 바깥쪽에 두는 게 코드에서 어떻게 보이는지를 다시 그려본 기록입니다.
1. 예제 요구사항
서버는 다음 결제 정책을 적용해야 한다고 해 봅시다.
- 결제 금액이 50,000 원 이상 이면 수수료 10% 를 더합니다.
- VIP 고객은 수수료를 면제 합니다.
- VIP 여부는 CRM 시스템 조회 를 통해 확인합니다.
- 결제는 내부 결제 시스템 을 통해 승인합니다.
요구사항을 두 덩어리로 갈라 보면 이렇게 나뉩니다.
- 도메인 정책 — VIP / 50,000원 / 수수료 10% 라는 비즈니스 규칙
- 외부 시스템 협력 — CRM 조회, 결제 승인 같은 바깥 세계와의 통신
이 두 덩어리를 같은 메서드 안에 섞어 두면 정책이 바뀔 때마다 외부 통신 코드를 함께 건드리게 됩니다. 헥사고날 아키텍처가 풀고 싶어 하는 게 정확히 이 지점입니다.
2. 전체 구조 — 안에서 바깥으로 동심원
헥사고날 아키텍처는 보통 동심원으로 그립니다. 안쪽이 도메인이고, 바깥으로 갈수록 외부에 가까워집니다. 이번 예제를 그림으로 그려보면 대략 이런 모양입니다.
외부 시스템
┌─────────────────────┐
│ CRM API │
│ Payment API │
└─────────┬───────────┘
│
┌─────────▼───────────┐
│ Adapter │ ← 외부 시스템과 실제로 통신
└─────────┬───────────┘
│ (Port 인터페이스 호출)
┌─────────▼───────────┐
│ Application │ ← 유스케이스를 조립
└─────────┬───────────┘
│
┌─────────▼───────────┐
│ Domain │ ← 정책 / 엔티티
└─────────────────────┘핵심은 화살표가 항상 안쪽을 향한다 는 점입니다. 도메인은 바깥을 모릅니다. Application 은 Port 인터페이스만 알고, 어떤 Adapter 가 들어와 있는지는 모릅니다.
3. 도메인 설계
3.1 엔티티 — 상태는 보유하되 정책은 모릅니다
Order 엔티티는 결제 여부 같은 상태만 갖습니다. "50,000 원이 넘으면 수수료 10%" 같은 정책은 엔티티가 직접 모릅니다.
public class Order {
private final Long id;
private final int price;
private boolean paid;
public Order(Long id, int price) {
this.id = id;
this.price = price;
this.paid = false;
}
public void markPaid() {
this.paid = true;
}
public int getPrice() { return price; }
public boolean isPaid() { return paid; }
}도메인 객체에 정책을 끼워 넣는 대신, 상태 변경 메서드만 노출합니다. 조건에 따라 값을 어떻게 바꿀지는 도메인 서비스가 정해서 호출 합니다. 이렇게 두면 엔티티가 점점 거대한 if 덩어리로 변하는 걸 막을 수 있습니다.
3.2 Command 객체 — 들어오는 요청을 한 묶음으로
애플리케이션 계층이 도메인 정책 실행을 요청할 때 사용하는 입력 묶음입니다.
public record PaymentCommand(
Order order,
Long customerId
) {}3.3 Context 객체 — 정책 계산에 필요한 정보만 따로
도메인 서비스가 정책을 계산할 때, 그 시점에 필요한 정보만 깔끔하게 묶어서 넘깁니다. CRM 호출 같은 외부 통신은 여기서 끝나 있어야 합니다. 도메인 서비스는 이미 결정된 사실(VIP 여부)만 받습니다.
public record PaymentPolicyContext(
Order order,
boolean isVip
) {}3.4 도메인 서비스 — 정책만 들어 있는 순수한 함수
도메인 서비스는 입력에서 결과를 계산할 뿐, 어떤 외부 시스템도 호출하지 않습니다. 그래서 단위 테스트가 거의 함수 테스트 수준으로 쉬워집니다.
public class PaymentPolicy {
public int calculate(PaymentPolicyContext context) {
if (context.isVip()) {
return context.order().getPrice();
}
if (context.order().getPrice() >= 50000) {
return (int) (context.order().getPrice() * 1.1);
}
return context.order().getPrice();
}
}이 함수만 보면 CRM 도, 결제 게이트웨이도, DB 도 등장하지 않습니다. 정책의 변화는 이 한 군데만 손대면 되는 구조 입니다.
4. 애플리케이션 레이어 — 유스케이스를 조립
애플리케이션 서비스가 하는 일은 "어떤 정책을 어떤 순서로 부를 것인가" 를 정하는 것입니다. 본인이 직접 정책을 계산하지도, 외부 시스템과 직접 통신하지도 않습니다.
public class OrderService {
private final PaymentPort paymentPort;
private final CrmPort crmPort;
private final PaymentPolicy policy;
public OrderService(
PaymentPort paymentPort,
CrmPort crmPort,
PaymentPolicy policy
) {
this.paymentPort = paymentPort;
this.crmPort = crmPort;
this.policy = policy;
}
public void execute(PaymentCommand command) {
boolean isVip = crmPort.isVip(command.customerId());
PaymentPolicyContext context = new PaymentPolicyContext(
command.order(),
isVip
);
int amount = policy.calculate(context);
boolean success = paymentPort.pay(amount);
if (success) {
command.order().markPaid();
}
}
}이 메서드를 한 줄로 설명해 보라고 하면 이렇게 답할 수 있습니다.
"CRM 에서 VIP 여부를 받아 와서, 정책으로 금액을 계산하고, 결제 포트에 승인을 요청한 뒤, 성공이면 주문을 결제 완료로 표시한다."
여기서 중요한 건 crmPort, paymentPort 가 인터페이스 라는 점입니다. 실제 통신은 그 뒤의 어댑터가 합니다.
5. Port — 외부에 필요한 능력을 인터페이스로
Port 는 "도메인이 외부에 무엇을 요구하는가" 를 표현합니다. Adapter 가 무엇을 어떻게 쓰는지 가 아니라 Application 이 무엇이 필요한지 의 언어로 적습니다.
public interface PaymentPort {
boolean pay(int amount);
}
public interface CrmPort {
boolean isVip(Long customerId);
}PaymentPort.pay(int) 는 "결제 한 건 승인" 이라는 도메인적 요구를 표현하지, "HTTP POST /payments/approve" 같은 외부 디테일을 끌고 들어오지 않습니다. 외부 시스템이 REST 든 gRPC 든 메시지 큐든 상관없이, 인터페이스는 그대로 둘 수 있습니다.
6. Adapter — 포트의 실제 구현
Adapter 는 Port 를 구현하면서, 그 안쪽에서 실제 외부 시스템과 통신합니다. 예제이므로 간단히 적습니다.
public class InternalPaymentAdapter implements PaymentPort {
@Override
public boolean pay(int amount) {
System.out.println("내부 결제 승인: " + amount);
return true;
}
}public class InternalCrmAdapter implements CrmPort {
@Override
public boolean isVip(Long customerId) {
return customerId == 1L;
}
}CRM 이 외주 시스템으로 바뀐다면 ExternalCrmAdapter 를 새로 만들어 끼우면 됩니다. 도메인 / 애플리케이션 코드는 손대지 않습니다.
7. 조립과 실행
마지막 단계는 빈/객체 그래프를 만드는 일입니다. Spring 환경이면 @Configuration 으로 풀겠지만, 예제이므로 main 안에서 묶어 둡니다.
public class App {
public static void main(String[] args) {
PaymentPort paymentPort = new InternalPaymentAdapter();
CrmPort crmPort = new InternalCrmAdapter();
PaymentPolicy policy = new PaymentPolicy();
OrderService service = new OrderService(paymentPort, crmPort, policy);
Order order = new Order(1L, 60000);
service.execute(new PaymentCommand(order, 1L));
System.out.println(order.isPaid());
}
}여기서 새 어댑터로 교체하는 비용이 거의 0 에 가깝다는 점이 헥사고날 구조의 분명한 이득입니다.
8. 이 구조가 가져다 주는 것
코드를 다 그려놓고 다시 보면, 이렇게 가르는 게 어떤 이득이 있는지가 정리됩니다.
- 정책과 외부 시스템이 분리됩니다. 정책 변경은 도메인 서비스 한 곳, 외부 시스템 변경은 어댑터 한 곳.
- 테스트가 쉬워집니다. 도메인 서비스는 외부 의존이 없어서 거의 함수 단위 테스트가 가능합니다.
- 외부 시스템 교체 비용이 작아집니다. 인터페이스가 안쪽을 향해 있어서, 바깥쪽 부품을 갈아 끼워도 안쪽이 무사합니다.
- 데이터 전달이 명시적입니다. Command / Context 객체가 입력 형태를 강제하니, 어떤 함수가 무엇을 요구하는지가 시그니처에서 바로 보입니다.
반대로, 이 구조가 모든 상황에 어울리는 건 아닙니다. 단순한 CRUD 한 줄을 처리하면서까지 Port / Adapter / Command / Context 를 깔면 코드가 의식의 흐름을 따라가지 못합니다. 다음 섹션에서 적용 기준을 정리해 둡니다.
9. 어떤 상황에 어울리나
이 패턴이 비용을 지불할 가치가 있는 상황을 적어 둡니다.
- 비즈니스 정책 변경이 잦은 도메인
- 보상 정책 변경, 등급 기준 변경, 대시보드 산식 변경처럼 정책이 자주 흔들리는 영역.
- 도메인 서비스에서 정책을 처리하면 변경 범위가 한곳에 갇힙니다.
- 외부 시스템 연동이 있고, 교체 가능성이 있는 영역
- 콜백 시스템 교체, 알림톡 벤더 변경, API 버전 업.
- Port / Adapter 구조 덕분에 어댑터만 갈아 끼우면 됩니다.
- 테스트가 중요한 핵심 도메인 로직
- 외부 의존이 없으면 단위 테스트가 거의 함수 테스트 수준으로 떨어집니다.
- 정책 회귀를 자동화하기 쉬워집니다.
- 여러 팀·시스템이 결합하는 환경
- MSA, B2B 연동처럼 외부 경계가 많은 상황.
- 결합도가 낮게 유지되는 게 운영 비용에 직접 들어옵니다.
반대로 한 페이지 CRUD, 일회성 스크립트, 사내 잡일 자동화 정도라면 이 패턴은 보통 과합니다. 이득이 어디서 오는지 를 먼저 적어 두고, 그 이득이 필요한 자리에만 끼우는 게 핵심이었습니다.
마무리
이번 정리에서 가장 크게 남는 한 줄은 이거였습니다.
도메인 안쪽에는 "왜" 만, 어댑터 바깥쪽에는 "어떻게" 만 둔다.
VIP 면제와 수수료 10% 라는 왜 는 도메인 서비스에 모이고, CRM API 호출이나 결제 게이트웨이 호출이라는 어떻게 는 어댑터에 모입니다. 사이를 잇는 Port / Command / Context 가 "둘 사이에 무엇이 오고 가는가" 를 시그니처로 못 박아 둡니다. 결제 한 건 짜리 예제에 끼우기에는 약간 과한 구조처럼 보이지만, 정책이 흔들리고 외부 시스템이 바뀔 때마다 진가가 드러나는 패턴이었습니다.
다음에 새 도메인을 짤 일이 생기면, 코드부터 짜기 전에 다음 세 줄을 먼저 적어 두려고 합니다.
- 이 도메인의 "왜" 는 무엇인가
- 그 "왜" 를 흔드는 외부 시스템은 무엇인가
- 두 세계를 잇는 입력 묶음(Command / Context)은 어떤 모양인가
이 세 줄이 잡히면 폴더 구조는 그 다음 자연스럽게 따라오는 것 같습니다.