Skip to content
Published on

Hexagonal Architecture(포트 & 어댑터) 실전 가이드 — 클린 아키텍처의 핵심

공유하기
Authors
Hexagonal Architecture

들어가며

"데이터베이스를 MySQL에서 PostgreSQL로 바꿔야 합니다." "REST API 대신 gRPC도 지원해야 합니다."

이런 요구사항을 들었을 때, 코드 전체를 뒤집어야 한다면 아키텍처에 문제가 있는 것입니다. Hexagonal Architecture(헥사고날 아키텍처)는 비즈니스 로직을 외부 의존성에서 완벽히 분리하여 이런 변경을 쉽게 만듭니다.

핵심 개념

3가지 핵심 구성 요소

1. 도메인 (Domain / Core)
   - 비즈니스 로직의 핵심
   - 외부 의존성 없음 (순수 코드)
   - Entity, Value Object, Domain Service

2. 포트 (Port)
   - 도메인과 외부 세계의 인터페이스(계약)
   - Input Port: 외부 → 도메인 (Use Case)
   - Output Port: 도메인 → 외부 (Repository 인터페이스)

3. 어댑터 (Adapter)
   - 포트의 구체적 구현
   - Input Adapter: REST Controller, gRPC Handler, CLI
   - Output Adapter: MySQL Repository, Redis Cache, HTTP Client

Layered Architecture와의 차이

# Layered Architecture (전통적)
# ControllerServiceRepositoryDB
# 의존성 방향: 위 → 아래 (DB에 종속)

# Hexagonal Architecture
# AdapterPortDomainPortAdapter
# 의존성 방향: 바깥 → 안쪽 (Domain이 중심)

의존성 방향이 전부다

포트와 어댑터라는 이름 때문에 "인터페이스를 많이 만드는 패턴"으로 오해하기 쉽습니다. 하지만 이 아키텍처가 실제로 강제하는 규칙은 하나, 의존성의 방향입니다. Alistair Cockburn이 원문에서 밝힌 의도도 한 문장으로 요약됩니다. 사용자가 부르든 자동화 테스트가 부르든 배치 스크립트가 부르든 애플리케이션이 똑같이 구동되어야 하고, 최종적으로 붙게 될 런타임 장치나 데이터베이스와 격리된 상태에서 개발하고 테스트할 수 있어야 한다는 것입니다.

화살표는 두 개입니다

혼란의 대부분은 화살표를 하나로 생각하는 데서 시작됩니다. 실제로는 방향이 서로 반대인 화살표가 두 개 있습니다. 하나는 어떤 모듈이 어떤 모듈을 import 하는지를 나타내는 임포트 화살표이고, 다른 하나는 실행 중에 누가 누구의 메서드를 부르는지를 나타내는 호출 화살표입니다. 임포트 화살표는 항상 안쪽을 향하고, 호출 화살표는 바깥을 향합니다.

[임포트 방향]   adapters ──import──▶ ports ──import──▶ domain
                (바깥)                                  (안쪽)

[호출 방향]     domain ──call──▶ ports(추상 타입) ──▶ adapters
                (안쪽)                                  (바깥)

같은 경계를 두 화살표가 서로 반대로 지나간다.
 반대 방향이 곧 의존성 역전이다.

앞의 코드로 확인해 보겠습니다. 추상 클래스 OrderRepositoryports/output/ 아래에 있습니다. 도메인 쪽 코드입니다. 구현체 PostgresOrderRepositoryadapters/output/ 아래에 있고, 자기보다 안쪽에 있는 OrderRepositoryimport 합니다. 임포트 화살표가 바깥에서 안쪽으로 들어온 것입니다. 반면 런타임에는 OrderServicesave()를 호출하고 그 호출은 실제로 PostgreSQL 어댑터에서 실행됩니다. 호출 화살표는 안쪽에서 바깥으로 나갑니다. 이 두 화살표가 서로 반대로 놓인 상태, 그것이 의존성 역전입니다.

포트를 "도메인이 소유한 인터페이스"라고 부르는 이유도 여기에 있습니다. 포트는 인프라가 도메인에게 제공하는 API가 아니라, 도메인이 인프라에게 요구하는 계약입니다. 그래서 포트의 이름과 시그니처는 도메인의 어휘로 쓰여야 합니다. find_by_customer(customer_id)는 도메인의 말이고 execute_query(sql)는 인프라의 말입니다. 포트에 인프라의 말이 섞이기 시작하면 이름만 포트인 껍데기가 됩니다.

이 규칙이 없으면 무엇이 깨지나

의존성 역전을 빼면 도메인 코드는 대개 이렇게 생깁니다.

# domain/models/order.py  ← 도메인인데 인프라를 import 한다
from sqlalchemy.orm import Session           # ORM 세션 타입
from infrastructure.db import OrderTable     # ORM 매핑 클래스


class Order:
    def confirm(self, session: Session) -> None:
        if not self.items:
            raise ValueError("상품이 없는 주문은 확정할 수 없습니다")
        self.status = "confirmed"
        session.merge(OrderTable.from_domain(self))   # 도메인이 커밋을 안다
        session.commit()

여기서 구체적으로 두 가지가 깨집니다.

첫째, 도메인을 단위 테스트할 수 없습니다. 위 모듈을 import 하는 순간 ORM이 로드되고, ORM은 대개 설정된 커넥션이나 메타데이터를 요구합니다. "상품이 없는 주문은 확정할 수 없다"라는 한 줄짜리 규칙을 검증하려고 테스트용 데이터베이스를 띄우게 됩니다. 테스트는 느려지고, 느린 테스트는 결국 아무도 돌리지 않습니다.

