본문으로 건너뛰기

의존 방향과 헥사고날 아키텍처(Hexagonal Architecture) - 디자인 패턴, 시스템 수준 편 ep.02

모듈을 나누고 나면 다음 질문이 온다. 이 모듈은 저 모듈을 알아도 되는가.


이 경계는 무엇을 청구하는가

앞 편에서 코드를 덩어리로 묶었습니다. 이제 덩어리와 덩어리 사이에 선을 긋고 나면, 그 선을 누가 어느 방향으로 넘는지가 남습니다.

의존 방향은 화살표입니다. A가 B를 import 하면 화살표는 A에서 B로 향합니다. 그 화살표에는 B가 바뀌면 A도 바뀔 수 있다는 조건이 하나 붙어 있습니다. 반대는 아닙니다. B는 A의 존재를 모르므로, A가 어떻게 바뀌든 영향받지 않습니다.

이 비대칭을 잘 살펴봐야 합니다. 화살표가 가리키는 쪽이 안정적이어야 하고, 화살표를 쏘는 쪽이 자주 바뀌어도 되는 쪽입니다. 이 조건이 뒤집히면 유지보수가 혼란스러워집니다.

청구되는 것은 교체 가능성입니다. 화살표를 잘못 가리키면, 예컨대 도메인 로직이 데이터베이스 라이브러리를 직접 알고 있으면, 데이터베이스를 바꿀 때 도메인 로직을 고쳐야 합니다. 만약 컴포넌트가 API 응답 형태를 직접 알고 있으면, API가 바뀔 때 컴포넌트를 고쳐야 합니다.

의존 방향이 답하는 질문은 하나입니다. 누가 누구를 알아야 하는가.


이 경계에 붙어 있는 이름들

화살표가 잘못된 상태

주문 처리 코드입니다. 흔한 형태부터 봅니다.

// domain/order-service.ts
import { PrismaClient } from '@prisma/client'
import { SendGridClient } from '@sendgrid/mail'

const prisma = new PrismaClient()
const mailer = new SendGridClient()

export class OrderService {
  async placeOrder(items: Item[], userId: string) {
    // 비즈니스 규칙
    const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0)
    if (total <= 0) throw new Error('Invalid order')

    // 저장 — Prisma를 직접 컨트롤
    const order = await prisma.order.create({
      data: { userId, total, items: { create: items } },
    })

    // 알림 — SendGrid를 직접 컨트롤
    await mailer.send({ to: userId, subject: '주문 완료', text: '...' })

    return order
  }
}

이 코드에서 화살표는 도메인 → 인프라 방향입니다. 주문 규칙이 Prisma와 SendGrid를 알고 있습니다.

문제는 두 가지입니다. 첫째, 메일 서비스를 바꾸면 주문 규칙 파일을 고쳐야 합니다. 둘째, 주문 규칙을 테스트하려면 데이터베이스와 메일 서버가 필요합니다. 중요하고 자주 검증해야 하는 코드가, 무거운 것들에 묶여 있습니다.

의존성 역전

화살표를 뒤집습니다. 도메인이 자기가 필요한 것의 모양을 선언하고, 인프라가 그 모양을 구현합니다.

// domain/ports.ts — 도메인이 선언하는 요구사항
export interface OrderRepository {
  save(order: Order): Promise<Order>
}

export interface Notifier {
  notifyOrderPlaced(userId: string, orderId: string): Promise<void>
}
// domain/order-service.ts — 인프라를 전혀 모릅니다
import type { OrderRepository, Notifier } from './ports'

export class OrderService {
  constructor(
    private repo: OrderRepository,
    private notifier: Notifier,
  ) {}

  async placeOrder(items: Item[], userId: string): Promise<Order> {
    const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0)
    if (total <= 0) throw new Error('Invalid order')

    const order = await this.repo.save({ userId, total, items })
    await this.notifier.notifyOrderPlaced(userId, order.id)

    return order
  }
}
// infra/prisma-order-repository.ts — 인프라가 도메인 쪽을 봅니다
import type { OrderRepository } from '@/domain/ports'

export class PrismaOrderRepository implements OrderRepository {
  constructor(private prisma: PrismaClient) {}

  async save(order: Order): Promise<Order> {
    return this.prisma.order.create({ data: /* ... */ })
  }
}

화살표가 뒤집혔습니다. 이제 인프라가 도메인을 알고, 도메인은 인프라를 모릅니다.

메일 서비스를 바꾸려면 인프라에 새 Notifier 구현을 하나 만들면 됩니다. 도메인은 손대지 않습니다. 테스트도 가벼워집니다.

