NestJS Clean Architecture 패턴 3가지 비교 — Hexagonal, Onion, Layered 선택 가이드

목차

결론부터 말하면, NestJS Clean Architecture 패턴 종류는 크게 Layered, Hexagonal, Onion 세 가지로 나뉘고, 프로젝트 규모와 팀 숙련도에 따라 선택이 달라진다. “무조건 Hexagonal이 좋다”는 주장은 틀렸다. 3인 이하 소규모 프로젝트에 Hexagonal을 억지로 적용하면 보일러플레이트만 늘어난다. 반대로, 도메인이 복잡한 서비스에서 단순 Layered를 고집하면 비즈니스 로직이 컨트롤러와 서비스에 뒤섞여 유지보수 비용이 기하급수적으로 증가한다.

이 글에서는 각 패턴의 계층 구조, NestJS DI와의 연동 방식, 실무 폴더 구조를 비교한 뒤 프로젝트 상황별 선택 체크리스트를 제공한다.

NestJS에서 Clean Architecture 패턴이 필요한 이유

NestJS는 모듈 기반 DI 컨테이너를 내장한 프레임워크다. @Module, @Injectable, @Inject 데코레이터만으로 의존성 주입이 가능하므로, 별도 아키텍처 없이도 동작하는 서버를 빠르게 만들 수 있다. 문제는 서비스가 커지면서 시작된다.

컨트롤러에서 직접 TypeORM Repository를 호출하고, 서비스 레이어에 HTTP 클라이언트 호출과 비즈니스 검증 로직이 뒤섞이면, 단위 테스트를 작성할 때 외부 의존성을 모두 모킹해야 한다. DB를 PostgreSQL에서 MongoDB로 교체하는 상황이 오면 서비스 레이어 전체를 수정해야 한다. Clean Architecture는 이 문제를 의존성 방향을 한쪽으로 고정하는 것으로 해결한다.

NestJS 공식 문서(docs.nestjs.com)에는 ‘Clean Architecture’ 전용 가이드 페이지가 없다. Hexagonal Architecture나 Onion Architecture를 직접 언급하지도 않는다. 대신 DI 시스템과 CQRS 레시피를 제공하므로, 이 도구들을 조합해 Clean Architecture를 직접 설계해야 한다.

핵심 원칙은 하나다. 의존성은 항상 안쪽(도메인)을 향한다. 바깥 계층(프레임워크, DB, HTTP)이 안쪽 계층(엔티티, 유스케이스)에 의존하되, 안쪽은 바깥을 모른다. 이 원칙을 NestJS 모듈 구조에 어떻게 매핑하느냐에 따라 Layered, Hexagonal, Onion으로 갈린다. 세 패턴을 한눈에 비교하면 다음과 같다.

항목Layered ArchitectureHexagonal ArchitectureOnion Architecture
계층 수3 (Domain, Application, Infrastructure)내부/외부 2영역 + Port/Adapter4 동심원 (Entities → Use Cases → Controllers → Frameworks)
의존성 방향위→아래 단방향바깥→안쪽 단방향바깥→안쪽 단방향
핵심 개념계층 간 인터페이스Port(인터페이스) + Adapter(구현체)도메인 모델이 중심, 나머지는 껍질
NestJS DI 연동useClass로 구현체 주입문자열/Symbol 토큰으로 Port 바인딩useClass + useFactory 조합
폴더 구조 복잡도낮음중간높음
권장 프로젝트 규모소~중중~대
테스트 용이성중간높음높음
초기 보일러플레이트적음많음많음

세 패턴 모두 “의존성은 안쪽으로”라는 원칙을 공유하지만, 그 원칙을 얼마나 엄격하게 적용하느냐가 다르다. Layered는 느슨하게, Hexagonal과 Onion은 엄격하게 적용한다.

Layered Architecture — 가장 익숙한 3계층 분리

계층 구조와 역할

Layered Architecture는 모듈별로 Domain, Application, Infrastructure 3계층으로 분리하는 패턴이다. NestJS + TypeORM 기반 Layered Architecture 템플릿에 따르면, Domain 계층에 엔티티와 리포지토리 인터페이스를 두고, Application 계층에 서비스와 DTO를 배치하며, Infrastructure 계층에 TypeORM 구현체를 넣는다.

modules/
├── user/
│   ├── domain/
│   │   ├── user.entity.ts          ← 순수 도메인 모델
│   │   └── user.repository.ts      ← 리포지토리 인터페이스
│   ├── application/
│   │   ├── user.service.ts          ← 비즈니스 로직 오케스트레이션
│   │   └── dto/
│   │       ├── create-user.dto.ts
│   │       └── user-response.dto.ts
│   └── infrastructure/
│       ├── typeorm-user.repository.ts  ← 인터페이스 구현체
│       └── user.controller.ts