둘째, 라이브러리 업그레이드가 도메인 변경이 됩니다. ORM 메이저 버전이 올라 세션 API가 바뀌면 변경 diff 안에 도메인 파일이 들어옵니다. 리뷰어는 비즈니스 규칙이 바뀐 것인지 배관이 바뀐 것인지 구분할 수 없습니다. 이런 diff가 몇 번 반복되면 도메인 파일의 git 히스토리는 규칙의 역사가 아니라 라이브러리 마이그레이션의 역사가 됩니다.

그래서 "DB를 갈아 끼우기 쉬워진다"는 흔한 홍보 문구는 정확히는 부수 효과입니다. 대부분의 팀은 PostgreSQL에서 MongoDB로 옮기는 일 없이 서비스를 끝냅니다. 진짜 이득은 도메인 규칙을 인프라 없이 읽고, 테스트하고, 고칠 수 있다는 쪽입니다.

주도 포트와 피주도 포트

Cockburn은 포트를 두 종류로 나눕니다. primary(주도, driving)와 secondary(피주도, driven)입니다. 정의는 간결합니다. 주도 액터는 애플리케이션을 구동하는 쪽이고, 피주도 액터는 애플리케이션이 구동하는 쪽입니다.

실무에서 둘을 가르는 기준은 하나로 충분합니다. 이 상호작용을 누가 먼저 시작하는가.

주도(primary / driving) 포트 — 바깥이 호출을 시작한다
    REST 컨트롤러 / gRPC 핸들러 / CLI / 배치 잡 / 큐 컨슈머 / 인수 테스트
      ┌────────────────────────────┐
      │        애플리케이션          │
          (유스케이스 + 도메인)      └────────────────────────────┘
피주도(secondary / driven) 포트 — 애플리케이션이 호출을 시작한다
    OrderRepository / PaymentGateway / NotificationSender / Clock

판별법: 이 대화를 누가 먼저 걸었는가?

주도 포트 쪽의 실제 예는 이렇습니다. HTTP 요청을 받는 REST 컨트롤러는 사용자가 시작합니다. 큐에서 메시지를 꺼내 처리하는 컨슈머는 브로커가 밀어 넣는 것처럼 보이지만, 애플리케이션의 유스케이스를 시작시키는 쪽이므로 주도입니다. 크론이 도는 정산 배치와 운영자가 치는 CLI 명령도 주도입니다. 인수 테스트도 마찬가지인데, 이 점이 원문에서 특히 강조되는 부분입니다. 테스트 하네스는 REST 컨트롤러와 정확히 같은 자리에 꽂힙니다.

피주도 포트 쪽은 이렇습니다. OrderRepository는 주문을 저장하고 조회합니다. PaymentGateway는 결제를 승인하고 환불합니다. NotificationSender는 이메일이나 푸시를 보냅니다. 여기에 시계와 난수 생성기를 포함시키는 것을 자주 잊습니다. 도메인에서 현재 시각을 직접 읽으면 그 도메인 테스트는 실행할 때마다 다른 값을 보게 됩니다. 시각과 난수를 피주도 포트로 빼면 테스트가 결정적이 됩니다.

애매한 경우도 있습니다. 메시지 큐 하나를 두고 컨슈머 쪽은 주도 포트, 프로듀서 쪽은 피주도 포트가 됩니다. 같은 기술을 쓴다고 같은 종류의 포트인 것은 아닙니다. 방향을 정하는 것은 기술이 아니라 호출의 시작점입니다.

포트를 몇 개나 둘 것인가에 대해 원문은 신중합니다. 개수를 "틀리게" 잡아도 특별한 해가 있어 보이지는 않으며 결국 직관의 문제라고 적고 있고, 본인은 두세 개, 많아야 네 개 정도를 선호한다고 밝힙니다. 외부 시스템 하나마다 포트를 하나씩 기계적으로 만들라는 규칙은 원문 어디에도 없습니다. 포트는 어댑터의 개수가 아니라 대화의 종류를 따라 나뉩니다.

Python 실전 구현

프로젝트 구조

order-service/
├── domain/                    # 핵심 도메인
│   ├── models/
│   │   ├── order.py          # Entity
│   │   └── order_item.py     # Value Object
│   └── services/
│       └── order_service.py  # Domain Service
├── ports/                     # 포트 (인터페이스)
│   ├── input/
│   │   └── order_use_case.py # Input Port
│   └── output/
│       ├── order_repository.py    # Output Port
│       └── payment_gateway.py     # Output Port
├── adapters/                  # 어댑터 (구현)
│   ├── input/
│   │   ├── rest_controller.py     # REST API
│   │   └── grpc_handler.py        # gRPC
│   └── output/
│       ├── postgres_order_repo.py # PostgreSQL 구현
│       ├── redis_order_cache.py   # Redis 캐시
│       └── stripe_payment.py      # Stripe 결제
└── config/
    └── dependency_injection.py    # DI 설정

도메인 모델

# domain/models/order.py
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import List
from uuid import UUID, uuid4


class OrderStatus(Enum):
    PENDING = "pending"
    CONFIRMED = "confirmed"
    SHIPPED = "shipped"
    CANCELLED = "cancelled"


@dataclass
class OrderItem:
    product_id: str
    product_name: str
    quantity: int
    unit_price: float

    @property
    def subtotal(self) -> float:
        return self.quantity * self.unit_price


