들어가며
헥사고날 아키텍처(Hexagonal Architecture)를 처음 접하면 흔히 이렇게 이해합니다.
"도메인 로직과 기술적인 구현(DB, 프레임워크)을 분리하려는 아키텍처구나."
틀린 말은 아닙니다. 하지만 이것은 헥사고날 아키텍처를 적용했을 때 얻는 결과이지, 아키텍처의 목적이 아닙니다.
목적을 결과로 오해하면, 구조는 따라하더라도 "왜 이렇게 해야 하는가"를 설명하지 못하게 됩니다. 어디까지 분리해야 하는지, 어떤 경우에 단순화해도 되는지 스스로 판단하기도 어려워집니다.
이 글에서는 헥사고날 아키텍처가 왜 등장했는지, 무엇을 해결하려는 것인지를 처음부터 차근차근 살펴보겠습니다.
1. 문제 제기 — 레이어드 아키텍처에서 생기는 문제
헥사고날 아키텍처를 이해하려면, 그 이전에 널리 쓰이던 레이어드 아키텍처(Layered Architecture)에서 어떤 문제가 발생하는지 먼저 봐야 합니다.
레이어드 아키텍처 자체는 나쁜 구조가 아닙니다. 관심사를 레이어로 나누고, 각 레이어가 아래 레이어에만 의존하도록 하는 것은 직관적이고 적용하기 쉬운 방식입니다.

문제는 이 구조를 그대로 따르면 도메인 레이어가 인프라 레이어를 의존하게 된다는 점입니다. 코드로 보면 이렇습니다.
// Domain Layer
public class OrderService {
// 도메인이 JPA Repository를 직접 의존
private final OrderJpaRepository orderJpaRepository;
public void placeOrder(Order order) {
order.validate();
orderJpaRepository.save(order);
}
}
// Infrastructure Layer
@Repository
public interface OrderJpaRepository extends JpaRepository<Order, Long> {
}
OrderService는 도메인 레이어에 있지만, JPA라는 기술에 직접 의존하고 있습니다. 이렇게 되면 다음과 같은 문제가 생깁니다.
- DB를 교체하면 도메인 로직도 함께 수정해야 합니다.
- 도메인 로직만 단독으로 테스트하기 어렵습니다. JPA 환경을 모두 세팅해야만 테스트가 가능합니다.
- 도메인 모델이 JPA 어노테이션으로 오염됩니다. 비즈니스 규칙과 기술적인 관심사가 한 클래스에 뒤섞입니다.
이 문제의 본질은 무엇일까요? 바로 의존성의 방향입니다. 도메인이 인프라를 의존하고 있기 때문에, 기술이 바뀌면 도메인도 흔들립니다. 도메인은 비즈니스의 핵심인데, 기술적인 선택에 종속되어 버린 것입니다.
2. 핵심 아이디어 — 의존성 방향을 뒤집다
Alistair Cockburn이 제안한 헥사고날 아키텍처(일명 포트와 어댑터 패턴)는 이 문제를 정면으로 해결합니다.
핵심 아이디어는 단 하나입니다.
모든 의존성이 항상 도메인을 향하도록 강제한다.
도메인이 외부(DB, 프레임워크, 외부 API)를 의존하는 것이 아니라, 외부가 도메인을 의존하도록 구조를 뒤집는 것입니다.

이렇게 되면 도메인은 아무것도 의존하지 않습니다. 기술이 바뀌어도 도메인은 흔들리지 않습니다.
그렇다면 어떻게 이 방향을 강제할 수 있을까요? 인프라 코드가 도메인 코드를 직접 참조하면 자연스럽게 도메인을 의존하는 구조가 되긴 합니다. 하지만 그것만으로는 부족합니다. 도메인이 인프라의 구체적인 구현을 전혀 몰라야 하면서도, 인프라의 기능(저장, 조회 등)은 사용할 수 있어야 하기 때문입니다.
이 문제를 해결하는 것이 바로 포트와 어댑터, 그리고 DIP입니다.
3. 포트와 어댑터 — 구조적으로 의존성을 통제하는 방법
DIP란?
DIP(의존성 역전 원칙, Dependency Inversion Principle)는 고수준 모듈(도메인)이 저수준 모듈(인프라)을 직접 의존하지 않도록, 둘 다 추상화(인터페이스)에 의존하게 만드는 원칙입니다.
핵심은 이 인터페이스를 누가 소유하느냐입니다. 인터페이스를 인프라 패키지에 두면 도메인이 인프라를 의존하는 구조가 그대로입니다. 반면 인터페이스를 도메인 패키지 안에 두면, 인프라가 도메인을 의존하는 구조가 만들어집니다.
포트(Port)
포트는 도메인이 외부에 요구하는 것을 인터페이스로 정의한 것입니다. 도메인 패키지 안에 위치합니다.
포트는 방향에 따라 두 종류로 나뉩니다.
- 인바운드 포트(Inbound Port): 외부에서 도메인을 호출하는 진입점. Use Case 인터페이스가 대표적입니다.
- 아웃바운드 포트(Outbound Port): 도메인이 외부에 요청하는 것. Repository 인터페이스가 대표적입니다.
어댑터(Adapter)
어댑터는 포트의 구현체입니다. 인프라 패키지에 위치하며, 도메인이 정의한 인터페이스를 실제 기술로 연결하는 역할을 합니다.
- 인바운드 어댑터: Web 컨트롤러, CLI, 이벤트 컨슈머 등. 외부 요청을 받아 도메인을 호출합니다.
- 아웃바운드 어댑터: JPA Repository, 외부 API 클라이언트 등. 도메인의 요청을 실제 기술로 처리합니다.
포트가 계약(인터페이스) 역할을 하기 때문에, 도메인은 손대지 않고 어댑터만 교체하거나 추가할 수 있습니다. DB를 MySQL에서 MongoDB로 바꾸고 싶다면 아웃바운드 어댑터만 새로 구현하면 됩니다. REST API 외에 Kafka 컨슈머로도 도메인을 호출하고 싶다면 인바운드 어댑터 하나를 추가하면 됩니다. 도메인 입장에서는 포트 너머에 무엇이 붙어 있는지 알 필요가 없습니다.
전체 구조를 그림으로 보면 다음과 같습니다.

