클린 아키텍처란?
Robert C. Martin(Uncle Bob)이 제안한 클린 아키텍처는 비즈니스 로직을 프레임워크, 데이터베이스, UI 등 외부 요소로부터 독립시키는 소프트웨어 설계 원칙입니다.
핵심 원칙: 의존성 규칙
# 의존성 방향 (바깥 -> 안쪽)
# [Infrastructure] -> [Application] -> [Domain]
# (DB, HTTP) (Use Cases) (Entities, Rules)
프로젝트 구조
src/
├── domain/
│ ├── entities/Order.ts
│ ├── value-objects/Money.ts
│ └── ports/
│ ├── OrderRepository.ts
│ └── PaymentGateway.ts
├── application/
│ ├── use-cases/CreateOrder.ts
│ └── dto/CreateOrderDto.ts
├── infrastructure/
│ ├── persistence/PrismaOrderRepository.ts
│ ├── payment/StripePaymentGateway.ts
│ └── http/OrderController.ts
└── main.ts
도메인 계층 구현
엔티티
import { Money } from '../value-objects/Money';
export interface OrderItem {
productId: string;
name: string;
quantity: number;
price: Money;
}
export class Order {
private constructor(
public readonly id: string,
public readonly customerId: string,
private _items: OrderItem[],
private _status: 'pending' | 'paid' | 'shipped' | 'cancelled',
public readonly createdAt: Date
) {}
static create(id: string, customerId: string, items: OrderItem[]): Order {
if (items.length === 0) throw new Error('주문에는 최소 1개의 상품이 필요합니다');
return new Order(id, customerId, items, 'pending', new Date());
}
get items(): ReadonlyArray { return [...this._items]; }
get status() { return this._status; }
get totalAmount(): Money {
return this._items.reduce(
(sum, item) => sum.add(item.price.multiply(item.quantity)),
Money.zero('KRW')
);
}
markAsPaid(): void {
if (this._status !== 'pending') throw new Error(\`\${this._status} 상태에서는 결제할 수 없습니다\`);
this._status = 'paid';
}
cancel(): void {
if (this._status === 'shipped') throw new Error('배송된 주문은 취소할 수 없습니다');
this._status = 'cancelled';
}
}
포트 (인터페이스)
import { Order } from '../entities/Order';
export interface OrderRepository {
save(order: Order): Promise;
findById(id: string): Promise;
findByCustomer(customerId: string): Promise;
}
import { Money } from '../value-objects/Money';
export interface PaymentGateway {
charge(customerId: string, amount: Money): Promise<{ transactionId: string }>;
refund(transactionId: string): Promise;
}
애플리케이션 계층 (유스케이스)
import { Order, OrderItem } from '../../domain/entities/Order';
import { OrderRepository } from '../../domain/ports/OrderRepository';
import { PaymentGateway } from '../../domain/ports/PaymentGateway';
interface CreateOrderInput {
customerId: string;
items: OrderItem[];
}
export class CreateOrderUseCase {
constructor(
private orderRepo: OrderRepository,
private paymentGateway: PaymentGateway,
private idGenerator: { generate(): string }
) {}
async execute(input: CreateOrderInput): Promise {
const order = Order.create(
this.idGenerator.generate(), input.customerId, input.items
);
const { transactionId } = await this.paymentGateway.charge(
input.customerId, order.totalAmount
);
order.markAsPaid();
await this.orderRepo.save(order);
return order;
}
}
인프라 계층 (어댑터)
import { PrismaClient } from '@prisma/client';
import { OrderRepository } from '../../domain/ports/OrderRepository';
import { Order } from '../../domain/entities/Order';
export class PrismaOrderRepository implements OrderRepository {
constructor(private prisma: PrismaClient) {}
async save(order: Order): Promise {
await this.prisma.order.upsert({
where: { id: order.id },
create: {
id: order.id, customerId: order.customerId,
status: order.status, items: JSON.stringify(order.items),
totalAmount: order.totalAmount.value, createdAt: order.createdAt
},
update: { status: order.status, items: JSON.stringify(order.items) }
});
}
async findById(id: string): Promise {
const row = await this.prisma.order.findUnique({ where: { id } });
return row ? this.toDomain(row) : null;
}
async findByCustomer(customerId: string): Promise {
const rows = await this.prisma.order.findMany({ where: { customerId } });
return rows.map(this.toDomain);
}
private toDomain(row: any): Order {
return Order.reconstruct(row.id, row.customerId,
JSON.parse(row.items), row.status, row.createdAt);
}
}
테스트 용이성
class InMemoryOrderRepo implements OrderRepository {
private orders: Map = new Map();
async save(order: Order) { this.orders.set(order.id, order); }
async findById(id: string) { return this.orders.get(id) || null; }
async findByCustomer(cid: string) {
return [...this.orders.values()].filter(o => o.customerId === cid);
}
}
const useCase = new CreateOrderUseCase(
new InMemoryOrderRepo(), new MockPaymentGateway(), new UuidGenerator()
);
const order = await useCase.execute({ customerId: 'C1', items: [...] });
expect(order.status).toBe('paid');
실전 팁
- 도메인 계층에는 절대 외부 라이브러리를 import하지 마세요.
- 모든 비즈니스 규칙은 엔티티와 유스케이스에 집중시키고, 컨트롤러는 입출력 변환만 담당합니다.
- 과도한 추상화를 피하세요. 소규모 프로젝트에서는 Domain + Application을 하나로 합쳐도 됩니다.
- 의존성 주입(DI) 컨테이너를 활용하면 조립 코드를 깔끔하게 관리할 수 있습니다.
댓글 0