Software Architecture/Architectural Styles
Architectural Styles - 03. 헥사고날 아키텍처
헥사고날 아키텍처 — 도메인을 프레임워크로부터 격리하기
2005년, Alistair Cockburn은 자신의 웹사이트에 "Ports and Adapters"라는 패턴을 올렸다. 그가 풀고자 했던 문제는 단순했다 — "같은 비즈니스 로직이 GUI, CLI, 테스트, 다른 애플리케이션에서 다시 작성되는 일"을 피하는 것. 그가 그린 그림은 도메인을 중심에 두고 여섯 개의 변이 둘러싼 육각형이었다. 이름은 헥사고날(hexagonal)이 됐고, 그 육각형의 변이 '포트(port)'가 됐다. 20년이 지난 지금, 이 패턴은 DDD 진영에서 가장 널리 쓰이는 구조적 패턴이 됐다. 그런데 이름은 자주 쓰이면서 본질은 자주 오해받는다. 헥사고날의 핵심은 '육각형 모양'이 아니라 '의존성 방향'이다.
비유로 감 잡기 — 가전과 어댑터
110V와 220V를 모두 지원하는 노트북을 생각하자. 노트북 본체(도메인)는 110V인지 220V인지 알 필요가 없다. 본체는 'DC 19V'라는 규격만 안다. 벽 콘센트(외부 세계)가 110V든 220V든, 어댑터(adapter)가 그걸 19V로 변환해 노트북에 먹여준다.
헥사고날의 포트는 노트북의 전원 단자(19V 규격)다. 어댑터는 변환기다. 도메인(노트북 본체)은 어댑터가 무슨 벽 콘센트에 꽂혀 있는지 모른다. 그저 정해진 규격(포트)으로 신호가 들어오고 나간다.
이 비유의 힘은 — '본체를 바꾸지 않고 어댑터만 바꿔 끼운다'는 현실 감각에 있다. REST API로 들어오던 주문을 gRPC로 바꾸거나, MySQL에서 PostgreSQL로 옮기거나, 실제 결제 대신 테스트용 목(mock)을 끼우는 일이 '어댑터 교체'가 된다. 도메인 로직은 손대지 않는다.
비유의 한계 — 가전 비유는 '1:1 변환'을 강조하지만, 헥사고날의 포트는 단일 입력이 아니라 '유스케이스 단위'다. 여러 유스케이스(주문, 취소, 환불)가 각각 포트가 되고, 한 어댑터가 여러 포트를 호출할 수도 있다. 또한 비동기 이벤트 포트 같은 것은 전기 변환 비유로 설명이 안 된다. 비유는 '교체 가능성의 단순함'에서 무너진다.
핵심 — 의존성 방향이 전부다
Cockburn이 이름 짓기 전에도 포트와 어댑터의 발상은 있었다. 그가 정리한 핵심은 의존성 역전 원칙(DIP)을 아키텍처 수준으로 밀어 올린 것이다. Robert Martin이
도메인(비즈니스 규칙)은 가장 안쪽이다. 도메인은 프레임워크(Spring), DB(JPA), 웹(MVC)을 모른다. 도메인이 아는 건 자기 자신과 자기가 정의한 '포트(인터페이스)'뿐이다. 포트의 구현(어댑터)은 도메인 바깥에 있다. 도메인은 어댑터를 import하지 않고, 어댑터가 도메인의 포트(인터페이스)를 구현한다.
flowchart TD
subgraph OUT["바깥 (어댑터)"]
REST[REST adapter]
JPA[JPA adapter]
KAFKA[Kafka adapter]
TEST[Test adapter]
end
subgraph IN["안 (도메인)"]
PORTS[포트 인터페이스<br/>OrderRepository, PaymentPort]
DOMAIN[도메인<br/>Order, OrderService]
end
REST -->|구현| PORTS
JPA -->|구현| PORTS
KAFKA -->|구현| PORTS
TEST -->|구현| PORTS
DOMAIN -->|사용| PORTS
화살표 방향이 핵심이다. 어댑터(바깥)가 포트(도메인의 인터페이스)를 '구현'한다. 도메인은 어댑터를 모른다. 이 방향이 반대로 되면(도메인이 JPA에 의존하면) 헥사고날이 아니다.
포트와 어댑터의 구체 형태
포트는 인터페이스다. 두 종류가 있다.
- 인바운드 포트(driving port) — 외부에서 도메인을 호출하는入口. 도메인이 정의하는 유스케이스 인터페이스. 예:
PlaceOrderUseCase. - 아웃바운드 포트(driven port) — 도메인이 외부를 호출하는出口. 도메인이 정의하는 외부 의존성 인터페이스. 예:
OrderRepository,PaymentGateway.
어댑터도 두 종류.
- 인바운드 어댑터(primary adapter) — REST 컨트롤러, CLI, gRPC 서비스. 인바운드 포트를 호출해 도메인에 진입한다.
- 아웃바운드 어댑터(secondary adapter) — JPA 리포지토리 구현, 외부 API 클라이언트, Kafka 발행자. 아웃바운드 포트를 구현한다.
flowchart LR
subgraph Primary["인바운드 (driving)"]
R[REST Controller]
C[CLI]
end
subgraph Domain["도메인"]
UC[PlaceOrderUseCase<br/>인바운드 포트]
SVC[OrderService<br/>도메인 서비스]
AGG[Order 애그리거트]
OR[OrderRepository<br/>아웃바운드 포트]
PG[PaymentGateway<br/>아웃바운드 포트]
end
subgraph Secondary["아웃바운드 (driven)"]
J[JpaOrderRepository]
P[HttpPaymentGateway]
K[KafkaEventPublisher]
end
R --> UC
C --> UC
UC -.구현.- SVC
SVC --> AGG
SVC --> OR
SVC --> PG
OR -.구현.- J
PG -.구현.- P
PG -.구현.- K
이 구조에서 핵심은 — 인바운드 어댑터(REST, CLI)가 도메인의 '인바운드 포트'를 호출하고, 도메인이 '아웃바운드 포트'를 호출하면, 그 아웃바운드 포트의 구현(JPA 어댑터)이 실제 일을 한다는 흐름이다. 도메인은 자기가 정의한 인터페이스만 보고, 구현체는 런타임에 주입된다.
"폴더 구조 흉내" 함정 — 헥사고날이 아닌 경우
헥사고날을 도입하겠다며 폴더만 adapter, port, domain으로 나누는 경우가 흔하다. 구조는 그럴듯해 보이지만 의존성 방향이 제대로 안 잡히면 헥사고날이 아니다.
// 흉내만 낸 헥사고날 — 도메인이 JPA에 묶여 있음
package com.shop.domain;
@Entity
public class Order { // 도메인 클래스가 JPA 애노테이션에 의존
@Id @GeneratedValue
private Long id;
@Column(name = "total_amount")
private BigDecimal total;
}
package com.shop.domain;
public interface OrderRepository extends JpaRepository<Order, Long> { // JPA에 의존!
// 도메인 패키지에 있는데 Spring Data JPA를 extend
}
이 코드는 폴더상 domain 패키지에 있지만, 도메인이 JPA에 묶여 있다. @Entity, JpaRepository가 도메인 침투한 상태. 이 상태에선 "DB를 바꾸겠다"고 하면 도메인 클래스까지 고쳐야 한다. 헥사고날의 목적(도메인을 외부로부터 격리)이 무너진다.
Vaughn Vernon이 @Entity는 persistence 어댑터 쪽에, 도메인은 순수한 Java 객체(POJO)여야 한다.
헥사고날 위반 감지 — 코드에서 어떻게 알아내나
| 신호 | 의미 |
|---|---|
도메인 패키지에 @Entity, @Table, @Column이 보임 |
JPA 침투 — 도메인이 영속성을 앎 |
도메인 인터페이스가 JpaRepository, CrudRepository를 extend |
Spring Data 침투 |
도메인 클래스가 @Component, @Service를 달고 있음 |
Spring 침투 |
도메인이 RestTemplate, WebClient를 직접 import |
외부 HTTP 클라이언트에 의존 |
도메인이 @RequestMapping, HttpServletRequest를 앎 |
웹 프레임워크 침투 |
| 도메인 테스트가 Spring Context를 띄워야만 돌아감 | 도메인이 프레임워크 없이 테스트 불가 |
마지막 행이 가장 뚜렷한 신호다. "OrderService 테스트하려면 @SpringBootTest 필요"하면 도메인이 Spring에 묶인 것이다. 헥사고날이 제대로 됐다면 도메인 테스트는 순수 JUnit 단위 테스트로 충분해야 한다.
설계 사례 — JPA에 묶인 도메인에서 헥사고날로
1단계 — 도메인이 JPA에 묶임 (전형적 Spring Boot)
// domain/Order.java
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue
private Long id;
private BigDecimal total;
public boolean isPaid() { return /* ... */; }
}
// domain/OrderRepository.java
public interface OrderRepository extends JpaRepository<Order, Long> {
// Spring Data JPA에 의존
}
// domain/OrderService.java
@Service
public class OrderService {
private final OrderRepository repo; // Spring Data 인터페이스
private final PaymentRestClient payment; // Spring WebClient
public Long placeOrder(OrderRequest req) {
Order order = new Order(req.total());
repo.save(order);
payment.charge(order.total());
return order.getId();
}
}
이 구조는 Spring Boot에서 너무 흔하다. 작동은 하지만, 도메인이 @Entity, JpaRepository, WebClient에 모두 묶여 있다. 도메인 단위 테스트를 하려면 @SpringBootTest로 컨텍스트를 띄워야 한다.
2단계 — 도메인을 순수 객체로, 포트를 도메인에
// domain/Order.java — 프레임워크 애노테이션 없음
public class Order {
private final OrderId id;
private final Money total;
private OrderStatus status;
public boolean isPaid() { return status == OrderStatus.PAID; }
public void markPaid() { this.status = OrderStatus.PAID; }
}
// domain/OrderRepository.java — 도메인이 정의하는 포트 (순수 인터페이스)
public interface OrderRepository { // JpaRepository를 extend하지 않음
void save(Order order);
Optional<Order> findById(OrderId id);
}
// domain/PaymentGateway.java — 외부 의존성 포트
public interface PaymentGateway {
void charge(Money amount, OrderId orderId);
}
// domain/PlaceOrderUseCase.java — 인바운드 포트
public interface PlaceOrderUseCase {
OrderId placeOrder(OrderRequest req);
}
// domain/OrderService.java — 도메인 서비스 (구현체)
public class OrderService implements PlaceOrderUseCase {
private final OrderRepository repo;
private final PaymentGateway payment;
@Override
public OrderId placeOrder(OrderRequest req) {
Order order = new Order(req.total());
repo.save(order);
payment.charge(order.total(), order.id());
return order.id();
}
}
도메인 패키지에 더 이상 @Entity, @Service, JpaRepository가 없다. 도메인은 순수 Java 객체다. OrderRepository는 JpaRepository를 extend하지 않는 순수 인터페이스다. 이제 도메인을 순수 JUnit으로 테스트할 수 있다.
class OrderServiceTest {
@Test
void 주문_생성시_결제가_호출된다() {
// Spring Context 없이, 메모리 구현으로
InMemoryOrderRepository repo = new InMemoryOrderRepository();
FakePaymentGateway payment = new FakePaymentGateway();
OrderService svc = new OrderService(repo, payment);
OrderId id = svc.placeOrder(new OrderRequest(Money.won(10000)));
assertThat(repo.findById(id)).isPresent();
assertThat(payment.chargedAmount()).isEqualTo(Money.won(10000));
}
}
이 테스트는 Spring Context 없이, 그 어떤 프레임워크도 없이 돌아간다. 도메인이 격리됐다는 가장 강력한 증거다.
3단계 — 어댑터를 별도 패키지로
// adapter/persistence/JpaOrderEntity.java — 영속성 전용 객체
@Entity
@Table(name = "orders")
public class JpaOrderEntity {
@Id @GeneratedValue
private Long id;
private BigDecimal total;
// 도메인의 Order와 다른 객체. 매핑은 어댑터가 책임진다.
}
// adapter/persistence/JpaOrderRepository.java — 아웃바운드 어댑터
@Repository
public class JpaOrderRepository implements OrderRepository { // 도메인 포트 구현
private final OrderJpaRepository jpa; // Spring Data JPA는 여기서만
@Override
public void save(Order order) {
JpaOrderEntity entity = new JpaOrderEntity();
entity.total = order.total().value();
jpa.save(entity);
}
@Override
public Optional<Order> findById(OrderId id) {
return jpa.findById(id.value()).map(this::toDomain);
}
}
// adapter/web/OrderController.java — 인바운드 어댑터
@RestController
@RequestMapping("/orders")
public class OrderController {
private final PlaceOrderUseCase placeOrder; // 도메인의 인바운드 포트만 앎
@PostMapping
public ResponseEntity<?> create(@RequestBody OrderRequest req) {
OrderId id = placeOrder.placeOrder(req);
return ResponseEntity.ok(Map.of("id", id.value()));
}
}
이제 어댑터 패키지가 프레임워크를 담당하고, 도메인 패키지는 완전히 순수하다. REST를 gRPC로 바꾸면 OrderController 대신 OrderGrpcService를 만들면 된다. JPA를 MongoDB로 바꾸면 JpaOrderRepository 대신 MongoOrderRepository를 만들면 된다. 도메인은 건드리지 않는다.
포트-어댑터 매핑 — 헥사고날에서 자주 쓰는 포트와 어댑터
| 포트 (도메인 인터페이스) | 어댑터 (구현체) | 외부 기술 |
|---|---|---|
PlaceOrderUseCase (인바운드) |
OrderController, OrderCli, OrderGrpcService |
Spring MVC, picocli, gRPC |
OrderRepository (아웃바운드) |
JpaOrderRepository, MongoOrderRepository, InMemoryOrderRepository |
JPA/Hibernate, Mongo, 테스트 |
PaymentGateway (아웃바운드) |
HttpPaymentGateway, FakePaymentGateway |
WebClient, 테스트 더블 |
EventPublisher (아웃바운드) |
KafkaEventPublisher, NoopEventPublisher |
Kafka, 로컬 테스트 |
이 표에서 핵심은 — 한 포트에 여러 어댑터가 꽂힐 수 있다는 점이다. JPA 어댑터, Mongo 어댑터, InMemory 어댑터가 모두 같은 OrderRepository 포트를 구현한다. 도메인은 어느 어댑터가 꽂혔는지 모른채 일한다.
헥사고날의 비용
헥사고날이 '정답'은 아니다. 명확한 비용이 있다.
- 보일러플레이트 증가 — 단순 CRUD 하나에도 포트, 어댑터, 도메인 객체, 영속성 객체가 각각 필요하다. 도메인과 영속성 객체 사이의 매핑 코드가 늘어난다.
- 초기 진입 장벽 — Spring Boot 기본 튜토리얼이
@Entity를 도메인에 두는 식이라, 거기서 헥사고날로 넘어오면 적응 비용이 있다. - 단순 CRUD엔 오버엔지니어링 — 비즈니스 규칙이 거의 없는 CRUD 앱에 헥사고날을 끼우면, 복잡성만 늘고 이득은 적다.
반대로 다음 상황에선 헥사고날이 가치를 발휘한다.
- 비즈니스 규칙이 풍부하고, 도메인 모델이 중심인 애플리케이션
- 같은 도메인을 REST·gRPC·배치·CLI 같은 다양한 진입점으로 노출해야 할 때
- 도메인을 빠르게 단위 테스트하고 싶을 때 (프레임워크 컨텍스트 없이)
- 외부 의존성(DB, 외부 API, 메시징) 교체 가능성이 열려 있을 때
다음으로 — 클린 아키텍처
헥사고날의 '포트와 어댑터'를 동심원으로 재배치하고 의존성 규칙을 한층 더 명시적으로 만든 게 다음 편의 클린 아키텍처(04편)다. Cockburn의 육각형과 Martin의 동심원은 형태만 다르고, 본질(도메인이 외부를 모른다)은 같다. 둘 다 '의존성 역전'을 아키텍처 수준에서 강제하는 패턴이다. 어느 쪽을 쓰든, 핵심은 폴더 구조가 아니라 의존성 방향이다.
참고
- Cockburn — "Ports and Adapters" / "Hexagonal Architecture" (alistair.cockburn.us, 2005) — 접근 2026-07-20
- Vernon —
(Addison-Wesley, 2013), Ch.4 (애그리거트·리포지토리)·Ch.12 (아키텍처) - Martin —
(Prentice Hall, 2017), Ch.22 (헥사고날)·Ch.11 (DIP) - Richards, Ford —
(O'Reilly, 2020), Ch.11 (헥사고날)
'Software Architecture > Architectural Styles' 카테고리의 다른 글
| Architectural Styles - 06. 마이크로서비스의 함정 (0) | 2026.07.20 |
|---|---|
| Architectural Styles - 05. 마이크로서비스 (0) | 2026.07.20 |
| Architectural Styles - 04. 클린 아키텍처 (0) | 2026.07.20 |
| Architectural Styles - 02. 계층형 아키텍처 (0) | 2026.07.20 |
| Architectural Styles - 01. 모놀리스 (1) | 2026.07.20 |