@dataclass
class Order:
    """주문 엔티티 — 비즈니스 규칙을 포함"""
    id: UUID = field(default_factory=uuid4)
    customer_id: str = ""
    items: List[OrderItem] = field(default_factory=list)
    status: OrderStatus = OrderStatus.PENDING
    created_at: datetime = field(default_factory=datetime.now)

    @property
    def total_amount(self) -> float:
        return sum(item.subtotal for item in self.items)

    def add_item(self, item: OrderItem) -> None:
        if self.status != OrderStatus.PENDING:
            raise ValueError("확정된 주문에는 상품을 추가할 수 없습니다")
        if item.quantity <= 0:
            raise ValueError("수량은 1 이상이어야 합니다")
        self.items.append(item)

    def confirm(self) -> None:
        if not self.items:
            raise ValueError("상품이 없는 주문은 확정할 수 없습니다")
        if self.status != OrderStatus.PENDING:
            raise ValueError(f"'{self.status.value}' 상태에서는 확정할 수 없습니다")
        self.status = OrderStatus.CONFIRMED

    def cancel(self) -> None:
        if self.status == OrderStatus.SHIPPED:
            raise ValueError("배송된 주문은 취소할 수 없습니다")
        self.status = OrderStatus.CANCELLED

포트 정의

# ports/input/order_use_case.py
from abc import ABC, abstractmethod
from uuid import UUID
from domain.models.order import Order, OrderItem


class CreateOrderUseCase(ABC):
    @abstractmethod
    def execute(self, customer_id: str, items: list[OrderItem]) -> Order:
        pass


class ConfirmOrderUseCase(ABC):
    @abstractmethod
    def execute(self, order_id: UUID) -> Order:
        pass


class CancelOrderUseCase(ABC):
    @abstractmethod
    def execute(self, order_id: UUID) -> Order:
        pass


# ports/output/order_repository.py
from abc import ABC, abstractmethod
from uuid import UUID
from domain.models.order import Order


class OrderRepository(ABC):
    @abstractmethod
    def save(self, order: Order) -> None:
        pass

    @abstractmethod
    def find_by_id(self, order_id: UUID) -> Order | None:
        pass

    @abstractmethod
    def find_by_customer(self, customer_id: str) -> list[Order]:
        pass


# ports/output/payment_gateway.py
from abc import ABC, abstractmethod
from uuid import UUID


class PaymentGateway(ABC):
    @abstractmethod
    def charge(self, order_id: UUID, amount: float, customer_id: str) -> bool:
        pass

    @abstractmethod
    def refund(self, order_id: UUID) -> bool:
        pass

도메인 서비스 (Use Case 구현)

# domain/services/order_service.py
from uuid import UUID
from domain.models.order import Order, OrderItem
from ports.input.order_use_case import (
    CreateOrderUseCase, ConfirmOrderUseCase, CancelOrderUseCase
)
from ports.output.order_repository import OrderRepository
from ports.output.payment_gateway import PaymentGateway


class OrderService(CreateOrderUseCase, ConfirmOrderUseCase, CancelOrderUseCase):
    """주문 서비스 — Input Port 구현"""

    def __init__(
        self,
        order_repo: OrderRepository,
        payment_gateway: PaymentGateway
    ):
        # Output Port에 의존 (구체 구현이 아님!)
        self._order_repo = order_repo
        self._payment_gateway = payment_gateway

    def execute(self, customer_id: str = None, items: list[OrderItem] = None,
                order_id: UUID = None) -> Order:
        # dispatch based on params (simplified)
        if customer_id and items:
            return self._create_order(customer_id, items)
        raise ValueError("Invalid parameters")

    def _create_order(self, customer_id: str, items: list[OrderItem]) -> Order:
        order = Order(customer_id=customer_id)
        for item in items:
            order.add_item(item)
        self._order_repo.save(order)
        return order

    def confirm_order(self, order_id: UUID) -> Order:
        order = self._order_repo.find_by_id(order_id)
        if not order:
            raise ValueError(f"주문을 찾을 수 없습니다: {order_id}")

        # 결제 처리
        success = self._payment_gateway.charge(
            order_id=order.id,
            amount=order.total_amount,
            customer_id=order.customer_id
        )
        if not success:
            raise ValueError("결제에 실패했습니다")

        order.confirm()
        self._order_repo.save(order)
        return order

    def cancel_order(self, order_id: UUID) -> Order:
        order = self._order_repo.find_by_id(order_id)
        if not order:
            raise ValueError(f"주문을 찾을 수 없습니다: {order_id}")

        order.cancel()
        self._payment_gateway.refund(order_id)
        self._order_repo.save(order)
        return order

어댑터 구현

# adapters/output/postgres_order_repo.py
import psycopg2
from uuid import UUID
from domain.models.order import Order, OrderItem, OrderStatus
from ports.output.order_repository import OrderRepository


class PostgresOrderRepository(OrderRepository):
    def __init__(self, connection_string: str):
        self._conn_str = connection_string

    def save(self, order: Order) -> None:
        with psycopg2.connect(self._conn_str) as conn:
            with conn.cursor() as cur:
                cur.execute("""
                    INSERT INTO orders (id, customer_id, status, created_at)
                    VALUES (%s, %s, %s, %s)
                    ON CONFLICT (id) DO UPDATE SET status = %s
                """, (str(order.id), order.customer_id,
                      order.status.value, order.created_at,
                      order.status.value))

                for item in order.items:
                    cur.execute("""
                        INSERT INTO order_items
                        (order_id, product_id, product_name, quantity, unit_price)
                        VALUES (%s, %s, %s, %s, %s)
                        ON CONFLICT DO NOTHING
                    """, (str(order.id), item.product_id,
                          item.product_name, item.quantity, item.unit_price))

    def find_by_id(self, order_id: UUID) -> Order | None:
        with psycopg2.connect(self._conn_str) as conn:
            with conn.cursor() as cur:
                cur.execute("SELECT * FROM orders WHERE id = %s", (str(order_id),))
                row = cur.fetchone()
                if not row:
                    return None
                return self._to_domain(row, cur)

    def find_by_customer(self, customer_id: str) -> list[Order]:
        # 구현 생략
        pass

    def _to_domain(self, row, cursor) -> Order:
        # DB 행 → 도메인 모델 변환
        pass


