NestJS 기본 구조
NestJS는 기본적으로 MVC 패턴을 기반으로 한 모듈형 아키텍처를 제공합니다.
1
2
3
4
5
6
src/
└── user/
├── user.controller.ts // 요청 진입점
├── user.service.ts // 비즈니스 로직
├── user.module.ts // 모듈 정의
└── entities/user.entity.ts // DB 모델
이 기본 구조는 단순 CRUD 프로젝트에서는 충분하지만 비즈니스 로직이 커지고, 도메인이 복잡해질수록 Service 레이어가 비대해지고, 코드 간 의존성 관리가 어려워집니다.
DDD와 NestJS의 궁합
NestJS는 기본적으로 의존성 주입(DI), 모듈화(Modular Architecture), 데코레이터 기반 구조를 가지고 있습니다.
즉, DDD의 도메인 단위 경계 설정(Bounded Context), 의존성 역전 원칙, 계층 분리를 표현하기에 최적화된 프레임워크입니다.
Nest는 “DDD를 자연스럽게 구현할 수 있는 프레임워크”
DDD 구조 비교
일반적인 NestJS 구조는 Controller와 Service에 비즈니스 로직이 섞이고 Domain 모델(Entity, Value Object)의 역할이 명확히 분리되지 않습니다.
다음처럼 계층을 나누면, 코드의 책임이 명확해지고 비즈니스 로직 중심의 구조가 완성됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
src/
├── modules/
│ ├── booking/
│ │ ├── application/
│ │ │ └── booking.service.ts // 유스케이스 조합
│ │ ├── domain/
│ │ │ ├── entities/
│ │ │ │ └── booking.entity.ts // 핵심 비즈니스 모델
│ │ │ ├── value-objects/
│ │ │ │ └── seat.vo.ts // 값 객체 (Seat)
│ │ │ ├── services/
│ │ │ │ └── booking-domain.service.ts // 순수 도메인 로직
│ │ │ └── repository/
│ │ │ ├── booking.repository.interface.ts // 추상 인터페이스
│ │ │ └── booking.repository.ts // 실제 구현체
│ │ ├── infrastructure/
│ │ │ └── booking.prisma.repository.ts // DB 접근 구현체
│ │ ├── presentation/
│ │ │ ├── booking.controller.ts // API Entry Point
│ │ │ └── booking.dto.ts // Request/Response DTO
│ │ └── booking.module.ts
└── main.ts
Nest DDD 구조의 핵심 철학
Controller는 입출력만 담당하라.
Application Service는 유스케이스의 흐름을 담당하라.
Domain Layer는 비즈니스 규칙을 표현하라.
Repository는 데이터 저장소를 추상화하라.
Infrastructure는 기술 세부사항을 캡슐화하라.
Nest DDD 기반 7계층 아키텍처
Nest에서의 DDD 7계층은 다음과 같이 해석할 수 있습니다.
| 계층 | 이름 | 역할 | 구성 파일 예시 |
|---|---|---|---|
| 1 | Controller (Presentation) | 사용자 요청을 받고 DTO 검증 수행 | booking.controller.ts |
| 2 | Application Service | 유스케이스 관리 및 트랜잭션 제어 | booking.service.ts |
| 3 | Domain Service | 비즈니스 규칙, 순수 도메인 로직 | booking-domain.service.ts |
| 4 | Entity | 식별 가능한 도메인 객체 | booking.entity.ts |
| 5 | Value Object | 값으로 구분되는 객체 | seat.vo.ts, money.vo.ts |
| 6 | Repository Interface | 추상화된 데이터 접근 규약 | booking.repository.interface.ts |
| 7 | Repository Implementation (Infra) | 실제 DB 접근, ORM 구현체 | booking.prisma.repository.ts |
DDD + Nest 7계층의 장점
- 유지보수성 향상 : 계층별 책임이 명확해 수정 시 영향 범위가 작음
- 테스트 용이성 : 도메인 단위로 독립 테스트 가능
- 기술 독립성 확보 : Prisma → TypeORM 교체 시 도메인 로직 영향 없음
- 확장성 우수 : 기능이 늘어나도 구조가 무너지지 않음
- 비즈니스 중심 구조 : 코드가 도메인을 직접 설명함
Repository Interface → Service → Controller 흐름 예시
1
2
3
4
5
// booking.repository.interface.ts
export interface IBookingRepository {
findById(id: string): Promise<Booking | null>;
save(booking: Booking): Promise<void>;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// booking.prisma.repository.ts
import { Injectable } from "@nestjs/common";
import { IBookingRepository } from "../domain/repository/booking.repository.interface";
import { PrismaService } from "@/common/prisma.service";
import { Booking } from "../domain/entities/booking.entity";
@Injectable()
export class BookingPrismaRepository implements IBookingRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<Booking | null> {
const booking = await this.prisma.booking.findUnique({ where: { id } });
return booking ? new Booking(booking) : null;
}
async save(booking: Booking): Promise<void> {
await this.prisma.booking.upsert({
where: { id: booking.id },
update: booking,
create: booking,
});
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// booking-domain.service.ts
import { Injectable } from "@nestjs/common";
import { Booking } from "../entities/booking.entity";
import { Seat } from "../value-objects/seat.vo";
@Injectable()
export class BookingDomainService {
reserveSeat(booking: Booking, seat: Seat) {
if (booking.isSeatTaken(seat)) {
throw new Error("Seat is already taken");
}
booking.reserve(seat);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// booking.service.ts (Application Layer)
import { Injectable } from "@nestjs/common";
import { IBookingRepository } from "../domain/repository/booking.repository.interface";
import { BookingDomainService } from "../domain/services/booking-domain.service";
@Injectable()
export class BookingService {
constructor(
private readonly bookingRepository: IBookingRepository,
private readonly bookingDomainService: BookingDomainService
) {}
async reserveSeat(bookingId: string, seatNumber: string) {
const booking = await this.bookingRepository.findById(bookingId);
if (!booking) throw new Error("Booking not found");
this.bookingDomainService.reserveSeat(booking, { seatNumber });
await this.bookingRepository.save(booking);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// booking.controller.ts
import { Controller, Post, Param, Body } from "@nestjs/common";
import { BookingService } from "../application/booking.service";
@Controller("booking")
export class BookingController {
constructor(private readonly bookingService: BookingService) {}
@Post(":id/reserve")
async reserve(@Param("id") id: string, @Body() body: { seatNumber: string }) {
await this.bookingService.reserveSeat(id, body.seatNumber);
return { message: "Seat reserved successfully" };
}
}
Controller : HTTP 요청 담당
Application Service : 유스케이스 조합
Domain Service : 순수 비즈니스 로직 처리
Repository Interface/Implementation : 데이터 접근 분리