Skip to content
ioob.dev
Go back

소프트웨어 아키텍처 3편 — 헥사고날 아키텍처

· 12분 읽기
Software Architecture 시리즈 (3/6)
  1. 소프트웨어 아키텍처 1편 — 아키텍처가 왜 필요한가
  2. 소프트웨어 아키텍처 2편 — 레이어드 아키텍처
  3. 소프트웨어 아키텍처 3편 — 헥사고날 아키텍처
  4. 소프트웨어 아키텍처 4편 — 클린 아키텍처와 어니언: 동심원 구조들의 공통점과 차이
  5. 소프트웨어 아키텍처 5편 — CQRS와 이벤트 드리븐: 읽기/쓰기 분리에서 이벤트 기반까지
  6. 소프트웨어 아키텍처 6편 — 모듈러 모놀리스: 마이크로서비스 전에 해볼 수 있는 것
Table of contents

Table of contents

헥사고날 아키텍처의 탄생

2005년, Alistair Cockburn이 한 가지 문제의식에서 출발했다. 애플리케이션의 핵심 로직이 UI, DB, 외부 시스템에 종속되어 있으면 테스트도 어렵고 교체도 어렵다. 이 문제를 풀기 위해 그가 제안한 것이 Ports & Adapters 패턴이다. 헥사고날 아키텍처라는 이름은 다이어그램에서 애플리케이션을 육각형으로 그린 것에서 유래했다. 육각형이라는 형태 자체에 특별한 의미가 있는 것은 아니고, 변이 여러 개 있어서 다양한 포트를 붙일 수 있다는 시각적 비유다.

핵심 아이디어는 단순하다. 애플리케이션의 안쪽(도메인 + 유스케이스)과 바깥쪽(UI, DB, 외부 API)을 명확히 분리하고, 그 경계에 포트(Port)라는 인터페이스를 둔다. 바깥쪽의 구현체를 어댑터(Adapter)라 부르며, 어댑터는 포트에 맞춰 끼워진다.

flowchart TB
    subgraph adapters_left["Driving Adapters"]
        WEB["Web Controller"]
        CLI["CLI"]
        TEST["Test"]
    end

    subgraph core["Application Core"]
        subgraph ports_in["Inbound Ports"]
            UC["UseCase 인터페이스"]
        end
        subgraph app["Application Service"]
            AS["UseCase 구현체"]
        end
        subgraph domain["Domain"]
            DM["Entity, Value Object<br/>비즈니스 규칙"]
        end
        subgraph ports_out["Outbound Ports"]
            RP["Repository 인터페이스<br/>외부 시스템 인터페이스"]
        end
    end

    subgraph adapters_right["Driven Adapters"]
        DB["JPA Repository"]
        MQ["Message Queue"]
        EXT["External API Client"]
    end

    WEB --> UC
    CLI --> UC
    TEST --> UC
    UC --> AS
    AS --> DM
    AS --> RP
    DB --> RP
    MQ --> RP
    EXT --> RP

    style core fill:#5ca45c,stroke:#2e7d32,color:#fff
    style domain fill:#ffd43b,color:#000
    style ports_in fill:#339af0,color:#fff
    style ports_out fill:#339af0,color:#fff
    style app fill:#51cf66,color:#fff

다이어그램을 보면 화살표의 방향이 중요하다. 왼쪽의 Driving 어댑터는 애플리케이션 코어를 향해 의존하고, 오른쪽의 Driven 어댑터도 애플리케이션 코어를 향해 의존한다. 모든 의존성이 안쪽을 향한다. 이것이 레이어드 아키텍처와의 결정적 차이다.

핵심 개념 세 가지

애플리케이션 코어 — 도메인 + 유스케이스

애플리케이션 코어는 두 부분으로 나뉜다. 하나는 도메인 모델(Entity, Value Object, 도메인 서비스)이고, 다른 하나는 유스케이스(Application Service)다.

도메인 모델은 비즈니스 규칙 그 자체다. 주문은 CREATED 상태에서만 취소할 수 있다, 재고는 음수가 될 수 없다 같은 불변식(invariant)이 여기에 속한다. 프레임워크 어노테이션이나 인프라 의존성이 없는 순수한 코드다.

아래는 JPA 어노테이션 없이 비즈니스 규칙만 담은 도메인 모델이다.