# adapters/input/rest_controller.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from uuid import UUID
from domain.services.order_service import OrderService
from domain.models.order import OrderItem

app = FastAPI()


class CreateOrderRequest(BaseModel):
    customer_id: str
    items: list[dict]


class OrderResponse(BaseModel):
    id: str
    customer_id: str
    status: str
    total_amount: float


def create_rest_controller(order_service: OrderService):

    @app.post("/orders", response_model=OrderResponse)
    async def create_order(request: CreateOrderRequest):
        items = [
            OrderItem(
                product_id=i["product_id"],
                product_name=i["product_name"],
                quantity=i["quantity"],
                unit_price=i["unit_price"]
            )
            for i in request.items
        ]
        order = order_service._create_order(request.customer_id, items)
        return OrderResponse(
            id=str(order.id),
            customer_id=order.customer_id,
            status=order.status.value,
            total_amount=order.total_amount
        )

    @app.post("/orders/{order_id}/confirm")
    async def confirm_order(order_id: UUID):
        try:
            order = order_service.confirm_order(order_id)
            return {"status": order.status.value}
        except ValueError as e:
            raise HTTPException(status_code=400, detail=str(e))

    return app

의존성 주입 설정

# config/dependency_injection.py
from domain.services.order_service import OrderService
from adapters.output.postgres_order_repo import PostgresOrderRepository
from adapters.output.stripe_payment import StripePaymentGateway
from adapters.input.rest_controller import create_rest_controller


def bootstrap():
    # Output Adapters
    order_repo = PostgresOrderRepository(
        connection_string="postgresql://user:pass@localhost/orders"
    )
    payment_gateway = StripePaymentGateway(
        api_key="sk_test_xxx"
    )

    # Domain Service (Port 구현)
    order_service = OrderService(
        order_repo=order_repo,
        payment_gateway=payment_gateway
    )

    # Input Adapter
    app = create_rest_controller(order_service)

    return app


# DB를 바꾸고 싶다면?
# PostgresOrderRepository → MongoOrderRepository로 교체만 하면 됨!
# 도메인 코드 변경 없음!

테스트 전략

도메인 단위 테스트 (외부 의존성 없음)

import pytest
from domain.models.order import Order, OrderItem, OrderStatus


class TestOrder:
    def test_add_item(self):
        order = Order(customer_id="C001")
        item = OrderItem("P001", "노트북", 1, 1500000)
        order.add_item(item)
        assert len(order.items) == 1
        assert order.total_amount == 1500000

    def test_cannot_add_item_to_confirmed_order(self):
        order = Order(customer_id="C001")
        order.add_item(OrderItem("P001", "노트북", 1, 1500000))
        order.confirm()
        with pytest.raises(ValueError, match="확정된 주문"):
            order.add_item(OrderItem("P002", "마우스", 1, 50000))

    def test_cannot_confirm_empty_order(self):
        order = Order(customer_id="C001")
        with pytest.raises(ValueError, match="상품이 없는"):
            order.confirm()

    def test_cannot_cancel_shipped_order(self):
        order = Order(customer_id="C001")
        order.add_item(OrderItem("P001", "노트북", 1, 1500000))
        order.confirm()
        order.status = OrderStatus.SHIPPED
        with pytest.raises(ValueError, match="배송된"):
            order.cancel()

Mock을 사용한 서비스 테스트

from unittest.mock import MagicMock
from domain.services.order_service import OrderService
from domain.models.order import Order, OrderItem


class TestOrderService:
    def setup_method(self):
        self.mock_repo = MagicMock()
        self.mock_payment = MagicMock()
        self.service = OrderService(self.mock_repo, self.mock_payment)

    def test_create_order(self):
        items = [OrderItem("P001", "노트북", 1, 1500000)]
        order = self.service._create_order("C001", items)

        assert order.customer_id == "C001"
        assert len(order.items) == 1
        self.mock_repo.save.assert_called_once()

    def test_confirm_order_with_payment(self):
        order = Order(customer_id="C001")
        order.add_item(OrderItem("P001", "노트북", 1, 1500000))
        self.mock_repo.find_by_id.return_value = order
        self.mock_payment.charge.return_value = True

        result = self.service.confirm_order(order.id)

        self.mock_payment.charge.assert_called_once()
        assert result.status.value == "confirmed"

요구사항 하나를 끝까지 따라가기

지금까지의 조각을 하나의 요구사항으로 꿰어 보겠습니다. 요구사항은 이것입니다. "주문을 취소하면 결제를 환불하고 재고를 되돌린다."

먼저 각 조각이 어느 파일에 떨어지는지부터 정합니다. 앞의 프로젝트 구조를 그대로 씁니다.

요구사항: 주문을 취소하면 결제를 환불하고 재고를 되돌린다

adapters/input/rest_controller.py       DELETE /orders/{id} 수신        ← 주도 어댑터
ports/input/order_use_case.py           CancelOrderUseCase 계약        ← 주도 포트
domain/services/order_service.py        취소 절차를 조율                ← 유스케이스
domain/models/order.py                  Order.cancel() 규칙 판정        ← 도메인
ports/output/order_repository.py        save / find_by_id              ← 피주도 포트
ports/output/payment_gateway.py         refund                         ← 피주도 포트
ports/output/inventory_port.py          restock  (이번에 새로 추가)      ← 피주도 포트
adapters/output/postgres_order_repo.py  실제 SQL                       ← 피주도 어댑터
adapters/output/stripe_payment.py       실제 Stripe 호출                ← 피주도 어댑터
adapters/output/wms_inventory.py        실제 WMS HTTP 호출              ← 피주도 어댑터