이 구조에서 Application 계층은 Domain에만 의존한다. Infrastructure의 TypeORM 구현체가 Domain의 리포지토리 인터페이스를 구현하므로, DB를 교체할 때 Infrastructure만 수정하면 된다.

모듈 간 통신과 Anticorruption 패턴

주목할 점은 모듈 간 통신 처리다. Layered Architecture 템플릿에서는 Anticorruption 패턴을 적용하여 모듈 내부 직접 의존을 차단한다. Application 계층에 인터페이스를 정의하고 Infrastructure에서 구현하는 방식이다. 예를 들어 user 모듈이 payment 모듈의 데이터를 필요로 할 때, payment 모듈의 서비스를 직접 주입하지 않는다. 대신 Application 계층에 PaymentQueryPort 인터페이스를 선언하고, Infrastructure에서 실제 payment 모듈과 통신하는 어댑터를 구현한다.

Layered Architecture 적용 체크리스트
Layered를 선택했다면 다음 항목을 확인하라. 첫째, 모든 리포지토리가 Domain 계층에 인터페이스로 존재하는가. 둘째, Application 계층의 서비스가 Infrastructure의 구체 클래스를 import하지 않는가. 셋째, 모듈 간 의존이 Anticorruption 패턴을 거치는가.
Layered Architecture의 한계는 명확하다. Port/Adapter 개념이 없으므로, 외부 시스템(메시지 큐, 외부 API, 캐시)이 늘어날수록 Infrastructure 계층이 비대해진다. 3개 이상의 외부 시스템과 통신하는 모듈이 있다면 Hexagonal로 전환을 고려할 시점이다.

Hexagonal Architecture — Port와 Adapter로 외부 의존성 격리

hexagonal-port-adapter-diagram

Port-Adapter 개념

Hexagonal Architecture(Port-Adapter 패턴)는 애플리케이션 코어를 중심에 두고, 외부와의 모든 통신을 Port(인터페이스)와 Adapter(구현체)로 처리한다. NestJS Hexagonal Architecture 데모 프로젝트에서는 domain, use_cases, infrastructure 3개 패키지로 분리한다. domain은 외부 의존성 없는 순수 비즈니스 로직이고, use_cases는 domain에만 의존하는 오케스트레이터다. infrastructure는 NestJS 프레임워크와 DB 등 기술 구현을 담당한다.

여기서 중요한 구분이 있다. Dependency Inversion Principle(DIP)과 NestJS DI는 별개 개념이다. NestJS DI는 런타임에 인스턴스를 주입하는 메커니즘이고, DIP는 설계 원칙으로서 상위 계층이 하위 계층의 구체 구현에 의존하지 않도록 인터페이스를 두는 것이다. NestJS DI를 쓴다고 자동으로 DIP가 적용되지 않는다.

NestJS DI 토큰으로 Port 바인딩하기

NestJS에서 Hexagonal의 Port를 구현할 때 주의할 점이 있다. TypeScript 인터페이스는 컴파일 시 사라지므로 DI 토큰으로 사용할 수 없다. NestJS 의존성 주입 공식 문서에 따르면, 문자열 토큰이나 Symbol을 @Inject() 데코레이터와 함께 써야 한다. NestJS DI는 useClass, useValue, useFactory, useExisting 4가지 커스텀 프로바이더를 지원한다.

const configServiceProvider = {
  provide: ConfigService,
  useClass: process.env.NODE_ENV === 'development'
    ? DevelopmentConfigService
    : ProductionConfigService,
};

이 패턴을 Port-Adapter에 적용하면 다음과 같은 구조가 된다.

// domain/ports/user-repository.port.ts
export const USER_REPOSITORY = Symbol('USER_REPOSITORY');

export interface UserRepositoryPort {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<void>;
}
// infrastructure/user.module.ts
@Module({
  providers: [
    {
      provide: USER_REPOSITORY,
      useClass: TypeOrmUserRepository,
    },
    CreateUserUseCase,
  ],
  controllers: [UserController],
})
export class UserModule {}
// use_cases/create-user.use-case.ts
@Injectable()
export class CreateUserUseCase {
  constructor(
    @Inject(USER_REPOSITORY)
    private readonly userRepo: UserRepositoryPort,
  ) {}
}

