📖 상세 설명
Hexagonal Architecture는 Alistair Cockburn이 2005년에 제안한 아키텍처 패턴입니다. "육각형"이라는 이름은 애플리케이션을 시각화할 때 여러 면(포트)을 가진 다각형으로 표현하기 때문입니다. 실제로 면의 개수는 중요하지 않으며, 핵심은 애플리케이션 내부와 외부를 명확히 구분하는 것입니다.
이 아키텍처의 핵심 개념은 Port와 Adapter입니다. Port는 애플리케이션 경계에 있는 인터페이스로, 애플리케이션이 외부와 상호작용하는 방식을 정의합니다. Adapter는 이 Port를 구현하여 실제 외부 기술(MySQL, Redis, REST API 등)과 연결합니다. 도메인 로직은 오직 Port(인터페이스)만 알고, 구체적인 Adapter는 모릅니다.
Port는 두 가지 유형으로 나뉩니다. Driving Port(주도 포트)는 외부에서 애플리케이션을 호출하는 인터페이스입니다 (예: REST Controller → Application Service). Driven Port(피주도 포트)는 애플리케이션이 외부 시스템을 호출하기 위한 인터페이스입니다 (예: Repository Interface → Database). Driving Adapter는 사용자 요청을 받아 애플리케이션에 전달하고, Driven Adapter는 애플리케이션의 요청을 외부 시스템으로 전달합니다.
실무에서 Hexagonal Architecture는 테스트 용이성, 기술 독립성, 유지보수성을 크게 향상시킵니다. Repository 인터페이스만 있으면 실제 DB 없이 비즈니스 로직을 단위 테스트할 수 있고, PostgreSQL에서 MongoDB로 전환해도 도메인 코드는 변경할 필요가 없습니다. DDD(Domain-Driven Design)와 함께 사용할 때 특히 효과적입니다.
핵심 개념
Port (포트)
애플리케이션 경계의 인터페이스. 도메인이 외부와 소통하는 계약. 기술 독립적으로 정의됨.
Adapter (어댑터)
Port 구현체. 특정 기술(HTTP, SQL, gRPC 등)과 Port 사이를 연결하는 변환 코드.
Driving (Primary)
애플리케이션을 "구동"하는 쪽. UI, API, 테스트 등이 애플리케이션을 호출함.
Driven (Secondary)
애플리케이션에 의해 "구동되는" 쪽. DB, 메시지 큐, 외부 API 등 인프라스트럭처.
💻 코드 예제
TypeScript - 주문 시스템 Hexagonal 구조
// ===== 프로젝트 구조 =====
// src/
// ├── domain/ # 도메인 레이어 (순수 비즈니스 로직)
// │ ├── model/ # 도메인 엔티티, 값 객체
// │ ├── service/ # 도메인 서비스
// │ └── port/ # 포트 인터페이스
// ├── application/ # 애플리케이션 레이어 (유스케이스)
// │ └── usecase/ # 애플리케이션 서비스
// └── infrastructure/ # 인프라스트럭처 레이어 (어댑터)
// ├── adapter/
// │ ├── in/ # Driving Adapters (Web, CLI)
// │ └── out/ # Driven Adapters (DB, External API)
// └── config/ # 의존성 주입 설정
// ===== Domain Layer =====
// domain/model/Order.ts - 도메인 엔티티
export class Order {
private constructor(
public readonly id: OrderId,
public readonly customerId: CustomerId,
private _items: OrderItem[],
private _status: OrderStatus,
public readonly createdAt: Date
) {}
static create(customerId: CustomerId, items: OrderItem[]): Order {
if (items.length === 0) {
throw new EmptyOrderError();
}
return new Order(
OrderId.generate(),
customerId,
items,
OrderStatus.PENDING,
new Date()
);
}
get items(): readonly OrderItem[] {
return this._items;
}
get status(): OrderStatus {
return this._status;
}
get totalAmount(): Money {
return this._items.reduce(
(sum, item) => sum.add(item.subtotal),
Money.zero()
);
}
confirm(): void {
if (this._status !== OrderStatus.PENDING) {
throw new InvalidOrderStateError(this._status, 'confirm');
}
this._status = OrderStatus.CONFIRMED;
}
cancel(): void {
if (this._status === OrderStatus.SHIPPED) {
throw new CannotCancelShippedOrderError(this.id);
}
this._status = OrderStatus.CANCELLED;
}
}
// domain/model/valueObjects.ts
export class OrderId {
constructor(public readonly value: string) {}
static generate(): OrderId {
return new OrderId(crypto.randomUUID());
}
equals(other: OrderId): boolean {
return this.value === other.value;
}
}
export class Money {
constructor(
public readonly amount: number,
public readonly currency: string = 'KRW'
) {}
static zero(): Money {
return new Money(0);
}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new CurrencyMismatchError();
}
return new Money(this.amount + other.amount, this.currency);
}
}
// ===== Ports (인터페이스) =====
// domain/port/in/OrderUseCase.ts - Driving Port
export interface CreateOrderUseCase {
execute(command: CreateOrderCommand): Promise;
}
export interface GetOrderUseCase {
execute(query: GetOrderQuery): Promise;
}
export interface CancelOrderUseCase {
execute(command: CancelOrderCommand): Promise;
}
// domain/port/out/OrderRepository.ts - Driven Port
export interface OrderRepository {
save(order: Order): Promise;
findById(id: OrderId): Promise;
findByCustomerId(customerId: CustomerId): Promise;
}
// domain/port/out/PaymentGateway.ts - Driven Port
export interface PaymentGateway {
processPayment(orderId: OrderId, amount: Money): Promise;
refund(paymentId: PaymentId): Promise;
}
// domain/port/out/NotificationService.ts - Driven Port
export interface NotificationService {
sendOrderConfirmation(order: Order): Promise;
sendOrderCancellation(order: Order): Promise;
}
// domain/port/out/EventPublisher.ts - Driven Port
export interface DomainEventPublisher {
publish(event: DomainEvent): Promise;
}
// ===== Application Layer (Use Cases) =====
// application/usecase/CreateOrderService.ts
export class CreateOrderService implements CreateOrderUseCase {
constructor(
private readonly orderRepository: OrderRepository,
private readonly paymentGateway: PaymentGateway,
private readonly eventPublisher: DomainEventPublisher
) {}
async execute(command: CreateOrderCommand): Promise {
// 1. 도메인 객체 생성 (비즈니스 규칙 검증)
const items = command.items.map(item =>
OrderItem.create(
new ProductId(item.productId),
item.quantity,
new Money(item.price)
)
);
const order = Order.create(
new CustomerId(command.customerId),
items
);
// 2. 결제 처리 (Driven Port 사용)
const paymentResult = await this.paymentGateway.processPayment(
order.id,
order.totalAmount
);
if (!paymentResult.isSuccess) {
throw new PaymentFailedError(paymentResult.errorMessage);
}
// 3. 주문 확정 (도메인 로직)
order.confirm();
// 4. 저장 (Driven Port 사용)
await this.orderRepository.save(order);
// 5. 이벤트 발행 (Driven Port 사용)
await this.eventPublisher.publish(
new OrderCreatedEvent(order.id, order.customerId, order.totalAmount)
);
return OrderDto.from(order);
}
}
// ===== Infrastructure Layer (Adapters) =====
// infrastructure/adapter/in/web/OrderController.ts - Driving Adapter
@Controller('/api/orders')
export class OrderController {
constructor(
private readonly createOrderUseCase: CreateOrderUseCase,
private readonly getOrderUseCase: GetOrderUseCase,
private readonly cancelOrderUseCase: CancelOrderUseCase
) {}
@Post('/')
async createOrder(@Body() request: CreateOrderRequest): Promise {
// HTTP 요청을 Command로 변환
const command = new CreateOrderCommand(
request.customerId,
request.items.map(item => ({
productId: item.productId,
quantity: item.quantity,
price: item.price,
}))
);
// Use Case 호출 (Port 사용)
const orderDto = await this.createOrderUseCase.execute(command);
// DTO를 HTTP 응답으로 변환
return OrderResponse.from(orderDto);
}
@Get('/:id')
async getOrder(@Param('id') id: string): Promise {
const query = new GetOrderQuery(id);
const orderDto = await this.getOrderUseCase.execute(query);
return OrderResponse.from(orderDto);
}
@Delete('/:id')
async cancelOrder(@Param('id') id: string): Promise {
const command = new CancelOrderCommand(id);
await this.cancelOrderUseCase.execute(command);
}
}
// infrastructure/adapter/out/persistence/TypeOrmOrderRepository.ts - Driven Adapter
@Injectable()
export class TypeOrmOrderRepository implements OrderRepository {
constructor(
@InjectRepository(OrderEntity)
private readonly ormRepository: Repository
) {}
async save(order: Order): Promise {
// 도메인 객체 → ORM 엔티티 변환
const entity = this.toEntity(order);
await this.ormRepository.save(entity);
}
async findById(id: OrderId): Promise {
const entity = await this.ormRepository.findOne({
where: { id: id.value },
relations: ['items'],
});
if (!entity) return null;
// ORM 엔티티 → 도메인 객체 변환
return this.toDomain(entity);
}
private toEntity(order: Order): OrderEntity {
const entity = new OrderEntity();
entity.id = order.id.value;
entity.customerId = order.customerId.value;
entity.status = order.status;
entity.items = order.items.map(item => this.toItemEntity(item));
entity.createdAt = order.createdAt;
return entity;
}
private toDomain(entity: OrderEntity): Order {
// Factory 또는 reconstitute 메서드 사용
return Order.reconstitute(
new OrderId(entity.id),
new CustomerId(entity.customerId),
entity.items.map(this.toItemDomain),
entity.status as OrderStatus,
entity.createdAt
);
}
}
// infrastructure/adapter/out/payment/StripePaymentAdapter.ts - Driven Adapter
@Injectable()
export class StripePaymentAdapter implements PaymentGateway {
constructor(private readonly stripeClient: Stripe) {}
async processPayment(orderId: OrderId, amount: Money): Promise {
try {
const paymentIntent = await this.stripeClient.paymentIntents.create({
amount: amount.amount,
currency: amount.currency.toLowerCase(),
metadata: { orderId: orderId.value },
});
return PaymentResult.success(new PaymentId(paymentIntent.id));
} catch (error) {
return PaymentResult.failure(error.message);
}
}
async refund(paymentId: PaymentId): Promise {
const refund = await this.stripeClient.refunds.create({
payment_intent: paymentId.value,
});
return RefundResult.success(new RefundId(refund.id));
}
}
// ===== Dependency Injection Configuration =====
// infrastructure/config/DependencyInjection.ts
@Module({
imports: [TypeOrmModule.forFeature([OrderEntity, OrderItemEntity])],
controllers: [OrderController],
providers: [
// Use Cases
{
provide: 'CreateOrderUseCase',
useClass: CreateOrderService,
},
{
provide: 'GetOrderUseCase',
useClass: GetOrderService,
},
// Driven Adapters
{
provide: 'OrderRepository',
useClass: TypeOrmOrderRepository,
},
{
provide: 'PaymentGateway',
useClass: StripePaymentAdapter,
},
{
provide: 'NotificationService',
useClass: SendGridNotificationAdapter,
},
],
})
export class OrderModule {}
아키텍처 다이어그램
┌─────────────────────────────────────────┐
│ DRIVING SIDE (왼쪽) │
│ 애플리케이션을 "호출하는" 쪽 │
└─────────────────────────────────────────┘
│
┌────────────────────────────┼────────────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ REST API │ │ GraphQL │ │ CLI │
│ Adapter │ │ Adapter │ │ Adapter │
│ (Controller) │ │ (Resolver) │ │ (Command) │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
└──────────────────────────┼──────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ DRIVING PORT (Input) │
│ <> CreateOrderUseCase │
│ <> GetOrderUseCase │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ │
│ ╔═══════════════════════════════════════════════════════════════════╗ │
│ ║ APPLICATION CORE ║ │
│ ║ ┌─────────────────────────────────────────────────────────────┐ ║ │
│ ║ │ APPLICATION LAYER │ ║ │
│ ║ │ (CreateOrderService, GetOrderService) │ ║ │
│ ║ └─────────────────────────────────────────────────────────────┘ ║ │
│ ║ │ ║ │
│ ║ ▼ ║ │
│ ║ ┌─────────────────────────────────────────────────────────────┐ ║ │
│ ║ │ DOMAIN LAYER │ ║ │
│ ║ │ Order, OrderItem, Money (Entities & Value Objects) │ ║ │
│ ║ │ OrderRepository, PaymentGateway (Port Interfaces) │ ║ │
│ ║ └─────────────────────────────────────────────────────────────┘ ║ │
│ ╚═══════════════════════════════════════════════════════════════════╝ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ DRIVEN PORT (Output) │
│ <> OrderRepository │
│ <> PaymentGateway │
│ <> NotificationService │
└─────────────────────────────────────────┘
│
┌──────────────────────────┼──────────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ PostgreSQL │ │ Stripe │ │ SendGrid │
│ Adapter │ │ Adapter │ │ Adapter │
│ (Repository) │ │ (Payment) │ │ (Email) │
└───────────────┘ └───────────────┘ └───────────────┘
│
┌─────────────────────────────────────────┐
│ DRIVEN SIDE (오른쪽) │
│ 애플리케이션이 "호출하는" 쪽 │
└─────────────────────────────────────────┘