NestJS 헥사고날 아키텍처 심화

헥사고날 아키텍처란?

헥사고날 아키텍처(Hexagonal Architecture), 또는 Ports & Adapters 패턴은 비즈니스 로직을 외부 의존성(DB, API, 메시지 큐 등)으로부터 완전히 분리하는 설계 방식입니다. Alistair Cockburn이 제안한 이 패턴은 도메인 코어가 어떤 기술 스택과도 독립적으로 동작하도록 보장합니다. NestJS의 모듈 시스템과 DI 컨테이너는 이 아키텍처를 자연스럽게 구현할 수 있는 최적의 환경을 제공합니다.

계층 역할 예시
Domain 비즈니스 로직, 엔티티, 값 객체 Order, Money, OrderStatus
Port (인바운드) 유스케이스 인터페이스 CreateOrderUseCase
Port (아웃바운드) 외부 의존성 인터페이스 OrderRepository, PaymentGateway
Adapter (인바운드) 외부 → 도메인 진입점 REST Controller, GraphQL Resolver
Adapter (아웃바운드) 도메인 → 외부 구현체 TypeORM Repository, Stripe Client

프로젝트 구조

NestJS에서 헥사고날 아키텍처를 적용한 디렉토리 구조입니다. 모듈 단위로 도메인을 분리합니다.

src/
├── order/
│   ├── domain/                    # 도메인 계층 (순수 TypeScript)
│   │   ├── model/
│   │   │   ├── order.entity.ts
│   │   │   ├── order-item.vo.ts
│   │   │   └── money.vo.ts
│   │   └── event/
│   │       └── order-created.event.ts
│   ├── application/               # 유스케이스 (Port 인바운드)
│   │   ├── port/
│   │   │   ├── in/
│   │   │   │   ├── create-order.use-case.ts
│   │   │   │   └── cancel-order.use-case.ts
│   │   │   └── out/
│   │   │       ├── order-repository.port.ts
│   │   │       ├── payment-gateway.port.ts
│   │   │       └── event-publisher.port.ts
│   │   └── service/
│   │       ├── create-order.service.ts
│   │       └── cancel-order.service.ts
│   ├── adapter/                   # 어댑터 (기술 구현체)
│   │   ├── in/
│   │   │   ├── rest/
│   │   │   │   ├── order.controller.ts
│   │   │   │   └── dto/
│   │   │   │       ├── create-order.request.ts
│   │   │   │       └── order.response.ts
│   │   │   └── graphql/
│   │   │       └── order.resolver.ts
│   │   └── out/
│   │       ├── persistence/
│   │       │   ├── order.orm-entity.ts
│   │       │   ├── order.typeorm-repository.ts
│   │       │   └── order.mapper.ts
│   │       ├── payment/
│   │       │   └── stripe-payment.adapter.ts
│   │       └── event/
│   │           └── kafka-event.publisher.ts
│   └── order.module.ts

도메인 모델: 순수 TypeScript

도메인 엔티티는 프레임워크 의존성이 전혀 없습니다. NestJS 데코레이터도, TypeORM 데코레이터도 없는 순수 클래스입니다.

// domain/model/money.vo.ts — 값 객체
export class Money {
  private constructor(
    readonly amount: number,
    readonly currency: string,
  ) {
    if (amount < 0) throw new Error('금액은 0 이상이어야 합니다');
  }

  static of(amount: number, currency = 'KRW'): Money {
    return new Money(amount, currency);
  }

  add(other: Money): Money {
    this.assertSameCurrency(other);
    return Money.of(this.amount + other.amount, this.currency);
  }

  multiply(factor: number): Money {
    return Money.of(this.amount * factor, this.currency);
  }

  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }

  private assertSameCurrency(other: Money): void {
    if (this.currency !== other.currency) {
      throw new Error(`통화 불일치: ${this.currency} vs ${other.currency}`);
    }
  }
}
// domain/model/order.entity.ts — 도메인 엔티티
export class Order {
  private constructor(
    readonly id: string | null,
    readonly customerId: string,
    private _items: OrderItem[],
    private _status: OrderStatus,
    readonly createdAt: Date,
  ) {}

