Hexagonal Architecture (육각형 아키텍처)
Hexagonal architecture (Ports and Adapters)는 비즈니스 로직을 프레임워크, 전송 계층, 영속성 세부 사항으로부터 독립시킵니다. 핵심 애플리케이션은 추상적인 포트(ports)에 의존하고, 어댑터(adapters)는 가장자리에서 이러한 포트를 구현합니다.
사용 시점
- 장기적인 유지보수성과 테스트 가능성이 중요한 새 기능을 구축할 때
- 도메인 로직이 입출력 문제와 혼재된 레이어드 아키텍처나 프레임워크 중심 코드를 리팩토링할 때
- 동일한 유스케이스에 대해 여러 인터페이스(HTTP, CLI, 큐 워커, 크론 잡)를 지원해야 할 때
- 비즈니스 규칙을 다시 작성하지 않고 인프라(데이터베이스, 외부 API, 메시지 버스)를 교체해야 할 때
요청에 경계, 도메인 중심 설계, 강하게 결합된 서비스 리팩토링, 또는 특정 라이브러리로부터 애플리케이션 로직 분리가 포함될 때 이 스킬을 사용하세요.
핵심 개념
- 도메인 모델: 비즈니스 규칙과 엔티티/값 객체. 프레임워크 임포트가 없습니다.
- 유스케이스 (애플리케이션 계층): 도메인 동작과 워크플로우 단계를 조율합니다.
- 인바운드 포트: 애플리케이션이 수행할 수 있는 작업을 설명하는 계약(커맨드/쿼리/유스케이스 인터페이스).
- 아웃바운드 포트: 애플리케이션이 필요로 하는 의존성에 대한 계약(리포지토리, 게이트웨이, 이벤트 발행자, 시계, UUID 등).
- 어댑터: 포트의 인프라 및 전달 구현체(HTTP 컨트롤러, DB 리포지토리, 큐 컨슈머, SDK 래퍼).
- 컴포지션 루트: 구체적인 어댑터가 유스케이스에 바인딩되는 단일 와이어링 위치.
아웃바운드 포트 인터페이스는 일반적으로 애플리케이션 계층에 위치하지만(추상화가 진정으로 도메인 수준인 경우에만 도메인에 위치), 인프라 어댑터가 이를 구현합니다.
의존성 방향은 항상 안쪽을 향합니다:
- 어댑터 -> 애플리케이션/도메인
- 애플리케이션 -> 포트 인터페이스(인바운드/아웃바운드 계약)
- 도메인 -> 도메인 전용 추상화(프레임워크나 인프라 의존성 없음)
- 도메인 -> 외부 의존성 없음
작동 방식
1단계: 유스케이스 경계 모델링
명확한 입력 및 출력 DTO를 가진 단일 유스케이스를 정의합니다. 전송 계층 세부 사항(Express req, GraphQL context, 잡 페이로드 래퍼)은 이 경계 밖에 유지합니다.
2단계: 아웃바운드 포트 먼저 정의
모든 부수 효과를 포트로 식별합니다:
- 영속성 (
UserRepositoryPort) - 외부 호출 (
BillingGatewayPort) - 횡단 관심사 (
LoggerPort,ClockPort)
포트는 기술이 아닌 기능을 모델링해야 합니다.
3단계: 순수한 오케스트레이션으로 유스케이스 구현
유스케이스 클래스/함수는 생성자/인수를 통해 포트를 전달받습니다. 애플리케이션 수준의 불변식을 검증하고, 도메인 규칙을 조정하며, 일반 데이터 구조를 반환합니다.
4단계: 가장자리에 어댑터 구축
- 인바운드 어댑터: 프로토콜 입력을 유스케이스 입력으로 변환합니다.
- 아웃바운드 어댑터: 애플리케이션 계약을 구체적인 API/ORM/쿼리 빌더에 매핑합니다.
- 매핑은 어댑터 내에 유지되고, 유스케이스 내부에는 없습니다.
5단계: 컴포지션 루트에서 모든 것을 와이어링
어댑터를 인스턴스화한 다음 유스케이스에 주입합니다. 숨겨진 서비스 로케이터 동작을 방지하려면 이 와이어링을 중앙 집중화하세요.
6단계: 경계별로 테스트
- 유스케이스 단위 테스트는 가짜 포트를 사용합니다.
- 어댑터 통합 테스트는 실제 인프라 의존성으로 수행합니다.
- 인바운드 어댑터를 통한 사용자 중심 흐름의 E2E 테스트.
아키텍처 다이어그램
flowchart LR
Client["클라이언트 (HTTP/CLI/Worker)"] --> InboundAdapter["인바운드 어댑터"]
InboundAdapter -->|"호출"| UseCase["유스케이스 (애플리케이션 계층)"]
UseCase -->|"사용"| OutboundPort["아웃바운드 포트 (인터페이스)"]
OutboundAdapter["아웃바운드 어댑터"] -->|"구현"| OutboundPort
OutboundAdapter --> ExternalSystem["DB/API/Queue"]
UseCase --> DomainModel["도메인 모델"]
제안되는 모듈 구조
명시적인 경계를 가진 기능 우선 구성 사용:
src/
features/
orders/
domain/
Order.ts
OrderPolicy.ts
application/
ports/
inbound/
CreateOrder.ts
outbound/
OrderRepositoryPort.ts
PaymentGatewayPort.ts
use-cases/
CreateOrderUseCase.ts
adapters/
inbound/
http/
createOrderRoute.ts
outbound/
postgres/
PostgresOrderRepository.ts
stripe/
StripePaymentGateway.ts
composition/
ordersContainer.ts
TypeScript 예제
포트 정의
export interface OrderRepositoryPort {
save(order: Order): Promise<void>;
findById(orderId: string): Promise<Order | null>;
}
export interface PaymentGatewayPort {
authorize(input: { orderId: string; amountCents: number }): Promise<{ authorizationId: string }>;
}
유스케이스
type CreateOrderInput = {
orderId: string;
amountCents: number;
};
type CreateOrderOutput = {
orderId: string;
authorizationId: string;
};
export class CreateOrderUseCase {
constructor(
private readonly orderRepository: OrderRepositoryPort,
private readonly paymentGateway: PaymentGatewayPort
) {}
async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
const order = Order.create({ id: input.orderId, amountCents: input.amountCents });
const auth = await this.paymentGateway.authorize({
orderId: order.id,
amountCents: order.amountCents,
});
// markAuthorized는 새로운 Order 인스턴스를 반환합니다. 제자리에서 변경하지 않습니다.
const authorizedOrder = order.markAuthorized(auth.authorizationId);
await this.orderRepository.save(authorizedOrder);
return {
orderId: order.id,
authorizationId: auth.authorizationId,
};
}
}
아웃바운드 어댑터
export class PostgresOrderRepository implements OrderRepositoryPort {
constructor(private readonly db: SqlClient) {}
async save(order: Order): Promise<void> {
await this.db.query(
"insert into orders (id, amount_cents, status, authorization_id) values ($1, $2, $3, $4)",
[order.id, order.amountCents, order.status, order.authorizationId]
);
}
async findById(orderId: string): Promise<Order | null> {
const row = await this.db.oneOrNone("select * from orders where id = $1", [orderId]);
return row ? Order.rehydrate(row) : null;
}
}
컴포지션 루트
export const buildCreateOrderUseCase = (deps: { db: SqlClient; stripe: StripeClient }) => {
const orderRepository = new PostgresOrderRepository(deps.db);
const paymentGateway = new StripePaymentGateway(deps.stripe);
return new CreateOrderUseCase(orderRepository, paymentGateway);
};
다중 언어 매핑
에코시스템 전반에 걸쳐 동일한 경계 규칙을 사용합니다. 구문과 와이어링 스타일만 변경됩니다.
- TypeScript/JavaScript
- 포트:
application/ports/*인터페이스/타입. - 유스케이스: 생성자/인수 주입을 사용하는 클래스/함수.
- 어댑터:
adapters/inbound/*,adapters/outbound/*. - 컴포지션: 명시적인 팩토리/컨테이너 모듈 (숨겨진 전역 변수 없음).
- 포트:
- Java
- 패키지:
domain,application.port.in,application.port.out,application.usecase,adapter.in,adapter.out. - 포트:
application.port.*의 인터페이스. - 유스케이스: 일반 클래스 (Spring
@Service는 선택 사항이며 필수가 아님). - 컴포지션: Spring 설정 또는 수동 와이어링 클래스; 와이어링을 domain/usecase 클래스 밖에 유지.
- 패키지:
- Kotlin
- 모듈/패키지는 Java 분할을 따름 (
domain,application.port,application.usecase,adapter). - 포트: Kotlin 인터페이스.
- 유스케이스: 생성자 주입을 사용하는 클래스 (Koin/Dagger/Spring/수동).
- 컴포지션: 모듈 정의 또는 전용 컴포지션 함수; 서비스 로케이터 패턴 피하기.
- 모듈/패키지는 Java 분할을 따름 (
- Go
- 패키지:
internal/<feature>/domain,application,ports,adapters/inbound,adapters/outbound. - 포트: 소비 애플리케이션 패키지가 소유한 작은 인터페이스.
- 유스케이스: 인터페이스 필드와 명시적인
New...생성자를 가진 구조체. - 컴포지션:
cmd/<app>/main.go(또는 전용 와이어링 패키지)에서 와이어링, 생성자를 명시적으로 유지.
- 패키지:
피해야 할 안티패턴
- 도메인 엔티티가 ORM 모델, 웹 프레임워크 타입, SDK 클라이언트를 임포트하는 것.
- 유스케이스가
req,res, 큐 메타데이터에서 직접 읽는 것. - 도메인/애플리케이션 매핑 없이 유스케이스에서 데이터베이스 행을 직접 반환하는 것.
- 유스케이스 포트를 통하지 않고 어댑터가 서로 직접 호출하게 하는 것.
- 숨겨진 전역 싱글톤으로 여러 파일에 의존성 와이어링을 분산시키는 것.
마이그레이션 플레이북
- 변경이 잦은 하나의 수직 슬라이스(단일 엔드포인트/잡)를 선택합니다.
- 명시적인 입력/출력 타입으로 유스케이스 경계를 추출합니다.
- 기존 인프라 호출 주변에 아웃바운드 포트를 도입합니다.
- 컨트롤러/서비스의 오케스트레이션 로직을 유스케이스로 이동합니다.
- 기존 어댑터를 유지하되, 새 유스케이스에 위임하도록 만듭니다.
- 새 경계 주변에 테스트를 추가합니다 (단위 + 어댑터 통합).
- 슬라이스별로 반복합니다. 전체 재작성은 피합니다.
기존 시스템 리팩토링
- Strangler 접근법: 현재 엔드포인트를 유지하고, 한 번에 하나의 유스케이스를 새 포트/어댑터를 통해 라우팅합니다.
- 빅뱅 재작성 금지: 기능 슬라이스별로 마이그레이션하고 특성화 테스트로 동작을 보존합니다.
- 퍼사드 우선: 내부를 교체하기 전에 레거시 서비스를 아웃바운드 포트 뒤에 래핑합니다.
- 컴포지션 동결: 새 의존성이 도메인/유스케이스 계층으로 누출되지 않도록 초기에 와이어링을 중앙 집중화합니다.
- 슬라이스 선택 규칙: 변경이 잦고 영향 범위가 좁은 흐름을 먼저 우선시합니다.
- 롤백 경로: 프로덕션 동작이 확인될 때까지 마이그레이션된 슬라이스별로 되돌릴 수 있는 토글 또는 경로 스위치를 유지합니다.
테스트 가이드 (동일한 헥사고날 경계)
- 도메인 테스트: 순수한 비즈니스 규칙으로 엔티티/값 객체를 테스트합니다 (목 없음, 프레임워크 설정 없음).
- 유스케이스 단위 테스트: 아웃바운드 포트에 대한 페이크/스텁으로 오케스트레이션을 테스트합니다. 비즈니스 결과와 포트 상호 작용을 단언합니다.
- 아웃바운드 어댑터 계약 테스트: 포트 수준에서 공유 계약 스위트를 정의하고 각 어댑터 구현에 대해 실행합니다.
- 인바운드 어댑터 테스트: 프로토콜 매핑(HTTP/CLI/큐 페이로드를 유스케이스 입력으로, 출력/에러 매핑을 프로토콜로 다시)을 검증합니다.
- 어댑터 통합 테스트: 직렬화, 스키마/쿼리 동작, 재시도, 타임아웃에 대해 실제 인프라(DB/API/큐)에서 실행합니다.
- 엔드 투 엔드 테스트: 인바운드 어댑터 -> 유스케이스 -> 아웃바운드 어댑터를 통한 중요한 사용자 여정을 다룹니다.
- 리팩토링 안전성: 추출 전에 특성화 테스트를 추가합니다. 새 경계 동작이 안정적이고 동등해질 때까지 유지합니다.
모범 사례 체크리스트
- 도메인 및 유스케이스 계층은 내부 타입과 포트만 임포트합니다.
- 모든 외부 의존성은 아웃바운드 포트로 표현됩니다.
- 검증은 경계에서 수행됩니다 (인바운드 어댑터 + 유스케이스 불변식).
- 불변 변환을 사용합니다 (공유 상태를 변경하는 대신 새 값/엔티티를 반환).
- 오류는 경계 간에 변환됩니다 (인프라 오류 -> 애플리케이션/도메인 오류).
- 컴포지션 루트는 명시적이고 감사하기 쉽습니다.
- 유스케이스는 포트에 대한 간단한 인메모리 페이크로 테스트 가능합니다.
- 리팩토링은 동작을 보존하는 테스트를 사용하여 하나의 수직 슬라이스에서 시작합니다.
- 언어/프레임워크별 세부 사항은 어댑터에 유지되고, 도메인 규칙에는 절대 포함되지 않습니다.