새 요구사항 때문에 늘어난 것은 피주도 포트 하나와 그 어댑터 하나뿐입니다. 재고를 되돌린다는 요구가 생겼다고 도메인 모델의 취소 규칙이 바뀌지는 않습니다. 이 구분이 유지되는지가 설계가 살아 있는지를 보는 첫 번째 신호입니다.

# ports/output/inventory_port.py — 도메인이 인프라에게 요구하는 계약
from abc import ABC, abstractmethod
from uuid import UUID


class InventoryPort(ABC):
    @abstractmethod
    def restock(self, order_id: UUID, lines: list[tuple[str, int]]) -> None:
        """(product_id, quantity) 목록만큼 재고를 되돌린다."""


# domain/models/order.py — 규칙 판정은 오직 여기서
    def cancel(self) -> None:
        if self.status == OrderStatus.SHIPPED:
            raise ValueError("배송된 주문은 취소할 수 없습니다")
        if self.status == OrderStatus.CANCELLED:
            raise ValueError("이미 취소된 주문입니다")
        self.status = OrderStatus.CANCELLED

    def restock_lines(self) -> list[tuple[str, int]]:
        return [(i.product_id, i.quantity) for i in self.items]


# domain/services/order_service.py — 조율만 하고 규칙은 판정하지 않는다
class CancelOrderService(CancelOrderUseCase):
    def __init__(self, orders: OrderRepository,
                 payments: PaymentGateway,
                 inventory: InventoryPort):
        self._orders = orders
        self._payments = payments
        self._inventory = inventory

    def execute(self, order_id: UUID) -> Order:
        order = self._orders.find_by_id(order_id)
        if order is None:
            raise ValueError(f"주문을 찾을 수 없습니다: {order_id}")

        order.cancel()                        # 규칙은 도메인이 판정
        self._orders.save(order)              # 피주도 포트 1
        self._payments.refund(order.id)       # 피주도 포트 2
        self._inventory.restock(order.id, order.restock_lines())  # 피주도 포트 3
        return order

유스케이스에 if 가 하나도 없다는 점을 봐 주십시오. "배송된 주문은 취소할 수 없다"는 판단은 Order.cancel() 안에 있고, 유스케이스는 순서를 정하고 포트를 부르는 일만 합니다. 이 경계가 무너지는 것이 다음 절의 첫 번째 함정입니다.

이제 인프라를 하나도 띄우지 않고 이 요구사항 전체를 검증합니다. Mock 라이브러리 대신 손으로 쓴 가짜 어댑터를 쓰면 무엇이 호출되었는지가 단언문에 그대로 드러나서 실패 메시지를 읽기 쉽습니다.

# tests/test_cancel_order.py — 가짜 어댑터만으로 전 구간을 돈다
import pytest
from domain.models.order import Order, OrderItem, OrderStatus
from domain.services.order_service import CancelOrderService


class FakeOrderRepository:
    def __init__(self, order):
        self._order, self.saved = order, []

    def find_by_id(self, order_id):
        return self._order

    def save(self, order):
        self.saved.append(order.status)


class FakePaymentGateway:
    def __init__(self):
        self.refunded = []

    def refund(self, order_id):
        self.refunded.append(order_id)
        return True


class FakeInventory:
    def __init__(self):
        self.restocked = []

    def restock(self, order_id, lines):
        self.restocked.append(lines)


def make_order() -> Order:
    order = Order(customer_id="C001")
    order.add_item(OrderItem("P001", "노트북", 1, 1_500_000))
    order.add_item(OrderItem("P002", "마우스", 2, 50_000))
    return order


def test_cancel_refunds_and_restocks():
    order = make_order()
    repo, pay, inv = FakeOrderRepository(order), FakePaymentGateway(), FakeInventory()
    service = CancelOrderService(repo, pay, inv)

    result = service.execute(order.id)

    assert result.status is OrderStatus.CANCELLED
    assert repo.saved == [OrderStatus.CANCELLED]
    assert pay.refunded == [order.id]
    assert inv.restocked == [[("P001", 1), ("P002", 2)]]


def test_shipped_order_is_not_cancellable():
    order = make_order()
    order.status = OrderStatus.SHIPPED
    service = CancelOrderService(FakeOrderRepository(order),
                                 FakePaymentGateway(), FakeInventory())

    with pytest.raises(ValueError, match="배송된"):
        service.execute(order.id)

예시 출력입니다. 아래는 pytest 8 계열에서의 출력 형태이며, 두 번째 실행은 유스케이스에서 환불 호출을 일부러 지운 뒤 다시 돌린 것입니다.

$ pytest tests/test_cancel_order.py -q
..                                                            [100%]
2 passed in 0.03s

$ pytest tests/test_cancel_order.py -q      # refund 호출을 지운 뒤
.F                                                            [100%]
=================================== FAILURES ===================================
_____________________ test_cancel_refunds_and_restocks ________________________
E       AssertionError: assert [] == [UUID('9f0c1a3e-...')]
E         Right contains one more item: UUID('9f0c1a3e-...')
tests/test_cancel_order.py:57: AssertionError
1 failed, 1 passed in 0.04s

주목할 점은 실행 시간입니다. 데이터베이스도 결제사도 WMS도 없이 밀리초 단위로 끝납니다. 이렇게 빠른 테스트만이 커밋할 때마다 돌아가고, 커밋할 때마다 도는 테스트만이 실제로 회귀를 막습니다. 헥사고날이 주는 이득의 대부분은 사실 이 지점에서 회수됩니다.