Symbol 토큰을 사용하면 Port 이름이 문자열 오타로 깨지는 문제를 방지할 수 있다. useClass로 환경별 구현체를 분기하는 것도 가능하다.

인터페이스를 DI 토큰으로 쓰면 안 되는 이유
TypeScript 인터페이스는 컴파일 후 JavaScript에서 완전히 사라진다. NestJS 런타임은 JavaScript로 동작하므로, 인터페이스 이름으로 프로바이더를 찾을 수 없다. 반드시 문자열 리터럴이나 Symbol을 토큰으로 사용해야 한다.
### Hexagonal 폴더 구조 예시
src/
├── domain/
│   ├── models/
│   │   └── user.ts                ← 순수 엔티티, 프레임워크 의존 없음
│   └── ports/
│       ├── in/
│       │   └── create-user.port.ts   ← Driving Port (유스케이스 인터페이스)
│       └── out/
│           └── user-repository.port.ts ← Driven Port (DB 인터페이스)
├── use_cases/
│   └── create-user.use-case.ts    ← domain에만 의존
└── infrastructure/
    ├── adapters/
    │   ├── in/
    │   │   └── user.controller.ts    ← Driving Adapter (HTTP)
    │   └── out/
    │       └── typeorm-user.repo.ts  ← Driven Adapter (DB)
    └── user.module.ts

Driving Port(In)는 외부에서 애플리케이션으로 들어오는 요청의 인터페이스이고, Driven Port(Out)는 애플리케이션이 외부 시스템에 요청하는 인터페이스다. Port를 in/out으로 나누면 의존성 방향이 명확해진다.

Onion Architecture — 도메인 중심의 동심원 구조

Onion Architecture는 Hexagonal과 유사하지만, 계층을 4개 동심원으로 더 세밀하게 나눈다. Hexagonal, Onion, Screaming Architecture를 통합 구현한 NestJS 프로젝트에 따르면, 4개 계층은 다음과 같다.

┌─────────────────────────────────────────┐
│  Frameworks (NestJS, TypeORM, Express)  │  ← 가장 바깥
│  ┌─────────────────────────────────┐    │
│  │  Controllers & Presenters      │    │
│  │  ┌─────────────────────────┐    │    │
│  │  │  Use Cases              │    │    │
│  │  │  ┌─────────────────┐    │    │    │
│  │  │  │  Entities       │    │    │    │  ← 가장 안쪽
│  │  │  └─────────────────┘    │    │    │
│  │  └─────────────────────────┘    │    │
│  └─────────────────────────────────┘    │
└─────────────────────────────────────────┘

의존성은 안쪽으로만 흐른다. 프레임워크와 DB는 세부사항(detail)으로 취급된다. Entities 계층은 어떤 프레임워크, 어떤 DB를 쓰는지 전혀 모른다. Use Cases 계층은 Entities만 알고, Controllers는 Use Cases만 안다.

Hexagonal과의 차이는 Controllers & Presenters 계층이 별도로 존재한다는 점이다. Hexagonal에서는 Controller가 Infrastructure(Adapter) 안에 위치하지만, Onion에서는 Use Cases와 Frameworks 사이에 독립된 계층으로 분리된다. 이 덕분에 Controller의 응답 변환 로직(Presenter)을 프레임워크 구현과 분리할 수 있다.

Onion vs Hexagonal — 실무에서의 차이
소규모 팀에서는 Onion과 Hexagonal의 차이가 거의 느껴지지 않는다. 두 패턴 모두 도메인을 중심에 두고 외부 의존성을 격리한다. 차이가 드러나는 지점은 Presenter 로직이 복잡해질 때다. GraphQL과 REST를 동시에 지원하거나, 같은 데이터를 웹과 모바일에 다르게 변환해야 할 때 Onion의 Presenter 계층이 유리하다.
Onion Architecture에서 NestJS 모듈을 구성할 때는 `useFactory`를 활용하면 계층 간 의존성을 명시적으로 관리할 수 있다. `useFactory`는 프로바이더 생성 시 다른 프로바이더를 주입받아 동적으로 인스턴스를 만들 수 있으므로, 계층 간 경계를 넘는 의존성 해결에 적합하다.

CQRS + Event Sourcing과 NestJS Clean Architecture 패턴 결합

NestJS Clean Architecture 패턴 종류를 논할 때, CQRS(Command Query Responsibility Segregation)를 빼놓을 수 없다. CQRS 자체는 아키텍처 패턴이 아니라 읽기/쓰기 모델 분리 전략이지만, Hexagonal이나 Onion과 결합하면 Command 처리와 Query 응답 경로를 계층 경계 안에서 분리해 도메인 모델의 복잡도를 명시적으로 관리할 수 있다.

