← all posts
DEV 2026.05.02 · 12 min read Intermediate

Hexagonal Architecture는 왜 도메인이 아무것도 모르게 설계하는가

레이어드 아키텍처의 의존성 문제부터 Port/Adapter 구조, DDD 통합, 실제 비용까지 — Hexagonal의 철학과 트레이드오프를 추적한다.


레이어드 아키텍처에서 OrderServiceJpaOrderRepository를 직접 주입받는 순간, 도메인은 JPA를 알게 된다. JPA가 바뀌면 서비스 코드가 바뀌고, KakaoPay SDK가 바뀌면 비즈니스 로직이 영향받는다. Hexagonal Architecture는 이 방향을 뒤집는다 — 도메인은 아무것도 모르고, 인프라가 도메인을 구현한다. 왜 이 단순한 역전이 테스트·유지보수·인프라 교체 전반을 바꾸는가?

육각형의 출발점 — Port란 무엇인가

2005년 Alistair Cockburn이 제안한 원래 이름은 “Ports and Adapters Architecture”다. “Hexagonal”은 각 면이 하나의 Port를 나타내는 육각형 다이어그램에서 붙은 별칭이다. 사각형(레이어드)이 위아래 방향만 허용하는 것과 달리, 육각형은 어느 방향에서도 포트를 통해 진입할 수 있다는 시각적 선언이다.

Port는 내부(Application Core)와 외부(Adapter) 사이의 인터페이스 계약이다. 두 종류가 있다.

  • Driving Port (port/in/): 외부 액터가 애플리케이션을 호출하는 진입점. PlaceOrderUseCase 인터페이스가 대표적이다. HTTP Controller, CLI, 배치 잡 — 어떤 Adapter가 호출해도 Application Core는 동일하게 동작한다.
  • Driven Port (port/out/): Application Core가 외부 인프라에 요청하는 계약. OrderRepository, PaymentPort가 여기에 해당한다. 반드시 domain 패키지에 위치해야 한다. 인터페이스를 infrastructure 패키지에 두면 Service(domain)가 infrastructure에 의존하게 되어 DIP가 전혀 적용되지 않는다.
[OrderController]            [JpaOrderRepository]
(Driving Adapter)            (Driven Adapter)
      │ calls                      │ implements
      ↓                            │
[PlaceOrderUseCase] ← [PlaceOrderService] → [OrderRepository]
(Driving Port)       implements              (Driven Port)

모든 화살표의 끝이 Application Core를 향한다. 이것이 규칙의 전부다.

의존성 역전의 메커니즘

자연스러운 방향은 PlaceOrderService → JpaOrderRepository다. DIP는 이 방향을 세 단계로 뒤집는다.

  1. OrderRepository 인터페이스를 추출한다.
  2. 그 인터페이스를 domain 패키지에 위치시킨다.
  3. JpaOrderRepository가 domain 패키지의 인터페이스를 구현한다.

결과적으로 JpaOrderRepository(infrastructure) → OrderRepository(domain) ← PlaceOrderService(domain). 인프라가 도메인을 향해 의존한다. PlaceOrderService의 import 목록에 org.springframework.data.jpa는 존재하지 않는다.

의존성 방향 위반 탐지

ArchUnit을 CI에 추가하면 이 규칙을 자동으로 강제할 수 있다. noClasses().that().resideInAPackage("..domain..").should().dependOnClassesThat().resideInAPackage("..infrastructure..") 한 줄이 무의식적 위반을 PR 단계에서 차단한다.

Adapter — 변환만 한다

Adapter의 유일한 책임은 **변환(Translation)**이다. HTTP Request를 Command로, JPA Entity를 Domain 객체로. 비즈니스 판단은 없다.

Driving Adapter(Controller)에 최소 금액 검증이 들어가는 순간, CLI Adapter와 배치 Adapter에도 같은 검증을 복사해야 한다. 비즈니스 규칙이 Adapter에 스며들면 중복과 불일치가 발생한다. 검증은 Order.place() 안에만 있어야 한다.

Driven Adapter 중 테스트에서 가장 가치 있는 것이 InMemory Adapter다.

class InMemoryOrderRepository implements OrderSavePort, OrderQueryPort {
    private final Map<OrderId, Order> store = new LinkedHashMap<>();

    @Override
    public void save(Order order) { store.put(order.getId(), order); }

    @Override
    public Optional<Order> findById(OrderId id) {
        return Optional.ofNullable(store.get(id));
    }