패키지 구조
com.example
├── domain
│ ├── model
│ │ └── Order.java // 도메인 모델
│ ├── service
│ │ └── OrderService.java // 도메인 서비스 (인바운드 포트 구현)
│ └── port
│ ├── in
│ │ └── PlaceOrderUseCase.java // 인바운드 포트 (인터페이스)
│ └── out
│ └── OrderRepository.java // 아웃바운드 포트 (인터페이스)
└── infrastructure
├── web
│ └── OrderController.java // 인바운드 어댑터
└── persistence
└── OrderJpaAdapter.java // 아웃바운드 어댑터
코드로 보기
인바운드 포트 (도메인 패키지)
// domain/port/in/PlaceOrderUseCase.java
public interface PlaceOrderUseCase {
void placeOrder(Order order);
}
아웃바운드 포트 (도메인 패키지)
// domain/port/out/OrderRepository.java
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(OrderId id);
}
도메인 서비스 (인바운드 포트를 구현)
// domain/service/OrderService.java
public class OrderService implements PlaceOrderUseCase {
private final OrderRepository orderRepository; // 아웃바운드 포트(인터페이스)에만 의존
@Override
public void placeOrder(Order order) {
order.validate();
orderRepository.save(order);
}
}
인바운드 어댑터 (인프라 패키지)
// infrastructure/web/OrderController.java
@RestController
public class OrderController {
private final PlaceOrderUseCase placeOrderUseCase; // 인바운드 포트에만 의존
@PostMapping("/orders")
public ResponseEntity<Void> placeOrder(@RequestBody OrderRequest request) {
placeOrderUseCase.placeOrder(request.toDomain());
return ResponseEntity.ok().build();
}
}
아웃바운드 어댑터 (인프라 패키지)
// infrastructure/persistence/OrderJpaAdapter.java
@Repository
public class OrderJpaAdapter implements OrderRepository { // 아웃바운드 포트를 구현
private final OrderJpaRepository jpaRepository;
@Override
public void save(Order order) {
jpaRepository.save(OrderJpaEntity.from(order));
}
@Override
public Optional<Order> findById(OrderId id) {
return jpaRepository.findById(id.getValue())
.map(OrderJpaEntity::toDomain);
}
}
코드에서 의존성 방향을 정리하면 다음과 같습니다.

- 모든 화살표가 도메인을 향하고 있습니다.
- 도메인은 컨트롤러도, JPA도 전혀 모릅니다.
인터페이스를 도메인 패키지 안에 두는 것이 이 구조의 핵심입니다. 인터페이스가 인프라 패키지에 있다면, 도메인이 인프라를 의존하는 기존 구조와 다를 바 없습니다.
4. 정리 — 분리는 목적이 아니라 결과다
헥사고날 아키텍처의 진짜 목적은 "도메인과 기술을 분리하는 것"이 아닙니다.
진짜 목적은 "모든 의존성이 항상 도메인을 향하도록 구조적으로 강제하는 것"입니다.
| 구분 | 내용 |
| 수단 | DIP — 인터페이스(포트)를 도메인 패키지 안에 정의 |
| 목적 | 의존성이 항상 도메인을 향하도록 강제 |
| 결과 | 도메인과 기술의 분리, 테스트 용이성, 기술 교체 용이성 |
레이어드 아키텍처와 비교하면 이 차이가 더 명확해집니다.
| 레이어드 아키텍처 | 헥사고날 아키텍처 | |
| 의존성 방향 | 도메인 → 인프라 | 인프라 → 도메인 |
| DB 교체 시 영향 | 도메인까지 수정 필요 | 어댑터만 교체 |
| 도메인 단독 테스트 | JPA 환경 필요 | 포트를 Mocking하면 됨 |
| 도메인 모델 순수성 | JPA 어노테이션 혼재 | 순수한 비즈니스 로직만 |
"도메인과 기술의 분리", "테스트 용이성", "기술 교체 용이성"은 모두 의존성 방향을 통제한 결과로 자연스럽게 따라오는 것들입니다.
목적이 명확해야 실무에서 스스로 판단할 수 있습니다. "어디까지 분리해야 하는가", "이 경우엔 어댑터를 단순화해도 되는가"라는 질문에 대한 답도, 결국 "의존성 방향이 도메인을 향하고 있는가?"로 귀결됩니다.
참고
- Alistair Cockburn, Hexagonal Architecture (2005)
- Vaughn Vernon, Implementing Domain-Driven Design (2013)
- Tom Homberger, 만들면서 배우는 클린 아키텍처 (2022)