| name | hexagonal-architecture |
| description | TypeScript, Java, Kotlin, Go 서비스 전반에서 명확한 도메인 경계, 의존성 역전 및 테스트 가능한 유스케이스 오케스트레이션을 갖춘 포트 및 어댑터(Ports & Adapters) 시스템을 설계, 구현 및 리팩터링합니다. |
| origin | ECC |
헥사고날 아키텍처 (Hexagonal Architecture)
헥사고날 아키텍처(포트 및 어댑터)는 비즈니스 로직을 프레임워크, 전송 방식, 지속성 세부 사항으로부터 독립적으로 유지합니다. 핵심 애플리케이션은 추상적인 포트에 의존하고, 어댑터는 가장자리에서 이러한 포트를 구현합니다.
활성화 시점
- 장기적인 유지보수성과 테스트 가능성이 중요한 새로운 기능을 구축할 때.
- 도메인 로직이 I/O 관심사와 혼합된 레이어드 또는 프레임워크 중심 코드를 리팩터링할 때.
- 동일한 유스케이스(HTTP, CLI, 큐 워커, 크론 잡)에 대해 여러 인터페이스를 지원해야 할 때.
- 비즈니스 규칙을 다시 작성하지 않고 인프라(데이터베이스, 외부 API, 메시지 버스)를 교체할 때.
요청에 경계 설정, 도메인 중심 설계, 밀접하게 결합된 서비스 리팩터링 또는 특정 라이브러리로부터 애플리케이션 로직 분리가 포함된 경우 이 스킬을 사용하십시오.
핵심 개념
- 도메인 모델 (Domain model): 비즈니스 규칙 및 엔티티/값 객체. 프레임워크 임포트가 없습니다.
- 유스케이스 (애플리케이션 계층): 도메인 동작 및 워크플로우 단계를 오케스트레이션합니다.
- 인바운드 포트 (Inbound ports): 애플리케이션이 수행할 수 있는 작업(명령/쿼리/유스케이스 인터페이스)을 설명하는 계약입니다.
- 아웃바운드 포트 (Outbound ports): 애플리케이션에 필요한 의존성(저장소, 게이트웨이, 이벤트 게시자, 시계, UUID 등)에 대한 계약입니다.
- 어댑터 (Adapters): 포트의 인프라 및 전달 구현체(HTTP 컨트롤러, DB 저장소, 큐 소비자, SDK 래퍼)입니다.
- 구성 루트 (Composition root): 구체적인 어댑터가 유스케이스에 바인딩되는 단일 배선 위치입니다.
아웃바운드 포트 인터페이스는 일반적으로 애플리케이션 계층에 위치하며(추상화가 진정으로 도메인 수준인 경우에만 도메인에 위치), 인프라 어댑터는 이를 구현합니다.
의존성 방향은 항상 내부를 향합니다:
- 어댑터 -> 애플리케이션/도메인
- 애플리케이션 -> 포트 인터페이스 (인바운드/아웃바운드 계약)
- 도메인 -> 도메인 전용 추상화 (프레임워크나 인프라 의존성 없음)
- 도메인 -> 외부 의존성 없음
작동 방식
1단계: 유스케이스 경계 모델링
명확한 입력 및 출력 DTO를 사용하여 단일 유스케이스를 정의합니다. 전송 세부 사항(Express req, GraphQL context, 작업 페이로드 래퍼)을 이 경계 외부에 둡니다.
2단계: 아웃바운드 포트 먼저 정의
모든 부수 효과를 포트로 식별합니다:
- 지속성 (
UserRepositoryPort)
- 외부 호출 (
BillingGatewayPort)
- 횡단 관심사 (
LoggerPort, ClockPort)
포트는 기술이 아니라 기능을 모델링해야 합니다.
3단계: 순수 오케스트레이션으로 유스케이스 구현
유스케이스 클래스/함수는 생성자/인수를 통해 포트를 받습니다. 애플리케이션 수준의 불변성을 검증하고, 도메인 규칙을 조정하며, 평범한 데이터 구조를 반환합니다.
4단계: 가장자리에 어댑터 구축
- 인바운드 어댑터는 프로토콜 입력을 유스케이스 입력으로 변환합니다.
- 아웃바운드 어댑터는 앱 계약을 구체적인 API/ORM/쿼리 빌더에 매핑합니다.
- 매핑은 유스케이스 내부가 아닌 어댑터에 유지됩니다.
5단계: 구성 루트에서 모든 것을 연결
어댑터를 인스턴스화한 다음 유스케이스에 주입합니다. 숨겨진 서비스 로케이터 동작을 피하기 위해 이 배선을 중앙 집중화합니다.
6단계: 경계별 테스트
- 가짜(fake) 포트를 사용하여 유스케이스를 단위 테스트합니다.
- 실제 인프라 의존성을 사용하여 어댑터를 통합 테스트합니다.
- 인바운드 어댑터를 통해 사용자 대면 흐름을 E2E 테스트합니다.
아키텍처 다이어그램
flowchart LR
Client["클라이언트 (HTTP/CLI/Worker)"] --> 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,
});
const authorizedOrder = order.markAuthorized(auth.);
..(authorizedOrder);
{
: order.,
: auth.,
};
}
}
아웃바운드 어댑터
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/수동).
- 구성: 모듈 정의 또는 전용 구성 함수. 서비스 로케이터 패턴을 피하십시오.
- Go
- 패키지:
internal/<feature>/domain, application, ports, adapters/inbound, adapters/outbound.
- 포트: 이를 사용하는 애플리케이션 패키지가 소유한 작은 인터페이스.
- 유스케이스: 인터페이스 필드와 명시적인
New... 생성자가 있는 구조체.
- 구성:
cmd/<app>/main.go (또는 전용 배선 패키지)에서 연결하고 생성자를 명시적으로 유지하십시오.
피해야 할 안티 패턴
- 도메인 엔티티가 ORM 모델, 웹 프레임워크 타입 또는 SDK 클라이언트를 임포트하는 경우.
- 유스케이스가
req, res 또는 큐 메타데이터를 직접 읽는 경우.
- 도메인/애플리케이션 매핑 없이 데이터베이스 행을 유스케이스에서 직접 반환하는 경우.
- 어댑터가 유스케이스 포트를 거치지 않고 서로를 직접 호출하는 경우.
- 숨겨진 전역 싱글톤을 사용하여 의존성 배선을 여러 파일에 분산시키는 경우.
마이그레이션 플레이북
- 변경 시 고통이 자주 발생하는 수직 슬라이스(단일 엔드포인트/작업) 하나를 선택합니다.
- 명시적인 입력/출력 타입을 사용하여 유스케이스 경계를 추출합니다.
- 기존 인프라 호출 주변에 아웃바운드 포트를 도입합니다.
- 오케스트레이션 로직을 컨트롤러/서비스에서 유스케이스로 옮깁니다.
- 기존 어댑터는 유지하되, 새로운 유스케이스로 위임하도록 만듭니다.
- 새로운 경계 주변에 테스트(단위 + 어댑터 통합)를 추가합니다.
- 슬라이스별로 반복하며 전체 재작성은 피하십시오.
기존 시스템 리팩터링
- Strangler 접근 방식: 현재 엔드포인트를 유지하면서 한 번에 하나의 유스케이스를 새로운 포트/어댑터를 통해 라우팅합니다.
- 빅뱅 식 재작성 금지: 기능 슬라이스별로 마이그레이션하고 성격 묘사 테스트(characterization tests)를 통해 동작을 보존합니다.
- 퍼사드 우선: 내부를 교체하기 전에 레거시 서비스를 아웃바운드 포트 뒤로 래핑합니다.
- 구성 동결: 새로운 의존성이 도메인/유스케이스 계층으로 누출되지 않도록 조기에 배선을 중앙 집중화합니다.
- 슬라이스 선택 규칙: 변경이 잦고 영향 범위가 좁은 흐름을 먼저 우선시합니다.
- 롤백 경로: 운영 동작이 확인될 때까지 마이그레이션된 슬라이스별로 가역적인 토글 또는 라우팅 스위치를 유지합니다.
테스트 가이드 (헥사고날 경계 준수)
- 도메인 테스트: 엔티티/값 객체를 순수 비즈니스 규칙으로 테스트합니다 (모의 객체나 프레임워크 설정 없음).
- 유스케이스 단위 테스트: 아웃바운드 포트에 대해 가짜(fakes)/스텁(stubs)을 사용하여 오케스트레이션을 테스트합니다. 비즈니스 결과 및 포트 상호 작용을 확인합니다.
- 아웃바운드 어댑터 계약 테스트: 포트 수준에서 공유 계약 슈트를 정의하고 각 어댑터 구현에 대해 실행합니다.
- 인바운드 어댑터 테스트: 프로토콜 매핑(HTTP/CLI/큐 페이로드를 유스케이스 입력으로, 출력/오류를 프로토콜로 다시 매핑)을 확인합니다.
- 어댑터 통합 테스트: 직렬화, 스키마/쿼리 동작, 재시도 및 타임아웃에 대해 실제 인프라(DB/API/큐)를 대상으로 실행합니다.
- 엔드 투 엔드 테스트: 인바운드 어댑터 -> 유스케이스 -> 아웃바운드 어댑터를 통한 핵심 사용자 여정을 다룹니다.
- 리팩터링 안전성: 추출 전에 성격 묘사 테스트를 추가하고, 새로운 경계 동작이 안정적이고 동등해질 때까지 유지합니다.
모범 사례 체크리스트
- 도메인 및 유스케이스 계층은 내부 타입과 포트만 임포트합니다.
- 모든 외부 의존성은 아웃바운드 포트로 표현됩니다.
- 검증은 경계(인바운드 어댑터 + 유스케이스 불변성)에서 발생합니다.
- 불변 변환을 사용합니다 (공유 상태를 수정하는 대신 새로운 값/엔티티 반환).
- 오류는 경계를 가로질러 번역됩니다 (인프라 오류 -> 애플리케이션/도메인 오류).
- 구성 루트는 명시적이고 감사가 쉽습니다.
- 유스케이스는 포트에 대한 간단한 인메모리 가짜(fakes)를 사용하여 테스트 가능합니다.
- 리팩터링은 동작 보존 테스트가 있는 하나의 수직 슬라이스에서 시작합니다.
- 언어/프레임워크 세부 사항은 어댑터에 유지되며 도메인 규칙에는 절대 포함되지 않습니다.