디자인 패턴 시리즈를 이어오면서 Strategy 패턴과 Adapter 패턴을 정리했는데, 이 둘을 아키텍처 레벨로 확장하면 어떤 모습이 될까 궁금해서 이번엔 헥사고날 아키텍처(Hexagonal Architecture) 를 정리해보려고 한다.
헥사고날 아키텍처는 Alistair Cockburn이 2005년에 제안한 아키텍처 패턴으로, 포트와 어댑터(Ports and Adapters) 아키텍처라고도 불린다. 핵심 목표는 하나다.
비즈니스 로직을 외부 기술(DB, 웹 프레임워크, 메시지 큐, 외부 API 등)로부터 완전히 격리시킨다.
특정 웹 프레임워크나 DB에 종속된 개념이 아니라 언어와 프레임워크를 가리지 않고 적용할 수 있는 아키텍처 원칙이라, 이번 글에서는 프레임워크 이야기는 최소화하고 개념과 구조 자체에 집중해봤다.
왜 필요한가 - 전통적인 레이어드 아키텍처의 문제
흔히 사용하는 Controller → Service → Repository 구조에서는 이런 코드를 자주 보게 된다.
// 흔히 볼 수 있는 서비스 코드
class OrderService(
private val orderRepository: JpaOrderRepository, // JPA에 강하게 결합
private val stripeClient: StripeClient // 특정 결제사 SDK에 직접 의존
) {
fun createOrder(userId: String, amount: Double): Order {
val entity = OrderEntity(userId = userId, amount = amount)
orderRepository.save(entity) // ORM 엔티티를 그대로 사용
stripeClient.charge(userId, amount) // 결제 로직도 서비스 안에서 직접 호출
return entity.toDomain()
}
}
이 코드에는 몇 가지 문제가 있다.
- 비즈니스 로직(주문 생성)이 JPA, Stripe SDK 같은 외부 기술과 뒤섞여 있다.
- 결제사를 Stripe에서 다른 PG사로 바꾸려면 OrderService 자체를 수정해야 한다.
- 단위 테스트를 하려면 실제 DB나 외부 API를 mocking 하는 번거로움이 생긴다.
헥사고날 아키텍처는 이런 결합을 끊어내기 위해 등장했다.
핵심 개념: 도메인, 포트, 어댑터
헥사고날 아키텍처는 세 가지 요소로 이해하면 쉽다.
| 도메인(Core/Domain) | 순수한 비즈니스 로직. 프레임워크, DB, 외부 라이브러리를 전혀 모른다 | Order, OrderService |
| 포트(Port) | 도메인이 외부와 소통하기 위해 정의하는 인터페이스(추상화) | OrderRepositoryPort, PaymentGatewayPort |
| 어댑터(Adapter) | 포트를 실제 기술로 구현한 것 | JpaOrderRepository, StripePaymentAdapter, HTTP 컨트롤러 |
여기서 "헥사곤(육각형)"이라는 이름 자체에 특별한 의미가 있는 건 아니다.도메인을 중심에 두고 여러 방향에서 어댑터가 붙을 수 있다는 걸 시각적으로 표현하기 위해 육각형 모양을 빌려온 것뿐이다.
[ 외부 세계 ]
│
┌───────────────┼───────────────┐
│ 어댑터(Adapter) │
│ ┌─────────────────────────┐ │
│ │ 포트(Port) │ │
│ │ ┌───────────────────┐ │ │
── │ │ │ 도메인(Core) │ │ │ ──
│ │ │ 비즈니스 로직 │ │ │
│ │ └───────────────────┘ │ │
│ └─────────────────────────┘ │
└─────────────────────────────────┘
중요한 건 의존성의 방향이다. 도메인은 어댑터를 몰라야 하고, 어댑터가 포트(도메인이 정의한 인터페이스)를 구현하는 방향으로 의존성이 흘러야 한다.
Driving Adapter vs Driven Adapter
헥사고날 아키텍처를 이해할 때 가장 헷갈리는 부분이 바로 이 구분이다.
- Driving Adapter (Primary Adapter): 외부에서 도메인을 "호출하는" 방향 → HTTP 컨트롤러, CLI, 메시지 컨슈머
- Driven Adapter (Secondary Adapter): 도메인이 외부를 "호출하는" 방향 → DB 리포지토리, 외부 API 클라이언트, 메시지 발행자
HTTP Controller ──▶ [ Port: OrderUseCase ] ──▶ OrderService (도메인)
(Driving Adapter) │
▼
[ Port: OrderRepository ]
│
▼
JpaOrderRepository
(Driven Adapter)
Driving Adapter는 도메인을 "부르고", Driven Adapter는 도메인에게 "불려간다"고 기억하면 헷갈리지 않는다.
코틀린으로 구현해보기
1) 도메인 계층 - 외부 의존성 전혀 없음
// domain/Order.kt
data class Order(
val id: String,
val userId: String,
val amount: Double
)
// 포트: 도메인이 정의하는 인터페이스
interface OrderRepositoryPort {
fun save(order: Order)
fun findById(orderId: String): Order?
}
interface PaymentGatewayPort {
fun charge(userId: String, amount: Double): Boolean
}
// 도메인 서비스: 순수 비즈니스 로직만 존재
class OrderService(
private val orderRepository: OrderRepositoryPort,
private val paymentGateway: PaymentGatewayPort
) {
fun createOrder(userId: String, amount: Double): Order {
require(amount > 0) { "주문 금액은 0보다 커야 합니다" }
val paymentSucceeded = paymentGateway.charge(userId, amount)
check(paymentSucceeded) { "결제 실패" }
val order = Order(id = generateId(), userId = userId, amount = amount)
orderRepository.save(order)
return order
}
}
여기까지는 Kotlin 표준 라이브러리 외에 어떤 프레임워크도 등장하지 않는다. JPA, Spring, Ktor 무엇을 쓰든 이 코드는 그대로 재사용할 수 있다.
2) Driven Adapter - 실제 기술 구현체
// adapter/persistence/JpaOrderRepository.kt
class JpaOrderRepository(
private val jpaRepository: SpringDataOrderJpaRepository
) : OrderRepositoryPort {
override fun save(order: Order) {
val entity = OrderEntity(id = order.id, userId = order.userId, amount = order.amount)
jpaRepository.save(entity)
}
override fun findById(orderId: String): Order? {
return jpaRepository.findById(orderId).orElse(null)?.toDomain()
}
}
// adapter/payment/StripePaymentAdapter.kt
class StripePaymentAdapter(
private val stripeClient: StripeClient
) : PaymentGatewayPort {
override fun charge(userId: String, amount: Double): Boolean {
val result = stripeClient.createPaymentIntent(userId, amount)
return result.status == "succeeded"
}
}
3) Driving Adapter - 외부 요청을 도메인으로 연결
// adapter/web/OrderController.kt
@RestController
@RequestMapping("/orders")
class OrderController(
private val orderService: OrderService
) {
@PostMapping
fun createOrder(@RequestBody request: OrderCreateRequest): OrderResponse {
val order = orderService.createOrder(request.userId, request.amount)
return OrderResponse(orderId = order.id)
}
}
컨트롤러가 OrderService를 직접 호출하고 있지만, OrderService는 여전히 OrderRepositoryPort와 PaymentGatewayPort라는 추상화에만 의존하고 있다는 점이 핵심이다. 결제사를 바꾸거나 DB를 교체해도 OrderService의 코드는 단 한 줄도 바뀌지 않는다.
테스트가 쉬워지는 이유
도메인이 포트(인터페이스)에만 의존하기 때문에, 테스트할 때는 가짜(Fake) 어댑터를 주입하면 된다.
실제 DB나 Stripe API를 전혀 건드리지 않는다.
class FakeOrderRepository : OrderRepositoryPort {
private val storage = mutableListOf<Order>()
override fun save(order: Order) {
storage.add(order)
}
override fun findById(orderId: String): Order? =
storage.find { it.id == orderId }
}
class FakePaymentGateway : PaymentGatewayPort {
override fun charge(userId: String, amount: Double): Boolean = true
}
class OrderServiceTest {
@Test
fun `정상적인 주문 생성 테스트`() {
val service = OrderService(FakeOrderRepository(), FakePaymentGateway())
val order = service.createOrder("user1", 10_000.0)
assertEquals(10_000.0, order.amount)
}
}
Spring Context를 띄우지 않고, DB 커넥션도 없이 순수 로직만 밀리초 단위로 테스트할 수 있다. 통합 테스트가 필요한 부분(어댑터)과 순수 단위 테스트가 필요한 부분(도메인)이 명확히 분리되는 게 체감상 가장 큰 장점이었다.
Before / After 비교
| 비즈니스 로직 위치 | Service가 ORM/외부 SDK와 뒤섞임 | 순수 도메인 객체로 분리 |
| 외부 기술 교체 | Service 코드 전반 수정 필요 | 새 Adapter만 구현하면 됨 |
| 단위 테스트 | DB mocking, 통합 테스트 위주 | 순수 로직 테스트 (빠르고 격리됨) |
| 의존성 방향 | 도메인 → 인프라 (직접 의존) | 인프라 → 도메인 (포트를 통해 역전) |
| 초기 설계 비용 | 낮음 | 상대적으로 높음 |
의존성 역전 원칙(DIP)과의 관계
헥사고날 아키텍처는 사실 SOLID의 D(의존성 역전 원칙) 를 아키텍처 레벨로 확장한 것으로 볼 수 있다. "고수준 모듈(도메인)이 저수준 모듈(DB, 외부 API)에 의존하지 않고, 둘 다 추상화(포트)에 의존한다"는 원칙을 아키텍처 전체에 그대로 적용한 셈이다.
이전에 정리했던 Strategy 패턴, Adapter 패턴과도 자연스럽게 연결되는 지점이 있었다.
- 포트를 통해 여러 구현체를 갈아끼우는 방식 → Strategy 패턴과 원리가 같다.
- 어댑터가 외부 기술의 인터페이스를 도메인이 원하는 형태로 변환해주는 역할
→ Adapter 패턴을 아키텍처 레벨로 확장한 것에 가깝다.
디자인 패턴 시리즈에서 다뤘던 개념들이 결국 실제 아키텍처 설계에 그대로 녹아있다는 걸 이번에 정리하면서 다시 한번 느꼈다.
실무에서 고려할 점
- 오버엔지니어링 주의: 단순한 CRUD 서비스에 포트/어댑터를 전부 씌우면 보일러플레이트만 늘어난다. 도메인 로직이 복잡하거나, 외부 의존성이 바뀔 가능성이 높은 영역에 적용할 때 효과가 크다.
- DTO ↔ 도메인 객체 변환 비용: 계층 경계마다 변환 로직이 필요해서 코드량이 늘어난다.
- DI 프레임워크와의 궁합: Spring이든 Ktor든, DI 컨테이너가 어댑터를 도메인에 주입해주는 역할을 자연스럽게 해줘서 실제 적용 시 구조가 깔끔해진다.
마치며
헥사고날 아키텍처는 결국 "도메인 로직을 순수하게 유지하자"는 단순한 목표를 위한 구조라는 걸 이번에 정리하면서 다시 느꼈다. 처음 접했을 때는 포트와 어댑터라는 용어가 낯설어서 복잡해 보였는데, 막상 뜯어보니 의존성 역전 원칙을 아키텍처 단위로 확장한 것이라 이해하고 나니 훨씬 명확해졌다.
'🍞 BackEnd' 카테고리의 다른 글
| 전략 패턴(Strategy Pattern), 알고리즘을 객체로 캡슐화하기 (0) | 2026.07.14 |
|---|---|
| BFF(BackEnd For FrontEnd)를 적용하는 이유에 대해서 (1) | 2025.09.18 |
| [Postman] Postman으로 Mock Server 구축하기 (0) | 2022.11.10 |
