Hexagonal Architecture
Hexagonal Architecture(헥사고날 아키텍처)는 Alistair Cockburn이 제안한 Ports and Adapters 패턴입니다. 애플리케이션 핵심 로직을 외부 기술(DB, UI, API)로부터 분리하여 테스트와 유지보수를 용이하게 합니다.
Hexagonal Architecture(헥사고날 아키텍처)는 Alistair Cockburn이 제안한 Ports and Adapters 패턴입니다. 애플리케이션 핵심 로직을 외부 기술(DB, UI, API)로부터 분리하여 테스트와 유지보수를 용이하게 합니다.
Hexagonal Architecture는 2005년 Alistair Cockburn이 제안한 아키텍처 패턴으로, 공식 명칭은 "Ports and Adapters"입니다. 육각형 모양으로 시각화되어 Hexagonal이라는 이름이 붙었으며, 핵심 아이디어는 "애플리케이션은 외부 세계와 Port와 Adapter를 통해서만 소통한다"입니다.
육각형은 특별한 의미가 없습니다. 여러 면이 있다는 것을 표현하기 위한 것으로, 다양한 입력 채널(HTTP, CLI, 메시지)과 출력 채널(DB, 외부 API, 파일)이 연결될 수 있음을 시각화한 것입니다.
src/
├── domain/ # Application Core
│ ├── model/
│ │ ├── User.ts
│ │ └── Order.ts
│ └── service/
│ └── OrderService.ts
├── application/ # Ports (Inbound)
│ └── port/
│ └── in/
│ ├── CreateOrderUseCase.ts
│ └── GetOrderUseCase.ts
├── port/ # Ports (Outbound)
│ └── out/
│ ├── OrderRepository.ts
│ └── PaymentGateway.ts
└── adapter/ # Adapters
├── in/ # Primary Adapters (Driving)
│ ├── web/
│ │ └── OrderController.ts
│ └── cli/
│ └── OrderCLI.ts
└── out/ # Secondary Adapters (Driven)
├── persistence/
│ └── PostgresOrderRepository.ts
└── external/
└── StripePaymentGateway.ts
// application/port/in/CreateOrderUseCase.ts
export interface CreateOrderCommand {
customerId: string;
items: Array<{ productId: string; quantity: number }>;
shippingAddress: string;
}
export interface CreateOrderResult {
orderId: string;
totalAmount: number;
status: string;
}
// Inbound Port: 외부에서 애플리케이션을 호출하는 인터페이스
export interface CreateOrderUseCase {
execute(command: CreateOrderCommand): Promise<CreateOrderResult>;
}
// application/port/in/GetOrderUseCase.ts
export interface GetOrderQuery {
orderId: string;
}
export interface GetOrderUseCase {
execute(query: GetOrderQuery): Promise<OrderDTO | null>;
}
// port/out/OrderRepository.ts
import { Order } from '../../domain/model/Order';
// Outbound Port: 애플리케이션이 외부를 호출하는 인터페이스
export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
findByCustomerId(customerId: string): Promise<Order[]>;
}
// port/out/PaymentGateway.ts
export interface PaymentRequest {
orderId: string;
amount: number;
currency: string;
}
export interface PaymentResult {
success: boolean;
transactionId?: string;
errorMessage?: string;
}
export interface PaymentGateway {
processPayment(request: PaymentRequest): Promise<PaymentResult>;
refund(transactionId: string, amount: number): Promise<PaymentResult>;
}
// domain/service/OrderService.ts
import { Order } from '../model/Order';
import { CreateOrderUseCase, CreateOrderCommand, CreateOrderResult } from '../../application/port/in/CreateOrderUseCase';
import { GetOrderUseCase, GetOrderQuery } from '../../application/port/in/GetOrderUseCase';
import { OrderRepository } from '../../port/out/OrderRepository';
import { ProductRepository } from '../../port/out/ProductRepository';
// Use Case 구현 (Application Core)
export class OrderService implements CreateOrderUseCase, GetOrderUseCase {
constructor(
private readonly orderRepository: OrderRepository,
private readonly productRepository: ProductRepository
) {}
async execute(command: CreateOrderCommand): Promise<CreateOrderResult> {
// 비즈니스 로직: 상품 가격 조회
const products = await Promise.all(
command.items.map(item => this.productRepository.findById(item.productId))
);
// 도메인 객체 생성 (순수 비즈니스 로직)
const order = Order.create({
customerId: command.customerId,
items: command.items.map((item, index) => ({
productId: item.productId,
quantity: item.quantity,
unitPrice: products[index]!.price,
})),
shippingAddress: command.shippingAddress,
});
// Outbound Port를 통해 저장 (어떤 DB인지 모름)
await this.orderRepository.save(order);
return {
orderId: order.id,
totalAmount: order.calculateTotal(),
status: order.status,
};
}
// GetOrderUseCase 구현
async execute(query: GetOrderQuery): Promise<OrderDTO | null> {
const order = await this.orderRepository.findById(query.orderId);
if (!order) return null;
return {
id: order.id,
customerId: order.customerId,
totalAmount: order.calculateTotal(),
status: order.status,
};
}
}
// adapter/in/web/OrderController.ts
import { Request, Response, Router } from 'express';
import { CreateOrderUseCase } from '../../../application/port/in/CreateOrderUseCase';
import { GetOrderUseCase } from '../../../application/port/in/GetOrderUseCase';
// Primary Adapter: HTTP 요청을 Use Case 호출로 변환
export class OrderController {
public readonly router = Router();
constructor(
private readonly createOrderUseCase: CreateOrderUseCase,
private readonly getOrderUseCase: GetOrderUseCase
) {
this.router.post('/orders', this.createOrder.bind(this));
this.router.get('/orders/:id', this.getOrder.bind(this));
}
async createOrder(req: Request, res: Response): Promise<void> {
try {
// HTTP → Use Case Command 변환
const command = {
customerId: req.body.customerId,
items: req.body.items,
shippingAddress: req.body.shippingAddress,
};
const result = await this.createOrderUseCase.execute(command);
res.status(201).json(result);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
}
async getOrder(req: Request, res: Response): Promise<void> {
const result = await this.getOrderUseCase.execute({
orderId: req.params.id,
});
if (!result) {
res.status(404).json({ error: 'Order not found' });
return;
}
res.json(result);
}
}
// adapter/out/persistence/PostgresOrderRepository.ts
import { Pool } from 'pg';
import { Order } from '../../../domain/model/Order';
import { OrderRepository } from '../../../port/out/OrderRepository';
// Secondary Adapter: OrderRepository Port를 PostgreSQL로 구현
export class PostgresOrderRepository implements OrderRepository {
constructor(private readonly pool: Pool) {}
async save(order: Order): Promise<void> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
await client.query(
`INSERT INTO orders (id, customer_id, status, shipping_address, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET status = $3`,
[order.id, order.customerId, order.status, order.shippingAddress, order.createdAt]
);
for (const item of order.items) {
await client.query(
`INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES ($1, $2, $3, $4)`,
[order.id, item.productId, item.quantity, item.unitPrice]
);
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async findById(id: string): Promise<Order | null> {
const orderResult = await this.pool.query(
'SELECT * FROM orders WHERE id = $1',
[id]
);
if (orderResult.rows.length === 0) return null;
const itemsResult = await this.pool.query(
'SELECT * FROM order_items WHERE order_id = $1',
[id]
);
return Order.reconstitute({
id: orderResult.rows[0].id,
customerId: orderResult.rows[0].customer_id,
status: orderResult.rows[0].status,
shippingAddress: orderResult.rows[0].shipping_address,
items: itemsResult.rows,
});
}
}
// main.ts
import { Pool } from 'pg';
import express from 'express';
import { OrderService } from './domain/service/OrderService';
import { PostgresOrderRepository } from './adapter/out/persistence/PostgresOrderRepository';
import { PostgresProductRepository } from './adapter/out/persistence/PostgresProductRepository';
import { OrderController } from './adapter/in/web/OrderController';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Secondary Adapters (바깥에서 안쪽으로)
const orderRepository = new PostgresOrderRepository(pool);
const productRepository = new PostgresProductRepository(pool);
// Application Core (Use Case 구현)
const orderService = new OrderService(orderRepository, productRepository);
// Primary Adapter
const orderController = new OrderController(orderService, orderService);
// Express 설정
const app = express();
app.use(express.json());
app.use('/api', orderController.router);
app.listen(3000);
"테스트 작성하려는데 DB 연결 없이 OrderService를 어떻게 테스트하죠?"
"Hexagonal Architecture라서 쉬워요. OrderRepository는 인터페이스(Port)잖아요. 테스트용 InMemoryOrderRepository를 만들어서 주입하면 DB 없이 순수 비즈니스 로직만 테스트할 수 있어요."
"결제 시스템을 Stripe에서 Toss Payments로 바꿔야 하는데 얼마나 걸릴까요?"
"PaymentGateway Port는 그대로 두고, TossPaymentGateway Adapter만 새로 구현하면 됩니다. 비즈니스 로직은 전혀 수정 안 해도 돼요. DI 설정만 바꾸면 끝입니다."
"새로운 CLI 도구 필요하다고요? OrderController가 REST용 Primary Adapter였죠. 같은 Use Case를 호출하는 OrderCLI Adapter를 추가하면 됩니다. 비즈니스 로직 재사용 100%예요."
과도한 인터페이스: 모든 것을 Port로 추상화하면 인터페이스가 넘쳐납니다. 실제로 교체 가능성이 있는 것만 Port로 분리하세요.
간단한 프로젝트에는 오버킬: CRUD 위주의 작은 프로젝트에 Hexagonal을 적용하면 보일러플레이트만 늘어납니다.
DTO 변환 비용: Adapter → Port → Domain → Port → Adapter 과정에서 여러 번의 데이터 변환이 필요합니다. 이 비용을 인지하고 필요한 곳에만 적용하세요.
팁: Hexagonal Architecture는 Clean Architecture, Onion Architecture와 핵심 원칙이 같습니다. "의존성은 안쪽으로"만 기억하면 어떤 이름을 쓰든 상관없습니다.