함정과 실패 사례

함정 1. 빈혈 도메인

증상부터 말하면 이렇습니다. 유스케이스 클래스가 계속 자랍니다. OrderService가 300줄을 넘고 스프린트마다 조건 분기가 하나씩 붙습니다. 반면 Order 엔티티는 몇 달째 변하지 않고, 열어 보면 getter와 setter뿐입니다. 클래스 다이어그램은 도메인 모델처럼 생겼는데 실제 규칙은 전부 서비스에 있습니다.

진단은 이 순서로 합니다. 첫째, 도메인 엔티티에서 예외를 던지는 곳을 찾습니다. 규칙 위반을 알리는 예외가 엔티티에 하나도 없다면 규칙이 엔티티에 없다는 뜻입니다. 둘째, 유스케이스에서 엔티티의 상태 필드를 읽어 조건 분기하는 줄을 셉니다. 상태를 보고 분기하는 코드가 여러 유스케이스에 흩어져 있으면 그 판정은 원래 엔티티가 해야 할 일입니다. 셋째, 같은 규칙이 두 개 이상의 유스케이스에 중복돼 있는지 확인합니다. 중복이 나오면 진단은 확정입니다.

# ❌ 빈혈: 판정이 유스케이스에 있다
class OrderService:
    def cancel(self, order_id):
        order = self._orders.find_by_id(order_id)
        if order.status == "shipped":          # 규칙이 여기 있다
            raise ValueError("배송된 주문은 취소 불가")
        if order.status == "cancelled":        # 다른 유스케이스에도 또 있다
            raise ValueError("이미 취소됨")
        order.status = "cancelled"             # 상태를 밖에서 바꾼다
        self._orders.save(order)

# ✅ 판정을 엔티티로 옮긴다
class OrderService:
    def cancel(self, order_id):
        order = self._orders.find_by_id(order_id)
        order.cancel()                          # 규칙은 엔티티가 판정
        self._orders.save(order)

처방은 상태를 밖에서 대입하는 코드를 찾아 엔티티의 메서드로 옮기는 것입니다. order.status = ... 같은 대입이 도메인 바깥에 남아 있으면 규칙은 언제든 다시 새어 나갑니다. 규칙이 엔티티로 모이면 유스케이스는 자연스럽게 짧아집니다.

함정 2. 새는 포트

증상은 어댑터를 갈아 끼우려는 순간 드러납니다. 인메모리 가짜 구현을 만들려고 보니 포트의 반환 타입이 ORM 모델이라 흉내 낼 수가 없습니다. 혹은 포트 메서드가 세션 객체를 인자로 받고 있어서, 세션이 없는 구현은 애초에 시그니처를 만족할 수 없습니다. 포트가 인프라 타입을 노출하는 순간 그 포트는 계약이 아니라 ORM의 별칭이 됩니다.

# ❌ 새는 포트 — 인프라가 시그니처에 드러난다
class OrderRepository(ABC):
    @abstractmethod
    def find_by_id(self, session, order_id) -> "OrderTable":   # ORM 모델 반환
        ...

    @abstractmethod
    def execute_query(self, sql: str) -> list[tuple]:          # 저장소의 어휘
        ...

# ✅ 도메인 타입과 도메인 어휘만 남긴다
class OrderRepository(ABC):
    @abstractmethod
    def find_by_id(self, order_id: UUID) -> Order | None:
        ...

    @abstractmethod
    def find_by_customer(self, customer_id: str) -> list[Order]:
        ...

진단은 grep 한 줄로 끝나는 경우가 많습니다. 도메인 패키지와 포트 패키지에서 인프라 임포트를 찾아 한 줄이라도 나오면 그 시점에 이미 새고 있는 것입니다.

# 도메인/포트가 인프라를 import 하는지 확인 — 결과가 비어야 정상
grep -rnE '^[[:space:]]*(from|import)[[:space:]]+(sqlalchemy|psycopg2|pymongo|django|fastapi|redis|boto3)' \
     domain/ ports/

# CI에 그대로 넣을 수 있는 형태 (하나라도 걸리면 실패)
if grep -rqE '^[[:space:]]*(from|import)[[:space:]]+(sqlalchemy|psycopg2|django|fastapi)' domain/ ports/; then
  echo "FAIL: 도메인 또는 포트가 인프라를 import 합니다"
  exit 1
fi

이 검사를 CI에 넣어 두면 규칙이 사람의 기억이 아니라 파이프라인에 남습니다. 아키텍처 규칙 중 자동으로 검사되지 않는 것은 몇 달 안에 대부분 무너집니다.

함정 3. DTO 폭발

경계마다 타입을 새로 만들다 보면 같은 주문 하나를 표현하는 클래스가 네다섯 개가 됩니다. 요청 DTO, 도메인 엔티티, 영속화 모델, 응답 DTO, 그리고 외부 결제사에 보낼 페이로드까지. 필드를 하나 추가하면 다섯 군데를 고쳐야 하고, 매핑 코드가 도메인 코드보다 길어집니다.

이 비용은 실재하고, 무조건 감수해야 하는 것도 아닙니다. 판단 기준을 두 가지로 잡으면 대체로 맞습니다. 첫째, 두 타입이 서로 다른 이유로 바뀌는가. API 응답 스키마는 클라이언트 사정으로 바뀌고 도메인 모델은 규칙 때문에 바뀝니다. 바뀌는 이유가 다르면 분리할 값어치가 있습니다. 둘째, 그 경계를 넘는 타입이 외부에 공개되는가. 공개 API의 응답 타입에 도메인 엔티티를 그대로 쓰면, 도메인 필드 이름을 바꾸는 순간 그것이 API 파괴적 변경이 됩니다.

