Hexagonal Architecture란 무엇일까
1. Hexagonal Architecture? 이게 왜 나왔을까
탄생 배경
Hexagonal Architecture는 2005년 Alistair Cockburn이 제안한 아키텍처 패턴입니다. 정식 명칭은 Ports and Adapters. "육각형"이라는 이름은 Cockburn 본인도 인정했듯이 그냥 placeholder였습니다 — 숫자 6에 특별한 의미는 없습니다.
이 패턴이 나온 이유는 명확합니다. 당시 주류였던 전통적 레이어드 아키텍처(N-Tier) 에 구조적 결함이 있었기 때문입니다.
레이어드 아키텍처의 문제:
[UI Layer] → [Business Logic Layer] → [Data Access Layer]
- 의존성이 위에서 아래로 흐릅니다. 전체 애플리케이션이 결국 Data Access Layer에 의존하게 됩니다.
- 비즈니스 로직이 시스템의 핵심인데, 정작 데이터 접근 계층이 토대가 되는 구조입니다.
- UI 코드에 비즈니스 로직이 스며들고, 비즈니스 로직에 인프라 관심사가 섞입니다.
- 테스트하려면 DB가 있어야 하고, UI를 띄워야 합니다.
- 경계가 관례(convention) 수준이라 강제력이 없습니다. "이 검증 로직, Service에 넣어? Model에 넣어?", "외부 API 호출은 Service에서 직접 해도 돼?" — 사람마다 다르게 판단하고, 시간이 지나면 Service 레이어가 뚱뚱해집니다.
Cockburn은 1994년 OOP 수업 자료를 만들면서 "모든 면에 인터페이스가 있고, 데이터베이스에 대해 루프백이 가능한 구조"를 그리기 시작했습니다. 그리고 핵심적인 통찰 하나에 도달합니다.
대칭성이라는 통찰
"UI 쪽이든 DB 쪽이든, 애플리케이션 입장에서는 결국 바깥에 있는 기술 일 뿐이다."
전통적 아키텍처는 사용자 인터페이스(왼쪽)와 데이터베이스(오른쪽)를 비대칭으로 봅니다. 한쪽은 "요청을 보내는 곳", 다른 쪽은 "데이터를 저장하는 곳". 하지만 Cockburn은 이 둘이 사실 대칭적 이라고 봤습니다.
애플리케이션을 쓰는 쪽 애플리케이션이 쓰는 쪽
(Driver / Primary) (Driven / Secondary)
───────────────── ─────────────────
HTTP 요청 PostgreSQL
CLI 명령어 Redis 캐시
테스트 코드 이메일 서비스
메시지 큐 외부 API
↘ ↙
[ Application Core ]
왼쪽은 애플리케이션을 호출하는 쪽(Driver), 오른쪽은 애플리케이션이 호출하는 쪽(Driven)입니다. 방향은 다르지만 공통점이 있습니다 — 둘 다 "바깥 세계"라는 것. 애플리케이션은 그 바깥이 뭔지 모르고, 알 필요도 없어야 합니다. 이 대칭성의 인식이 Hexagonal Architecture의 출발점입니다.
Cockburn의 원문 Intent
"Allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases."
— Alistair Cockburn, Ports and Adapters (2005)
핵심 키워드: equally, isolation. 어떤 드라이버가 쓰든 동일하게 작동하고, 실제 런타임 장치 없이도 개발/테스트 가능해야 합니다.
2. 핵심 개념 — Port, Adapter, 그리고 Hexagon
The Hexagon (애플리케이션 코어)
육각형은 애플리케이션 그 자체를 나타냅니다. 구성 요소는 다음과 같습니다.
- Domain Model: 비즈니스 규칙이 담긴 Aggregate, Entity, Value Object
- Application Service: Use Case를 오케스트레이션하는 계층
이 육각형 안에는 프레임워크도, DB 드라이버도, HTTP 라이브러리도 없습니다. 순수한 비즈니스 로직만 존재합니다.
┌─────────────────────┐
│ │
Driver ──Port──│ Application Core │──Port──▶ Driven
(Primary) │ │ (Secondary)
│ ┌───────────────┐ │
│ │ Domain Model │ │
│ │ App Services │ │
│ └───────────────┘ │
│ │
└─────────────────────┘
Port (포트)
"A port identifies a purposeful conversation." — Alistair Cockburn
포트는 기술에 무관한(technology-agnostic) 진입/출구점 입니다. 인터페이스(혹은 Protocol)로 정의되며, 애플리케이션이 외부와 나누는 purposeful conversation(목적이 있는 소통)의 계약을 규정합니다.
포트가 아닌 것: - HTTP 엔드포인트 (이건 Adapter) - DB 커넥션 (이것도 Adapter) - 외부 라이브러리의 API (이것도 Adapter)
포트가 맞는 것:
- OrderRepository — "주문을 저장하고 조회한다"는 conversation
- PaymentGateway — "결제를 요청하고 결과를 받는다"는 conversation
- PlacingOrders — "주문을 접수한다"는 conversation
- NotificationSender — "알림을 보낸다"는 conversation
포트는 "무엇을(what)" 정의하지, "어떻게(how)" 는 정의하지 않습니다.
Adapter (어댑터)
어댑터는 포트를 실제 기술로 구현(implement) 합니다. 하나의 포트에 여러 어댑터가 붙을 수 있습니다.
Port: OrderRepository
├── Adapter: PostgresOrderRepository (운영용)
├── Adapter: InMemoryOrderRepository (테스트용)
└── Adapter: MongoOrderRepository (마이그레이션 중)
Port: PlacingOrders
├── Adapter: RestApiController (HTTP)
├── Adapter: GraphQLResolver (GraphQL)
├── Adapter: CLIHandler (커맨드라인)
└── Adapter: TestHarness (자동화 테스트)
이것이 Hexagonal Architecture가 주는 궁극적 이점입니다. 어댑터만 바꾸면 기술을 바꿀 수 있고, 비즈니스 로직은 한 줄도 안 건드립니다. 극단적으로는 설정 파일 하나만 수정해서 소스코드 변경이나 재빌드 없이도 전환이 가능합니다.
"어디에 넣고, 어디서 찾는가" — 구조가 주는 가이드레일
아키텍처의 실질적 가치는 결국 개발자가 코드를 어디에 넣어야 하는지, 어디서 찾아야 하는지 즉시 판단할 수 있는가입니다.
Layered에서는 이 판단이 모호합니다. 검증 로직이 Controller에도, Service에도, Model에도 들어갈 수 있습니다. 외부 API 호출을 Service에서 직접 하는 팀도 있고, 별도 클래스로 빼는 팀도 있습니다. 경계가 부드러워서 팀 내 관례가 유일한 가드레일인데, 관례는 사람이 바뀌면 같이 바뀝니다.
Hexagonal에서는 "이건 구현(how)이야, 계약(what)이야?" 하나만 판단하면 위치가 결정됩니다:
- 비즈니스 규칙 → Domain Model
- 유스케이스 흐름 → Application Service
- 기술 구현 → Adapter
- 내부와 외부의 경계 → Port
포트라는 인터페이스가 추가되는 것은 사실입니다. Layered에서 기능 하나 추가할 때 4개 파일을 건드린다면, Hexagonal에서는 포트 정의가 추가되어 6개쯤 됩니다. 하지만 그 2개의 추가 파일은 오버헤드가 아니라 경계를 구조적으로 강제하는 장치입니다. Layered의 경계가 시간이 지나면 무너지는 것에 비해, Hexagonal의 경계는 인터페이스라는 물리적 형태로 존재하기 때문에 쉽게 무시할 수 없습니다.
3. Driving과 Driven — 두 가지 방향
Driver(Primary) 쪽: "누가 애플리케이션을 호출하나요"
Driver는 애플리케이션을 작동시키는 주체입니다. 사용자가 버튼을 누르든, 테스트 코드가 함수를 호출하든, 크론잡이 트리거하든 — 이들이 Driver입니다.
[REST Controller] ──uses──▶ [PlacingOrders Port] ──▶ [Application Core]
[Test Harness] ──uses──▶ [PlacingOrders Port] ──▶ [Application Core]
[CLI Command] ──uses──▶ [PlacingOrders Port] ──▶ [Application Core]
Driver Adapter → (의존) → Application Core
Driver 어댑터는 애플리케이션 코어에 의존합니다. Driver 포트는 애플리케이션이 제공하는 인터페이스이고, 어댑터가 그걸 호출합니다. 의존성 방향이 자연스럽습니다 — 호출하는 쪽이 호출당하는 쪽에 의존하니까요.
Driven(Secondary) 쪽: "애플리케이션이 누구에게 말을 거나요"
Driven은 애플리케이션이 사용하는 외부 자원입니다. DB, 이메일, 외부 API 등이 해당됩니다.
[Application Core] ──uses──▶ [OrderRepository Port]
│
implements│
▼
[PostgresOrderRepository Adapter]
여기서 핵심적인 반전이 일어납니다. 자연스러운 흐름대로라면 Application Core가 PostgresOrderRepository에 직접 의존해야 합니다. 하지만 그러면 코어가 Postgres를 알게 됩니다. 그래서:
Application Core → (의존) → Port(Interface) ← (구현) ← Driven Adapter
애플리케이션은 포트(인터페이스)에만 의존하고, 실제 구현체(어댑터)는 그 인터페이스를 구현합니다. 바로 Dependency Inversion Principle입니다.
Dependency Inversion Principle (DIP)
SOLID 원칙 중 하나로, "고수준 모듈이 저수준 모듈에 의존하면 안 된다. 둘 다 추상화에 의존해야 한다"는 원칙입니다. 여기서 Application Core(고수준)가 PostgresOrderRepository(저수준)에 직접 의존하는 대신, OrderRepository라는 추상화(포트)에 의존하는 것이 DIP의 전형적인 적용입니다.
Configurable Dependency — 패턴의 진짜 엔진
Cockburn의 공저자 Juan Manuel Garrido de Paz는 Hexagonal Architecture가 기반하는 가장 중요한 패턴으로 Configurable Dependency를 꼽습니다.
"Configurable Dependency는 Ports & Adapters Architecture의 기반이 되는 가장 중요한 패턴이다. 이것이 hexagon을 모든 기술로부터 분리(decouple)시키는 것을 가능하게 한다."
대칭과 비대칭이 공존합니다:
| Driver(Primary) | Driven(Secondary) | |
|---|---|---|
| Conversation 시작 | 어댑터가 시작 | 애플리케이션이 시작 |
| 의존 방향 | 어댑터 → 코어 | 코어 → 포트 ← 어댑터 |
| DIP 필요 여부 | 불필요 (자연스러운 방향) | 필요 (역전시켜야 함) |
| 코어의 인식 | 누가 부르는지 모름 | 인터페이스만 앎 |
대칭: 양쪽 모두 어댑터가 hexagon에 의존합니다. 애플리케이션은 양쪽 모두에 대해 기술 무관(technology agnostic)입니다.
비대칭: Configurable Dependency 구현 방식이 다릅니다. Driver 쪽은 자연스러운 의존 방향이라 별도 처리가 필요 없습니다. Driven 쪽은 DIP가 필요합니다.
Configurable Dependency가 있어서 Hexagonal의 핵심 목표가 가능해집니다. 어떤 드라이버든 동일하게 구동할 수 있고, 실제 저장소나 외부 서비스 없이도 테스트할 수 있는 애플리케이션을 만들 수 있습니다.
4. 코드로 보기 — Python 예제
주문 시스템으로 예시를 들어보겠습니다. 규모는 작지만 Hexagonal의 모든 요소가 드러납니다.
프로젝트 구조
order_service/
├── domain/ # 도메인 모델 (순수 비즈니스 로직)
│ ├── model.py # Entity, Value Object
│ └── ports.py # 포트 (인터페이스) 정의
├── application/ # 유스케이스 오케스트레이션
│ └── service.py # Application Service
├── adapters/ # 어댑터 (기술 구현)
│ ├── inbound/ # Driver(Primary) 어댑터
│ │ └── api.py # FastAPI REST 컨트롤러
│ └── outbound/ # Driven(Secondary) 어댑터
│ ├── postgres_repo.py # PostgreSQL 구현
│ └── memory_repo.py # In-Memory 구현 (테스트용)
└── config.py # 의존성 조립 (Composition Root)
Domain Layer — 순수 비즈니스 로직
# domain/model.py
from dataclasses import dataclass
from enum import Enum
from uuid import UUID, uuid4
class OrderStatus(Enum):
PENDING = "pending"
CONFIRMED = "confirmed"
CANCELLED = "cancelled"
@dataclass
class Order:
id: UUID
product: str
quantity: int
status: OrderStatus
@staticmethod
def create(product: str, quantity: int) -> "Order":
if quantity <= 0:
raise ValueError("수량은 1 이상이어야 합니다")
return Order(
id=uuid4(),
product=product,
quantity=quantity,
status=OrderStatus.PENDING,
)
def confirm(self) -> None:
if self.status != OrderStatus.PENDING:
raise ValueError(f"{self.status.value} 상태의 주문은 확정할 수 없습니다")
self.status = OrderStatus.CONFIRMED
def cancel(self) -> None:
if self.status == OrderStatus.CONFIRMED:
raise ValueError("이미 확정된 주문은 취소할 수 없습니다")
self.status = OrderStatus.CANCELLED
외부 의존성이 하나도 없고, import도 전부 표준 라이브러리입니다. Order.create()가 수량 검증을 하고, confirm()과 cancel()이 상태 전이 규칙을 담고 있습니다. 이게 도메인 모델입니다.
Ports — Conversation의 계약
# domain/ports.py
from abc import ABC, abstractmethod
from uuid import UUID
from domain.model import Order
# ── Driven(Secondary) Port ──────────────────────────────
# 애플리케이션이 "주문을 저장/조회하겠다"는 conversation을 정의
class OrderRepository(ABC):
@abstractmethod
def save(self, order: Order) -> None: ...
@abstractmethod
def find_by_id(self, order_id: UUID) -> Order | None: ...
# ── Driven(Secondary) Port ──────────────────────────────
# 애플리케이션이 "알림을 보내겠다"는 conversation을 정의
class NotificationSender(ABC):
@abstractmethod
def send(self, message: str) -> None: ...
# ── Driver(Primary) Port ────────────────────────────────
# 외부가 "주문을 다루겠다"는 conversation을 정의
class OrderUseCase(ABC):
@abstractmethod
def place_order(self, product: str, quantity: int) -> Order: ...
@abstractmethod
def confirm_order(self, order_id: UUID) -> Order: ...
포트가 도메인 안에 위치한다는 게 핵심입니다. 포트는 기술이 아니라 conversation의 intent를 표현하기 때문입니다.
Application Service — 유스케이스 오케스트레이션
# application/service.py
from uuid import UUID
from domain.model import Order
from domain.ports import OrderRepository, NotificationSender, OrderUseCase
class OrderService(OrderUseCase):
def __init__(
self,
repo: OrderRepository, # Driven 포트에 의존
notifier: NotificationSender, # Driven 포트에 의존
):
self._repo = repo
self._notifier = notifier
def place_order(self, product: str, quantity: int) -> Order:
order = Order.create(product, quantity) # 도메인 로직
self._repo.save(order) # 포트를 통해 저장
return order
def confirm_order(self, order_id: UUID) -> Order:
order = self._repo.find_by_id(order_id)
if order is None:
raise ValueError("주문을 찾을 수 없습니다")
order.confirm() # 도메인 로직
self._repo.save(order) # 포트를 통해 저장
self._notifier.send( # 포트를 통해 알림
f"주문 {order.id} 확정됨"
)
return order
OrderService는 OrderRepository가 PostgreSQL인지 In-Memory인지 모릅니다. NotificationSender가 이메일인지 Slack인지 모릅니다. 포트에만 의존하고, 비즈니스 흐름을 조율할 뿐입니다.
Adapters — 기술 구현
# adapters/outbound/memory_repo.py
from uuid import UUID
from domain.model import Order
from domain.ports import OrderRepository
class InMemoryOrderRepository(OrderRepository):
def __init__(self):
self._store: dict[UUID, Order] = {}
def save(self, order: Order) -> None:
self._store[order.id] = order
def find_by_id(self, order_id: UUID) -> Order | None:
return self._store.get(order_id)
# adapters/outbound/postgres_repo.py
from uuid import UUID
from domain.model import Order
from domain.ports import OrderRepository
class PostgresOrderRepository(OrderRepository):
def __init__(self, connection_string: str):
self._conn_str = connection_string
# 실제 DB 커넥션 설정...
def save(self, order: Order) -> None:
# INSERT/UPDATE SQL 실행
...
def find_by_id(self, order_id: UUID) -> Order | None:
# SELECT SQL 실행
...
# adapters/inbound/api.py (Driver Adapter)
from uuid import UUID
from fastapi import FastAPI, Depends, HTTPException
from domain.ports import OrderUseCase
from config import create_order_service
app = FastAPI()
def get_order_use_case() -> OrderUseCase:
return create_order_service()
@app.post("/orders")
def create_order(
product: str,
quantity: int,
use_case: OrderUseCase = Depends(get_order_use_case),
):
try:
order = use_case.place_order(product, quantity)
return {"id": str(order.id), "status": order.status.value}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.post("/orders/{order_id}/confirm")
def confirm_order(
order_id: UUID,
use_case: OrderUseCase = Depends(get_order_use_case),
):
try:
order = use_case.confirm_order(order_id)
return {"id": str(order.id), "status": order.status.value}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
REST 컨트롤러(Driver Adapter)는 FastAPI의 Depends를 통해 OrderUseCase 포트를 주입받아 호출합니다. FastAPI의 세부사항(HTTP, JSON, DI)은 이 어댑터 안에 갇혀 있습니다.
Composition Root — 조립
# config.py
from application.service import OrderService
from adapters.outbound.memory_repo import InMemoryOrderRepository
from adapters.outbound.postgres_repo import PostgresOrderRepository
import os
def create_order_service() -> OrderService:
env = os.getenv("ENV", "dev")
if env == "test":
repo = InMemoryOrderRepository()
else:
repo = PostgresOrderRepository(os.getenv("DATABASE_URL"))
notifier = ConsoleNotifier() # 또는 EmailNotifier, SlackNotifier...
return OrderService(repo=repo, notifier=notifier)
여기가 유일하게 "어떤 어댑터를 쓸지" 결정하는 곳입니다. 전체 시스템에서 이 파일만 구체적 구현체를 알고 있습니다.
테스트 — 외부 의존 없이 검증하기
# tests/test_order_service.py
from application.service import OrderService
from adapters.outbound.memory_repo import InMemoryOrderRepository
class FakeNotifier(NotificationSender):
def __init__(self):
self.messages = []
def send(self, message: str) -> None:
self.messages.append(message)
def test_place_and_confirm_order():
repo = InMemoryOrderRepository()
notifier = FakeNotifier()
service = OrderService(repo=repo, notifier=notifier)
order = service.place_order("커피", 2)
assert order.status.value == "pending"
confirmed = service.confirm_order(order.id)
assert confirmed.status.value == "confirmed"
assert len(notifier.messages) == 1
DB 없이, HTTP 서버 없이, 비즈니스 로직 전체를 테스트합니다. InMemoryOrderRepository와 FakeNotifier를 꽂았을 뿐인데 모든 유스케이스를 검증할 수 있습니다. Cockburn이 말한 "isolation from its eventual run-time devices and databases" 가 바로 이겁니다.
의존성 흐름 요약
┌────────────────────────────────────────────────────────┐
│ │
Driver │ ┌──────────────────────────────────────────┐ │ Driven
Adapters │ │ Application Core │ │ Adapters
│ │ │ │
REST ────┼──▶│ OrderUseCase ◁── OrderService │ │
CLI ────┼──▶│ │ │ │ │
Test ────┼──▶│ │ │ │ │
│ │ ▼ ▼ │ │
│ │ OrderRepository NotificationSender│ │
│ │ ▲ ▲ │ │
│ └──────────────┼────────────────┼──────────┘ │
│ │ │ │
│ Postgres Repo Email Notifier │
│ Memory Repo Slack Notifier │
└────────────────────────────────────────────────────────┘
모든 화살표가 안쪽(Core)을 향합니다.
5. 컨벤션, 팁, 노하우
표준 컨벤션
네이밍
어댑터 이름에 기술을 명시해서, 이름만 보고 어떤 기술의 어댑터인지 알 수 있게 합니다:
| 패턴 | 클래스명 | 파일명 |
|---|---|---|
[기술][포트명] |
PostgresOrderRepository |
postgres_order_repository.py |
[포트명]Using[기술] |
OrderRepositoryUsingPostgres |
order_repository_using_postgres.py |
[기술][행위]Adapter |
RestApiPlaceOrderAdapter |
rest_api_place_order_adapter.py |
어느 패턴이든 상관없습니다. 중요한 건 팀 내에서 하나로 통일하는 것입니다.
디렉토리 구조
# 추천 구조 (기능/모듈 단위)
order/
├── domain/
│ ├── model.py
│ └── ports.py
├── application/
│ └── service.py
└── adapters/
├── inbound/
└── outbound/
# 또는 (계층 단위 — 소규모 프로젝트)
domain/
application/
adapters/
inbound/
outbound/
포트 위치: 포트는 반드시 도메인/애플리케이션 내부에 둡니다. 어댑터 쪽이 아닙니다. 포트가 애플리케이션의 경계를 정의하기 때문입니다.
실전 팁
1. 포트를 conversation 단위로 끊으세요
# BAD — 너무 큰 포트 하나에 다 몰아넣음
class DatabasePort(ABC):
def save_order(self, order): ...
def find_order(self, id): ...
def save_user(self, user): ...
def find_user(self, id): ...
# GOOD — conversation(목적) 단위로 분리
class OrderRepository(ABC):
def save(self, order): ...
def find_by_id(self, id): ...
class UserRepository(ABC):
def save(self, user): ...
def find_by_id(self, id): ...
Interface Segregation Principle과 같은 맥락입니다. 포트가 커지면 어댑터도 비대해지고, 테스트용 가짜 구현체(fake, stub, mock 등)도 만들기 어려워집니다.
2. Application Service는 얇게 유지하세요
Application Service는 오케스트레이터지, 비즈니스 로직을 담는 곳이 아닙니다.
# BAD — 비즈니스 로직이 서비스에 있음
class OrderService:
def confirm_order(self, order_id):
order = self.repo.find_by_id(order_id)
if order.status != "pending": # 이 로직은
raise ValueError("확정 불가") # 도메인에 있어야 한다
order.status = "confirmed"
self.repo.save(order)
# GOOD — 도메인 모델에 비즈니스 규칙이 캡슐화됨
class OrderService:
def confirm_order(self, order_id):
order = self.repo.find_by_id(order_id)
order.confirm() # 도메인이 판단
self.repo.save(order)
3. "Tell, Don't Ask" 패턴을 활용하세요
도메인 객체에게 상태를 물어서 판단하지 말고, 행위를 지시하세요.
# BAD — 상태를 물어서 밖에서 판단 (Ask)
if order.status == OrderStatus.PENDING:
order.status = OrderStatus.CONFIRMED
# GOOD — 행위를 지시 (Tell)
order.confirm() # 판단 로직이 도메인 안에 캡슐화됨
이렇게 하면 "주문을 확정해도 되는가?"라는 판단이 Order 안에 머뭅니다. 밖에서 상태를 꺼내 판단하기 시작하면 비즈니스 로직이 여기저기 흩어지게 됩니다.
4. Application Service에 기술 세부사항을 넣지 마세요
Cockburn의 원문에서 명시적으로 경고한 내용입니다:
"use cases에 각 포트 바깥에 있는 기술의 세부사항을 넣는 것은 흔한 실수다. 이런 use case는 길고, 읽기 어렵고, 지루하고, 깨지기 쉽고, 유지보수 비용이 높다는 이유로 정당하게 나쁜 평판을 얻었다."
Application Service는 Use Case를 오케스트레이션하는 곳이므로, 기술 세부사항이 침투하면 안 됩니다:
# BAD — Application Service에 HTTP 기술이 침투함
class OrderService:
def place_order(self, request_json: dict): # HTTP 세부사항이 여기까지 옴
product = request_json["product"] # JSON 파싱이 서비스 안에 있음
...
# GOOD — Application Service는 기술 무관
class OrderService:
def place_order(self, product: str, quantity: int): # 순수한 파라미터만 받음
...
# JSON 파싱은 Driver Adapter(REST Controller)가 처리합니다
5. Composition Root는 딱 한 곳에
의존성 조립(어떤 어댑터를 어떤 포트에 연결할지)은 시스템 진입점에서 한 번만 합니다. 이 "Composition Root"를 여러 곳에 분산시키면 어디서 뭘 쓰는지 추적이 안 됩니다.
흔한 안티패턴
1. Spaghetti Use Cases
# BAD — Use Case가 다른 Use Case를 호출
class ConfirmOrderService:
def __init__(self, notify_service: NotifyService): # 다른 서비스에 의존
self._notify = notify_service
def confirm(self, order_id):
...
self._notify.send_confirmation(order_id) # 서비스 간 커플링
각 Use Case는 독립적인 오케스트레이션이어야 합니다. 서비스끼리 호출하기 시작하면 레이어드 아키텍처 때의 스파게티가 다시 만들어집니다.
2. 비즈니스 로직 누출
자동으로 감지할 메커니즘이 없어서, 시간이 지나면 UI 레이어에 비즈니스 로직 조각이 흩어지거나 비즈니스 로직에 인프라 관심사가 섞이게 됩니다. 코드 리뷰와 팀 컨벤션으로 꾸준히 지켜봐야 합니다.
3. 과도한 추상화
포트와 어댑터는 꼭 필요한 경우에만 만듭니다. Logger를 쓰기 위해 포트-어댑터를 만드는 건 대부분 과잉입니다. "이 외부 의존성을 나중에 바꿀 가능성이 있는가?", "이것 때문에 테스트가 어려운가?"를 기준으로 판단하세요.
4. 점진적 아키텍처 침식
프로젝트가 진화하면서 개발자들이 원칙을 간과하거나 위반하기 쉬워집니다. 처음에는 잘 분리했더라도 시간이 지나면 무너질 수 있습니다. ArchUnit 같은 아키텍처 테스트 도구를 도입하거나, 모듈 경계를 빌드 시스템 수준에서 강제하는 것을 고려해야 합니다.
6. 언제 쓰고, 언제 안 쓰나요
쓰면 좋은 경우
- 복잡한 비즈니스 규칙이 있고, 수년간 유지보수할 시스템
- 외부 시스템을 자주 교체해야 하는 환경 (DB 마이그레이션, 벤더 변경 등)
- 테스트 격리가 중요한 프로젝트 (CI/CD 파이프라인에서 외부 의존 없이 테스트)
- 여러 인터페이스를 제공해야 하는 경우 (REST + GraphQL + CLI + 메시지 큐)
- 팀 규모가 크고, 모듈 경계가 명확해야 독립적으로 작업 가능한 경우
안 쓰는 게 나은 경우
- 단순한 CRUD 앱 — 오버헤드만 늘어납니다
- 프로토타입이나 MVP — 빠른 검증이 우선일 때
- 비즈니스 규칙이 거의 없는 경우 — 정당화할 복잡성이 없습니다
- 1인 소규모 프로젝트에서 기술 스택이 확정된 경우
결정 가이드
비즈니스 규칙이 복잡한가?
├── No → Layered Architecture로 충분
└── Yes ↓
외부 시스템 교체 가능성이 높은가?
├── Yes → Hexagonal Architecture ✓
└── No ↓
도메인 모델이 핵심 복잡성인가?
├── Yes → Onion Architecture (DDD 결합)
└── No → Clean Architecture (범용)
Cockburn 본인도 인정했듯이, 이 패턴은 "수년에서 수십 년의 수명이 예상되는 복잡한 비즈니스 애플리케이션" 에 적합합니다. 아키텍처는 "올바른" 것이 아니라 "적절한" 것을 선택하는 것입니다.
출처
- Alistair Cockburn, Hexagonal Architecture (원문, 2005)
- Alistair Cockburn & Juan Manuel Garrido de Paz, Hexagonal Architecture Explained (2024)
- Juan Manuel Garrido de Paz, Hexagonal Architecture — Ports and Adapters Pattern
- Juan Manuel Garrido de Paz, Interview with Alistair Cockburn
- Wikipedia: Hexagonal architecture (software)
- HappyCoders: Hexagonal Architecture
- TSH: Hexagonal Architecture — Overview and Best Practices
- Szymon Miks: Hexagonal Architecture in Python
- Herberto Graça, DDD, Hexagonal, Onion, Clean, CQRS — How I Put It All Together