헥사고날 아키텍처(Hexagonal Architecture), 포트와 어댑터

2026. 7. 16. 10:17·🍞 BackEnd
반응형

디자인 패턴 시리즈를 이어오면서 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
'🍞 BackEnd' 카테고리의 다른 글
  • 전략 패턴(Strategy Pattern), 알고리즘을 객체로 캡슐화하기
  • BFF(BackEnd For FrontEnd)를 적용하는 이유에 대해서
  • [Postman] Postman으로 Mock Server 구축하기
박빵이
박빵이
칭찬은 박빵도 춤추게 한다
  • 박빵이
    기억보다 기록
    박빵이
  • 전체
    오늘
    어제
    • 분류 전체보기 (336) N
      • 🍞 AI (1)
      • 🍞 Cloud·Infra (1)
        • AWS SAA (1)
      • 🍞 FrontEnd (89)
        • HTML · CSS (4)
        • JavaScript (16)
        • TypeScript (3)
        • React (45)
        • Next.js (1)
        • Android (15)
      • 🍞 BackEnd (4) N
        • FastAPI (1)
        • Java (15)
        • Node.js (6)
        • SpringBoot (1)
      • 🍞 Algorithm (147)
        • C++ (4)
        • Baekjoon (41)
        • Programmers (97)
      • 🍞 Computer Science (18)
        • 운영체제 (1)
        • 네트워크 (6)
        • 데이터 통신 (6)
        • 데이터베이스 (1)
      • 🍞 대외활동 & 부트캠프 (42)
        • 삼성 청년 SW 아카데미 (1)
        • LG유플러스 유레카 (0)
        • 한국대학생IT경영학회 (1)
        • IT연합동아리 UMC (17)
        • IT연합동아리 피로그래밍 (3)
        • 길벗 블로깅 멘토 (18)
        • 개발 컨퍼런스 (2)
      • 🍞 개발 협업 & 생산성 (2)
  • 블로그 메뉴

    • 글쓰기
    • Admin
  • 링크

    • GitHub
  • 공지사항

  • 인기 글

  • 태그

    길벗 블로깅 멘토
    level2
    Android
    level1
    백준
    플로이드 와샬
    안드로이드
    위상정렬
    코틀린
    Java
    react
    umc
    Front
    코딩자율학습
    프로그래머스
    C++
    길벗 블로깅 멘토링
    유니온파인드
    알고리즘
    JavaScript
  • 최근 댓글

  • 최근 글

  • 반응형
  • hELLO· Designed By정상우.v4.10.6
박빵이
헥사고날 아키텍처(Hexagonal Architecture), 포트와 어댑터
상단으로

티스토리툴바