헥사고날 아키텍처(Ports & Adapters)

시작하기에 앞서
지난 글에서 DDD(Domain-Driven Design)는 업무 규칙을 도메인 모델 가까이에 두는 접근이라고 정리했다. 그런데 Order가 자신의 상태 전이를 잘 지키고 있어도, 그 옆에 ORM 호출과 HTTP 요청이 함께 들어오기 시작하면 다른 문제가 생긴다.
예를 들어 주문을 생성할 때 다음 코드들이 한 서비스에 모이기 쉽다.
주문 상태와 금액을 검증하는 업무 규칙
Prisma 같은 ORM으로 데이터를 저장하는 영속성 구현
결제사 API를 호출하는 외부 연동 구현
HTTP 요청과 응답을 다루는 전달 방식
처음에는 한 파일에서 모두 보이니 편하다. 하지만 데이터베이스를 바꾸거나, REST API 외에 배치 작업에서도 주문을 만들거나, 결제 실패 규칙을 테스트하려는 순간부터 코드의 관심사가 엉키기 시작하고 도메인 로직을 확인하려면 데이터베이스 설정과 네트워크, 예외까지 읽어야 하는 상황생긴다.
헥사고날 아키텍처(Hexagonal Architecture)는 이때 애플리케이션의 핵심을 바깥 기술로부터 분리하는 방법이다. 뭔가 있어보이는 이름이지만, 핵심은 간단하다. 이 코드가 다른 관심사여서 변경되는것인가?, 아니면 외부 기술(라이브러리, API 등) 때문인가?
도메인을 바깥에서 지켜야 하는 이유
데이터베이스와 웹 프레임워크, 결제 API는 애플리케이션에 꼭 필요하다. 다만 이것들이 도메인의 중심이 될 필요는 없다.
잘못된 예
import { PrismaClient } from "@prisma/client";
export class OrderService {
constructor(private readonly prisma: PrismaClient) {}
async createPaidOrder(input: { id: string; amount: number }) {
if (input.amount <= 0) {
throw new Error("주문 금액은 0보다 커야 합니다.");
}
await fetch("https://payment.example.com/authorize", {
method: "POST",
body: JSON.stringify({ orderId: input.id, amount: input.amount }),
});
await this.prisma.order.create({
data: { id: input.id, amount: input.amount, status: "PAID" },
});
}
}
주문 규칙이
PrismaClient, HTTP 호출 방식과 한 클래스에 섞여 있음단위 테스트를 하려면 데이터베이스와 결제 API를 함께 준비하거나 복잡하게 흉내 내야 함
DB를 교체하거나 주문 생성 진입점을 HTTP에서 메시지 소비자로 늘릴 때 업무 규칙까지 수정될 가능성
서비스에 Prisma나 fetch 존제하는게 문제가 아니라 OrderService가 무엇을에 해당하는 로직과 어떻게에 해당 하는 로직이 같이 있다는 점이다.
헥사고날 아키텍처는 애플리케이션이 UI나 데이터베이스 없이 동작하고, 자동화된 테스트 및 다른 연결 방식에 대응하기 위한 방법이다.
포트와 어댑터란 무엇인가?
헥사고날 아키텍처는 보통 다음 세 가지로 나누어 이해하면 편하다.
HTTP Controller
│
Inbound Adapter
│
┌──── Inbound Port / Use Case ────┐
│ │
│ Application + Domain │
│ │
└── Outbound Port ────────────────┘
│
Outbound Adapter
┌─────┴─────┐
PostgreSQL Payment API
애플리케이션 코어(Application Core)
유스케이스와 도메인 규칙이 있는 곳.
주문을 생성할 수 있는지, 결제를 승인한 뒤 어떤 상태가 되는지를 결정함
포트(Port)
코어가 바깥과 대화하기 위해 정의한 계약.
인터페이스, 함수 시그니처, 메시지 형식 등으로 표현
어댑터(Adapter)
포트를 실제 기술과 연결하는 구현.
REST 컨트롤러, CLI 명령, Prisma 리포지터리, 외부 API 클라이언트 등
인바운드와 아웃바운드
인바운드(Inbound) 포트/어댑터
바깥에서 애플리케이션을 호출하는 방향.
PlaceOrder유스케이스를 REST 컨트롤러, CLI, 메시지 소비자가 호출
아웃바운드(Outbound) 포트/어댑터
애플리케이션이 바깥을 호출하는 방향.
OrderRepository,PaymentGateway포트를 Prisma나 HTTP 클라이언트가 구현
여기서 중요한건 의존성 방향이다.
애플리케이션 코어 → 포트(추상화) ← 어댑터(구현체)
코어는 PrismaOrderRepository를 알 필요가 없다. OrderRepository라는 계약만 알고 있으면 된다. 구현체는 포트에 맞추어 바뀔 수 있지만, 구현체가 바뀌어도 업무 로직이 변하지 않는다. 이를 의존성 역전(Dependency Inversion)으로도 볼 수 있다.
사용 예: 주문 유스케이스 분리하기
아래 예시는 주문 기능 하나의 경계와 의존성 방향을 보이기 위한 예시다. 디렉토리 구조가 동일할 필요는 없다.
src/orders/
├── domain/
│ ├── money.ts
│ └── order.ts
├── application/
│ ├── place-order.ts
│ └── ports/
│ ├── order-repository.ts
│ └── payment-gateway.ts
├── adapters/
│ ├── inbound/
│ │ └── http/place-order-controller.ts
│ └── outbound/
│ ├── persistence/prisma-order-repository.ts
│ └── payment/http-payment-gateway.ts
└── composition-root.ts
먼저 애플리케이션이 필요로 하는 바깥 기능을 포트로 선언한다. 이 인터페이스는 ORM 패키지가 아니라 주문 유스케이스의 언어로 작성하는 편이 좋다.
// application/ports/order-repository.ts
import { Order } from "../../domain/order";
export interface OrderRepository {
save(order: Order): Promise<void>;
}
// application/ports/payment-gateway.ts
export interface PaymentGateway {
authorize(input: {
orderId: string;
amount: number;
}): Promise<void>;
}
유스케이스는 포트에만 의존한다. 결제 승인과 주문 상태 변경의 순서는 유스케이스에 작성하고, 어느 DB와 결제사를 쓸지는 포함하지 않는다다.
// application/place-order.ts
import { Order } from "../domain/order";
import { OrderRepository } from "./ports/order-repository";
import { PaymentGateway } from "./ports/payment-gateway";
export class PlaceOrder {
constructor(
private readonly orders: OrderRepository,
private readonly payments: PaymentGateway,
) {}
async execute(input: { id: string; amount: number }): Promise<void> {
const order = Order.create(input.id, input.amount);
await this.payments.authorize({
orderId: order.id,
amount: order.amount,
});
order.markPaid();
await this.orders.save(order);
}
}
실제 기술과의 연결은 어댑터가 담당한다.
// adapters/outbound/persistence/prisma-order-repository.ts
import { PrismaClient } from "@prisma/client";
import { OrderRepository } from "../../../application/ports/order-repository";
import { Order } from "../../../domain/order";
export class PrismaOrderRepository implements OrderRepository {
constructor(private readonly prisma: PrismaClient) {}
async save(order: Order): Promise<void> {
await this.prisma.order.upsert({
where: { id: order.id },
create: order.toPersistence(),
update: order.toPersistence(),
});
}
}
그리고 컴포지션 루트(composition root)에서만 구체 구현체를 연결한다.
const placeOrder = new PlaceOrder(
new PrismaOrderRepository(prisma),
new HttpPaymentGateway(httpClient),
);
app.post("/orders", createPlaceOrderController(placeOrder));
이제 HTTP 컨트롤러는 요청을 유스케이스 입력으로 바꾸는 어댑터가 된다. PlaceOrder를 배치 작업이나 메시지 소비자에서 호출하고 싶을때에는 새 인바운드 어댑터만 추가한다
테스트에서 보이는 차이
포트를 분리하면 유스케이스 테스트에 실제 PostgreSQL이나 결제 API를 제외하고 테스트용 어댑터를 작게 만들 수 있다.
class InMemoryOrderRepository implements OrderRepository {
saved: Order[] = [];
async save(order: Order): Promise<void> {
this.saved.push(order);
}
}
class FakePaymentGateway implements PaymentGateway {
async authorize(): Promise<void> {
// 결제 성공만 표현하는 테스트용 구현
}
}
const orders = new InMemoryOrderRepository();
const useCase = new PlaceOrder(orders, new FakePaymentGateway());
await useCase.execute({ id: "order-1", amount: 10_000 });
console.assert(orders.saved[0].status === "PAID");
유스케이스의 규칙을 빠르게 확인 가능
DB 스키마나 결제사 응답 검증은 실제 어댑터의 통합 테스트로 분리 가능
테스트 대역이 업무 규칙을 재구현하지 않도록 포트의 계약은 작고 명확하게 유지 필요
위 예시는 플로우를 단순화한 코드로 포트를 통해서 어느 계층의 책임인지 명확히 하고 있다. 실제로 구현시에는 멱등성(idempotency), 롤백처리, 트랜잭션같은 부분을 신경써서 설계해야 한다.
DDD, 클린 아키텍처와는 어떤 관계일까?
여러 아키텍쳐를 공부하다보면 그게그거 같거나 상호배타적인 아키텍쳐처럼 보이지만 실무에서는 겹쳐서 사용하는 경우가 많다.
DDD: 업무 언어와 도메인 모델, 경계에 집중
헥사고날 아키텍처: 애플리케이션 코어와 외부 입출력을 포트·어댑터로 분리
클린 아키텍처(Clean Architecture): 의존성이 안쪽 정책을 향하도록 하는 여러 구조를 묶어 설명
레이어드 아키텍처(Layered Architecture): 프레젠테이션·애플리케이션·도메인·인프라 같은 층으로 관심사를 나누는 방식
DDD로 Order의 책임을 정하고 헥사고날 구조로 그 Order가 ORM과 외부 API에 직접 묶이지 않도록 설계하거나 CRUD가 대부분인 작은 내부 도구라면 리포지터리 포트를 먼저 만들기보다 단순한 레이어드 구조로 설계할 수 있다.
도입할 때의 작은 기준
헥사고날 아키텍처를 사용할때 모든 클래스에 인터페이스를 만들 필요는 없다. 한 번만 쓰는 내부 계산 로직까지 포트로 감싸면 탐색해야 할 파일만 늘어난다.
다음 기준부터 적용해 보면 충분하다.
바뀔 가능성이 큰 경계부터: 결제사, 파일 저장소, 이메일 발송, 외부 API처럼 교체하거나 실패를 다뤄야 하는 연결부터 분리
유스케이스 언어로 포트 이름 짓기:
DatabaseService보다OrderRepository,PaymentGateway처럼 필요한 행위를 드러내기조립 지점 하나 두기: 구체 구현체를 생성하는 코드를
main,bootstrap,composition-root같은 한곳에 모으기포트는 작게 유지하기: 미래의 모든 요구를 예상해 메서드를 늘리기보다, 현재 유스케이스가 실제로 요구하는 계약만 두기
단순한 CRUD는 단순하게: 도메인 규칙과 외부 연동 복잡도가 낮다면 구조의 이득보다 비용이 커질 수 있음
핵심은 로직을 숨기는게아니라 도메인 로직이나 결정사항들과 분리해서 의사결정에 영향을 덜 받도록하는것이다
마무리
DDD가 "업무 규칙을 어디에 둘까?"에 대한 내용이었다면 헥사고날 아키텍처는 "그 규칙을 바깥 기술로부터 어떻게 지킬까?"에 대한 내용이다. 포트는 경계를 선언하고, 어댑터는 그 경계를 현실의 HTTP·DB·API와 연결한다.

![[잡담] vive coding](https://cdn.hashnode.com/uploads/covers/68c02f05dc3a532e679042a4/97d8d740-9e88-49d0-9c60-548f7795bacf.jpg)