class Order private constructor(
    val id: OrderId,
    val productId: ProductId,
    val quantity: Int,
    val totalAmount: Money,
    private var status: OrderStatus
) {

    fun cancel() {
        require(status == OrderStatus.CREATED) {
            "취소할 수 없는 상태: $status"
        }
        status = OrderStatus.CANCELLED
    }

    fun complete() {
        require(status == OrderStatus.PAID) {
            "완료할 수 없는 상태: $status"
        }
        status = OrderStatus.COMPLETED
    }

    fun currentStatus(): OrderStatus = status

    companion object {
        fun create(productId: ProductId, quantity: Int, unitPrice: Money): Order {
            require(quantity > 0) { "수량은 1 이상이어야 한다" }
            return Order(
                id = OrderId.generate(),
                productId = productId,
                quantity = quantity,
                totalAmount = unitPrice * quantity,
                status = OrderStatus.CREATED
            )
        }
    }
}

@Entity@Column도 없다. JPA가 사라져도 이 클래스는 멀쩡히 동작한다. 비즈니스 규칙을 테스트하기 위해 DB를 띄울 필요도 없다.

유스케이스는 도메인 모델을 조합하여 하나의 시나리오를 수행하는 계층이다. 주문을 생성한다라는 유스케이스는 재고를 확인하고, 주문을 만들고, 저장하는 흐름을 조율한다.

포트 — 경계의 인터페이스

포트(Port)는 애플리케이션 코어의 경계에 위치한 인터페이스다. 안쪽이 바깥과 소통하기 위해 정의한 계약이라고 생각하면 된다.

포트는 방향에 따라 두 가지로 나뉜다.

인바운드 포트의 예시를 보자. 주문 생성이라는 유스케이스를 인터페이스로 정의한다.

interface CreateOrderUseCase {
    fun execute(command: CreateOrderCommand): OrderId
}

data class CreateOrderCommand(
    val productId: ProductId,
    val quantity: Int
)

아웃바운드 포트는 외부 의존성에 대한 추상화다. 데이터를 저장하거나 외부 API를 호출하는 행위를 인터페이스로 선언한다.

interface OrderRepository {
    fun save(order: Order)
    fun findById(id: OrderId): Order?
}

interface PaymentPort {
    fun requestPayment(orderId: OrderId, amount: Money): PaymentResult
}

interface NotificationPort {
    fun sendOrderConfirmation(orderId: OrderId, userId: UserId)
}

포트는 애플리케이션 코어가 소유한다. OrderRepository는 DB 기술이 아니라 도메인이 나는 주문을 저장하고 조회하는 기능이 필요하다고 선언한 것이다. 어떤 DB에 어떻게 저장하는지는 포트의 관심사가 아니다.

어댑터 — 외부 구현체

어댑터(Adapter)는 포트의 구현체다. 실제로 HTTP를 받거나, DB에 쿼리를 날리거나, 외부 API를 호출하는 코드가 여기에 위치한다.

어댑터도 방향에 따라 두 가지로 나뉜다.

Driving(Primary) 어댑터는 애플리케이션을 구동하는 쪽이다. 사용자의 행위가 시작되는 지점이라고 보면 된다.

아래는 웹 어댑터 예시다. 인바운드 포트(CreateOrderUseCase)를 호출한다.

@RestController
@RequestMapping("/api/orders")
class OrderWebAdapter(
    private val createOrderUseCase: CreateOrderUseCase
) {

    @PostMapping
    fun createOrder(@RequestBody request: CreateOrderRequest): ResponseEntity<CreateOrderResponse> {
        val command = CreateOrderCommand(
            productId = ProductId(request.productId),
            quantity = request.quantity
        )
        val orderId = createOrderUseCase.execute(command)
        return ResponseEntity.status(HttpStatus.CREATED)
            .body(CreateOrderResponse(orderId.value))
    }
}

Controller가 CreateOrderUseCase라는 인터페이스에 의존한다. 구현체가 뭔지 모른다. Spring이 적절한 구현체를 주입해 줄 뿐이다.

Driven(Secondary) 어댑터는 애플리케이션이 외부 시스템을 사용하기 위해 작성하는 쪽이다.

아래는 JPA 기반 영속화 어댑터의 예시다. 아웃바운드 포트(OrderRepository)를 구현한다.

@Repository
class OrderJpaAdapter(
    private val jpaRepository: OrderJpaRepository
) : OrderRepository {

    override fun save(order: Order) {
        jpaRepository.save(OrderEntity.fromDomain(order))
    }

    override fun findById(id: OrderId): Order? {
        return jpaRepository.findByIdOrNull(id.value)?.toDomain()
    }
}

