Skip to main content

Command Palette

Search for a command to run...

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

Updated
•6 min read•View as Markdown
헥사고날 아키텍처(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가 대부분인 작은 내부 도구라면 리포지터리 포트를 먼저 만들기보다 단순한 레이어드 구조로 설계할 수 있다.

도입할 때의 작은 기준

헥사고날 아키텍처를 사용할때 모든 클래스에 인터페이스를 만들 필요는 없다. 한 번만 쓰는 내부 계산 로직까지 포트로 감싸면 탐색해야 할 파일만 늘어난다.

다음 기준부터 적용해 보면 충분하다.

  1. 바뀔 가능성이 큰 경계부터: 결제사, 파일 저장소, 이메일 발송, 외부 API처럼 교체하거나 실패를 다뤄야 하는 연결부터 분리

  2. 유스케이스 언어로 포트 이름 짓기: DatabaseService보다 OrderRepository, PaymentGateway처럼 필요한 행위를 드러내기

  3. 조립 지점 하나 두기: 구체 구현체를 생성하는 코드를 main, bootstrap, composition-root 같은 한곳에 모으기

  4. 포트는 작게 유지하기: 미래의 모든 요구를 예상해 메서드를 늘리기보다, 현재 유스케이스가 실제로 요구하는 계약만 두기

  5. 단순한 CRUD는 단순하게: 도메인 규칙과 외부 연동 복잡도가 낮다면 구조의 이득보다 비용이 커질 수 있음

핵심은 로직을 숨기는게아니라 도메인 로직이나 결정사항들과 분리해서 의사결정에 영향을 덜 받도록하는것이다

마무리

DDD가 "업무 규칙을 어디에 둘까?"에 대한 내용이었다면 헥사고날 아키텍처는 "그 규칙을 바깥 기술로부터 어떻게 지킬까?"에 대한 내용이다. 포트는 경계를 선언하고, 어댑터는 그 경계를 현실의 HTTP·DB·API와 연결한다.


레퍼런스

More from this blog

OpenClaw 파헤치기

서론 OpenClaw가 어떻게 작동하는지 작성하기 전에 잡답을 먼저 작성해보려고한다. AI 비서는 예전부터 많이 생각하던 주제였다.나는 평소 귀찮음이 많고, 관심 없는 것에는 신경을 잘 쓰지 않는다. 그래서 통학길이나 출퇴근길에 AI가 내 일정을 알고, 집을 나서기 전에 "오늘 비 오니까 우산 챙기세요" 정도만 알려줘도 충분히 편리할 거라고 생각했다.그래서

Apr 14, 20262 min read
OpenClaw 파헤치기

[잡담] vive coding

서론 퇴근후 혹은 주말에 사이드 프로젝트를 진행해보려고 했지만 육아로 인해서 집중 할 수 있는 시간이 없어서 AI를 통해서 프로젝트를 진행하고 나는 목표를 제시하고 진행 사항만 받으려고한다 이전에도 개발시에 AI를 이용해서 보일러플레이트를 구현하거나 반복작업이나 해야하는게 명확한 작업, 기존에 코드를 참고만해서 진행하는 작업들을 할때 AI를 이용했으나 조금

Mar 15, 20261 min read
[잡담] vive coding

토큰 효율성(Token Efficiency)

토큰 효율성이란? 토큰 효율성은 AI 코딩 도구를 활용할 때 최소한의 컨텍스트로 최대한 정확한 결과를 얻기 위한 원칙AI가 코드를 이해하고 수정하는 데 필요한 정보를 최적화하여 생산성을 향상가능 토큰 효율성이 필요한 이유 Context Window 한계 AI 모델은 한 번에 처리할 수 있는 토큰 수가 제한됨 (128K, 200K 등) 더 많은 파일을 읽을수록 중요한 정보가 희석됨 집중된 컨텍스트 = 더 정확한 이해 응답 속도 적은 토큰 = ...

Dec 3, 20253 min read
토큰 효율성(Token Efficiency)

gglee.dev

10 posts