
헥사고날 아키텍처(Ports & Adapters): 의존성 방향 뒤집기
헥사고날 아키텍처는 비즈니스 로직을 외부 세계로부터 철저히 격리시키는 설계 패턴이야. 포트(Port)와 어댑터(Adapter) 개념을 통해 의존성 방향을 뒤집고, 어떤 DB나 프레임워크가 들어와도 흔들리지 않는 코어를 만드는 방법을 TypeScript 예제로 풀어봤어.

헥사고날 아키텍처는 비즈니스 로직을 외부 세계로부터 철저히 격리시키는 설계 패턴이야. 포트(Port)와 어댑터(Adapter) 개념을 통해 의존성 방향을 뒤집고, 어떤 DB나 프레임워크가 들어와도 흔들리지 않는 코어를 만드는 방법을 TypeScript 예제로 풀어봤어.
로버트 C. 마틴(Uncle Bob)이 제안한 클린 아키텍처의 핵심은 무엇일까요? 양파 껍질 같은 계층 구조와 의존성 규칙(Dependency Rule)을 통해 프레임워크와 UI로부터 독립적인 소프트웨어를 만드는 방법을 정리합니다.

Uncle Bob의 Clean Architecture를 프론트엔드에 그대로 적용하면 과도한 복잡도가 생긴다. 실전에서 통하는 수준으로 다듬은 프론트엔드 레이어 분리 전략을 정리했다.

단일 모델이 복잡해질수록 읽기와 쓰기가 서로 발목을 잡는다. CQRS가 그 문제를 어떻게 해결하는지, 단순한 분리부터 이벤트 소싱까지 TypeScript 예제로 완벽 정리.

은행 계좌의 잔액이 왜 0원인지 궁금해본 적 있나요? 단순히 현재 상태(잔액)만 저장하는 대신, 입금, 출금 같은 모든 '사건(Event)'을 저장하여 시스템을 구축하는 이벤트 소싱 패턴. CQRS와 함께 사용되는 이유와 구현 시 마주하는 현실적인 문제들을 깊이 있게 다룹니다.