NestJS 공식 CQRS 레시피CommandBus, QueryBus, EventBus를 제공한다. @CommandHandler, @QueryHandler, @EventsHandler 데코레이터로 읽기·쓰기 모델을 분리하며, 도메인 모델은 AggregateRoot를 확장하고 apply()로 도메인 이벤트를 발행한다.

CQRS를 Hexagonal Architecture와 결합하면 commands, queries, events가 domain 계층에 분리되고, use_cases 계층에 각각의 Handler가 위치하는 구조가 된다.

src/
├── domain/
│   ├── models/
│   │   └── order.aggregate.ts   ← AggregateRoot 확장
│   ├── commands/
│   │   └── create-order.command.ts
│   ├── queries/
│   │   └── get-order.query.ts
│   └── events/
│       └── order-created.event.ts
├── use_cases/
│   ├── commands/
│   │   └── create-order.handler.ts  ← @CommandHandler
│   └── queries/
│       └── get-order.handler.ts     ← @QueryHandler
└── infrastructure/
    ├── adapters/
    │   └── out/
    │       └── typeorm-order.repo.ts
    └── event-handlers/
        └── order-created.handler.ts ← @EventsHandler

CQRS + DDD + Clean Architecture를 결합한 NestJS 보일러플레이트 중에는 MongoDB 기반으로 Event Sourcing까지 포함하면서 Prometheus·Grafana 관측성, Swagger API 문서, Docker 배포를 갖춘 프로젝트도 존재한다. 다만, 이 조합은 초기 구축 비용이 상당하다. 도메인 이벤트가 10개 미만인 서비스에서는 과도한 설계가 될 수 있다.

CQRS 도입 전 확인 사항
읽기와 쓰기의 비율이 크게 다르지 않거나(예: 관리자 CRUD 위주 서비스), 도메인 이벤트가 거의 없는 프로젝트에서는 CQRS의 이점이 미미하다. CommandBus와 QueryBus를 도입하면 단순 CRUD에도 Command, Handler, Query, Handler 4개 파일이 생긴다. 트레이드오프를 명확히 따져야 한다.
## 프로젝트 규모별 NestJS Clean Architecture 패턴 선택 기준
project-scale-architecture-selection

소규모 프로젝트 (모듈 5개 이하, 개발자 1~3명)

Layered Architecture를 권장한다. Domain, Application, Infrastructure 3계층만 나누고, NestJS DI의 useClass로 리포지토리 구현체를 주입하면 충분하다. Port/Adapter까지 갈 필요가 없다. 이 규모에서 Hexagonal을 적용하면 모듈당 파일 수가 늘어나는데, 그 보일러플레이트를 감당할 팀 체력이 없다면 역효과다.

중규모 프로젝트 (모듈 10~20개, 개발자 4~8명)

Hexagonal Architecture가 적합하다. 모듈 수가 늘어나면 모듈 간 의존성 관리가 핵심 과제가 된다. Port를 명시적으로 정의하면 모듈 간 계약(contract)이 명확해지고, 팀원 간 병렬 작업이 수월해진다. Symbol 토큰으로 Port를 바인딩하면 리팩토링 시 IDE의 참조 검색도 활용할 수 있다.

대규모 프로젝트 (모듈 20개 이상, 마이크로서비스 분리 예정)

Onion Architecture + CQRS 결합을 고려한다. Presenter 계층을 분리하면 REST, GraphQL, gRPC 등 다중 인터페이스 지원이 깔끔해진다. Event Sourcing까지 적용할지는 도메인 이벤트 복잡도에 따라 결정한다.

다음은 선택 체크리스트다.

□ 모듈 수가 5개 이하인가?
  → Yes: Layered로 시작
  → No: 다음 질문으로

□ 외부 시스템(DB, 메시지 큐, 외부 API)이 모듈당 2개 이상인가?
  → Yes: Hexagonal (Port-Adapter로 각각 격리)
  → No: Layered로도 충분

□ 같은 데이터를 REST + GraphQL로 동시 제공하는가?
  → Yes: Onion (Presenter 계층 분리)
  → No: Hexagonal로 충분

□ 도메인 이벤트가 10개 이상이고 읽기/쓰기 비율이 극단적인가?
  → Yes: CQRS + Event Sourcing 결합 검토
  → No: 단순 서비스 호출로 처리

□ 팀원 전원이 DIP와 Port 개념을 이해하고 있는가?
  → No: Layered부터 시작하고, 팀 역량에 맞춰 점진 전환
