- Published on
클린 아키텍처와 헥사고날 아키텍처 실전 구현: 포트와 어댑터 패턴으로 도메인 독립성 확보
- Authors
- Name
- 들어가며
- 아키텍처 비교: 헥사고날 vs 클린 vs 어니언 vs 레이어드
- TypeScript 실전 구현
- Python 실전 구현
- 테스트 전략
- 안티패턴과 주의사항
- 레이어드 아키텍처에서 마이그레이션
- 프로덕션 체크리스트
- 참고자료

들어가며
소프트웨어가 성장하면서 가장 먼저 무너지는 것은 대개 아키텍처다. 처음에는 간단한 레이어드 아키텍처로 시작했지만, 비즈니스 로직이 컨트롤러에 침투하고, 데이터베이스 스키마 변경이 도메인 로직에 영향을 미치며, 프레임워크 업그레이드가 전체 코드베이스를 흔들어놓는 상황을 많은 개발자가 경험한다.
클린 아키텍처(Clean Architecture)와 헥사고날 아키텍처(Hexagonal Architecture, Ports and Adapters)는 이 문제를 해결하기 위한 대표적인 접근법이다. 두 아키텍처 모두 핵심 비즈니스 로직을 외부 관심사(데이터베이스, 프레임워크, UI)로부터 격리하는 것을 목표로 한다.
이 글에서는 두 아키텍처의 이론적 배경을 비교한 뒤, TypeScript와 Python으로 실제 구현하는 방법을 코드와 함께 설명한다.
아키텍처 비교: 헥사고날 vs 클린 vs 어니언 vs 레이어드
핵심 개념 비교 표
| 항목 | 헥사고날 아키텍처 | 클린 아키텍처 | 어니언 아키텍처 | 레이어드 아키텍처 |
|---|---|---|---|---|
| 제안자 | Alistair Cockburn (2005) | Robert C. Martin (2012) | Jeffrey Palermo (2008) | 전통적 패턴 |
| 핵심 원칙 | 포트와 어댑터 | 의존성 규칙 | 도메인 중심 레이어 | 계층 간 단방향 의존 |
| 레이어 구조 | 코어 + 포트 + 어댑터 | 엔티티/유스케이스/인터페이스 어댑터/프레임워크 | 도메인/서비스/인프라 | 프레젠테이션/비즈니스/데이터 |
| 의존성 방향 | 외부 -> 내부 | 외부 -> 내부 | 외부 -> 내부 | 위 -> 아래 |
| 테스트 용이성 | 높음 | 높음 | 높음 | 보통 |
| 인프라 교체 | 어댑터 교체로 간단 | 외부 레이어 교체 | 외부 레이어 교체 | 전체 레이어 수정 필요 |
| 학습 곡선 | 보통 | 높음 | 보통 | 낮음 |
| 적합한 규모 | 중~대규모 | 대규모 | 중~대규모 | 소~중규모 |
클린 아키텍처의 의존성 규칙
클린 아키텍처의 핵심은 **의존성 규칙(Dependency Rule)**이다. 의존성은 반드시 바깥쪽에서 안쪽으로만 향해야 한다.
안쪽 레이어부터 바깥쪽 레이어까지 4계층으로 구성된다:
- Entities (엔티티): 엔터프라이즈 비즈니스 규칙. 외부 의존성이 전혀 없다.
- Use Cases (유스케이스): 애플리케이션 비즈니스 규칙. 엔티티만 의존한다.
- Interface Adapters (인터페이스 어댑터): 유스케이스와 외부 세계를 연결. 컨트롤러, 프레젠터, 게이트웨이가 여기 위치한다.
- Frameworks & Drivers (프레임워크): 웹 프레임워크, DB, UI 등 외부 도구.
헥사고날 아키텍처의 포트와 어댑터
헥사고날 아키텍처는 애플리케이션을 육각형(Hexagon)으로 표현하며, 두 종류의 포트를 정의한다:
- 인바운드 포트 (Driving Port): 외부에서 애플리케이션을 호출하기 위한 인터페이스 (예: 유스케이스 인터페이스)
- 아웃바운드 포트 (Driven Port): 애플리케이션이 외부 시스템에 접근하기 위한 인터페이스 (예: 리포지토리 인터페이스)
각 포트에는 대응하는 어댑터가 있다:
- 인바운드 어댑터 (Driving Adapter): HTTP 컨트롤러, CLI, 메시지 리스너 등
- 아웃바운드 어댑터 (Driven Adapter): DB 리포지토리, 외부 API 클라이언트, 메시지 발행자 등
TypeScript 실전 구현
프로젝트 구조
src/
domain/ # 도메인 레이어 (엔티티, 밸류 오브젝트)
entities/
Order.ts
Product.ts
value-objects/
Money.ts
OrderStatus.ts
application/ # 애플리케이션 레이어 (유스케이스, 포트)
ports/
inbound/
CreateOrderUseCase.ts
GetOrderUseCase.ts
outbound/
OrderRepository.ts
PaymentGateway.ts
EventPublisher.ts
services/
CreateOrderService.ts
GetOrderService.ts
infrastructure/ # 인프라스트럭처 레이어 (어댑터)
adapters/
inbound/
http/
OrderController.ts
messaging/
OrderEventListener.ts
outbound/
persistence/
TypeOrmOrderRepository.ts
payment/
StripePaymentAdapter.ts
messaging/
KafkaEventPublisher.ts
config/
DependencyContainer.ts
도메인 레이어: 엔티티와 밸류 오브젝트
// domain/value-objects/Money.ts
export class Money {
private constructor(
private readonly amount: number,
private readonly currency: string
) {
if (amount < 0) {
throw new Error('Amount cannot be negative')
}
}
static of(amount: number, currency: string = 'KRW'): Money {
return new Money(amount, currency)
}
add(other: Money): Money {
this.ensureSameCurrency(other)
return Money.of(this.amount + other.amount, this.currency)
}
multiply(factor: number): Money {
return Money.of(this.amount * factor, this.currency)
}
isGreaterThan(other: Money): boolean {
this.ensureSameCurrency(other)
return this.amount > other.amount
}
getAmount(): number {
return this.amount
}
private ensureSameCurrency(other: Money): void {
if (this.currency !== other.currency) {
throw new Error('Currency mismatch')
}
}
}
// domain/value-objects/OrderStatus.ts
export enum OrderStatus {
PENDING = 'PENDING',
CONFIRMED = 'CONFIRMED',
PAID = 'PAID',
SHIPPED = 'SHIPPED',
DELIVERED = 'DELIVERED',
CANCELLED = 'CANCELLED',
}
// domain/entities/Order.ts
import { Money } from '../value-objects/Money'
import { OrderStatus } from '../value-objects/OrderStatus'
export interface OrderItem {
productId: string
productName: string
quantity: number
unitPrice: Money
}
export class Order {
private constructor(
private readonly id: string,
private readonly customerId: string,
private items: OrderItem[],
private status: OrderStatus,
private readonly createdAt: Date
) {}
static create(id: string, customerId: string, items: OrderItem[]): Order {
if (items.length === 0) {
throw new Error('Order must have at least one item')
}
return new Order(id, customerId, items, OrderStatus.PENDING, new Date())
}
confirm(): void {
if (this.status !== OrderStatus.PENDING) {
throw new Error('Only pending orders can be confirmed')
}
this.status = OrderStatus.CONFIRMED
}
pay(): void {
if (this.status !== OrderStatus.CONFIRMED) {
throw new Error('Only confirmed orders can be paid')
}
this.status = OrderStatus.PAID
}
cancel(): void {
if (this.status === OrderStatus.SHIPPED || this.status === OrderStatus.DELIVERED) {
throw new Error('Cannot cancel shipped or delivered orders')
}
this.status = OrderStatus.CANCELLED
}
getTotalAmount(): Money {
return this.items.reduce(
(total, item) => total.add(item.unitPrice.multiply(item.quantity)),
Money.of(0)
)
}
getId(): string {
return this.id
}
getCustomerId(): string {
return this.customerId
}
getStatus(): OrderStatus {
return this.status
}
getItems(): ReadonlyArray<OrderItem> {
return [...this.items]
}
}
애플리케이션 레이어: 포트와 유스케이스
// application/ports/inbound/CreateOrderUseCase.ts
export interface CreateOrderCommand {
customerId: string
items: Array<{
productId: string
productName: string
quantity: number
unitPrice: number
}>
}
export interface CreateOrderResult {
orderId: string
totalAmount: number
status: string
}
export interface CreateOrderUseCase {
execute(command: CreateOrderCommand): Promise<CreateOrderResult>
}
// application/ports/outbound/OrderRepository.ts
import { Order } from '../../domain/entities/Order'
export interface OrderRepository {
save(order: Order): Promise<void>
findById(id: string): Promise<Order | null>
findByCustomerId(customerId: string): Promise<Order[]>
}
// application/ports/outbound/PaymentGateway.ts
export interface PaymentRequest {
orderId: string
amount: number
currency: string
}
export interface PaymentResult {
transactionId: string
success: boolean
errorMessage?: string
}
export interface PaymentGateway {
processPayment(request: PaymentRequest): Promise<PaymentResult>
}
// application/ports/outbound/EventPublisher.ts
export interface DomainEvent {
eventType: string
aggregateId: string
payload: Record<string, unknown>
occurredAt: Date
}
export interface EventPublisher {
publish(event: DomainEvent): Promise<void>
}
// application/services/CreateOrderService.ts
import {
CreateOrderUseCase,
CreateOrderCommand,
CreateOrderResult,
} from '../ports/inbound/CreateOrderUseCase'
import { OrderRepository } from '../ports/outbound/OrderRepository'
import { PaymentGateway } from '../ports/outbound/PaymentGateway'
import { EventPublisher } from '../ports/outbound/EventPublisher'
import { Order, OrderItem } from '../../domain/entities/Order'
import { Money } from '../../domain/value-objects/Money'
import { v4 as uuidv4 } from 'uuid'
export class CreateOrderService implements CreateOrderUseCase {
constructor(
private readonly orderRepository: OrderRepository,
private readonly paymentGateway: PaymentGateway,
private readonly eventPublisher: EventPublisher
) {}
async execute(command: CreateOrderCommand): Promise<CreateOrderResult> {
// 1. 도메인 객체 생성
const orderItems: OrderItem[] = command.items.map((item) => ({
productId: item.productId,
productName: item.productName,
quantity: item.quantity,
unitPrice: Money.of(item.unitPrice),
}))
const order = Order.create(uuidv4(), command.customerId, orderItems)
// 2. 주문 확인
order.confirm()
// 3. 결제 처리
const paymentResult = await this.paymentGateway.processPayment({
orderId: order.getId(),
amount: order.getTotalAmount().getAmount(),
currency: 'KRW',
})
if (!paymentResult.success) {
order.cancel()
await this.orderRepository.save(order)
throw new Error('Payment failed: ' + (paymentResult.errorMessage || 'Unknown error'))
}
// 4. 결제 완료 처리
order.pay()
// 5. 저장
await this.orderRepository.save(order)
// 6. 도메인 이벤트 발행
await this.eventPublisher.publish({
eventType: 'OrderCreated',
aggregateId: order.getId(),
payload: {
customerId: order.getCustomerId(),
totalAmount: order.getTotalAmount().getAmount(),
itemCount: order.getItems().length,
},
occurredAt: new Date(),
})
return {
orderId: order.getId(),
totalAmount: order.getTotalAmount().getAmount(),
status: order.getStatus(),
}
}
}
인프라스트럭처 레이어: 어댑터 구현
// infrastructure/adapters/outbound/persistence/TypeOrmOrderRepository.ts
import { OrderRepository } from '../../../application/ports/outbound/OrderRepository'
import { Order, OrderItem } from '../../../domain/entities/Order'
import { Repository, DataSource } from 'typeorm'
// TypeORM 엔티티 (인프라 관심사)
import { OrderEntity } from './entities/OrderEntity'
import { OrderItemEntity } from './entities/OrderItemEntity'
export class TypeOrmOrderRepository implements OrderRepository {
private readonly repository: Repository<OrderEntity>
constructor(dataSource: DataSource) {
this.repository = dataSource.getRepository(OrderEntity)
}
async save(order: Order): Promise<void> {
const entity = this.toEntity(order)
await this.repository.save(entity)
}
async findById(id: string): Promise<Order | null> {
const entity = await this.repository.findOne({
where: { id },
relations: ['items'],
})
return entity ? this.toDomain(entity) : null
}
async findByCustomerId(customerId: string): Promise<Order[]> {
const entities = await this.repository.find({
where: { customerId },
relations: ['items'],
})
return entities.map((e) => this.toDomain(e))
}
private toEntity(order: Order): OrderEntity {
// 도메인 -> 영속성 변환 로직
const entity = new OrderEntity()
entity.id = order.getId()
entity.customerId = order.getCustomerId()
entity.status = order.getStatus()
entity.totalAmount = order.getTotalAmount().getAmount()
return entity
}
private toDomain(entity: OrderEntity): Order {
// 영속성 -> 도메인 변환 로직
// Order.reconstitute() 같은 팩토리 메서드를 사용
return Order.create(entity.id, entity.customerId, /* items mapping */ [])
}
}
// infrastructure/adapters/inbound/http/OrderController.ts
import { Router, Request, Response } from 'express'
import { CreateOrderUseCase } from '../../../application/ports/inbound/CreateOrderUseCase'
export class OrderController {
private readonly router: Router
constructor(private readonly createOrderUseCase: CreateOrderUseCase) {
this.router = Router()
this.setupRoutes()
}
private setupRoutes(): void {
this.router.post('/orders', this.createOrder.bind(this))
}
private async createOrder(req: Request, res: Response): Promise<void> {
try {
const result = await this.createOrderUseCase.execute({
customerId: req.body.customerId,
items: req.body.items,
})
res.status(201).json(result)
} catch (error) {
if (error instanceof Error) {
res.status(400).json({ error: error.message })
} else {
res.status(500).json({ error: 'Internal server error' })
}
}
}
getRouter(): Router {
return this.router
}
}
DI 컨테이너 설정
// infrastructure/config/DependencyContainer.ts
import { DataSource } from 'typeorm'
import { CreateOrderService } from '../../application/services/CreateOrderService'
import { TypeOrmOrderRepository } from '../adapters/outbound/persistence/TypeOrmOrderRepository'
import { StripePaymentAdapter } from '../adapters/outbound/payment/StripePaymentAdapter'
import { KafkaEventPublisher } from '../adapters/outbound/messaging/KafkaEventPublisher'
import { OrderController } from '../adapters/inbound/http/OrderController'
export class DependencyContainer {
private static instance: DependencyContainer
private constructor(private readonly dataSource: DataSource) {}
static async create(dataSource: DataSource): Promise<DependencyContainer> {
if (!DependencyContainer.instance) {
DependencyContainer.instance = new DependencyContainer(dataSource)
}
return DependencyContainer.instance
}
createOrderController(): OrderController {
// 아웃바운드 어댑터 생성
const orderRepository = new TypeOrmOrderRepository(this.dataSource)
const paymentGateway = new StripePaymentAdapter()
const eventPublisher = new KafkaEventPublisher()
// 유스케이스 생성 (포트 구현체 주입)
const createOrderService = new CreateOrderService(
orderRepository,
paymentGateway,
eventPublisher
)
// 인바운드 어댑터 생성
return new OrderController(createOrderService)
}
}
Python 실전 구현
프로젝트 구조와 도메인 레이어
# domain/entities/order.py
from dataclasses import dataclass, field
from enum import Enum
from datetime import datetime
from typing import List
from domain.value_objects.money import Money
class OrderStatus(Enum):
PENDING = "PENDING"
CONFIRMED = "CONFIRMED"
PAID = "PAID"
SHIPPED = "SHIPPED"
CANCELLED = "CANCELLED"
@dataclass
class OrderItem:
product_id: str
product_name: str
quantity: int
unit_price: Money
class Order:
def __init__(
self,
order_id: str,
customer_id: str,
items: List[OrderItem],
status: OrderStatus = OrderStatus.PENDING,
created_at: datetime = None,
):
if not items:
raise ValueError("Order must have at least one item")
self._id = order_id
self._customer_id = customer_id
self._items = items
self._status = status
self._created_at = created_at or datetime.utcnow()
def confirm(self) -> None:
if self._status != OrderStatus.PENDING:
raise ValueError("Only pending orders can be confirmed")
self._status = OrderStatus.CONFIRMED
def pay(self) -> None:
if self._status != OrderStatus.CONFIRMED:
raise ValueError("Only confirmed orders can be paid")
self._status = OrderStatus.PAID
def cancel(self) -> None:
if self._status in (OrderStatus.SHIPPED,):
raise ValueError("Cannot cancel shipped orders")
self._status = OrderStatus.CANCELLED
@property
def total_amount(self) -> Money:
total = Money(0)
for item in self._items:
total = total.add(item.unit_price.multiply(item.quantity))
return total
@property
def id(self) -> str:
return self._id
@property
def customer_id(self) -> str:
return self._customer_id
@property
def status(self) -> OrderStatus:
return self._status
포트 정의 (추상 클래스)
# application/ports/outbound/order_repository.py
from abc import ABC, abstractmethod
from typing import Optional, List
from domain.entities.order import Order
class OrderRepository(ABC):
@abstractmethod
async def save(self, order: Order) -> None:
pass
@abstractmethod
async def find_by_id(self, order_id: str) -> Optional[Order]:
pass
@abstractmethod
async def find_by_customer_id(self, customer_id: str) -> List[Order]:
pass
# application/ports/outbound/payment_gateway.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class PaymentRequest:
order_id: str
amount: float
currency: str
@dataclass
class PaymentResult:
transaction_id: str
success: bool
error_message: str = ""
class PaymentGateway(ABC):
@abstractmethod
async def process_payment(self, request: PaymentRequest) -> PaymentResult:
pass
유스케이스 구현
# application/services/create_order_service.py
import uuid
from dataclasses import dataclass
from typing import List
from domain.entities.order import Order, OrderItem
from domain.value_objects.money import Money
from application.ports.outbound.order_repository import OrderRepository
from application.ports.outbound.payment_gateway import PaymentGateway, PaymentRequest
@dataclass
class CreateOrderCommand:
customer_id: str
items: List[dict]
@dataclass
class CreateOrderResult:
order_id: str
total_amount: float
status: str
class CreateOrderService:
def __init__(
self,
order_repository: OrderRepository,
payment_gateway: PaymentGateway,
):
self._order_repository = order_repository
self._payment_gateway = payment_gateway
async def execute(self, command: CreateOrderCommand) -> CreateOrderResult:
# 도메인 객체 생성
items = [
OrderItem(
product_id=item["product_id"],
product_name=item["product_name"],
quantity=item["quantity"],
unit_price=Money(item["unit_price"]),
)
for item in command.items
]
order = Order(
order_id=str(uuid.uuid4()),
customer_id=command.customer_id,
items=items,
)
order.confirm()
# 결제 처리
payment_result = await self._payment_gateway.process_payment(
PaymentRequest(
order_id=order.id,
amount=order.total_amount.amount,
currency="KRW",
)
)
if not payment_result.success:
order.cancel()
await self._order_repository.save(order)
raise Exception(f"Payment failed: {payment_result.error_message}")
order.pay()
await self._order_repository.save(order)
return CreateOrderResult(
order_id=order.id,
total_amount=order.total_amount.amount,
status=order.status.value,
)
테스트 전략
도메인 유닛 테스트
도메인 레이어는 외부 의존성이 없으므로 순수 유닛 테스트가 가능하다.
// tests/domain/entities/Order.test.ts
import { Order, OrderItem } from '../../../domain/entities/Order'
import { Money } from '../../../domain/value-objects/Money'
import { OrderStatus } from '../../../domain/value-objects/OrderStatus'
describe('Order', () => {
const sampleItems: OrderItem[] = [
{
productId: 'prod-1',
productName: 'Widget',
quantity: 2,
unitPrice: Money.of(10000),
},
]
it('should create an order with PENDING status', () => {
const order = Order.create('order-1', 'customer-1', sampleItems)
expect(order.getStatus()).toBe(OrderStatus.PENDING)
})
it('should throw when creating order with empty items', () => {
expect(() => Order.create('order-1', 'customer-1', [])).toThrow(
'Order must have at least one item'
)
})
it('should calculate total amount correctly', () => {
const order = Order.create('order-1', 'customer-1', sampleItems)
expect(order.getTotalAmount().getAmount()).toBe(20000)
})
it('should transition from PENDING to CONFIRMED', () => {
const order = Order.create('order-1', 'customer-1', sampleItems)
order.confirm()
expect(order.getStatus()).toBe(OrderStatus.CONFIRMED)
})
it('should not allow paying before confirming', () => {
const order = Order.create('order-1', 'customer-1', sampleItems)
expect(() => order.pay()).toThrow('Only confirmed orders can be paid')
})
})
유스케이스 통합 테스트 (어댑터 모킹)
// tests/application/services/CreateOrderService.test.ts
import { CreateOrderService } from '../../../application/services/CreateOrderService'
import { OrderRepository } from '../../../application/ports/outbound/OrderRepository'
import { PaymentGateway } from '../../../application/ports/outbound/PaymentGateway'
import { EventPublisher } from '../../../application/ports/outbound/EventPublisher'
describe('CreateOrderService', () => {
let service: CreateOrderService
let mockOrderRepo: jest.Mocked<OrderRepository>
let mockPayment: jest.Mocked<PaymentGateway>
let mockEvents: jest.Mocked<EventPublisher>
beforeEach(() => {
mockOrderRepo = {
save: jest.fn().mockResolvedValue(undefined),
findById: jest.fn(),
findByCustomerId: jest.fn(),
}
mockPayment = {
processPayment: jest.fn().mockResolvedValue({
transactionId: 'tx-1',
success: true,
}),
}
mockEvents = {
publish: jest.fn().mockResolvedValue(undefined),
}
service = new CreateOrderService(mockOrderRepo, mockPayment, mockEvents)
})
it('should create and pay for an order successfully', async () => {
const result = await service.execute({
customerId: 'cust-1',
items: [
{
productId: 'prod-1',
productName: 'Widget',
quantity: 1,
unitPrice: 10000,
},
],
})
expect(result.status).toBe('PAID')
expect(mockOrderRepo.save).toHaveBeenCalledTimes(1)
expect(mockPayment.processPayment).toHaveBeenCalledTimes(1)
expect(mockEvents.publish).toHaveBeenCalledTimes(1)
})
it('should cancel order when payment fails', async () => {
mockPayment.processPayment.mockResolvedValue({
transactionId: '',
success: false,
errorMessage: 'Insufficient funds',
})
await expect(
service.execute({
customerId: 'cust-1',
items: [
{
productId: 'prod-1',
productName: 'Widget',
quantity: 1,
unitPrice: 10000,
},
],
})
).rejects.toThrow('Payment failed')
expect(mockOrderRepo.save).toHaveBeenCalledTimes(1)
})
})
안티패턴과 주의사항
안티패턴 1: 도메인이 인프라에 의존
// 나쁜 예: 도메인 엔티티가 TypeORM 데코레이터에 의존
import { Entity, Column, PrimaryColumn } from 'typeorm'
@Entity() // 인프라 프레임워크 의존!
export class Order {
@PrimaryColumn()
id: string
@Column()
status: string
// ...
}
// 좋은 예: 도메인 엔티티는 순수 클래스로 유지
// 영속성 매핑은 인프라 레이어에서 별도의 Entity 클래스로 처리
export class Order {
private readonly id: string
private status: OrderStatus
// 순수 비즈니스 로직만 포함
}
안티패턴 2: 빈약한 도메인 모델 (Anemic Domain Model)
// 나쁜 예: 도메인 엔티티가 데이터 구조에 불과
export class Order {
id: string
status: string
items: OrderItem[]
totalAmount: number
// getter/setter만 존재, 비즈니스 로직 없음
}
// 비즈니스 로직이 서비스에 모두 집중
export class OrderService {
confirmOrder(order: Order): void {
if (order.status !== 'PENDING') throw new Error('...')
order.status = 'CONFIRMED'
order.totalAmount = this.calculateTotal(order.items)
}
}
// 좋은 예: 도메인 엔티티 안에 비즈니스 로직 캡슐화
export class Order {
confirm(): void {
if (this.status !== OrderStatus.PENDING) {
throw new Error('Only pending orders can be confirmed')
}
this.status = OrderStatus.CONFIRMED
}
getTotalAmount(): Money {
return this.items.reduce(
(total, item) => total.add(item.unitPrice.multiply(item.quantity)),
Money.of(0)
)
}
}
안티패턴 3: 과도한 엔지니어링 (Over-engineering)
작은 CRUD 애플리케이션에 헥사고날 아키텍처를 적용하면 보일러플레이트 코드만 증가하고 생산성이 저하된다. 아키텍처 선택은 프로젝트의 복잡도와 수명에 맞춰야 한다.
레이어드 아키텍처에서 마이그레이션
단계별 마이그레이션 전략
기존 레이어드 아키텍처에서 헥사고날/클린 아키텍처로 전환할 때는 점진적으로 접근해야 한다.
- 1단계: 도메인 레이어 분리 -- 비즈니스 로직을 서비스에서 엔티티로 이동
- 2단계: 포트(인터페이스) 정의 -- 리포지토리와 외부 서비스에 대한 인터페이스 추출
- 3단계: 어댑터 구현 -- 기존 구현체를 어댑터로 래핑
- 4단계: DI 컨테이너 설정 -- 의존성 주입으로 포트와 어댑터 연결
- 5단계: 테스트 추가 -- 도메인 유닛 테스트와 포트 모킹 테스트 작성
경계 결정의 실패 사례
잘못된 바운더리(경계) 설정은 아키텍처의 이점을 무효화한다. 예를 들어 하나의 유스케이스가 너무 많은 아웃바운드 포트에 의존하면, 해당 유스케이스의 책임이 너무 크다는 신호다. 유스케이스를 더 작은 단위로 분리하는 것이 좋다.
반대로 너무 세밀하게 포트를 나누면 포트의 수가 폭발적으로 증가하여 관리가 어려워진다. 일반적으로 하나의 Aggregate Root당 하나의 Repository 포트, 하나의 외부 시스템당 하나의 Gateway 포트가 적절하다.
프로덕션 체크리스트
아키텍처 원칙
- 의존성이 항상 바깥쪽에서 안쪽으로만 향하는지 확인
- 도메인 레이어에 프레임워크 import가 없는지 확인
- 도메인 엔티티가 충분한 비즈니스 로직을 캡슐화하는지 확인
- 포트 인터페이스가 도메인 용어로 정의되어 있는지 확인 (기술 용어가 아닌)
- DI 컨테이너가 컴포지션 루트(Composition Root)에서만 사용되는지 확인
테스트
- 도메인 레이어 유닛 테스트 커버리지 90% 이상
- 유스케이스 테스트에서 아웃바운드 포트가 모킹되는지 확인
- 어댑터 통합 테스트가 존재하는지 확인
- E2E 테스트에서 전체 흐름이 검증되는지 확인
코드 품질
- 패키지 간 순환 의존성이 없는지 확인
- 유스케이스당 책임이 단일한지 확인 (SRP)
- 포트 인터페이스가 최소한의 메서드만 정의하는지 확인 (ISP)
- ArchUnit 또는 유사 도구로 아키텍처 규칙 자동 검증