  static create(customerId: string, items: OrderItem[]): Order {
    if (items.length === 0) throw new Error('주문 항목이 비어있습니다');
    return new Order(null, customerId, items, OrderStatus.PENDING, new Date());
  }

  static reconstitute(
    id: string, customerId: string, items: OrderItem[],
    status: OrderStatus, createdAt: Date,
  ): Order {
    return new Order(id, customerId, items, status, createdAt);
  }

  get totalAmount(): Money {
    return this._items.reduce(
      (sum, item) => sum.add(item.subtotal),
      Money.of(0),
    );
  }

  get status(): OrderStatus { return this._status; }
  get items(): ReadonlyArray<OrderItem> { return [...this._items]; }

  confirm(): void {
    if (this._status !== OrderStatus.PENDING) {
      throw new Error(`확정 불가 상태: ${this._status}`);
    }
    this._status = OrderStatus.CONFIRMED;
  }

  cancel(reason: string): void {
    if (this._status === OrderStatus.SHIPPED) {
      throw new Error('배송 중인 주문은 취소할 수 없습니다');
    }
    this._status = OrderStatus.CANCELLED;
  }
}

Port 정의

Port는 도메인과 외부 세계의 계약(인터페이스)입니다. NestJS에서는 추상 클래스나 Symbol 토큰으로 구현합니다.

// application/port/in/create-order.use-case.ts — 인바운드 포트
export interface CreateOrderUseCase {
  execute(command: CreateOrderCommand): Promise<Order>;
}

export class CreateOrderCommand {
  constructor(
    readonly customerId: string,
    readonly items: { productId: string; quantity: number; price: number }[],
  ) {}
}

export const CREATE_ORDER_USE_CASE = Symbol('CreateOrderUseCase');
// application/port/out/order-repository.port.ts — 아웃바운드 포트
export interface OrderRepositoryPort {
  save(order: Order): Promise<Order>;
  findById(id: string): Promise<Order | null>;
  findByCustomerId(customerId: string): Promise<Order[]>;
}

export const ORDER_REPOSITORY_PORT = Symbol('OrderRepositoryPort');
// application/port/out/payment-gateway.port.ts
export interface PaymentGatewayPort {
  charge(orderId: string, amount: Money): Promise<PaymentResult>;
  refund(transactionId: string): Promise<void>;
}

export const PAYMENT_GATEWAY_PORT = Symbol('PaymentGatewayPort');

Application Service: 유스케이스 구현

Application Service는 포트를 조합하여 유스케이스를 구현합니다. 도메인 모델을 조율하는 오케스트레이터 역할입니다.

// application/service/create-order.service.ts
@Injectable()
export class CreateOrderService implements CreateOrderUseCase {
  constructor(
    @Inject(ORDER_REPOSITORY_PORT)
    private readonly orderRepo: OrderRepositoryPort,
    @Inject(PAYMENT_GATEWAY_PORT)
    private readonly paymentGateway: PaymentGatewayPort,
    @Inject(EVENT_PUBLISHER_PORT)
    private readonly eventPublisher: EventPublisherPort,
  ) {}

  async execute(command: CreateOrderCommand): Promise<Order> {
    // 1. 도메인 객체 생성 (비즈니스 규칙 검증은 도메인 내부)
    const items = command.items.map(
      (i) => OrderItem.create(i.productId, i.quantity, Money.of(i.price)),
    );
    const order = Order.create(command.customerId, items);

    // 2. 결제 처리 (아웃바운드 포트)
    const paymentResult = await this.paymentGateway.charge(
      order.id, order.totalAmount,
    );
    if (!paymentResult.success) {
      throw new PaymentFailedException(paymentResult.errorMessage);
    }

    // 3. 주문 확정 (도메인 로직)
    order.confirm();

    // 4. 영속화 (아웃바운드 포트)
    const savedOrder = await this.orderRepo.save(order);

    // 5. 이벤트 발행 (아웃바운드 포트)
    await this.eventPublisher.publish(
      new OrderCreatedEvent(savedOrder.id, savedOrder.customerId),
    );

    return savedOrder;
  }
}