@Entity
@Table(name = "orders")
class OrderEntity(
    @Id val id: Long,
    @Column val productId: Long,
    @Column val quantity: Int,
    @Column val totalAmount: Long,
    @Column @Enumerated(EnumType.STRING) val status: String
) {
    fun toDomain(): Order = Order.reconstitute(
        id = OrderId(id),
        productId = ProductId(productId),
        quantity = quantity,
        totalAmount = Money(totalAmount),
        status = OrderStatus.valueOf(status)
    )

    companion object {
        fun fromDomain(order: Order): OrderEntity = OrderEntity(
            id = order.id.value,
            productId = order.productId.value,
            quantity = order.quantity,
            totalAmount = order.totalAmount.value,
            status = order.currentStatus().name
        )
    }
}

@Entity 어노테이션은 OrderEntity에만 붙어 있다. 도메인의 Order 클래스는 JPA를 모른다. fromDomain()toDomain() 메서드가 도메인 모델과 영속화 모델 사이를 변환한다. 이 변환 비용이 헥사고날 아키텍처의 트레이드오프 중 하나다.

의존성 방향 — 왜 항상 안쪽인가

헥사고날 아키텍처의 의존성 규칙은 하나다. 모든 의존성은 바깥에서 안쪽으로 향한다. 안쪽(도메인, 유스케이스)은 바깥쪽(어댑터)의 존재를 모른다.

이것은 SOLID 원칙 중 DIP(Dependency Inversion Principle, 의존성 역전 원칙)의 직접적인 적용이다. OOP 설계 원칙 3편에서 다룬 내용이 여기서 아키텍처 수준으로 확대된다.

flowchart LR
    subgraph outside_left["바깥 — Driving"]
        WA["Web Adapter<br/>Controller"]
    end

    subgraph inside["안쪽 — Application Core"]
        IP["Inbound Port<br/>UseCase 인터페이스"]
        SVC["Application Service<br/>UseCase 구현"]
        DOM["Domain Model"]
        OP["Outbound Port<br/>Repository 인터페이스"]
    end

    subgraph outside_right["바깥 — Driven"]
        PA["Persistence Adapter<br/>JPA 구현체"]
    end

    WA -->|"의존"| IP
    IP --- SVC
    SVC --> DOM
    SVC -->|"사용"| OP
    PA -->|"구현"| OP

    style inside fill:#5ca45c,stroke:#2e7d32,color:#fff
    style DOM fill:#ffd43b,color:#000

여기서 핵심적인 부분은 오른쪽이다. Persistence AdapterOutbound Port를 향해 의존한다(구현한다). 레이어드에서는 Service가 Repository를 향해 의존했는데, 헥사고날에서는 방향이 뒤집어진 것이다.

이 역전 덕분에 다음이 가능해진다.

유스케이스 구현

인바운드 포트(유스케이스 인터페이스)를 구현하는 Application Service를 작성해 보자.

@Service
class CreateOrderService(
    private val orderRepository: OrderRepository,
    private val productPort: ProductPort,
    private val paymentPort: PaymentPort
) : CreateOrderUseCase {

    @Transactional
    override fun execute(command: CreateOrderCommand): OrderId {
        // 1. 상품 조회
        val product = productPort.findById(command.productId)
            ?: throw ProductNotFoundException(command.productId)

        // 2. 재고 확인 — 도메인 로직
        product.ensureStockAvailable(command.quantity)

        // 3. 주문 생성 — 도메인 모델의 팩토리 메서드
        val order = Order.create(
            productId = product.id,
            quantity = command.quantity,
            unitPrice = product.price
        )

        // 4. 주문 저장 — 아웃바운드 포트 사용
        orderRepository.save(order)

        // 5. 결제 요청 — 아웃바운드 포트 사용
        val paymentResult = paymentPort.requestPayment(order.id, order.totalAmount)
        if (!paymentResult.isSuccess) {
            throw PaymentFailedException(order.id)
        }

        return order.id
    }
}

Application Service는 오케스트레이터 역할을 한다. 비즈니스 규칙 자체는 도메인 모델(Order.create(), product.ensureStockAvailable())에 위임하고, 포트를 통해 외부와 소통하며, 전체 흐름을 조율한다.

Spring에서의 패키지 구조

헥사고날 아키텍처를 Spring Boot 프로젝트에 적용할 때 흔히 쓰이는 패키지 구조는 다음과 같다.

