목차
1. 아키텍처의 진화: Big Ball of Mud에서 Clean Architecture까지
소프트웨어 아키텍처는 수십 년에 걸쳐 진화해왔습니다. 각 단계는 이전의 문제를 해결하기 위해 등장했습니다.
1.1 아키텍처 진화의 여정
Big Ball of Mud → Layered → Hexagonal → Onion → Clean → Modular
(혼돈) (계층화) (포트/어댑터) (양파) (깨끗) (모듈화)
1990s 2000s 2005 2008 2012 2020s
Big Ball of Mud (혼돈의 시대)
모든 코드가 뒤섞여 있습니다. UI 코드에서 직접 DB를 호출하고, 비즈니스 로직이 여기저기 흩어져 있습니다.
Layered Architecture (계층형 아키텍처)
가장 전통적인 접근입니다. Presentation - Business Logic - Data Access 3계층으로 나눕니다.
┌─────────────────────────┐
│ Presentation Layer │ ← UI, Controllers
├─────────────────────────┤
│ Business Logic Layer │ ← Services, Rules
├─────────────────────────┤
│ Data Access Layer │ ← Repositories, ORM
├─────────────────────────┤
│ Database │
└─────────────────────────┘
위에서 아래로 의존
문제점:
- 모든 의존성이 아래로 향함 → 비즈니스 로직이 DB에 의존
- 프레임워크/DB 교체가 어려움
- 테스트할 때 DB 연결이 필요
왜 의존성 역전이 필요한가
핵심 질문: 비즈니스 로직이 DB를 알아야 하는가?
전통적 계층형에서는 OrderService → OrderRepository(PostgreSQL)처럼 비즈니스 레이어가 인프라 레이어에 직접 의존합니다. DB를 MongoDB로 바꾸면 비즈니스 로직도 수정해야 합니다.
의존성 역전을 적용하면: OrderService → OrderRepository(Interface) ← PostgresOrderRepository
비즈니스 레이어는 인터페이스만 알고, 인프라 레이어가 그 인터페이스를 구현합니다.
2. Hexagonal Architecture (Ports & Adapters)
2.1 Alistair Cockburn의 아이디어 (2005)
Hexagonal Architecture의 핵심 아이디어는 간단합니다: 애플리케이션을 외부 세계로부터 격리하라.
┌─────────────────────────────────┐
│ Driving Side │
│ (Primary/Input Adapters) │
│ │
│ ┌─────┐ ┌──────┐ ┌───────┐ │
│ │ REST │ │ gRPC │ │ CLI │ │
│ │ API │ │ │ │ │ │
│ └──┬───┘ └──┬───┘ └──┬────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────┐ │
│ │ Input Ports │ │
│ │ (Use Cases) │ │
│ ├─────────────────────────┤ │
│ │ │ │
│ │ Application Core │ │
│ │ (Domain Logic) │ │
│ │ │ │
│ ├─────────────────────────┤ │
│ │ Output Ports │ │
│ │ (Interfaces) │ │
│ └─────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────┐ ┌───────┐ ┌───────┐ │
│ │ DB │ │ Email │ │ Queue │ │
│ │Adapter│ │Adapter│ │Adapter│ │
│ └──────┘ └───────┘ └───────┘ │
│ │
│ Driven Side │
│ (Secondary/Output Adapters) │
└─────────────────────────────────┘
2.2 Ports (포트)
Port는 도메인이 정의하는 인터페이스입니다.
Input Port (Driving Port)
외부에서 애플리케이션으로 들어오는 요청을 정의합니다.
// Input Port: Use Case 인터페이스
interface PlaceOrderUseCase {
execute(command: PlaceOrderCommand): Promise<OrderId>;
}
interface GetOrderUseCase {
execute(query: GetOrderQuery): Promise<OrderDTO>;
}
interface CancelOrderUseCase {
execute(command: CancelOrderCommand): Promise<void>;
}
Output Port (Driven Port)
애플리케이션이 외부 시스템에 요청하는 것을 정의합니다.
// Output Port: Repository 인터페이스
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
nextId(): OrderId;
}
// Output Port: 알림 서비스 인터페이스
interface NotificationPort {
sendOrderConfirmation(
customerId: CustomerId,
orderId: OrderId
): Promise<void>;
}
// Output Port: 결제 서비스 인터페이스
interface PaymentPort {
processPayment(
orderId: OrderId,
amount: Money
): Promise<PaymentResult>;
}
2.3 Adapters (어댑터)
Adapter는 Port를 구현하는 외부 기술입니다.
Primary Adapter (Driving Adapter)
외부 요청을 Input Port로 전달합니다.
// Primary Adapter: REST Controller
class OrderController {
constructor(
private placeOrder: PlaceOrderUseCase,
private getOrder: GetOrderUseCase,
private cancelOrder: CancelOrderUseCase
) {}
async handlePlaceOrder(req: Request, res: Response): Promise<void> {
const command = new PlaceOrderCommand(
req.body.customerId,
req.body.items.map((item: any) => ({
productId: item.productId,
productName: item.productName,
price: item.price,
quantity: item.quantity,
})),
new Address(
req.body.address.street,
req.body.address.city,
req.body.address.state,
req.body.address.zipCode
)
);
const orderId = await this.placeOrder.execute(command);
res.status(201).json({ orderId: orderId.value });
}
async handleGetOrder(req: Request, res: Response): Promise<void> {
const query = new GetOrderQuery(req.params.orderId);
const order = await this.getOrder.execute(query);
res.json(order);
}
async handleCancelOrder(req: Request, res: Response): Promise<void> {
const command = new CancelOrderCommand(
req.params.orderId,
req.body.reason
);
await this.cancelOrder.execute(command);
res.status(204).send();
}
}
// Primary Adapter: gRPC Handler
class OrderGrpcHandler {
constructor(private placeOrder: PlaceOrderUseCase) {}
async placeOrder(
call: grpc.ServerUnaryCall<PlaceOrderRequest, PlaceOrderResponse>,
callback: grpc.sendUnaryData<PlaceOrderResponse>
): Promise<void> {
const command = this.toCommand(call.request);
const orderId = await this.placeOrder.execute(command);
callback(null, { orderId: orderId.value });
}
}
// Primary Adapter: CLI Command
class PlaceOrderCLI {
constructor(private placeOrder: PlaceOrderUseCase) {}
async run(args: string[]): Promise<void> {
const command = this.parseArgs(args);
const orderId = await this.placeOrder.execute(command);
console.log(`Order created: ${orderId.value}`);
}
}
Secondary Adapter (Driven Adapter)
Output Port를 구현하여 외부 시스템과 통신합니다.
// Secondary Adapter: PostgreSQL Repository
class PostgresOrderRepository implements OrderRepository {
constructor(private pool: Pool) {}
async findById(id: OrderId): Promise<Order | null> {
const result = await this.pool.query(
'SELECT * FROM orders WHERE id = $1',
[id.value]
);
if (result.rows.length === 0) return null;
return this.toDomain(result.rows[0]);
}
async save(order: Order): Promise<void> {
await this.pool.query(
`INSERT INTO orders (id, customer_id, status, total_amount)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE
SET status = $3, total_amount = $4`,
[order.id.value, order.customerId.value,
order.status.value, order.totalAmount.value]
);
}
nextId(): OrderId {
return OrderId.generate();
}
}
// Secondary Adapter: MongoDB Repository (같은 Port, 다른 구현)
class MongoOrderRepository implements OrderRepository {
constructor(private collection: Collection) {}
async findById(id: OrderId): Promise<Order | null> {
const doc = await this.collection.findOne({ _id: id.value });
if (!doc) return null;
return this.toDomain(doc);
}
async save(order: Order): Promise<void> {
await this.collection.updateOne(
{ _id: order.id.value },
{ $set: this.toDocument(order) },
{ upsert: true }
);
}
nextId(): OrderId {
return OrderId.generate();
}
}
// Secondary Adapter: Email Notification
class EmailNotificationAdapter implements NotificationPort {
constructor(private mailer: Mailer) {}
async sendOrderConfirmation(
customerId: CustomerId,
orderId: OrderId
): Promise<void> {
const customer = await this.getCustomerEmail(customerId);
await this.mailer.send({
to: customer.email,
subject: `Order ${orderId.value} Confirmed`,
template: 'order-confirmation',
data: { orderId: orderId.value },
});
}
}
// Secondary Adapter: SMS Notification (같은 Port, 다른 구현)
class SmsNotificationAdapter implements NotificationPort {
constructor(private smsClient: SmsClient) {}
async sendOrderConfirmation(
customerId: CustomerId,
orderId: OrderId
): Promise<void> {
const customer = await this.getCustomerPhone(customerId);
await this.smsClient.send({
to: customer.phone,
message: `Your order ${orderId.value} has been confirmed.`,
});
}
}
3. Clean Architecture (Robert C. Martin, 2012)
3.1 동심원 레이어와 의존성 규칙
Clean Architecture의 핵심은 의존성 규칙(Dependency Rule): 의존성은 항상 바깥에서 안쪽으로만 향해야 합니다.
┌───────────────────────────────────────────────┐
│ Frameworks & Drivers │
│ ┌───────────────────────────────────────┐ │
│ │ Interface Adapters │ │
│ │ ┌───────────────────────────────┐ │ │
│ │ │ Application Business │ │ │
│ │ │ Rules │ │ │
│ │ │ ┌───────────────────────┐ │ │ │
│ │ │ │ Enterprise Business │ │ │ │
│ │ │ │ Rules │ │ │ │
│ │ │ │ (Entities) │ │ │ │
│ │ │ └───────────────────────┘ │ │ │
│ │ │ (Use Cases) │ │ │
│ │ └───────────────────────────────┘ │ │
│ │ (Controllers, Gateways, Presenters)│ │
│ └───────────────────────────────────────┘ │
│ (Web, DB, UI, Devices, External Interfaces) │
└───────────────────────────────────────────────┘
의존성 방향: 바깥 → 안쪽 (항상)
3.2 4개의 레이어
Layer 1: Entities (Enterprise Business Rules)
가장 안쪽 레이어. 비즈니스의 핵심 규칙을 담습니다. 어떤 외부 변화에도 변경되지 않아야 합니다.
// Entity Layer
class Order {
private readonly id: OrderId;
private status: OrderStatus;
private items: OrderLineItem[];
place(): void {
if (this.items.length === 0) {
throw new EmptyOrderError();
}
if (this.status !== OrderStatus.DRAFT) {
throw new InvalidOrderStateError(this.status);
}
this.status = OrderStatus.PLACED;
}
get totalAmount(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.subtotal),
Money.zero('KRW')
);
}
canCancel(): boolean {
return this.status !== OrderStatus.SHIPPED
&& this.status !== OrderStatus.DELIVERED;
}
}
// Value Object (Entity Layer)
class Money {
constructor(
readonly value: number,
readonly currency: Currency
) {
if (value < 0) throw new NegativeMoneyError();
}
add(other: Money): Money {
this.assertSameCurrency(other);
return new Money(this.value + other.value, this.currency);
}
}
Layer 2: Use Cases (Application Business Rules)
애플리케이션 고유의 비즈니스 규칙. Entity를 조합하여 사용자 시나리오를 구현합니다.
// Use Case Layer
class PlaceOrderUseCase {
constructor(
private orderRepo: OrderRepository, // Output Port
private paymentPort: PaymentPort, // Output Port
private notificationPort: NotificationPort // Output Port
) {}
async execute(command: PlaceOrderCommand): Promise<OrderId> {
// 1. Aggregate 생성
const orderId = this.orderRepo.nextId();
const order = new Order(
orderId,
new CustomerId(command.customerId)
);
// 2. 아이템 추가
for (const item of command.items) {
order.addItem(
new ProductId(item.productId),
item.productName,
Money.of(item.price, 'KRW'),
new Quantity(item.quantity)
);
}
// 3. 주문 실행
order.place();
// 4. 결제 처리
const paymentResult = await this.paymentPort.processPayment(
orderId,
order.totalAmount
);
if (!paymentResult.isSuccess) {
throw new PaymentFailedError(paymentResult.reason);
}
// 5. 저장
await this.orderRepo.save(order);
// 6. 알림
await this.notificationPort.sendOrderConfirmation(
order.customerId,
orderId
);
return orderId;
}
}
// Use Case: Input/Output DTO
class PlaceOrderCommand {
constructor(
readonly customerId: string,
readonly items: OrderItemDTO[],
readonly shippingAddress: AddressDTO
) {}
}
class OrderItemDTO {
constructor(
readonly productId: string,
readonly productName: string,
readonly price: number,
readonly quantity: number
) {}
}
Layer 3: Interface Adapters
외부와 내부를 연결하는 어댑터. Controller, Presenter, Gateway가 여기 속합니다.
// Interface Adapter: Controller
class OrderRestController {
constructor(
private placeOrderUseCase: PlaceOrderUseCase,
private getOrderUseCase: GetOrderUseCase
) {}
async placeOrder(req: Request, res: Response): Promise<void> {
try {
const command = new PlaceOrderCommand(
req.body.customerId,
req.body.items,
req.body.address
);
const orderId = await this.placeOrderUseCase.execute(command);
res.status(201).json(
OrderPresenter.toCreatedResponse(orderId)
);
} catch (error) {
if (error instanceof EmptyOrderError) {
res.status(400).json({ error: 'Order must have items' });
} else if (error instanceof PaymentFailedError) {
res.status(402).json({ error: 'Payment failed' });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
}
}
// Interface Adapter: Presenter
class OrderPresenter {
static toCreatedResponse(orderId: OrderId): object {
return {
orderId: orderId.value,
message: 'Order placed successfully',
links: {
self: `/orders/${orderId.value}`,
cancel: `/orders/${orderId.value}/cancel`,
},
};
}
static toDetailResponse(order: OrderDTO): object {
return {
id: order.id,
status: order.status,
items: order.items.map(item => ({
product: item.productName,
price: item.price,
quantity: item.quantity,
subtotal: item.subtotal,
})),
total: order.totalAmount,
placedAt: order.placedAt.toISOString(),
};
}
}
// Interface Adapter: Gateway (Output Port 구현)
class PostgresOrderGateway implements OrderRepository {
constructor(private dataSource: DataSource) {}
async findById(id: OrderId): Promise<Order | null> {
const entity = await this.dataSource
.getRepository(OrderEntity)
.findOne({ where: { id: id.value }, relations: ['items'] });
if (!entity) return null;
return OrderMapper.toDomain(entity);
}
async save(order: Order): Promise<void> {
const entity = OrderMapper.toPersistence(order);
await this.dataSource.getRepository(OrderEntity).save(entity);
}
nextId(): OrderId {
return OrderId.generate();
}
}
Layer 4: Frameworks & Drivers
가장 바깥 레이어. 웹 프레임워크, DB 드라이버, UI 프레임워크 등이 속합니다.
// Framework Layer: Express Setup
import express from 'express';
const app = express();
app.use(express.json());
// Dependency Injection (Composition Root)
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const orderRepo = new PostgresOrderGateway(pool);
const paymentAdapter = new StripePaymentAdapter(process.env.STRIPE_KEY);
const notificationAdapter = new EmailNotificationAdapter(mailer);
const placeOrderUseCase = new PlaceOrderUseCase(
orderRepo,
paymentAdapter,
notificationAdapter
);
const getOrderUseCase = new GetOrderUseCase(orderRepo);
const controller = new OrderRestController(
placeOrderUseCase,
getOrderUseCase
);
// Routes
app.post('/orders', (req, res) => controller.placeOrder(req, res));
app.get('/orders/:id', (req, res) => controller.getOrder(req, res));
app.listen(3000);
4. Onion Architecture (Jeffrey Palermo, 2008)
4.1 양파 레이어 구조
Onion Architecture는 Clean Architecture의 선행자입니다. 동심원 구조가 유사하지만 레이어 명칭이 다릅니다.
┌─────────────────────────────────────────────┐
│ Infrastructure │
│ ┌───────────────────────────────────────┐ │
│ │ Application Services │ │
│ │ ┌───────────────────────────────┐ │ │
│ │ │ Domain Services │ │ │
│ │ │ ┌───────────────────────┐ │ │ │
│ │ │ │ Domain Model │ │ │ │
│ │ │ │ (Entities + VOs) │ │ │ │
│ │ │ └───────────────────────┘ │ │ │
│ │ └───────────────────────────────┘ │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
레이어 설명:
| 레이어 | 역할 | 예시 |
|---|---|---|
| Domain Model | 핵심 비즈니스 엔티티 | Order, Money, Customer |
| Domain Services | 여러 엔티티에 걸친 도메인 로직 | OrderPricingService |
| Application Services | 유스케이스 조율, 트랜잭션 관리 | PlaceOrderService |
| Infrastructure | 외부 기술 구현 | PostgreSQL, Redis, Email |
5. 아키텍처 비교 (10가지 차원)
| 차원 | Layered | Hexagonal | Onion | Clean |
|---|---|---|---|---|
| 의존성 방향 | 위→아래 | 바깥→안쪽 | 바깥→안쪽 | 바깥→안쪽 |
| 핵심 원칙 | 관심사 분리 | 포트/어댑터 | 도메인 중심 | 의존성 규칙 |
| 제안자 | 전통적 | Cockburn (2005) | Palermo (2008) | Martin (2012) |
| 레이어 수 | 3~4 | 2 (내부/외부) | 4 | 4 |
| 도메인 격리 | 약함 | 강함 | 강함 | 강함 |
| 테스트 용이성 | 보통 | 높음 | 높음 | 높음 |
| 프레임워크 독립성 | 낮음 | 높음 | 높음 | 높음 |
| 학습 곡선 | 낮음 | 중간 | 중간 | 높음 |
| 적합한 규모 | 소~중 | 중~대 | 중~대 | 대규모 |
| 오버헤드 | 적음 | 중간 | 중간 | 많음 |
핵심 공통점:
세 아키텍처(Hexagonal, Onion, Clean) 모두 같은 목표를 가집니다:
- 도메인을 중심에 놓는다
- 의존성이 안쪽(도메인)으로만 향한다
- 외부 기술(DB, 프레임워크)에 의존하지 않는다
차이는 주로 용어와 세부 레이어 구분에 있습니다.
6. 의존성 역전 실전 적용
6.1 전통적 의존 vs 역전된 의존
전통적 (Layered):
Controller → Service → Repository(PostgreSQL)
↓ ↓
비즈니스 로직이 DB 기술에
DB에 의존 직접 결합
역전된 (Clean/Hexagonal):
Controller → UseCase → Repository(Interface)
↑
PostgresRepository(구현)
UseCase는 Interface만 알고, 구현체는 모른다
6.2 Python으로 구현하는 의존성 역전
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional
# === Domain Layer (가장 안쪽) ===
@dataclass(frozen=True)
class Money:
amount: int
currency: str = "KRW"
def add(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError("Currency mismatch")
return Money(self.amount + other.amount, self.currency)
@dataclass(frozen=True)
class OrderId:
value: str
class Order:
def __init__(self, order_id: OrderId, customer_id: str) -> None:
self.id = order_id
self.customer_id = customer_id
self.items: list[dict] = []
self.status = "DRAFT"
@property
def total_amount(self) -> Money:
total = Money(0)
for item in self.items:
subtotal = Money(item["price"] * item["quantity"])
total = total.add(subtotal)
return total
def add_item(self, product_id: str, name: str,
price: int, quantity: int) -> None:
if self.status != "DRAFT":
raise ValueError("Can only add items to draft orders")
self.items.append({
"product_id": product_id,
"name": name,
"price": price,
"quantity": quantity,
})
def place(self) -> None:
if not self.items:
raise ValueError("Cannot place empty order")
if self.status != "DRAFT":
raise ValueError("Can only place draft orders")
self.status = "PLACED"
# === Port (도메인이 정의하는 인터페이스) ===
class OrderRepository(ABC):
@abstractmethod
async def find_by_id(self, order_id: OrderId) -> Optional[Order]:
...
@abstractmethod
async def save(self, order: Order) -> None:
...
@abstractmethod
def next_id(self) -> OrderId:
...
class PaymentPort(ABC):
@abstractmethod
async def process_payment(
self, order_id: OrderId, amount: Money
) -> bool:
...
class NotificationPort(ABC):
@abstractmethod
async def send_order_confirmation(
self, customer_id: str, order_id: OrderId
) -> None:
...
# === Use Case Layer ===
@dataclass
class PlaceOrderCommand:
customer_id: str
items: list[dict]
class PlaceOrderUseCase:
def __init__(
self,
order_repo: OrderRepository,
payment_port: PaymentPort,
notification_port: NotificationPort,
) -> None:
self._order_repo = order_repo
self._payment_port = payment_port
self._notification_port = notification_port
async def execute(self, command: PlaceOrderCommand) -> str:
order_id = self._order_repo.next_id()
order = Order(order_id, command.customer_id)
for item in command.items:
order.add_item(
product_id=item["product_id"],
name=item["name"],
price=item["price"],
quantity=item["quantity"],
)
order.place()
success = await self._payment_port.process_payment(
order_id, order.total_amount
)
if not success:
raise ValueError("Payment failed")
await self._order_repo.save(order)
await self._notification_port.send_order_confirmation(
command.customer_id, order_id
)
return order_id.value
# === Adapter Layer (가장 바깥) ===
from uuid import uuid4
class InMemoryOrderRepository(OrderRepository):
def __init__(self) -> None:
self._store: dict[str, Order] = {}
async def find_by_id(self, order_id: OrderId) -> Optional[Order]:
return self._store.get(order_id.value)
async def save(self, order: Order) -> None:
self._store[order.id.value] = order
def next_id(self) -> OrderId:
return OrderId(str(uuid4()))
class StripePaymentAdapter(PaymentPort):
def __init__(self, api_key: str) -> None:
self._api_key = api_key
async def process_payment(
self, order_id: OrderId, amount: Money
) -> bool:
# Stripe API 호출
# stripe.Charge.create(...)
return True
class ConsoleNotificationAdapter(NotificationPort):
async def send_order_confirmation(
self, customer_id: str, order_id: OrderId
) -> None:
print(
f"Order {order_id.value} confirmed "
f"for customer {customer_id}"
)
# === Composition Root ===
async def main():
# 의존성 조립
order_repo = InMemoryOrderRepository()
payment = StripePaymentAdapter("sk_test_xxx")
notification = ConsoleNotificationAdapter()
use_case = PlaceOrderUseCase(order_repo, payment, notification)
# 실행
result = await use_case.execute(
PlaceOrderCommand(
customer_id="cust-1",
items=[
{
"product_id": "prod-1",
"name": "Widget",
"price": 10000,
"quantity": 2,
}
],
)
)
print(f"Order created: {result}")
7. 프로젝트 구조
7.1 TypeScript 프로젝트 구조
src/
├── domain/ # Entity Layer
│ ├── model/
│ │ ├── Order.ts # Aggregate Root
│ │ ├── OrderLineItem.ts # Entity
│ │ ├── Money.ts # Value Object
│ │ ├── OrderId.ts # Value Object
│ │ └── OrderStatus.ts # Enum
│ ├── event/
│ │ ├── OrderPlaced.ts
│ │ └── OrderCancelled.ts
│ ├── service/
│ │ └── OrderPricingService.ts # Domain Service
│ └── port/
│ ├── OrderRepository.ts # Output Port (Interface)
│ ├── PaymentPort.ts # Output Port (Interface)
│ └── NotificationPort.ts # Output Port (Interface)
│
├── application/ # Use Case Layer
│ ├── command/
│ │ ├── PlaceOrderCommand.ts
│ │ ├── PlaceOrderHandler.ts # Use Case Implementation
│ │ ├── CancelOrderCommand.ts
│ │ └── CancelOrderHandler.ts
│ ├── query/
│ │ ├── GetOrderQuery.ts
│ │ └── GetOrderHandler.ts
│ └── dto/
│ ├── OrderDTO.ts
│ └── OrderItemDTO.ts
│
├── adapter/ # Interface Adapter Layer
│ ├── in/ # Primary (Driving) Adapters
│ │ ├── rest/
│ │ │ ├── OrderController.ts
│ │ │ └── OrderPresenter.ts
│ │ ├── grpc/
│ │ │ └── OrderGrpcHandler.ts
│ │ └── cli/
│ │ └── PlaceOrderCLI.ts
│ └── out/ # Secondary (Driven) Adapters
│ ├── persistence/
│ │ ├── PostgresOrderRepository.ts
│ │ ├── MongoOrderRepository.ts
│ │ └── entity/
│ │ └── OrderEntity.ts # ORM Entity
│ ├── payment/
│ │ ├── StripePaymentAdapter.ts
│ │ └── TossPaymentAdapter.ts
│ ├── notification/
│ │ ├── EmailNotificationAdapter.ts
│ │ └── SmsNotificationAdapter.ts
│ └── messaging/
│ └── KafkaEventPublisher.ts
│
├── config/ # Framework Layer
│ ├── container.ts # DI Container
│ ├── database.ts
│ └── server.ts
│
└── main.ts # Composition Root
7.2 Python 프로젝트 구조
src/
├── domain/
│ ├── __init__.py
│ ├── model/
│ │ ├── order.py
│ │ ├── order_line_item.py
│ │ └── value_objects.py
│ ├── events/
│ │ └── order_events.py
│ ├── services/
│ │ └── pricing_service.py
│ └── ports/
│ ├── order_repository.py # ABC
│ ├── payment_port.py # ABC
│ └── notification_port.py # ABC
│
├── application/
│ ├── __init__.py
│ ├── commands/
│ │ ├── place_order.py
│ │ └── cancel_order.py
│ ├── queries/
│ │ └── get_order.py
│ └── dto/
│ └── order_dto.py
│
├── adapters/
│ ├── __init__.py
│ ├── inbound/
│ │ ├── fastapi_routes.py
│ │ └── cli.py
│ └── outbound/
│ ├── sqlalchemy_order_repo.py
│ ├── stripe_payment.py
│ └── email_notification.py
│
├── config/
│ ├── container.py # DI
│ └── settings.py
│
└── main.py
8. Use Case 패턴 상세
8.1 Input Port, Output Port, Interactor
// Input Port (인터페이스)
interface PlaceOrderInputPort {
execute(command: PlaceOrderCommand): Promise<PlaceOrderResult>;
}
// Output Port (인터페이스)
interface PlaceOrderOutputPort {
orderCreated(orderId: OrderId, totalAmount: Money): void;
paymentFailed(reason: string): void;
validationFailed(errors: ValidationError[]): void;
}
// Interactor (Use Case 구현체)
class PlaceOrderInteractor implements PlaceOrderInputPort {
constructor(
private orderRepo: OrderRepository,
private paymentPort: PaymentPort,
private presenter: PlaceOrderOutputPort
) {}
async execute(
command: PlaceOrderCommand
): Promise<PlaceOrderResult> {
// Validation
const errors = this.validate(command);
if (errors.length > 0) {
this.presenter.validationFailed(errors);
return PlaceOrderResult.failure('VALIDATION_ERROR');
}
// Domain logic
const orderId = this.orderRepo.nextId();
const order = new Order(
orderId,
new CustomerId(command.customerId)
);
for (const item of command.items) {
order.addItem(
new ProductId(item.productId),
item.productName,
Money.of(item.price, 'KRW'),
new Quantity(item.quantity)
);
}
order.place();
// Payment
const paymentResult = await this.paymentPort.processPayment(
orderId,
order.totalAmount
);
if (!paymentResult.isSuccess) {
this.presenter.paymentFailed(paymentResult.reason);
return PlaceOrderResult.failure('PAYMENT_FAILED');
}
await this.orderRepo.save(order);
this.presenter.orderCreated(orderId, order.totalAmount);
return PlaceOrderResult.success(orderId);
}
private validate(command: PlaceOrderCommand): ValidationError[] {
const errors: ValidationError[] = [];
if (!command.customerId) {
errors.push(new ValidationError('customerId', 'Required'));
}
if (!command.items || command.items.length === 0) {
errors.push(new ValidationError('items', 'At least one item'));
}
return errors;
}
}
9. 테스트 전략
9.1 레이어별 테스트
Clean Architecture의 가장 큰 장점 중 하나는 테스트 용이성입니다.
Domain Layer 테스트 (Mock 불필요)
describe('Order', () => {
it('should place order with items', () => {
const order = new Order(
OrderId.generate(),
new CustomerId('cust-1')
);
order.addItem(
new ProductId('prod-1'),
'Widget',
Money.of(10000, 'KRW'),
new Quantity(2)
);
order.place();
expect(order.status).toBe(OrderStatus.PLACED);
expect(order.totalAmount).toEqual(Money.of(20000, 'KRW'));
});
it('should not place empty order', () => {
const order = new Order(
OrderId.generate(),
new CustomerId('cust-1')
);
expect(() => order.place()).toThrow(EmptyOrderError);
});
it('should not add items to placed order', () => {
const order = createPlacedOrder();
expect(() =>
order.addItem(
new ProductId('prod-2'),
'Gadget',
Money.of(5000, 'KRW'),
new Quantity(1)
)
).toThrow(InvalidOrderStateError);
});
});
describe('Money', () => {
it('should add same currency', () => {
const a = Money.of(10000, 'KRW');
const b = Money.of(5000, 'KRW');
expect(a.add(b)).toEqual(Money.of(15000, 'KRW'));
});
it('should reject negative amount', () => {
expect(() => Money.of(-100, 'KRW')).toThrow(NegativeMoneyError);
});
it('should reject currency mismatch', () => {
const krw = Money.of(10000, 'KRW');
const usd = Money.of(10, 'USD');
expect(() => krw.add(usd)).toThrow(CurrencyMismatchError);
});
});
Use Case 테스트 (Test Double 사용)
describe('PlaceOrderUseCase', () => {
let useCase: PlaceOrderUseCase;
let orderRepo: InMemoryOrderRepository;
let paymentPort: FakePaymentAdapter;
let notificationPort: SpyNotificationAdapter;
beforeEach(() => {
orderRepo = new InMemoryOrderRepository();
paymentPort = new FakePaymentAdapter();
notificationPort = new SpyNotificationAdapter();
useCase = new PlaceOrderUseCase(
orderRepo, paymentPort, notificationPort
);
});
it('should place order successfully', async () => {
paymentPort.willSucceed();
const command = new PlaceOrderCommand(
'cust-1',
[{ productId: 'prod-1', productName: 'Widget',
price: 10000, quantity: 2 }],
new AddressDTO('Street', 'City', 'State', '12345')
);
const orderId = await useCase.execute(command);
expect(orderId).toBeDefined();
const saved = await orderRepo.findById(orderId);
expect(saved).not.toBeNull();
expect(saved!.status).toBe(OrderStatus.PLACED);
expect(notificationPort.wasCalled).toBe(true);
});
it('should fail when payment fails', async () => {
paymentPort.willFail('Insufficient funds');
const command = new PlaceOrderCommand(
'cust-1',
[{ productId: 'prod-1', productName: 'Widget',
price: 10000, quantity: 1 }],
new AddressDTO('Street', 'City', 'State', '12345')
);
await expect(useCase.execute(command))
.rejects.toThrow(PaymentFailedError);
});
});
// Test Doubles
class InMemoryOrderRepository implements OrderRepository {
private store = new Map<string, Order>();
async findById(id: OrderId): Promise<Order | null> {
return this.store.get(id.value) || null;
}
async save(order: Order): Promise<void> {
this.store.set(order.id.value, order);
}
nextId(): OrderId { return OrderId.generate(); }
}
class FakePaymentAdapter implements PaymentPort {
private shouldSucceed = true;
willSucceed(): void { this.shouldSucceed = true; }
willFail(reason: string): void { this.shouldSucceed = false; }
async processPayment(): Promise<PaymentResult> {
return this.shouldSucceed
? PaymentResult.success()
: PaymentResult.failure('Declined');
}
}
class SpyNotificationAdapter implements NotificationPort {
wasCalled = false;
async sendOrderConfirmation(): Promise<void> {
this.wasCalled = true;
}
}
Integration 테스트 (실제 어댑터)
describe('PostgresOrderRepository', () => {
let repo: PostgresOrderRepository;
let pool: Pool;
beforeAll(async () => {
pool = new Pool({
connectionString: process.env.TEST_DATABASE_URL,
});
repo = new PostgresOrderRepository(pool);
});
afterAll(async () => {
await pool.end();
});
it('should save and retrieve order', async () => {
const order = createSampleOrder();
await repo.save(order);
const retrieved = await repo.findById(order.id);
expect(retrieved).not.toBeNull();
expect(retrieved!.id).toEqual(order.id);
expect(retrieved!.status).toBe(OrderStatus.PLACED);
expect(retrieved!.totalAmount).toEqual(order.totalAmount);
});
});
10. 프레임워크 독립성
10.1 Express에서 Fastify로 교체
도메인과 유스케이스는 전혀 변경하지 않고 프레임워크만 교체할 수 있습니다.
// Before: Express
import express from 'express';
const app = express();
app.post('/orders', (req, res) =>
controller.placeOrder(req, res)
);
// After: Fastify (도메인/유스케이스 코드 변경 없음!)
import Fastify from 'fastify';
const fastify = Fastify();
// 새로운 Primary Adapter만 작성
class OrderFastifyAdapter {
constructor(private useCase: PlaceOrderUseCase) {}
async placeOrder(
request: FastifyRequest,
reply: FastifyReply
): Promise<void> {
const command = new PlaceOrderCommand(
request.body.customerId,
request.body.items,
request.body.address
);
const orderId = await this.useCase.execute(command);
reply.code(201).send({ orderId: orderId.value });
}
}
const adapter = new OrderFastifyAdapter(placeOrderUseCase);
fastify.post('/orders', (req, reply) =>
adapter.placeOrder(req, reply)
);
10.2 PostgreSQL에서 MongoDB로 교체
// 도메인 코드: 변경 없음
// Use Case 코드: 변경 없음
// Output Port (Interface): 변경 없음
// 새로운 Secondary Adapter만 작성
class MongoOrderRepository implements OrderRepository {
constructor(private db: Db) {}
async findById(id: OrderId): Promise<Order | null> {
const doc = await this.db
.collection('orders')
.findOne({ _id: id.value });
return doc ? this.toDomain(doc) : null;
}
async save(order: Order): Promise<void> {
await this.db.collection('orders').updateOne(
{ _id: order.id.value },
{ $set: this.toDocument(order) },
{ upsert: true }
);
}
nextId(): OrderId { return OrderId.generate(); }
}
// Composition Root만 변경
// Before:
// const orderRepo = new PostgresOrderRepository(pool);
// After:
const mongoClient = new MongoClient(process.env.MONGO_URL);
const orderRepo = new MongoOrderRepository(mongoClient.db('shop'));
11. 흔한 실수와 주의사항
11.1 오버엔지니어링
단순 CRUD 앱에 Clean Architecture를 적용하면:
Entity → Use Case → Input Port → Controller
Output Port → Repository
Output Port → Notification
Output Port → Payment
파일 수: 20+
그냥 이렇게 하면 됩니다:
Controller → Service → Repository
파일 수: 3
원칙: 복잡한 도메인 로직이 있을 때만 Clean Architecture를 적용하세요.
11.2 잘못된 레이어 배치
// 나쁜 예: 도메인에 인프라 의존성
class Order {
async save(): Promise<void> {
// 도메인이 DB를 직접 알면 안 됨!
await db.query('INSERT INTO orders ...');
}
}
// 나쁜 예: Use Case에 프레임워크 의존성
class PlaceOrderUseCase {
async execute(req: express.Request): Promise<void> {
// Use Case가 Express를 직접 알면 안 됨!
const body = req.body;
}
}
// 좋은 예: 각 레이어가 자기 역할만
class Order {
place(): void { /* 순수 도메인 로직 */ }
}
class PlaceOrderUseCase {
execute(command: PlaceOrderCommand): Promise<OrderId> {
/* DTO만 받고, Port를 통해 외부와 통신 */
}
}
11.3 Leaky Abstraction
// 나쁜 예: Port가 특정 기술에 종속
interface OrderRepository {
// SQL을 직접 노출 - PostgreSQL에 종속!
findByQuery(sql: string): Promise<Order[]>;
// Mongoose Document를 노출 - MongoDB에 종속!
findByMongoQuery(filter: FilterQuery<OrderDoc>): Promise<Order[]>;
}
// 좋은 예: 기술 중립적 Port
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
findByCustomer(customerId: CustomerId): Promise<Order[]>;
findByStatus(status: OrderStatus): Promise<Order[]>;
save(order: Order): Promise<void>;
}
11.4 언제 사용하고 언제 사용하지 말 것인가
사용해야 하는 경우:
- 복잡한 비즈니스 도메인 (금융, 이커머스, 의료)
- 장기 유지보수가 필요한 프로젝트
- 외부 시스템 교체 가능성이 있는 경우
- 팀 규모가 크고 경계가 명확해야 하는 경우
- 높은 테스트 커버리지가 요구되는 경우
사용하지 말아야 하는 경우:
- 단순 CRUD 애플리케이션
- 프로토타입/MVP
- 단기 프로젝트
- 1인 개발 소규모 프로젝트
- 비즈니스 로직이 거의 없는 경우
12. 실전 체크리스트
12.1 아키텍처 선택 체크리스트
- 도메인 복잡성이 높은가?
- 장기 유지보수가 필요한가?
- 외부 기술 교체 가능성이 있는가?
- 팀 규모가 3명 이상인가?
- 테스트가 중요한가?
3개 이상 "예"라면 Clean/Hexagonal Architecture를 고려하세요.
12.2 구현 체크리스트
- 의존성이 항상 안쪽으로만 향하는가?
- 도메인 레이어에 외부 의존성이 없는가?
- Port(인터페이스)가 도메인 레이어에 정의되어 있는가?
- Adapter가 Port를 구현하고 있는가?
- Use Case가 하나의 유스케이스만 담당하는가?
- Composition Root에서 의존성을 조립하는가?
- 도메인 테스트에 Mock이 필요 없는가?
13. 퀴즈
Q1. Clean Architecture의 의존성 규칙(Dependency Rule)이란 무엇인가요?
A1. 의존성 규칙은 소스 코드 의존성은 항상 안쪽(고수준 정책)으로만 향해야 한다는 원칙입니다. 바깥 레이어(프레임워크, DB)가 안쪽 레이어(Use Case, Entity)를 의존하는 것은 허용되지만, 안쪽 레이어가 바깥 레이어를 직접 의존하는 것은 금지됩니다. 이를 통해 도메인이 프레임워크나 DB 변경에 영향받지 않습니다.
Q2. Hexagonal Architecture에서 Port와 Adapter의 차이는 무엇인가요?
A2.
Port는 도메인(애플리케이션 코어)이 정의한 인터페이스입니다. 도메인이 외부와 어떻게 상호작용할지 계약을 정합니다. Adapter는 Port를 구현하는 외부 기술입니다. 예를 들어 OrderRepository(Port)를 PostgresOrderRepository(Adapter)가 구현합니다. Port는 도메인 레이어에, Adapter는 인프라 레이어에 위치합니다.
Q3. Clean Architecture, Hexagonal Architecture, Onion Architecture의 공통점은 무엇인가요?
A3. 세 아키텍처 모두 (1) 도메인을 중심에 배치, (2) 의존성이 안쪽(도메인)으로만 향함, (3) 외부 기술(DB, 프레임워크)으로부터 도메인 격리, (4) 의존성 역전 원칙 적용이라는 공통 목표를 가집니다. 차이는 주로 용어(Layer 이름)와 세부 레이어 구분에 있습니다.
Q4. Clean Architecture를 사용하면 안 되는 경우는 언제인가요?
A4. Clean Architecture는 오버헤드가 있으므로 (1) 단순 CRUD 앱 (비즈니스 로직이 거의 없음), (2) 프로토타입/MVP (빠른 검증이 목적), (3) 단기 프로젝트 (유지보수 필요 없음), (4) 1인 소규모 프로젝트 (팀 간 경계 불필요)에서는 불필요합니다. 이런 경우 전통적인 3계층 아키텍처가 더 효율적입니다.
Q5. 프레임워크 독립성이 왜 중요한가요? 실제로 프레임워크를 교체하는 경우가 있나요?
A5. 프레임워크 독립성의 진정한 가치는 교체 자체보다 테스트 용이성과 도메인 집중에 있습니다. 도메인이 프레임워크에 의존하지 않으면 (1) 순수 단위 테스트가 가능하고, (2) 도메인 로직이 명확해지며, (3) 프레임워크 업그레이드가 쉬워집니다. 실제로 Express에서 Fastify, REST에서 GraphQL 전환 등은 빈번하게 발생하며, 이때 도메인 코드를 건드리지 않을 수 있다는 것은 큰 장점입니다.
14. 참고 자료
- Robert C. Martin - "Clean Architecture: A Craftsman's Guide to Software Structure and Design" (2017)
- Alistair Cockburn - "Hexagonal Architecture" (원본 블로그 글, 2005)
- Jeffrey Palermo - "The Onion Architecture" (2008)
- Robert C. Martin - "The Clean Architecture" (블로그 게시물, 2012)
- Vaughn Vernon - "Implementing Domain-Driven Design" (아키텍처 패턴 장)
- Martin Fowler - "InversionOfControl" (블로그 게시물)
- Mark Seemann - "Dependency Injection in .NET" (의존성 주입 패턴)
- Herberto Graca - "DDD, Hexagonal, Onion, Clean, CQRS" (통합 분석 블로그)
- Tom Hombergs - "Get Your Hands Dirty on Clean Architecture" (실전 가이드)
- Netflix Tech Blog - "Ready for Changes with Hexagonal Architecture"
- Uncle Bob - "Architecture the Lost Years" (발표 영상)
- ThoughtWorks Technology Radar - Ports and Adapters Architecture
- Microsoft - "Clean Architecture with ASP.NET Core"
- Steve Smith - "Clean Architecture Template" (GitHub)
현재 단락 (1/1110)
소프트웨어 아키텍처는 수십 년에 걸쳐 진화해왔습니다. 각 단계는 이전의 문제를 해결하기 위해 등장했습니다.