    public int count() { return store.size(); }
    public void clear() { store.clear(); }
}

Port 계약은 100% 준수하면서 Spring, JPA, 외부 API 없이 UseCase를 테스트할 수 있다. PlaceOrderService 테스트는 new PlaceOrderService(orderRepository, paymentPort, eventPublisher)로 시작하고, 실행 시간은 0.01초다.

테스트 속도 — 추상적 이론이 아닌 측정 가능한 이익

100개 테스트 기준으로 레이어드 아키텍처는 @SpringBootTest 위주로 40분이 넘는다. Hexagonal로 전환하면 Domain 단위 50%, UseCase 단위 30%, Adapter 통합 15%, E2E 5% 비율로 4분 이내로 줄어든다.

이 차이는 TDD 실천 가능 여부를 바꾼다. 코드 수정 후 피드백이 40분이면 리듬을 유지할 수 없다. 4분이면 가능하다.

트레이드오프 — 비용 없는 아키텍처는 없다

Hexagonal의 비용은 구체적이다. 레이어드 대비 파일 수가 2-3배 늘고, Domain Entity와 JPA Entity를 분리하면 매핑 코드가 40-60줄 추가된다. 팀 학습에 7-11시간, 신규 팀원 온보딩에 1-2주가 필요하다.

과잉 엔지니어링 주의

단순 공지사항 CRUD에 Port/Adapter/Mapper를 전부 적용하면 3개 파일짜리 기능이 18개 파일이 된다. 비즈니스 로직이 없으면 도메인 테스트할 것도 없고, 매핑 비용만 남는다. 현실적 전략: 복잡한 핵심 도메인만 Hexagonal 완전 적용, 단순 지원 기능은 레이어드 + DIP 개선형으로 충분하다.

MapStruct로 매핑 비용을 줄일 수 있다. @Mapping 어노테이션 몇 줄이 컴파일 타임에 타입 안전한 구현체를 생성한다. 그래도 비용이 이익을 초과하는 조건이 있다: 비즈니스 로직이 없거나, 프로젝트 수명이 짧거나, 팀 전체 동의 없이 일방 적용되는 경우다.

DDD와의 통합 — Aggregate가 중앙에

DDD가 Hexagonal과 결합하면 강력한 시너지가 생긴다. DDD는 도메인을 어떻게 구조화할지 알려주고, Hexagonal은 그 도메인을 인프라에서 어떻게 보호할지 알려준다.

핵심 규칙은 Repository Port는 Aggregate Root 단위로 설계한다는 것이다. OrderLineRepository는 없다. Order.addLine()을 호출하고 OrderRepository.save(order)로 Aggregate 전체를 저장한다. 한 트랜잭션에서 Aggregate 내부 일관성이 보장된다.

Domain Event도 같은 원칙을 따른다. Order.place()OrderPlacedEvent를 내부 리스트에 수집하고, Application Service가 OrderEventPublisher Port를 통해 발행한다. PlaceOrderService의 import 목록에 org.apache.kafka는 없다. Kafka는 Driven Adapter가 안다.

@Override
public OrderId placeOrder(PlaceOrderCommand command) {
    Order order = Order.create(command.userId(), command.lines());
    order.place(); // 이벤트 내부 수집

    PaymentResult payment = paymentPort.charge(PaymentRequest.of(order));
    order.confirmPayment(payment.transactionId());

    orderSavePort.save(order);

    order.pullDomainEvents().forEach(event -> {
        if (event instanceof OrderPlacedEvent e) eventPublisher.publish(e);
    });

    return order.getId();
}

정리

  • Hexagonal의 핵심은 방향이다. 모든 의존성이 Application Core를 향한다. 도메인은 인프라를 모른다.
  • Driving Port(UseCase)는 외부 Adapter가 호출하고, Driven Port(Repository, PaymentPort)는 domain 패키지에 위치하며 infrastructure Adapter가 구현한다.
  • InMemory Adapter로 Spring 없이 UseCase를 테스트하면 실행 시간이 0.01초다. 이것이 추상적 이론이 아닌 즉각적 이익이다.
  • 비용(파일 수 증가, 매핑 코드, 학습 곡선)은 실재한다. 단순 CRUD에는 레이어드 + DIP 개선형으로 충분하다.

다음 글에서는 Clean Architecture와 Hexagonal Architecture의 차이를 — 특히 Use Case 레이어와 Entity 레이어의 역할 분리를 — 추적한다.