반대로 내부에서만 쓰이고 도메인과 같은 이유로 함께 변하는 계층이라면 매핑을 생략해도 됩니다. 영속화 모델과 도메인 엔티티가 사실상 같은 모양이고 앞으로도 같이 변할 것이 분명하다면, 굳이 두 벌을 유지하는 것은 원칙을 위한 원칙입니다. 이 판단은 매번 다시 해야 하고, 정답이 하나로 정해져 있지 않습니다.

함정 4. 트랜잭션 경계

가장 어려운 실전 문제입니다. 도메인은 트랜잭션을 몰라야 하는데, 앞의 취소 유스케이스에서는 주문 저장과 재고 복원이 함께 커밋되어야 합니다. 하나만 성공하면 재고가 늘어난 채 주문은 살아 있거나 그 반대가 됩니다.

정직하게 말하면 만능 해법은 없고, 흔히 쓰이는 답이 셋 있으며 각각 비용이 있습니다.

첫째, 트랜잭션 스크립트 방식입니다. 유스케이스 바깥, 즉 인바운드 어댑터나 데코레이터에서 트랜잭션을 열고 닫습니다. 도메인은 완전히 깨끗해집니다. 비용은 트랜잭션 경계가 유스케이스와 어긋나기 쉽다는 것입니다. 한 요청에서 두 유스케이스를 부르면 의도치 않게 하나의 트랜잭션이 되고, 그 사실이 코드 어디에도 드러나지 않습니다.

둘째, Unit of Work를 포트로 만드는 방식입니다. 트랜잭션이라는 인프라 개념 대신 "하나의 작업 단위"라는 도메인 개념을 포트로 노출합니다. 커밋이 실제로 무엇인지는 어댑터만 압니다. 비용은 추상화가 새기 쉽다는 것입니다. 격리 수준, 중첩 트랜잭션, 세이브포인트 같은 것이 필요해지는 순간 포트 시그니처가 데이터베이스를 닮아 갑니다.

# ports/output/unit_of_work.py — 도메인 어휘로 감싼 트랜잭션 경계
from abc import ABC, abstractmethod


class UnitOfWork(ABC):
    """하나의 작업 단위라는 개념만 노출한다.
    커밋과 롤백이 실제로 무엇인지는 어댑터만 안다."""

    @abstractmethod
    def __enter__(self) -> "UnitOfWork":
        ...

    @abstractmethod
    def __exit__(self, exc_type, exc, tb) -> None:
        ...

    @abstractmethod
    def commit(self) -> None:
        ...


# 유스케이스에서
    def execute(self, order_id: UUID) -> Order:
        with self._uow:
            order = self._orders.find_by_id(order_id)
            order.cancel()
            self._orders.save(order)
            self._inventory.restock(order.id, order.restock_lines())
            self._uow.commit()

        # 결제 환불은 같은 트랜잭션에 넣을 수 없다 (외부 시스템)
        # → 아웃박스에 기록하고 별도 워커가 재시도한다
        self._outbox.append("order.cancelled", order.id)
        return order

셋째, 결과적 일관성을 받아들이는 방식입니다. 위 코드의 마지막 줄이 그것입니다. 같은 데이터베이스에 있는 것만 한 트랜잭션으로 묶고, 외부 시스템 호출은 아웃박스 테이블에 기록한 뒤 별도 워커가 재시도합니다. 비용은 명확합니다. 재시도가 있으므로 수신 측이 멱등해야 하고, "취소는 됐는데 환불이 아직"인 중간 상태가 실제로 존재하게 되므로 그 상태를 운영과 CS가 이해할 수 있어야 합니다.

어느 쪽을 고르든 공짜는 없습니다. 다만 결제사나 WMS 같은 외부 시스템은 애초에 우리 데이터베이스 트랜잭션에 참여할 수 없으므로, 두 개 이상의 외부 시스템이 얽히는 순간 세 번째 답이 사실상 유일한 현실적 선택지가 됩니다.

언제 쓰지 않나

이 아키텍처를 쓰지 않는 편이 나은 경우가 분명히 있습니다.

첫째, 도메인 규칙이 없는 CRUD 서비스입니다. 요청을 받아 검증하고 한 테이블에 넣고 돌려주는 것이 전부라면, 포트와 어댑터는 안전을 늘리지 않고 이동 거리만 늘립니다. 필드 하나 추가하는 데 요청 DTO, 도메인 모델, 포트 시그니처, 어댑터 매핑, 응답 DTO를 차례로 고쳐야 합니다. 이런 서비스에서는 컨트롤러가 ORM을 직접 쓰는 편이 읽기도 고치기도 낫습니다.

둘째, 어댑터를 끝내 바꾸지 않는 작은 팀입니다. 간접 계층의 비용은 매일 지불되지만 이득은 교체나 테스트 시점에만 회수됩니다. 회수 시점이 오지 않으면 비용만 남습니다.

셋째, 수명이 짧은 코드입니다. 실험용 서비스나 한 분기만 쓰고 버릴 도구에는 프레임워크보다 오래 살아남을 도메인이 없습니다. 이 아키텍처의 전제 자체가 성립하지 않습니다.