// 사용할 때 데이터베이스도 메일 서버도 필요 없습니다
const service = new OrderService(
  { save: async (o) => ({ ...o, id: 'test-1' }) },
  { notifyOrderPlaced: async () => {} },
)

의존 방향 반전 전후의 비교 다이어그램. 상단은 반전 전 상태로, 좌측의 도메인 박스에서 우측의 인프라 박스들을 향해 화살표가 나갑니다. 인프라 박스에는 Prisma와 SendGrid가 들어 있고, 도메인이 이들을 직접 알고 있다는 점이 강조됩니다. '인프라가 바뀌면 도메인이 바뀐다'는 경고 라벨이 붙어 있습니다. 하단은 반전 후 상태로, 중앙에 포트 인터페이스 박스가 놓이고 도메인은 포트를 향해 화살표를 쏘고, 인프라 구현체들도 포트를 향해 위쪽으로 화살표를 쏩니다. 두 화살표가 모두 포트를 향하는 모습이 강조되며, '도메인은 인프라를 모른다'는 라벨이 붙습니다. 우측에는 이 구조에서 테스트가 가벼워지는 이유가 짧게 표시되어 있습니다.

Hexagonal Architecture

이 구조에 붙은 이름이 Hexagonal Architecture(헥사고날 아키텍처) 입니다. Alistair Cockburn이 2005년에 정리했고, Ports and Adapters(포트와 어댑터) 라고도 부릅니다.

육각형 안쪽에 도메인이 있습니다. 육각형의 각 변에 포트가 뚫려 있고, 바깥에서 어댑터가 그 포트에 꽂힙니다. (육각형이라는 모양 자체에는 의미가 없습니다. 변이 여러 개라는 것, 즉 여러 종류의 바깥 세계가 붙는다는 것만 표현합니다.)

포트는 두 종류입니다.

  • 주도하는(driving) 포트: 바깥에서 도메인을 부르는 쪽입니다. HTTP 컨트롤러, CLI, 스케줄러, 테스트 코드가 여기 속합니다.
  • 주도당하는(driven) 포트: 도메인이 바깥을 부르는 쪽입니다. 데이터베이스, 메일, 결제 게이트웨이, 파일 저장소가 여기 속합니다.

두 방향 모두 화살표는 안쪽을 향합니다. 바깥은 안쪽을 알고, 안쪽은 바깥을 모릅니다.

Hexagonal Architecture 구조 다이어그램. 중앙에 육각형이 그려져 있고 그 안에 도메인이라 표시됩니다. 육각형의 좌측 변들에는 주도 포트가, 우측 변들에는 주도당하는 포트가 뚫려 있습니다. 좌측 바깥에는 세 개의 주도 어댑터가 배치됩니다. HTTP 컨트롤러, CLI, 테스트 코드입니다. 이들에서 육각형을 향해 화살표가 들어갑니다. 우측 바깥에는 세 개의 주도당하는 어댑터가 배치됩니다. Prisma 저장소, SendGrid 알림, 결제 게이트웨이입니다. 이들에서도 육각형을 향해 화살표가 들어갑니다. 모든 화살표가 안쪽을 향한다는 점이 다이어그램 하단에 라벨로 강조되어 있으며, 도메인은 바깥의 어떤 구현도 알지 못한다고 표시됩니다.

Clean Architecture와 Onion Architecture도 같은 원칙의 다른 그림입니다. 동심원으로 그리든 육각형으로 그리든, 규칙은 하나입니다. 의존 화살표는 언제나 안쪽을 향합니다.

프론트엔드에서의 같은 문제

이 논의는 백엔드만의 이야기가 아닙니다. 프론트엔드에서도 같은 구조가 나타납니다.

// 컴포넌트가 API 응답 형태를 직접 사용
function OrderList() {
  const { data } = useQuery({
    queryKey: ['orders'],
    queryFn: () => fetch('/api/orders').then(r => r.json()),
  })

  return (
    <ul>
      {data?.result_list?.map((o: any) => (
        // API의 snake_case 필드명이 JSX에 그대로 드러납니다
        <li key={o.order_id}>
          {o.order_status_cd} — {o.total_amt}원
        </li>
      ))}
    </ul>
  )
}

order_status_cd가 컴포넌트 안에 있습니다. 백엔드가 이 필드명을 바꾸면 화면이 깨집니다. order_status_cd에 대한 해석도 없이 그대로 출력합니다.

어댑터를 하나 두면 방향이 정리됩니다.

// features/orders/types.ts — 화면이 원하는 모양
export type Order = {
  id: string
  status: 'pending' | 'paid' | 'shipped'
  totalPrice: number
}

