육각형 아키텍처은(는) 무엇을 하나요?
육각형 아키텍처(포트와 어댑터)는 비즈니스 로직을 프레임워크, 전송, 영속성 세부 사항으로부터 독립적으로 유지합니다. 핵심 앱은 추상적인 포트에 의존하고, 어댑터는 경계에서 이러한 포트를 구현합니다.
사용 시기
- 장기적인 유지보수성과 테스트 용이성이 중요한 새 기능을 구축할 때.
- 도메인 로직이 I/O 문제와 혼합된 계층형 또는 프레임워크 중심 코드를 리팩토링할 때.
- 동일한 유스케이스에 대해 여러 인터페이스(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/워커)"] --> InboundAdapter["인바운드 어댑터"]
InboundAdapter -->|"호출"| UseCase["유스케이스 (애플리케이션 계층)"]
UseCase -->|"사용"| OutboundPort["아웃바운드 포트 (인터페이스)"]
OutboundAdapter["아웃바운드 어댑터"] -->|"구현"| OutboundPort
OutboundAdapter --> ExternalSystem["DB/API/큐"]
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 설정 또는 수동 와이어링 클래스; 와이어링을 도메인/유스케이스 클래스 외부에 유지.
- 패키지:
- 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/큐)에 대해 실행합니다.
- 종단 간 테스트: 인바운드 어댑터 -> 유스케이스 -> 아웃바운드 어댑터를 통해 중요한 사용자 경로를 다룹니다.
- 리팩토링 안전성: 추출 전에 특성화 테스트를 추가합니다. 새 경계 동작이 안정적이고 동등해질 때까지 유지합니다.
모범 사례 체크리스트
- 도메인 및 유스케이스 계층은 내부 유형과 포트만 임포트합니다.
- 모든 외부 종속성은 아웃바운드 포트로 표현됩니다.
- 유효성 검사는 경계(인바운드 어댑터 + 유스케이스 불변성)에서 수행됩니다.
- 불변 변환을 사용합니다 (공유 상태를 변경하는 대신 새 값/엔티티를 반환).
- 오류는 경계 간에 변환됩니다 (인프라 오류 -> 애플리케이션/도메인 오류).
- 컴포지션 루트는 명시적이고 감사하기 쉽습니다.
- 유스케이스는 포트에 대한 간단한 인메모리 가짜로 테스트할 수 있습니다.
- 리팩토링은 동작을 보존하는 테스트와 함께 하나의 수직 슬라이스에서 시작합니다.
- 언어/프레임워크 특정 사항은 어댑터에 유지되며, 도메인 규칙에 절대 없습니다.