🧪 테스트 용이성
도메인이 외부 의존성을 모름. fake adapter 만 끼우면 DB·HTTP·LLM 없이 비즈니스 로직 단위 테스트 가능.
Architecture Deep Dive
Ports & Adapters — 도메인을 외부 세계로부터 격리하는 설계
도메인(비즈니스 로직)을 중앙에 두고, 외부 세계와의 모든 통신을 port(인터페이스)와 adapter(구현)로 분리하는 아키텍처.
— Alistair Cockburn, 2005
이름의 유래는 다이어그램을 그렸을 때 도메인 둘레가 육각형(hexagon)으로 보였기 때문. 사실 면의 개수는 의미 없고, "외부와 닿는 면이 여러 개"라는 점이 핵심.
도메인이 외부 의존성을 모름. fake adapter 만 끼우면 DB·HTTP·LLM 없이 비즈니스 로직 단위 테스트 가능.
PostgreSQL → MongoDB, OpenAI → Gemini, REST → GraphQL. 도메인 코드는 한 줄도 안 바뀜.
외부 SDK 가 도메인을 의존(adapter implements port). 도메인이 SDK 를 의존하지 않음.
비즈니스 규칙이 프레임워크 변경(FastAPI → Litestar)에 영향 받지 않음. 도메인은 순수 Python/TS.
"이 코드는 비즈니스 룰인가, 인프라인가?"가 폴더만 봐도 명확. 신규 합류자 온보딩 가속.
인프라는 자주 바뀌고(라이브러리 업데이트, 클라우드 이전), 도메인은 천천히 바뀜. 이 두 속도를 분리.
비즈니스 규칙, 엔티티, use case
⚠️ 외부 SDK·DB session·HTTP client import 금지
port 의 구체 구현 — 외부 세계의 번역기
SQLAlchemy, httpx, google.genai 등 실제 라이브러리는 여기서만
adapter ↔ use case wiring
여기서만 의존성 주입이 일어남. 도메인은 자기 자신을 wire 하지 않음
POST /properties 도착RegisterPropertyUseCase 호출PropertyRepository port 의 .save() 호출 — 구현은 모름EmbeddingPort.embed() 호출 → VertexAiEmbeddingAdapter 가 LLM 호출 → Qdrant 저장NotificationPort.publish() 호출 → RedisPubSubAdapter 가 Socket.io 로 broadcastuse case 코드 어디에도 sqlalchemy, google.genai, redis 라는 단어가 등장하지 않는다. 그것이 헥사고날의 증거.
# domains/properties/ports/property_repository.py
from abc import ABC, abstractmethod
from domains.properties.entities import Property
class PropertyRepository(ABC):
@abstractmethod
async def save(self, property: Property) -> Property: ...
@abstractmethod
async def find_by_id(self, id: str) -> Property | None: ...
# domains/properties/use_cases/register_property.py
from dataclasses import dataclass
from domains.properties.ports import PropertyRepository, EmbeddingPort
from domains.properties.entities import Property
@dataclass
class RegisterPropertyUseCase:
repo: PropertyRepository # ← 인터페이스만 알고 있음
embedder: EmbeddingPort
async def execute(self, cmd: RegisterPropertyCommand) -> Property:
prop = Property.create(cmd.address, cmd.price, cmd.area)
saved = await self.repo.save(prop)
await self.embedder.embed(saved.id, saved.description)
return saved
# adapters/database/sqlalchemy_property_repository.py
from sqlalchemy.ext.asyncio import AsyncSession
from domains.properties.ports import PropertyRepository
from domains.properties.entities import Property
class SqlAlchemyPropertyRepository(PropertyRepository):
def __init__(self, session: AsyncSession):
self.session = session
async def save(self, property: Property) -> Property:
row = PropertyORM.from_entity(property)
self.session.add(row)
await self.session.flush()
return row.to_entity()
# api/properties.py
from fastapi import APIRouter, Depends
router = APIRouter()
@router.post("/properties")
async def register(
body: RegisterPropertySchema,
session: AsyncSession = Depends(get_db),
embedder: EmbeddingPort = Depends(get_embedder),
):
use_case = RegisterPropertyUseCase(
repo=SqlAlchemyPropertyRepository(session), # ← 여기서만 SQLAlchemy 등장
embedder=embedder,
)
result = await use_case.execute(body.to_command())
return PropertyResponse.from_entity(result)
# tests/unit/test_register_property.py
class FakePropertyRepo(PropertyRepository):
def __init__(self):
self.items = []
async def save(self, p):
self.items.append(p); return p
async def find_by_id(self, id):
return next((x for x in self.items if x.id == id), None)
async def test_register():
repo, embedder = FakePropertyRepo(), FakeEmbedder()
uc = RegisterPropertyUseCase(repo, embedder)
result = await uc.execute(RegisterPropertyCommand(...))
assert result.id in [p.id for p in repo.items]
# DB·LLM 컨테이너 없이 비즈니스 로직만 검증
backend/src/app/ 의 실제 구조:
domains/{domain}/ # 38개 도메인 (auth, properties, contracts, ai, …)
├── entities/ # 순수 모델 (외부 의존 0)
├── ports/ # ABC 인터페이스
└── use_cases/ # 비즈니스 로직 (port 만 의존)
core/
├── ports/ # 공용 port (Repository, UoW, Clock, TokenService, …)
├── result.py # Result[T, E] 모나드 — exception 대신 명시적 에러
└── errors.py # DomainError 계층
adapters/ # port 의 구체 구현
├── database/ # SQLAlchemy 2.0 async repo
├── infra/ # Qdrant, Redis, S3, Dramatiq
├── oauth/ # Google, Kakao, Naver
├── auth/ # PasswordHasher, TokenService
└── realtime/ # Socket.io 어댑터
api/ # FastAPI route (composition root)
workers/ # Dramatiq actors (composition root)
| 데이터 성격 | SSOT 위치 |
|---|---|
| 영속·다중 사용자 공유 | PostgreSQL (SQLAlchemy + Alembic) |
| 휘발·짧은 TTL | Redis |
| Job queue 상태 | Dramatiq + Redis broker |
| Vector embedding | Qdrant |
| 서버 상태 클라 캐시 | TanStack Query (mutate 금지) |
| 클라 ephemeral UI | Jotai |
api/·workers/ 가 유일한 composition rootResult[T, E] 로 — 도메인에서 exception raise 자제app.adapters.* / sqlalchemy / httpx importSqlAlchemyRow 반환 타입 등)services/ 신규 추가 — cross-domain 외엔 use_cases/ 로| 스타일 | 의존 방향 | 핵심 차이 |
|---|---|---|
| Layered (3-tier) | UI → Service → DAO → DB | 도메인이 DB 를 직접 의존 → 인프라 변경이 도메인까지 침투 |
| Hexagonal | UI → UseCase → Port ← Adapter ← DB | 의존성 역전. 도메인은 port 만 알고, adapter 가 port 를 구현 |
| Onion | UI → App → Domain ← Infra | 헥사고날의 동심원 표현. 본질적으로 동일 |
| Clean Architecture | Frameworks → Adapters → UseCases → Entities | 헥사고날의 4계층 변형. Uncle Bob 의 재포장 |
Hexagonal · Onion · Clean 셋은 본질이 같다 — 의존성 역전을 통한 도메인 보호. 다이어그램이 다를 뿐.
Repository 는 port 의 한 종류. Port 는 더 넓은 개념 — DB 외에도 LLM, 메시지 브로커, 외부 API 등 모든 외부 통신이 port. Repository 는 "영속성"이라는 특정 port 의 관용 명칭.
아니. 도메인 로직이 거의 없으면 오버엔지니어링. 헥사고날의 효용은 비즈니스 규칙이 복잡할수록 커진다. 어디살지처럼 38개 도메인이면 필수.
안 됨. ORM 모델은 SQLAlchemy 의존 → 도메인 오염. PropertyORM.to_entity() / Property.to_orm() 변환 메서드로 분리. 처음엔 귀찮지만 ORM 교체 시 진가 발휘.
Use case 단위. UnitOfWork port 를 도입해 use case 내에서 async with uow: 로 묶음. 어디살지 core/ports/uow.py 참조.
FastAPI 의 Depends 로 충분. 별도 DI 컨테이너는 어지간히 큰 프로젝트 아니면 과잉. 어디살지도 FastAPI native + 함수형 wiring 으로 운영.