점진적 전환 전략
Layered에서 시작해 Hexagonal로 전환하는 것은 어렵지 않다. Domain 계층의 리포지토리 인터페이스를 Port로 이름만 바꾸고, Infrastructure의 구현체를 Adapter로 재배치하면 된다. 핵심은 도메인 계층이 처음부터 외부 의존성 없이 순수하게 설계되었느냐다.
## 실무 적용 시 주의사항과 트러블슈팅

Repository Pattern과 NestJS DI 결합 시 함정

NestJS 공식 문서에는 Repository Pattern을 DI와 결합하는 전용 레시피가 없다. 공식 문서에도 명시되어 있지 않으므로, 커뮤니티 패턴에 의존해야 한다. 자주 발생하는 실수는 TypeORM의 @InjectRepository() 데코레이터를 Domain 계층에서 직접 사용하는 것이다. 이렇게 하면 Domain이 TypeORM에 직접 의존하게 되어 Clean Architecture의 핵심 원칙이 깨진다.

// ❌ 잘못된 패턴 — Domain이 TypeORM에 의존
@Injectable()
export class UserService {
  constructor(
    @InjectRepository(UserEntity)
    private readonly repo: Repository<UserEntity>,
  ) {}
}

// ✅ 올바른 패턴 — 추상화를 통해 의존성 역전
@Injectable()
export class UserService {
  constructor(
    @Inject(USER_REPOSITORY)
    private readonly repo: UserRepositoryPort,
  ) {}
}

테스트 전략 차이

Layered Architecture에서는 서비스 레이어 테스트 시 리포지토리 구현체를 모킹한다. Hexagonal에서는 Port 인터페이스의 인메모리 구현체(Fake)를 만들어 테스트한다. Fake를 사용하면 모킹 라이브러리 없이도 테스트가 가능하고, 테스트가 구현 상세에 덜 의존한다. 다만 Fake 구현체를 별도로 유지보수해야 하는 비용이 추가된다. 소규모에서는 모킹이 빠르고, 대규모에서는 Fake가 안정적이다.

💡 관련 상품: 노트북
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

한국어 자료 부족 문제

한국어로 된 NestJS Clean Architecture 공식 자료가 없다는 점도 인지해야 한다. 대부분의 레퍼런스가 영어 GitHub 저장소와 영문 문서다. 팀 내 아키텍처 결정 문서(ADR)를 작성할 때는 참조한 저장소와 패턴 이름을 명시해 두는 것이 좋다. 각 패턴 간 성능·복잡도 트레이드오프를 정량적으로 비교한 자료도 부족하므로, 벤치마크 수치보다는 유지보수성과 팀 적합성을 기준으로 판단하는 것이 현실적이다.

NestJS Clean Architecture 패턴 종류 정리와 다음 단계

NestJS Clean Architecture 패턴 종류를 Layered, Hexagonal, Onion 세 가지로 비교했다. 셋 모두 “의존성은 안쪽으로”라는 원칙을 공유하지만, 경계의 엄격함과 계층 세분화 수준이 다르다. Layered는 진입 장벽이 낮고, Hexagonal은 Port-Adapter로 외부 의존성을 명시적으로 격리하며, Onion은 Presenter까지 분리해 다중 인터페이스 환경에 대응한다.

패턴 선택보다 중요한 것은 도메인 계층의 순수성을 처음부터 확보하는 것이다. 도메인 엔티티에 @Column() 같은 ORM 데코레이터가 붙는 순간, 어떤 아키텍처 패턴을 적용하든 DB 교체 시 도메인 전체를 수정해야 한다. NestJS의 useClass와 Symbol 토큰을 활용한 의존성 역전이 이 순수성을 지키는 핵심 도구다.

아키텍처가 잡혔다면 다음 단계로 NestJS CQRS 패턴을 본격 도입하면서 CommandBus와 EventBus 기반의 이벤트 드리븐 설계를 탐색해볼 만하다. 모듈 간 통신이 복잡해지면 NestJS 모듈 구조 설계와 Anticorruption 패턴도 같이 다뤄야 한다. 그리고 NestJS DDD 적용 시 Aggregate 경계를 어떻게 나눌 것인지도 아키텍처 결정에서 빠질 수 없는 NestJS Clean Architecture 패턴의 연장선이다.

관련 글

  • NestJS 시작하기 – 설치부터 설정까지 – NestJS 를 시작 하려면 첫걸음부터 시작해야 합니다. 이 포스트에서는 설치부터 초기 설정에 이르는 NestJS 시작 가이드를 제공하며,…
이 글 공유하기