"지난번에 클린 아키텍처 글 봤는데, 헥사고날은 또 다른 거야?"
맞아. 다르면서도 닮았어. 클린 아키텍처가 "의존성은 안쪽으로만"이라는 큰 원칙을 얘기한다면, 헥사고날(Hexagonal Architecture)은 그 원칙을 좀 더 실용적이고 구체적인 모양으로 구현하는 방법을 제시해.
Alistair Cockburn이 2005년에 처음 제안한 이 패턴의 공식 이름은 Ports and Adapters야. "헥사고날"은 Cockburn이 다이어그램을 육각형으로 그렸기 때문에 붙은 별명이고, 사실 6이라는 숫자에는 별 의미가 없어. 그냥 "여러 방향으로 연결될 수 있다"는 걸 시각화한 거야.
핵심 아이디어는 딱 하나야:
"애플리케이션의 핵심(Core)은 외부 세계가 어떻게 생겼는지 몰라야 한다."
DB가 MySQL이든 MongoDB든, HTTP 요청이 REST든 gRPC든, UI가 React든 CLI든 — 코어 로직은 그런 거 신경 안 써야 해.
먼저 우리가 흔히 쓰는 3계층 아키텍처(Layered Architecture)를 보자.
[Presentation Layer] → [Business Layer] → [Data Layer]
Controller → Service → Repository → Database
이게 나쁜 건 아냐. 하지만 시간이 지나면 이런 일이 생겨:
// 전형적인 레이어드 아키텍처의 비즈니스 로직
class UserService {
async registerUser(email: string, password: string) {
// DB 직접 의존
const existing = await db.query(
'SELECT * FROM users WHERE email = ?', [email]
);
if (existing.rows.length > 0) {
throw new Error('이미 존재하는 이메일');
}
// 이메일 전송 라이브러리 직접 의존
await nodemailer.sendMail({
to: email,
subject: '환영합니다',
text: '가입을 축하합니다',
});
// 외부 결제 SDK 직접 의존
await stripe.customers.create({ email });
return db.query(
'INSERT INTO users (email, password) VALUES (?, ?)',
[email, hashedPassword]
);
}
}
UserService 안에 db, nodemailer, stripe가 직접 들어와 있어. 이 서비스를 테스트하려면?
그리고 만약 MySQL → PostgreSQL로 갈아타면? 이메일 서비스를 SendGrid로 바꾸면? 결제를 Toss로 바꾸면?
UserService 코드를 직접 손대야 해. 비즈니스 로직과 인프라가 뒤엉켜 있기 때문이야.
포트는 인터페이스(Interface)야. 코어가 "나는 이런 기능이 필요해"라고 선언하는 계약서.
포트에는 두 종류가 있어:
// 아웃바운드 포트: 코어가 필요로 하는 것들
interface UserRepository {
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<void>;
}
interface EmailService {
sendWelcomeEmail(to: string): Promise<void>;
}
interface PaymentService {
createCustomer(email: string): Promise<string>; // customerId 반환
}
이 인터페이스들은 UserService와 같은 패키지(코어)에 있어야 해. 구현체(PostgreSQL adapter, SendGrid adapter)는 바깥에 있고.
어댑터는 포트 인터페이스를 구현하는 실제 코드야. 외부 세계와의 번역기 역할을 해.
// PostgreSQL 어댑터: UserRepository 포트 구현
class PostgresUserRepository implements UserRepository {
constructor(private db: Pool) {}
async findByEmail(email: string): Promise<User | null> {
const result = await this.db.query(
'SELECT * FROM users WHERE email = $1', [email]
);
if (result.rows.length === 0) return null;
return this.mapToDomain(result.rows[0]);
}
async save(user: User): Promise<void> {
await this.db.query(
'INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)',
[user.id, user.email, user.passwordHash]
);
}
private mapToDomain(row: any): User {
return new User(row.id, row.email, row.password_hash);
}
}
// SendGrid 어댑터: EmailService 포트 구현
class SendGridEmailService implements EmailService {
constructor(private client: MailService) {}
async sendWelcomeEmail(to: string): Promise<void> {
await this.client.send({
to,
from: 'noreply@myapp.com',
subject: '환영합니다!',
text: '가입을 환영합니다.',
});
}
}
이제 진짜 핵심이야. UserService를 다시 써보자:
// core/domain/User.ts — 순수한 도메인 엔티티
class User {
private constructor(
public readonly id: string,
public readonly email: string,
private readonly passwordHash: string,
) {}
static create(id: string, email: string, passwordHash: string): User {
if (!email.includes('@')) {
throw new Error('유효하지 않은 이메일');
}
return new User(id, email, passwordHash);
}
verifyPassword(plain: string): boolean {
return bcrypt.compareSync(plain, this.passwordHash);
}
}
// core/ports/outbound.ts — 아웃바운드 포트들
interface UserRepository {
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<void>;
}
interface EmailService {
sendWelcomeEmail(to: string): Promise<void>;
}
interface PaymentService {
createCustomer(email: string): Promise<string>;
}
// core/ports/inbound.ts — 인바운드 포트
interface RegisterUserUseCase {
execute(command: RegisterUserCommand): Promise<RegisterUserResult>;
}
interface RegisterUserCommand {
email: string;
password: string;
}
interface RegisterUserResult {
userId: string;
}
// core/application/RegisterUserService.ts — 순수한 비즈니스 로직
class RegisterUserService implements RegisterUserUseCase {
constructor(
private userRepository: UserRepository, // 인터페이스만 알아
private emailService: EmailService, // 인터페이스만 알아
private paymentService: PaymentService, // 인터페이스만 알아
) {}
async execute(command: RegisterUserCommand): Promise<RegisterUserResult> {
// 비즈니스 로직 1: 중복 이메일 체크
const existing = await this.userRepository.findByEmail(command.email);
if (existing) {
throw new DomainError('EMAIL_ALREADY_EXISTS', '이미 존재하는 이메일입니다');
}
// 비즈니스 로직 2: 유저 생성
const id = generateId();
const passwordHash = bcrypt.hashSync(command.password, 10);
const user = User.create(id, command.email, passwordHash);
// 비즈니스 로직 3: 저장, 이메일, 결제 고객 생성 (순서 중요)
await this.userRepository.save(user);
await this.emailService.sendWelcomeEmail(user.email);
await this.paymentService.createCustomer(user.email);
return { userId: user.id };
}
}
RegisterUserService는 PostgreSQL을 모르고, SendGrid를 모르고, Stripe를 몰라. 오직 인터페이스만 알아. 이게 의존성 역전(Dependency Inversion)이야.
| 항목 | 레이어드 아키텍처 | 헥사고날 아키텍처 |
|---|---|---|
| 의존성 방향 | Controller → Service → Repository → DB | 항상 코어를 향함 |
| DB 교체 | Service 코드 수정 필요 | Adapter만 교체 |
| 단위 테스트 | 실제 DB/외부 서비스 mock 복잡 | 인터페이스 mock 간단 |
| 비즈니스 로직 위치 | 여러 레이어에 분산 가능 | 코어에 집중 |
| 코드 복잡도 | 낮음 (초기) | 높음 (초기), 유지보수에서 역전 |
| 학습 곡선 | 낮음 | 중간~높음 |
| 적합한 규모 | 소규모 CRUD | 복잡한 도메인 로직 |
코어를 호출하는 쪽도 어댑터가 필요해. HTTP Controller, CLI, 메시지 큐 컨슈머 같은 것들이 인바운드 어댑터야.
// adapters/inbound/http/UserController.ts
class UserController {
constructor(
private registerUserUseCase: RegisterUserUseCase // 인바운드 포트
) {}
async register(req: Request, res: Response): Promise<void> {
try {
// HTTP 요청을 도메인 커맨드로 변환
const command: RegisterUserCommand = {
email: req.body.email,
password: req.body.password,
};
const result = await this.registerUserUseCase.execute(command);
res.status(201).json({ userId: result.userId });
} catch (error) {
if (error instanceof DomainError) {
res.status(400).json({ code: error.code, message: error.message });
} else {
res.status(500).json({ message: '서버 오류' });
}
}
}
}
// adapters/inbound/cli/RegisterUserCommand.ts — CLI 인바운드 어댑터
class RegisterUserCLI {
constructor(private registerUserUseCase: RegisterUserUseCase) {}
async run(args: string[]): Promise<void> {
const [email, password] = args;
const result = await this.registerUserUseCase.execute({ email, password });
console.log(`유저 생성 완료: ${result.userId}`);
}
}
같은 코어 로직이 HTTP로도, CLI로도 호출될 수 있어.
모든 어댑터를 코어에 연결하는 건 한 곳에서 해야 해. 이걸 Composition Root라고 불러.
// main.ts — Composition Root
async function bootstrap() {
// 인프라 초기화
const dbPool = new Pool({ connectionString: process.env.DATABASE_URL });
const sendgridClient = new MailService();
sendgridClient.setApiKey(process.env.SENDGRID_API_KEY!);
const stripeClient = new Stripe(process.env.STRIPE_SECRET_KEY!);
// 아웃바운드 어댑터 인스턴스화
const userRepository = new PostgresUserRepository(dbPool);
const emailService = new SendGridEmailService(sendgridClient);
const paymentService = new StripePaymentService(stripeClient);
// 코어 서비스 인스턴스화 (의존성 주입)
const registerUserService = new RegisterUserService(
userRepository,
emailService,
paymentService,
);
// 인바운드 어댑터 인스턴스화
const userController = new UserController(registerUserService);
// Express 설정
const app = express();
app.post('/users', (req, res) => userController.register(req, res));
app.listen(3000);
}
bootstrap();
의존성의 흐름:
UserController → (인바운드 포트) → RegisterUserServiceRegisterUserService → (아웃바운드 포트) → PostgresUserRepository화살표가 항상 코어를 향해. 코어는 어댑터를 몰라.
헥사고날의 가장 큰 실용적 이점이 여기 있어.
// tests/RegisterUserService.test.ts
describe('RegisterUserService', () => {
// 인터페이스를 구현하는 인메모리 어댑터 (테스트용)
const mockUserRepository: UserRepository = {
users: new Map<string, User>(),
async findByEmail(email: string) {
for (const user of this.users.values()) {
if (user.email === email) return user;
}
return null;
},
async save(user: User) {
this.users.set(user.id, user);
},
};
const mockEmailService: EmailService = {
sentEmails: [] as string[],
async sendWelcomeEmail(to: string) {
this.sentEmails.push(to);
},
};
const mockPaymentService: PaymentService = {
async createCustomer(email: string) {
return `mock_customer_${email}`;
},
};
let service: RegisterUserService;
beforeEach(() => {
// 테스트마다 초기화
mockUserRepository.users.clear();
mockEmailService.sentEmails = [];
service = new RegisterUserService(
mockUserRepository,
mockEmailService,
mockPaymentService,
);
});
it('새 유저 등록 성공', async () => {
const result = await service.execute({
email: 'test@example.com',
password: 'password123',
});
expect(result.userId).toBeDefined();
expect(mockEmailService.sentEmails).toContain('test@example.com');
});
it('중복 이메일로 등록 실패', async () => {
await service.execute({ email: 'dup@example.com', password: 'pw1' });
await expect(
service.execute({ email: 'dup@example.com', password: 'pw2' })
).rejects.toThrow('이미 존재하는 이메일입니다');
});
});
DB 없어도 돼. 네트워크 없어도 돼. 테스트가 빠르고, 결정론적이고, 외부 의존성이 없어.
src/
├── core/ # 코어 도메인 (외부 의존 없음)
│ ├── domain/
│ │ ├── User.ts # 도메인 엔티티
│ │ └── DomainError.ts
│ ├── ports/
│ │ ├── inbound/
│ │ │ └── RegisterUserUseCase.ts
│ │ └── outbound/
│ │ ├── UserRepository.ts
│ │ ├── EmailService.ts
│ │ └── PaymentService.ts
│ └── application/
│ └── RegisterUserService.ts
│
├── adapters/ # 어댑터 (외부 세계와의 연결)
│ ├── inbound/
│ │ ├── http/
│ │ │ └── UserController.ts
│ │ └── cli/
│ │ └── RegisterUserCLI.ts
│ └── outbound/
│ ├── postgres/
│ │ └── PostgresUserRepository.ts
│ ├── sendgrid/
│ │ └── SendGridEmailService.ts
│ └── stripe/
│ └── StripePaymentService.ts
│
└── main.ts # Composition Root
이 구조에서 core/ 폴더는 node_modules에서 DB 클라이언트나 HTTP 프레임워크를 import하는 코드가 없어야 해.
헥사고날 아키텍처의 핵심을 한 문장으로 요약하면:
"코어는 포트(인터페이스)를 통해 세상과 대화하고, 어댑터가 실제 세상과 연결한다."
처음엔 파일도 많아지고 인터페이스도 많아져서 복잡하게 느껴질 수 있어. 하지만 6개월 뒤에 "결제를 Toss로 바꿔야 해" 소리를 들었을 때, StripePaymentService 파일 하나만 새로 만들면 끝나는 경험을 해보면 생각이 달라져.
코어를 지키는 게 곧 비즈니스 로직을 지키는 거야.
"I already read the Clean Architecture post. Is Hexagonal just another name for the same thing?"
Similar, but distinct. Clean Architecture gives you the grand principle — "dependencies point inward." Hexagonal Architecture (or Ports and Adapters) is a more concrete, practical shape for realizing that principle.
Alistair Cockburn introduced this pattern in 2005. The official name is Ports and Adapters. "Hexagonal" is just the nickname — Cockburn drew it as a hexagon to visualize "many connection points." The number six has no special significance.
The core idea is one sentence:
"The application core must not know what the outside world looks like."
Whether the database is MySQL or MongoDB, whether traffic arrives via REST or gRPC, whether the UI is React or a terminal — the core should not care.
Consider the classic 3-tier layout:
[Presentation] → [Business] → [Data Layer]
This is fine until it isn't. Here's what typical service code looks like after a few months:
class UserService {
async registerUser(email: string, password: string) {
// Direct dependency on db
const existing = await db.query(
'SELECT * FROM users WHERE email = ?', [email]
);
if (existing.rows.length > 0) {
throw new Error('Email already exists');
}
// Direct dependency on nodemailer
await nodemailer.sendMail({
to: email,
subject: 'Welcome',
text: 'Thanks for signing up',
});
// Direct dependency on Stripe SDK
await stripe.customers.create({ email });
return db.query(
'INSERT INTO users (email, password) VALUES (?, ?)',
[email, hashedPassword]
);
}
}
db, nodemailer, and stripe are wired directly into the service. To test this:
And when you switch MySQL to PostgreSQL? Or replace nodemailer with SendGrid? You touch UserService directly. Business logic and infrastructure are entangled.
A port is an interface. It's the core declaring "I need this capability" or "I provide this capability."
Two kinds:
// Outbound ports — what the core needs
interface UserRepository {
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<void>;
}
interface EmailService {
sendWelcomeEmail(to: string): Promise<void>;
}
interface PaymentService {
createCustomer(email: string): Promise<string>; // returns customerId
}
These interfaces live in the core package. Implementations (PostgresUserRepository, SendGridEmailService) live outside.
An adapter implements a port interface. It translates between the core's language and the outside world's language.
// PostgreSQL adapter
class PostgresUserRepository implements UserRepository {
constructor(private db: Pool) {}
async findByEmail(email: string): Promise<User | null> {
const result = await this.db.query(
'SELECT * FROM users WHERE email = $1', [email]
);
if (result.rows.length === 0) return null;
return this.mapToDomain(result.rows[0]);
}
async save(user: User): Promise<void> {
await this.db.query(
'INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)',
[user.id, user.email, user.passwordHash]
);
}
private mapToDomain(row: any): User {
return new User(row.id, row.email, row.password_hash);
}
}
// SendGrid adapter
class SendGridEmailService implements EmailService {
constructor(private client: MailService) {}
async sendWelcomeEmail(to: string): Promise<void> {
await this.client.send({
to,
from: 'noreply@myapp.com',
subject: 'Welcome!',
text: 'Thanks for signing up.',
});
}
}
Now rewrite UserService with this approach:
// core/domain/User.ts — pure domain entity
class User {
private constructor(
public readonly id: string,
public readonly email: string,
private readonly passwordHash: string,
) {}
static create(id: string, email: string, passwordHash: string): User {
if (!email.includes('@')) {
throw new Error('Invalid email format');
}
return new User(id, email, passwordHash);
}
verifyPassword(plain: string): boolean {
return bcrypt.compareSync(plain, this.passwordHash);
}
}
// core/application/RegisterUserService.ts
class RegisterUserService implements RegisterUserUseCase {
constructor(
private userRepository: UserRepository, // knows only the interface
private emailService: EmailService, // knows only the interface
private paymentService: PaymentService, // knows only the interface
) {}
async execute(command: RegisterUserCommand): Promise<RegisterUserResult> {
const existing = await this.userRepository.findByEmail(command.email);
if (existing) {
throw new DomainError('EMAIL_ALREADY_EXISTS', 'Email already exists');
}
const id = generateId();
const passwordHash = bcrypt.hashSync(command.password, 10);
const user = User.create(id, command.email, passwordHash);
await this.userRepository.save(user);
await this.emailService.sendWelcomeEmail(user.email);
await this.paymentService.createCustomer(user.email);
return { userId: user.id };
}
}
RegisterUserService knows nothing about PostgreSQL, SendGrid, or Stripe. It only knows interfaces. This is Dependency Inversion in practice.
| Aspect | Layered Architecture | Hexagonal Architecture |
|---|---|---|
| Dependency direction | Controller → Service → Repository → DB | Always toward the core |
| Swap the database | Must edit Service code | Replace only the adapter |
| Unit testing | Complex mocks for DB/external services | Simple interface mocks |
| Business logic location | Can spread across layers | Concentrated in the core |
| Initial complexity | Low | Higher upfront |
| Maintenance complexity | Grows fast | Stays manageable |
| Best for | Simple CRUD | Rich domain logic |
The callers of the core also need adapters — HTTP controllers, CLI handlers, message queue consumers.
// adapters/inbound/http/UserController.ts
class UserController {
constructor(private registerUserUseCase: RegisterUserUseCase) {}
async register(req: Request, res: Response): Promise<void> {
try {
const command: RegisterUserCommand = {
email: req.body.email,
password: req.body.password,
};
const result = await this.registerUserUseCase.execute(command);
res.status(201).json({ userId: result.userId });
} catch (error) {
if (error instanceof DomainError) {
res.status(400).json({ code: error.code, message: error.message });
} else {
res.status(500).json({ message: 'Internal server error' });
}
}
}
}
// adapters/inbound/cli/RegisterUserCLI.ts
class RegisterUserCLI {
constructor(private registerUserUseCase: RegisterUserUseCase) {}
async run(args: string[]): Promise<void> {
const [email, password] = args;
const result = await this.registerUserUseCase.execute({ email, password });
console.log(`User created: ${result.userId}`);
}
}
The same core logic is reachable via HTTP or CLI without changing a line in the core.
Wire everything together in one place.
// main.ts — Composition Root
async function bootstrap() {
// Infrastructure
const dbPool = new Pool({ connectionString: process.env.DATABASE_URL });
const sendgridClient = new MailService();
sendgridClient.setApiKey(process.env.SENDGRID_API_KEY!);
const stripeClient = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Outbound adapters
const userRepository = new PostgresUserRepository(dbPool);
const emailService = new SendGridEmailService(sendgridClient);
const paymentService = new StripePaymentService(stripeClient);
// Core service (dependency injection)
const registerUserService = new RegisterUserService(
userRepository,
emailService,
paymentService,
);
// Inbound adapter
const userController = new UserController(registerUserService);
const app = express();
app.post('/users', (req, res) => userController.register(req, res));
app.listen(3000);
}
bootstrap();
The dependency flow:
UserController → (inbound port) → RegisterUserServiceRegisterUserService → (outbound port) → PostgresUserRepositoryArrows always point toward the core. The core never imports an adapter.
This is the biggest practical payoff.
describe('RegisterUserService', () => {
const mockUserRepository: UserRepository = {
users: new Map<string, User>(),
async findByEmail(email: string) {
for (const user of this.users.values()) {
if (user.email === email) return user;
}
return null;
},
async save(user: User) {
this.users.set(user.id, user);
},
};
const mockEmailService: EmailService = {
sentEmails: [] as string[],
async sendWelcomeEmail(to: string) {
this.sentEmails.push(to);
},
};
const mockPaymentService: PaymentService = {
async createCustomer(email: string) {
return `mock_customer_${email}`;
},
};
beforeEach(() => {
mockUserRepository.users.clear();
(mockEmailService as any).sentEmails = [];
});
it('successfully registers a new user', async () => {
const service = new RegisterUserService(
mockUserRepository, mockEmailService, mockPaymentService
);
const result = await service.execute({
email: 'test@example.com',
password: 'password123',
});
expect(result.userId).toBeDefined();
expect((mockEmailService as any).sentEmails).toContain('test@example.com');
});
it('rejects duplicate email', async () => {
const service = new RegisterUserService(
mockUserRepository, mockEmailService, mockPaymentService
);
await service.execute({ email: 'dup@example.com', password: 'pw1' });
await expect(
service.execute({ email: 'dup@example.com', password: 'pw2' })
).rejects.toThrow('Email already exists');
});
});
No database. No network. Fast, deterministic, zero external dependencies.
src/
├── core/ # Zero external dependencies
│ ├── domain/
│ │ ├── User.ts
│ │ └── DomainError.ts
│ ├── ports/
│ │ ├── inbound/
│ │ │ └── RegisterUserUseCase.ts
│ │ └── outbound/
│ │ ├── UserRepository.ts
│ │ ├── EmailService.ts
│ │ └── PaymentService.ts
│ └── application/
│ └── RegisterUserService.ts
│
├── adapters/
│ ├── inbound/
│ │ ├── http/UserController.ts
│ │ └── cli/RegisterUserCLI.ts
│ └── outbound/
│ ├── postgres/PostgresUserRepository.ts
│ ├── sendgrid/SendGridEmailService.ts
│ └── stripe/StripePaymentService.ts
│
└── main.ts # Composition Root
Enforce one rule: nothing inside core/ may import from node_modules packages that represent infrastructure (ORMs, HTTP clients, email SDKs). Only pure TypeScript and your own domain types.