본문 바로가기
Backend2024년 8월 25일11분 읽기

클린 아키텍처 실전 — TypeScript로 구현하는 Hexagonal 패턴

YS
김영삼
조회 637

클린 아키텍처란?

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

아직 댓글이 없습니다.
Ctrl+Enter로 등록