그래서 도입 전제를 두 가지로 정리할 수 있습니다. 하나는 프레임워크보다 오래 살아남을 진짜 도메인 로직이 있는가입니다. "상태 전이 규칙", "요금 계산 규칙", "승인 조건"처럼 서술할 수 있는 규칙이 여러 개 있고 그것이 자주 바뀐다면 전제가 성립합니다. 다른 하나는 포트마다 어댑터가 둘 이상 존재하는가입니다. 여기서 테스트 더블을 두 번째 어댑터로 세는 것은 정당합니다. 원문에서도 목 어댑터로 애플리케이션을 완전히 격리해 돌릴 수 있다는 점을 핵심 이득으로 꼽습니다. 다만 그 테스트가 실제로 존재해야 세어 줄 수 있습니다. 쓰지 않는 인터페이스는 두 번째 구현이 아니라 그냥 파일입니다.

마지막으로, 전부 아니면 전무가 아닙니다. 결제나 정산처럼 규칙이 몰려 있는 모듈에만 포트와 어댑터를 적용하고, 나머지는 평범한 계층형으로 두는 절충이 실제로 가장 흔하고 대체로 잘 작동합니다.

마무리

Hexagonal Architecture의 핵심 가치:

  1. 도메인 독립성: 비즈니스 로직이 DB, 프레임워크에 종속되지 않음
  2. 교체 용이성: 어댑터만 바꾸면 외부 시스템 변경 가능
  3. 테스트 용이성: 도메인은 Mock 없이, 서비스는 포트 Mock으로 테스트
  4. 포트가 계약: 인터페이스(Port)가 내부와 외부의 명확한 계약 역할

참고 자료

  • Alistair Cockburn, "Hexagonal Architecture" (Ports and Adapters 원문) — 의도 한 문장, 주도/피주도 액터의 정의, 포트 개수에 대한 저자의 입장, 목 어댑터로 애플리케이션을 격리해 돌리는 이득이 모두 여기서 왔습니다. https://alistair.cockburn.us/hexagonal-architecture/ (2026-08-16 확인)

이 글의 코드는 Python 3.10 이상 문법(X | None 형태의 유니온 타입)을 전제로 하며, 테스트 출력 예시는 pytest 8 계열 기준입니다. 사용 중인 버전에 따라 출력 형식이 다를 수 있으니 실제 값은 각자의 환경에서 확인하세요.


📝 퀴즈 (6문제)

Q1. Hexagonal Architecture의 세 가지 핵심 구성 요소는? Domain(Core), Port(인터페이스), Adapter(구현)

Q2. Input Port와 Output Port의 차이는? Input Port: 외부에서 도메인으로 (Use Case), Output Port: 도메인에서 외부로 (Repository 인터페이스)

Q3. Hexagonal Architecture에서 의존성 방향은? 바깥(Adapter) → 안쪽(Domain). 도메인은 외부에 의존하지 않음

Q4. DB를 MySQL에서 PostgreSQL로 바꿀 때 수정해야 하는 것은? Output Adapter만 교체 (도메인 코드 변경 없음)

Q5. 도메인 단위 테스트에서 Mock이 불필요한 이유는? 도메인 모델에 외부 의존성이 없으므로 순수 로직만 테스트 가능

Q6. Layered Architecture 대비 Hexagonal의 주요 장점은? 비즈니스 로직이 DB/프레임워크에 종속되지 않아 교체와 테스트가 용이

퀴즈

Q1: 임포트 화살표와 호출 화살표는 각각 어느 방향을 향하나요? 임포트(컴파일 시점) 화살표는 어댑터에서 도메인 쪽으로, 즉 항상 안쪽을 향합니다. 호출(런타임) 화살표는 도메인에서 포트를 거쳐 어댑터로, 즉 바깥을 향합니다. 같은 경계를 두 화살표가 서로 반대로 지나가는 상태가 의존성 역전입니다.

Q2: 주도 포트와 피주도 포트를 실무에서 어떻게 구분하나요? 기준은 하나, 누가 상호작용을 먼저 시작하는가입니다. 바깥이 애플리케이션을 부르면 주도(REST 컨트롤러, CLI, 배치 잡, 큐 컨슈머, 인수 테스트), 애플리케이션이 바깥을 부르면 피주도(리포지터리, 결제 게이트웨이, 알림 발송, 시계)입니다. 같은 메시지 큐라도 컨슈머 쪽은 주도, 프로듀서 쪽은 피주도가 됩니다.

Q3: 빈혈 도메인의 증상과 진단 순서는 무엇인가요? 증상은 유스케이스 클래스만 계속 자라고 엔티티는 getter와 setter뿐인 상태입니다. 진단은 엔티티에 규칙 위반 예외가 있는지 확인하고, 유스케이스가 엔티티 상태를 읽어 분기하는 줄을 세고, 같은 규칙이 두 유스케이스에 중복됐는지 확인하는 순서로 합니다.

Q4: 두 아웃바운드 어댑터가 함께 커밋되어야 할 때 어떤 선택지가 있나요? 유스케이스 바깥에서 트랜잭션을 여는 방식은 경계가 유스케이스와 어긋나기 쉽고, Unit of Work 포트는 격리 수준이나 세이브포인트가 필요해지면 추상화가 샙니다. 아웃박스를 통한 결과적 일관성은 수신 측 멱등성과 중간 상태 운영 부담을 요구합니다. 외부 시스템은 우리 트랜잭션에 참여할 수 없으므로 세 번째가 현실적인 경우가 많습니다.

Q5: 헥사고날 아키텍처를 쓰지 않는 편이 나은 경우는? 도메인 규칙이 없는 CRUD 서비스, 어댑터를 끝내 교체하지 않는 작은 팀, 수명이 짧은 실험용 코드입니다. 도입 전제는 프레임워크보다 오래 살아남을 도메인 로직이 있고 포트마다 어댑터가 둘 이상 존재하는 것이며, 실제로 돌아가는 테스트 더블은 두 번째 어댑터로 세도 됩니다.