com.example.shop/
├── order/
│   ├── domain/                          ← 도메인 모델
│   │   ├── Order.kt
│   │   ├── OrderId.kt
│   │   ├── OrderStatus.kt
│   │   └── Money.kt
│   ├── application/                     ← 유스케이스
│   │   ├── port/
│   │   │   ├── in/                      ← 인바운드 포트
│   │   │   │   ├── CreateOrderUseCase.kt
│   │   │   │   └── GetOrderQuery.kt
│   │   │   └── out/                     ← 아웃바운드 포트
│   │   │       ├── OrderRepository.kt
│   │   │       └── PaymentPort.kt
│   │   └── service/                     ← 유스케이스 구현
│   │       ├── CreateOrderService.kt
│   │       └── GetOrderService.kt
│   └── adapter/                         ← 어댑터
│       ├── in/
│       │   └── web/                     ← Driving 어댑터
│       │       ├── OrderWebAdapter.kt
│       │       ├── CreateOrderRequest.kt
│       │       └── OrderResponse.kt
│       └── out/
│           ├── persistence/             ← Driven 어댑터
│           │   ├── OrderJpaAdapter.kt
│           │   ├── OrderEntity.kt
│           │   └── OrderJpaRepository.kt
│           └── payment/
│               └── PaymentApiAdapter.kt
└── product/
    ├── domain/
    ├── application/
    └── adapter/

파일 수가 레이어드보다 확실히 많다. 이것이 헥사고날 아키텍처의 명시적 비용이다. 하지만 각 파일의 책임이 명확하고, 의존 방향이 구조적으로 강제된다는 이점을 얻는다.

패키지 구조만 봐도 이 프로젝트의 아키텍처를 파악할 수 있다. domain/은 인프라를 모르고, application/port/가 경계를 정의하며, adapter/가 외부와의 연결을 담당한다는 것이 디렉토리 이름에서 드러난다.

테스트 용이성 — 어댑터만 교체한다

헥사고날 아키텍처의 실질적인 이점이 가장 잘 드러나는 곳이 테스트다.

비즈니스 로직을 테스트할 때 DB가 필요 없다. 아웃바운드 포트의 인메모리 구현체를 만들면 된다.

class InMemoryOrderRepository : OrderRepository {

    private val store = mutableMapOf<OrderId, Order>()

    override fun save(order: Order) {
        store[order.id] = order
    }

    override fun findById(id: OrderId): Order? {
        return store[id]
    }
}

class StubPaymentPort : PaymentPort {

    var shouldSucceed = true

    override fun requestPayment(orderId: OrderId, amount: Money): PaymentResult {
        return if (shouldSucceed) PaymentResult.success() else PaymentResult.failure("결제 실패")
    }
}

이 가짜 구현체로 유스케이스를 테스트한다. Spring Context도 DB도 필요 없다.

class CreateOrderServiceTest {

    private val orderRepository = InMemoryOrderRepository()
    private val productPort = StubProductPort()
    private val paymentPort = StubPaymentPort()

    private val sut = CreateOrderService(orderRepository, productPort, paymentPort)

    @Test
    fun `주문이 정상적으로 생성된다`() {
        // given
        productPort.register(
            Product(id = ProductId(1), name = "키보드", price = Money(50000), stock = 10)
        )

        // when
        val orderId = sut.execute(
            CreateOrderCommand(productId = ProductId(1), quantity = 2)
        )

        // then
        val saved = orderRepository.findById(orderId)
        assertNotNull(saved)
        assertEquals(OrderStatus.CREATED, saved!!.currentStatus())
        assertEquals(Money(100000), saved.totalAmount)
    }

    @Test
    fun `재고가 부족하면 예외가 발생한다`() {
        // given
        productPort.register(
            Product(id = ProductId(1), name = "키보드", price = Money(50000), stock = 1)
        )

        // when & then
        assertThrows<IllegalArgumentException> {
            sut.execute(
                CreateOrderCommand(productId = ProductId(1), quantity = 5)
            )
        }
    }
}

테스트가 빠르고 안정적이다. 네트워크 지연도 없고, DB 초기화도 없으며, 외부 시스템 장애에 영향받지도 않는다. 이 테스트가 깨지면 그것은 비즈니스 로직의 문제다. 인프라 문제가 아니다.

레이어드 vs 헥사고날 비교

두 아키텍처의 핵심 차이를 정리하면 다음과 같다.