// features/orders/api.ts — 어댑터
const STATUS_MAP = {
  '01': 'pending',
  '02': 'paid',
  '03': 'shipped',
} as const

export async function fetchOrders(): Promise<Order[]> {
  const res = await fetch('/api/orders')
  const raw = await res.json()

  return raw.result_list.map((o: RawOrder) => ({
    id: o.order_id,
    status: STATUS_MAP[o.order_status_cd],
    totalPrice: o.total_amt,
  }))
}

컴포넌트는 이 어댑터만 부릅니다.

// features/orders/OrderList.tsx — 화면은 Order만 압니다
import { fetchOrders } from './api'

const STATUS_LABEL = {
  pending: '결제 대기',
  paid: '결제 완료',
  shipped: '배송 중',
} as const

function OrderList() {
  const { data } = useQuery({
    queryKey: ['orders'],
    queryFn: fetchOrders,
  })

  return (
    <ul>
      {data?.map((order) => (
        <li key={order.id}>
          {STATUS_LABEL[order.status]} — {order.totalPrice.toLocaleString()}원
        </li>
      ))}
    </ul>
  )
}

order_status_cd도 '01'도 JSX에서 사라졌습니다. 컴포넌트는 이제 Order 타입만 알면 됩니다. API가 바뀌면 어댑터 한 파일만 고칩니다. 나중에 상태를 하나 더 늘릴 때는 STATUS_MAP과 STATUS_LABEL 양쪽에서 컴파일 에러가 나므로, 고칠 자리를 타입이 알려줍니다. 화면이 원하는 모양을 화면 쪽에서 선언하고, 바깥이 그 모양에 맞추는 것은 백엔드에서 본 의존성 역전과 정확히 같습니다.

“codegen을 쓰면 타입이 자동으로 생기는데 왜 손으로 매핑하는가”라는 반론이 나올 수 있습니다. 맞는 지적이고, 실제로 많은 팀이 그렇게 합니다. 다만 생성된 타입이 어떻게 생겼는지 보면 이야기가 조금 달라집니다.

// generated/api.ts — OpenAPI 스펙에서 자동 생성됩니다. 손으로 고치지 않습니다.
export interface components {
  schemas: {
    OrderListResponse: {
      result_list: components['schemas']['OrderResponse'][]
    }
    OrderResponse: {
      order_id: string
      order_status_cd: string // '01'이 무엇을 뜻하는지는 여기 없습니다
      total_amt: number
    }
  }
}

이 타입 자체는 정확합니다. 서버가 실제로 내려주는 모양 그대로니까요. 문제는 이 타입을 어디까지 들여보내느냐입니다.

// 생성 타입을 컴포넌트에서 직접 씁니다
import type { components } from '@/generated/api'

type OrderListResponse = components['schemas']['OrderListResponse']

function OrderList() {
  const { data } = useQuery<OrderListResponse>({ /* ... */ })

  return (
    <ul>
      {data?.result_list.map((o) => (
        <li key={o.order_id}>
          {o.order_status_cd} — {o.total_amt}원
        </li>
      ))}
    </ul>
  )
}

any가 사라지고 자동완성이 됩니다. 실제로 나아진 부분이고, 백엔드가 필드명을 바꾸면 런타임이 아니라 빌드에서 깨진다는 점도 이득입니다. 하지만 맨 처음 봤던 코드와 나란히 놓고 보면 달라진 것은 타입이 붙었다는 사실뿐입니다. order_status_cd는 물론 result_list라는 봉투까지 여전히 JSX 안에 있고, 화살표는 여전히 화면 → 백엔드입니다. 스펙이 바뀌면 고쳐야 할 파일은 여전히 화면 쪽입니다. 타입 안전성과 의존 방향은 다른 축입니다. codegen은 타입 안전성은 해결했지만 의존 방향은 건드리지 않습니다.

codegen이 만드는 타입은 백엔드의 모양이지 화면의 모양이 아닙니다. 그러니 생성된 타입은 어댑터의 입력으로 쓰는 편이 방향에 맞습니다. 앞의 어댑터 코드에 나온 RawOrder가 그 입력입니다.

// features/orders/api.ts — 생성 타입은 여기 어댑터까지만 들어옵니다
import type { components } from '@/generated/api'

type RawOrder = components['schemas']['OrderResponse']

export async function fetchOrders(): Promise<Order[]> {
  // 매핑은 앞과 동일합니다. RawOrder만 codegen에서 가져왔습니다.
}

이렇게 두면 codegen의 이득은 그대로 가져갑니다. 스펙이 바뀌면 RawOrder가 따라 바뀌고, 어댑터가 컴파일 에러로 알려줍니다. 다만 그 에러가 나는 곳이 화면 여러 개가 아니라 어댑터 한 파일입니다. 손으로 쓰는 것은 타입이 아니라 경계입니다.