Adapter 구현: 인바운드

인바운드 어댑터는 외부 요청을 유스케이스로 변환합니다. 같은 유스케이스를 REST, GraphQL, CLI 등 다양한 진입점에서 재사용할 수 있습니다.

// adapter/in/rest/order.controller.ts
@Controller('orders')
export class OrderController {
  constructor(
    @Inject(CREATE_ORDER_USE_CASE)
    private readonly createOrderUseCase: CreateOrderUseCase,
  ) {}

  @Post()
  async createOrder(
    @Body() request: CreateOrderRequest,
  ): Promise<OrderResponse> {
    const command = new CreateOrderCommand(
      request.customerId,
      request.items,
    );
    const order = await this.createOrderUseCase.execute(command);
    return OrderResponse.from(order);
  }
}

Adapter 구현: 아웃바운드

아웃바운드 어댑터는 포트의 구체적인 기술 구현체입니다. 도메인 모델과 ORM 엔티티 간 매퍼를 통해 변환합니다.

// adapter/out/persistence/order.typeorm-repository.ts
@Injectable()
export class OrderTypeormRepository implements OrderRepositoryPort {
  constructor(
    @InjectRepository(OrderOrmEntity)
    private readonly ormRepo: Repository<OrderOrmEntity>,
    private readonly mapper: OrderMapper,
  ) {}

  async save(order: Order): Promise<Order> {
    const ormEntity = this.mapper.toOrmEntity(order);
    const saved = await this.ormRepo.save(ormEntity);
    return this.mapper.toDomain(saved);
  }

  async findById(id: string): Promise<Order | null> {
    const entity = await this.ormRepo.findOne({
      where: { id },
      relations: ['items'],
    });
    return entity ? this.mapper.toDomain(entity) : null;
  }
}
// adapter/out/persistence/order.mapper.ts
@Injectable()
export class OrderMapper {
  toDomain(entity: OrderOrmEntity): Order {
    const items = entity.items.map((i) =>
      OrderItem.create(i.productId, i.quantity, Money.of(i.price)),
    );
    return Order.reconstitute(
      entity.id, entity.customerId, items,
      entity.status as OrderStatus, entity.createdAt,
    );
  }

  toOrmEntity(order: Order): OrderOrmEntity {
    const entity = new OrderOrmEntity();
    if (order.id) entity.id = order.id;
    entity.customerId = order.customerId;
    entity.status = order.status;
    entity.totalAmount = order.totalAmount.amount;
    entity.items = order.items.map((item) => {
      const ormItem = new OrderItemOrmEntity();
      ormItem.productId = item.productId;
      ormItem.quantity = item.quantity;
      ormItem.price = item.price.amount;
      return ormItem;
    });
    return entity;
  }
}

NestJS 모듈 조립

NestJS의 DI 컨테이너에서 포트와 어댑터를 연결합니다. Symbol 토큰을 통해 인터페이스와 구현체를 바인딩합니다.

// order.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([OrderOrmEntity, OrderItemOrmEntity])],
  controllers: [OrderController],
  providers: [
    // 유스케이스 (인바운드 포트 → 서비스)
    {
      provide: CREATE_ORDER_USE_CASE,
      useClass: CreateOrderService,
    },
    {
      provide: CANCEL_ORDER_USE_CASE,
      useClass: CancelOrderService,
    },
    // 아웃바운드 포트 → 어댑터
    {
      provide: ORDER_REPOSITORY_PORT,
      useClass: OrderTypeormRepository,
    },
    {
      provide: PAYMENT_GATEWAY_PORT,
      useClass: StripePaymentAdapter,
    },
    {
      provide: EVENT_PUBLISHER_PORT,
      useClass: KafkaEventPublisher,
    },
    // 매퍼
    OrderMapper,
  ],
})
export class OrderModule {}

테스트: 어댑터 교체

헥사고날 아키텍처의 최대 장점은 테스트 용이성입니다. 아웃바운드 포트를 모킹하면 DB나 외부 API 없이 도메인 로직을 검증할 수 있습니다.