항목레이어드헥사고날
의존 방향위 → 아래 (Business → Persistence)바깥 → 안 (Adapter → Core)
도메인 모델JPA Entity와 혼합순수 도메인 객체
DIP 적용선택적 (대부분 미적용)구조적으로 강제
테스트DB 필요한 경우 많음인메모리 구현체로 가능
파일 수적음많음 (포트, 어댑터, 변환 코드)
학습 비용낮음중간~높음
인프라 교체이론적 가능, 실제로는 어려움어댑터만 교체하면 가능
적합한 상황CRUD 중심, 낮은 복잡도복잡한 도메인, 인프라 독립성 필요

레이어드에서 헥사고날로의 전환이 항상 옳은 것은 아니다. 단순한 CRUD API를 위해 포트와 어댑터를 나누고, 도메인 모델과 JPA Entity를 분리하고, 변환 코드를 작성하는 것은 과설계일 수 있다.

헥사고날이 빛나는 시점은 명확하다. 비즈니스 규칙이 복잡해서 도메인 모델에 충분한 표현력이 필요하거나, 외부 시스템이 자주 바뀌거나, 도메인 로직의 단위 테스트가 핵심적으로 중요할 때다.

흔한 오해와 주의점

”헥사고날이면 Spring을 안 써도 되나?”

아니다. 헥사고날은 코드의 의존 방향에 관한 아키텍처이지, 프레임워크를 배제하라는 뜻이 아니다. Spring의 DI 컨테이너는 오히려 헥사고날 아키텍처를 구현하는 데 큰 도움이 된다. 포트 인터페이스를 선언하고 어댑터 구현체에 @Repository를 붙이면 Spring이 알아서 주입해 준다.

다만 도메인 모델에는 Spring 어노테이션을 붙이지 않는다. @Entity, @Component, @Autowired 같은 어노테이션이 도메인 클래스에 없어야 한다.

”모든 프로젝트에 헥사고날을 적용해야 하나?”

그렇지 않다. 프로젝트의 도메인 복잡도, 팀의 숙련도, 유지보수 기간을 고려해야 한다. 3개월짜리 프로토타입에 헥사고날을 적용하는 것은 배보다 배꼽이 클 수 있다. 반대로 5년 이상 운영할 핵심 서비스라면 초기 투자가 장기적으로 돌아온다.

도메인 모델과 영속화 모델의 변환 비용

OrderOrderEntity 변환 코드를 작성하고 유지해야 하는 비용은 무시할 수 없다. 필드가 추가되면 양쪽 모두 수정하고 변환 로직도 갱신해야 한다. 이 비용이 도메인 보호라는 이점보다 큰 경우에는 레이어드가 더 현실적일 수 있다.

실무에서는 MapStruct 같은 매핑 라이브러리를 사용하거나, Kotlin의 확장 함수로 변환을 간결하게 처리하는 방법을 쓰기도 한다.

포트를 너무 잘게 나누는 것

인바운드 포트를 유스케이스마다 하나씩 만들면 인터페이스 수가 폭발한다. CreateOrderUseCase, GetOrderUseCase, CancelOrderUseCase, UpdateOrderUseCase… 프로젝트 규모에 따라 관련 유스케이스를 하나의 포트로 묶는 것도 합리적인 선택이다. ISP(Interface Segregation Principle, 인터페이스 분리 원칙)와의 균형이 필요하다.

핵심 정리

개념설명
애플리케이션 코어도메인 모델 + 유스케이스. 인프라에 의존하지 않는다
포트 (Port)코어와 외부 사이의 경계 인터페이스. 인바운드(기능 제공)와 아웃바운드(기능 요구)로 나뉜다
어댑터 (Adapter)포트의 구현체. Driving(외부→코어 호출)과 Driven(코어→외부 사용)으로 나뉜다
의존 방향항상 바깥에서 안쪽으로. DIP가 구조적으로 적용된다
핵심 이점도메인 보호, 인프라 교체 용이, 테스트 용이
핵심 비용파일 수 증가, 변환 코드 작성, 학습 곡선

다음 편에서는 헥사고날 아키텍처와 사촌 관계에 있는 클린 아키텍처(Clean Architecture)와 어니언 아키텍처(Onion Architecture)를 다룬다. 셋 모두 의존성이 안쪽을 향한다는 공통 원칙을 공유하지만, 계층의 구분과 이름이 다르다. 이 동심원 구조들의 공통점과 차이를 비교해 본다.

4편: 클린 아키텍처와 어니언 — 동심원 구조들의 공통점과 차이


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
소프트웨어 아키텍처 2편 — 레이어드 아키텍처
Next Post
소프트웨어 아키텍처 4편 — 클린 아키텍처와 어니언: 동심원 구조들의 공통점과 차이