헥사고날 아키텍처란?
헥사고날 아키텍처(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 컨테이너는 포트-어댑터 바인딩을 자연스럽게 지원하며, 테스트 시 어댑터를 자유롭게 교체할 수 있어 높은 테스트 커버리지를 달성할 수 있습니다. 도메인이 복잡한 프로젝트에서 기술 부채를 최소화하려면 헥사고날 아키텍처를 적극 고려해 보세요.