안티패턴

추상화를 위한 추상화: 구현이 하나뿐이고 앞으로도 하나일 인터페이스는 비용만 만듭니다. 예를 들어 UserRepository 인터페이스와 함께 PrismaUserRepository 구현만 있고 다른 구현이 영영 없다면, 인터페이스는 파일 하나를 더 열게 할 뿐입니다. 두 번째 구현이 보일 때, 또는 테스트에 문제가 생길 때 추상화합니다.

포트에 인프라 개념이 새는 것: OrderRepository의 메서드가 findByQuery(sql: string)이라면, 그 포트는 이미 SQL을 전제합니다. 도메인이 선언하는 인터페이스는 인프라가 아닌 도메인의 언어로만 쓰여야 합니다.

어댑터 없는 어댑터 폴더: 폴더 이름만 adapters/이고 그 안에서 도메인 로직이 함께 굴러가는 경우입니다. 구조는 이름이 아니라 화살표로 판정하는 것입니다.


이 경계가 화면에 드러나는 자리

디자인 시스템에도 화살표가 있습니다. 그리고 이 화살표는 방향이 정해져 있습니다.

토큰  →  컴포넌트  →  화면

토큰은 컴포넌트를 모릅니다. 컴포넌트는 화면을 모릅니다. Button은 자기가 결제 화면에 쓰일지 마이페이지에 쓰일지 알지 못한 채로 존재합니다. 그래서 어디에나 쓰일 수 있습니다.

이 방향이 역류하기 시작할 때 디자인 시스템이 무너집니다.

역류의 첫 신호는 이름입니다. color.checkout.cta라는 토큰이 생기면, 토큰이 화면을 알게 된 것입니다. Button에 isCheckoutPage prop이 생기면, 컴포넌트가 화면을 알게 된 것입니다.

// 역류 — 컴포넌트가 화면을 앎
<Button isCheckoutPage variant="primary">결제</Button>

// 방향 유지 — 화면이 자기 필요를 표현
<Button variant="primary" size="lg" fullWidth>결제</Button>

두 번째 코드에서 Button은 여전히 결제 화면을 모릅니다. 화면이 자기가 원하는 것을 시스템의 언어(variant, size, fullWidth)로 요청할 뿐입니다.

디자인 토큰의 단방향 흐름과 역류를 보여주는 다이어그램. 상단에는 정상 흐름이 그려져 있습니다. 좌측 토큰 영역에서 중앙 컴포넌트 영역으로, 다시 우측 화면 영역으로 화살표가 한 방향으로 흐릅니다. 각 영역 아래에는 그 영역이 무엇을 모르는지가 표시되어 있습니다. 토큰은 컴포넌트를 모르고, 컴포넌트는 화면을 모릅니다. 하단에는 역류 상태가 그려져 있습니다. 화면 영역에서 컴포넌트 영역으로 거꾸로 향하는 붉은 화살표가 있고, 그 위에 isCheckoutPage prop이라는 라벨이 붙습니다. 또 다른 역류 화살표가 화면에서 토큰으로 향하며 color.checkout.cta라는 토큰 이름이 라벨로 붙습니다. 하단 캡션에는 역류의 신호는 이름에서 먼저 나타난다는 문장이 배치되어 있습니다.

역류를 막는 실무적 방법은 이름 심사입니다. 시스템에 들어오는 토큰이나 prop의 이름에 특정 화면·기능·페이지의 이름이 들어 있으면 일단 멈춥니다.

  • color.brand.primary: 통과 - 컴포넌트도 화면도 모릅니다.
  • color.danger: 통과 - 의미를 말할 뿐입니다.
  • color.checkout.cta: 정지 - 화면 이름이 들어 있습니다.
  • spacing.orderCardGap: 정지 - 특정 컴포넌트에 묶여 있습니다.

앞 편의 shared/ 비대화 문제와 같은 처방은 여기서도 통합니다. 당장 필요하다고 시스템에 올리지 않습니다. 두 번째까지는 프로덕트 쪽에 두고, 세 번째 사용처가 나타났을 때 시스템의 언어로 다시 이름 붙여 올립니다.


참고 자료


다음 편 예고

ep.03 - 계약 경계와 런타임 타입 검증

화살표의 방향을 정리해도 남는 문제가 있습니다. 경계를 넘어온 데이터가 정말 그 모양이라는 보장은 어디에 있는가. TypeScript 타입은 컴파일이 끝나는 순간 사라집니다.