describe('CreateOrderService', () => {
  let service: CreateOrderService;
  let mockOrderRepo: jest.Mocked<OrderRepositoryPort>;
  let mockPaymentGateway: jest.Mocked<PaymentGatewayPort>;
  let mockEventPublisher: jest.Mocked<EventPublisherPort>;

  beforeEach(async () => {
    mockOrderRepo = {
      save: jest.fn(),
      findById: jest.fn(),
      findByCustomerId: jest.fn(),
    };
    mockPaymentGateway = {
      charge: jest.fn(),
      refund: jest.fn(),
    };
    mockEventPublisher = { publish: jest.fn() };

    const module = await Test.createTestingModule({
      providers: [
        CreateOrderService,
        { provide: ORDER_REPOSITORY_PORT, useValue: mockOrderRepo },
        { provide: PAYMENT_GATEWAY_PORT, useValue: mockPaymentGateway },
        { provide: EVENT_PUBLISHER_PORT, useValue: mockEventPublisher },
      ],
    }).compile();

    service = module.get(CreateOrderService);
  });

  it('결제 성공 시 주문을 확정하고 이벤트를 발행한다', async () => {
    mockPaymentGateway.charge.mockResolvedValue({ success: true });
    mockOrderRepo.save.mockImplementation(async (order) => order);

    const command = new CreateOrderCommand('cust-1', [
      { productId: 'prod-1', quantity: 2, price: 15000 },
    ]);

    const result = await service.execute(command);

    expect(result.status).toBe(OrderStatus.CONFIRMED);
    expect(mockEventPublisher.publish).toHaveBeenCalled();
  });
});

도메인 모델 단위 테스트

도메인 엔티티는 프레임워크 의존성이 없으므로 순수 단위 테스트로 검증합니다.

describe('Order', () => {
  it('빈 항목으로 주문 생성 시 에러를 던진다', () => {
    expect(() => Order.create('cust-1', [])).toThrow('주문 항목이 비어있습니다');
  });

  it('PENDING 상태에서만 확정할 수 있다', () => {
    const order = Order.create('cust-1', [
      OrderItem.create('prod-1', 1, Money.of(10000)),
    ]);
    order.confirm();
    expect(order.status).toBe(OrderStatus.CONFIRMED);
    expect(() => order.confirm()).toThrow('확정 불가 상태');
  });

  it('배송 중인 주문은 취소할 수 없다', () => {
    const order = Order.reconstitute(
      'ord-1', 'cust-1', [], OrderStatus.SHIPPED, new Date(),
    );
    expect(() => order.cancel('변심')).toThrow('배송 중인 주문');
  });
});

실전 팁

  • 도메인에 프레임워크 침투 금지: @Injectable(), @Entity() 같은 데코레이터를 도메인 모델에 넣지 않습니다. ORM 엔티티는 별도 클래스로 분리하고 매퍼로 변환합니다
  • 포트 네이밍: 인바운드는 ~UseCase, 아웃바운드는 ~Port 또는 ~Gateway로 명명하면 역할이 명확합니다
  • 과도한 추상화 경계: 소규모 CRUD에는 과잉 설계일 수 있습니다. 복잡한 도메인 로직이 있는 모듈에만 선택적으로 적용합니다
  • Symbol 토큰: NestJS에서 인터페이스는 런타임에 사라지므로, Symbol이나 문자열 토큰으로 DI를 연결해야 합니다
  • 어댑터 교체 시나리오: TypeORM → Prisma 전환, Stripe → PayPal 교체 등이 포트 구현체만 바꾸면 되므로 리팩토링 비용이 최소화됩니다

마무리

헥사고날 아키텍처는 "비즈니스 로직이 기술적 결정에 의존하지 않아야 한다"는 원칙을 구조적으로 강제합니다. NestJS의 모듈 시스템과 DI 컨테이너는 포트-어댑터 바인딩을 자연스럽게 지원하며, 테스트 시 어댑터를 자유롭게 교체할 수 있어 높은 테스트 커버리지를 달성할 수 있습니다. 도메인이 복잡한 프로젝트에서 기술 부채를 최소화하려면 헥사고날 아키텍처를 적극 고려해 보세요.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux