NestJS 프레임워크
NestJS 12의 모듈, 컨트롤러, 프로바이더와 의존성 주입으로 API를 구성하고, 파이프로 경로 값과 요청 본문을 변환하고 검증하며, 가드로 접근을 막은 뒤, 요청이 미들웨어부터 예외 필터까지 처리되는 순서를 따라갑니다.
Express는 구조를 강제하지 않아서 작은 서버를 빠르게 만들기 좋지만, 서버가 커지면 파일을 어떻게 나누고 객체를 어디서 만들어 넘길지를 팀이 직접 정해야 합니다. NestJS는 그 구조를 프레임워크가 정해 주는 Node.js 서버 프레임워크로, 기본으로 Express 위에서 동작하면서 모듈(module), 컨트롤러(controller), 프로바이더(provider)라는 단위와 의존성 주입(dependency injection)을 제공합니다.
NestJS는 처음부터 타입스크립트로 쓰도록 만들어졌고, 클래스와 데코레이터로 라우팅과 주입을 선언합니다. 이 글은 2026년 10월 현재 최신인 NestJS 12로 사용자 API를 만들고, 파이프와 가드로 요청을 검사한 뒤, 요청 하나가 어떤 순서로 처리되는지 따라갑니다.
프로젝트 만들기
NestJS 프로젝트는 Nest CLI로 만듭니다. 첫 명령은 nest 명령을 어디서나 쓸 수 있도록 CLI를 전역으로 설치하고, 이어지는 세 명령이 프로젝트를 만들고 그 폴더로 들어가 개발 서버를 띄웁니다.
npm install -g @nestjs/cli
nest new my-nestjs-app
cd my-nestjs-app
npm run start:devnest new는 사용할 패키지 관리자와 모듈 방식 같은 몇 가지를 물은 뒤, 타입스크립트 설정과 테스트 설정, 예제 모듈을 갖춘 프로젝트를 만들고 의존성을 설치합니다. 모듈 방식을 묻는 Which module system would you like to use?에는 CJS (CommonJS)를 고릅니다. 기본값인 ESM을 고르면 프로젝트 안의 상대 경로 import에 .js를 붙여야 하므로, 확장자 없이 적은 이 글과 ORM 사용의 코드가 그대로는 컴파일되지 않습니다. npm run start:dev는 파일이 바뀔 때마다 다시 컴파일하고 서버를 다시 시작하며, http://localhost:3000/에 접속하면 Hello World!가 보입니다. 설치된 정확한 버전은 만들어진 package.json에서 확인합니다.
만들어진 tsconfig.json에는 experimentalDecorators와 emitDecoratorMetadata가 켜져 있습니다. NestJS는 생성자 매개변수에 붙이는 데코레이터처럼 TypeScript 5.0의 표준 데코레이터에 없는 기능을 쓰기 때문에 예전 방식의 데코레이터를 켜고, 컴파일러가 생성자 매개변수의 타입을 실행 중의 메타데이터로 남기게 해서 주입할 클래스를 알아냅니다. 같은 이유로 의존성 주입과 IoC 컨테이너에서 본 것처럼, Node.js의 타입 제거 실행으로는 이 코드를 실행할 수 없고 컴파일을 거쳐야 합니다.
모듈, 컨트롤러, 프로바이더
세 단위는 각각 할 일이 다릅니다. 컨트롤러는 HTTP 요청을 받아 어느 메서드가 처리할지 정하고, 프로바이더는 업무 로직을 담으며, 모듈은 둘을 묶어 NestJS에 등록합니다. 먼저 사용자 데이터를 다루는 서비스를 만듭니다.
import { Injectable } from "@nestjs/common";
export interface User {
id: number;
name: string;
email: string;
}
@Injectable()
export class AppService {
private readonly users: User[] = [{ id: 1, name: "Alice", email: "[email protected]" }];
private nextId = 2;
findAll(): User[] {
return this.users;
}
findOne(id: number): User | undefined {
return this.users.find((user) => user.id === id);
}
create(name: string, email: string): User {
const user: User = { id: this.nextId++, name, email };
this.users.push(user);
return user;
}
}@Injectable()은 이 클래스를 NestJS의 주입 대상으로 표시합니다. 사용자는 메모리의 배열에만 저장되므로 서버를 다시 시작하면 처음 상태로 돌아가며, ID는 1씩 늘어나는 nextId로 정해 겹치지 않게 했습니다. 다음은 이 서비스를 쓰는 컨트롤러입니다.
import { Body, Controller, Get, NotFoundException, Param, ParseIntPipe, Post } from "@nestjs/common";
import { AppService } from "./app.service";
import type { User } from "./app.service";
import { CreateUserDto } from "./create-user.dto";
@Controller("users")
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
findAll(): User[] {
return this.appService.findAll();
}
@Get(":id")
findOne(@Param("id", ParseIntPipe) id: number): User {
const user = this.appService.findOne(id);
if (!user) {
throw new NotFoundException(`User ${id} not found`);
}
return user;
}
@Post()
create(@Body() dto: CreateUserDto): User {
return this.appService.create(dto.name, dto.email);
}
}@Controller("users")는 이 클래스의 경로 앞부분을 /users로 정하고, @Get(":id")는 GET /users/:id 요청을 findOne에 연결합니다. 처리 메서드가 값을 돌려주면 NestJS가 그 값을 JSON 응답으로 바꾸고, NotFoundException을 던지면 404 응답이 됩니다. @Param, @Body 같은 매개변수 데코레이터는 요청의 어느 부분을 그 매개변수에 넣을지 정합니다.
생성자의 private readonly appService: AppService가 의존성 주입입니다. 컨트롤러는 서비스를 직접 만들지 않고, NestJS가 생성자 매개변수의 타입 메타데이터를 보고 등록된 AppService의 인스턴스를 만들어 넣어 줍니다. 그러려면 두 클래스가 모듈에 등록되어 있어야 합니다.
import { Module } from "@nestjs/common";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
@Module({
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}providers에서 AppService를 빼면 서버가 시작될 때 Nest can't resolve dependencies of the AppController (?).로 시작하는 오류가 나며, 그 뒤에 AppService를 AppModule에서 쓸 수 있게 하라는 안내가 붙습니다. 타입스크립트의 import는 파일을 연결할 뿐이고, NestJS가 무엇을 주입할 수 있는지는 모듈의 등록으로 정해지기 때문입니다.
NEST MODULE · DI VISIBILITY
Nest module은 provider token의 가시성과 조립 경계를 정한다
확장 설계: 원문 AppModule은 AppController와 AppService만 등록합니다. 아래 feature module·repository token은 추가 구조입니다. 다른 module에는 imports와 exports metadata로 공개한 provider token이 보이며, TypeScript import 자체는 DI 가시성을 만들지 않습니다.
-
root bootstrap
NestFactory는 하나의 root module을 기준으로 전체 DI graph를 조립합니다. -
feature 등록
feature module이 controller와 provider를 metadata에 등록해 같은 module 안의 가시성을 만듭니다.
-
token 주입
provider는 concrete class 또는 명시 token을 통해 repository port를 주입받습니다.
-
export 경계
다른 module은 feature module을 import해도
exports에 공개된 token만 주입할 수 있습니다.
파일 import는 JavaScript 모듈 연결이고 Nest module metadata는 런타임 DI 연결이다. 두 그래프를 같은 것으로 취급하면 provider 해석 실패가 생긴다.
이 글의 AppModule은 컨트롤러와 서비스 하나씩만 등록하지만, 서버가 커지면 그림처럼 기능마다 모듈을 나눕니다. 루트 모듈이 기능 모듈을 imports에 두고, 기능 모듈은 자기 컨트롤러와 프로바이더를 등록하며, 다른 모듈에 주입을 허락할 프로바이더만 exports에 적습니다. 다른 모듈은 그 기능 모듈을 imports에 넣어도 exports에 없는 프로바이더는 주입받을 수 없으므로, 파일을 가져오는 일과 NestJS의 모듈을 잇는 일을 따로 맞춰야 합니다.
더 깊이: 프로바이더의 수명과 그 전파
NestJS가 만든 AppService는 기본적으로 애플리케이션 전체에서 인스턴스 하나를 함께 씁니다. NestJS의 주입 범위 문서는 이 기본값(DEFAULT) 말고도 두 가지 범위를 설명합니다. REQUEST는 들어오는 요청마다 새 인스턴스를 만들고, TRANSIENT는 그 프로바이더를 주입받는 쪽마다 따로 인스턴스를 만듭니다. 문서는 대부분의 경우 기본값인 싱글턴을 권합니다.
REQUEST 범위에는 알아 둘 성질이 있습니다. 문서는 이 범위가 주입의 사슬을 따라 위로 퍼진다고 설명하는데, 요청 범위의 프로바이더에 기대는 컨트롤러는 그 자신도 요청 범위가 된다는 뜻입니다. 그래서 깊은 곳의 서비스 하나를 요청 범위로 바꾸면, 그것을 주입받는 서비스와 컨트롤러도 요청마다 새로 만들어집니다. 문서는 요청 범위의 프로바이더가 요청마다 인스턴스를 만들어야 하므로 평균 응답 시간을 늘리는 등 성능에 영향을 준다고 경고합니다.
파이프로 값 바꾸고 검사하기
파이프(pipe)는 처리 메서드가 실행되기 직전에 인수를 바꾸거나 검사합니다. 앞의 @Param("id", ParseIntPipe)는 내장 ParseIntPipe가 경로의 문자열을 정수로 바꾸게 하며, 그래서 id의 타입을 number로 적을 수 있습니다.
curl http://localhost:3000/users/1
curl http://localhost:3000/users/12abc{"id":1,"name":"Alice","email":"[email protected]"}
{"message":"Validation failed (numeric string is expected)","error":"Bad Request","statusCode":400}내장 ParseIntPipe는 문자열 전체가 정수 모양인지 검사하므로 12abc를 거절하고 400을 돌려줍니다. 다만 앞에 붙은 마이너스도 정수로 받으므로 /users/-2는 -2로 바뀌어 findOne에 들어가고, 그런 사용자가 없어 404가 됩니다. ID는 양의 정수만 받는다는 규칙이 있다면, PipeTransform을 구현한 파이프를 직접 만듭니다.
import { BadRequestException, Injectable } from "@nestjs/common";
import type { PipeTransform } from "@nestjs/common";
@Injectable()
export class ParsePositiveIntPipe implements PipeTransform<string, number> {
transform(value: string): number {
if (!/^[1-9]\d*$/.test(value)) {
throw new BadRequestException("id must be a positive integer");
}
const id = Number(value);
if (!Number.isSafeInteger(id)) {
throw new BadRequestException("id is too large");
}
return id;
}
}PipeTransform<string, number>의 두 타입 인수는 파이프가 받는 값과 돌려주는 값의 타입입니다. 이 파이프를 @Param("id", ParsePositiveIntPipe)로 쓰면 -2와 0, 01이 모두 400으로 거절되어, 형식이 틀린 ID와 없는 사용자가 다른 상태 코드로 구별됩니다.
요청 본문은 ValidationPipe로 검사합니다. 이 파이프는 class-validator의 데코레이터로 적은 규칙대로 본문을 검사하므로, 먼저 두 패키지를 설치하고 본문의 모양을 클래스로 적습니다.
npm install class-validator class-transformerimport { IsEmail, IsString, MinLength } from "class-validator";
export class CreateUserDto {
@IsString()
@MinLength(1)
name!: string;
@IsEmail()
email!: string;
}import { ValidationPipe } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
await app.listen(process.env.PORT ?? 3000);
}
void bootstrap();본문의 타입을 인터페이스가 아니라 클래스로 적은 데는 이유가 있습니다. 인터페이스는 컴파일하면 사라지지만, 클래스는 실행 중에도 남아 ValidationPipe가 @Body() dto: CreateUserDto의 메타데이터에서 그 클래스를 찾아 규칙을 읽을 수 있습니다. whitelist: true는 규칙이 없는 속성을 본문에서 지워, 클라이언트가 id 같은 필드를 몰래 넣어도 서비스에 닿지 않게 합니다.
이제 {"name":12}를 POST로 보내면, name이 문자열이 아니고 email이 이메일 형식이 아니라는 메시지 배열과 함께 400이 돌아오며 create는 실행되지 않습니다. 올바른 본문 {"name":"Carol","email":"[email protected]"}을 보내면 201과 {"id":2,"name":"Carol","email":"[email protected]"}이 돌아옵니다. NestJS는 POST 처리 메서드의 성공 응답에 기본으로 201을 씁니다.
가드로 접근 막기
가드(guard)는 요청을 처리 메서드까지 보낼지를 정합니다. CanActivate를 구현해 true를 돌려주면 요청이 계속 진행되고, false를 돌려주면 NestJS가 403 응답을 보냅니다.
import { Injectable } from "@nestjs/common";
import type { CanActivate, ExecutionContext } from "@nestjs/common";
import type { Request } from "express";
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
return typeof request.headers.authorization === "string";
}
}import { Controller, Get, UseGuards } from "@nestjs/common";
import { AuthGuard } from "./auth.guard";
@Controller("admin")
@UseGuards(AuthGuard)
export class AdminController {
@Get("dashboard")
getDashboard(): string {
return "Welcome to the Admin Dashboard!";
}
}AdminController를 AppModule의 controllers에 더하면, Authorization 헤더 없이 GET /admin/dashboard를 보낸 요청은 403과 {"message":"Forbidden resource","error":"Forbidden","statusCode":403}을 받습니다. getRequest<Request>()의 타입 인수는 요청 객체를 Express의 Request로 다루겠다는 표시여서, request.headers.authorization을 string | undefined로 읽을 수 있습니다.
이 가드는 헤더가 있는지만 보므로 Authorization: Bearer invalid도 통과시킵니다. 실제 인증은 토큰의 서명과 만료를 검증하고 사용자의 권한까지 확인해야 하며, JWT로 그 과정을 구현하는 방법은 16장의 실전 프로젝트에서 다룹니다.
요청이 처리되는 순서
NestJS는 요청 하나를 정해진 순서의 단계로 처리합니다. 단계마다 할 수 있는 일이 다르므로, 어떤 일을 어느 단계에 둘지 정하려면 이 순서를 알아야 합니다.
Nest 요청은 성공과 실패가 서로 다른 되감기 경로를 가진다 — 상태 추적
요청이 guard/pipe/controller와 interceptor 반환 또는 exception filter로 이동하며 현재 실행 위치·허용 여부가 달라진다. 등록되지 않은 hook은 확장 모델임을 명시한다.
NEST REQUEST · EXECUTION ORDER
Nest 요청은 성공과 실패가 서로 다른 되감기 경로를 가진다
일반 생명주기: 원문에 interceptor와 filter가 모두 등록된 것은 아닙니다. 성공은 interceptor를 역순으로 돌아오며, pipe·handler·service 오류는 catchError로 처리할 수 있습니다. 미처리 예외만 가장 가까운 matching filter가 응답으로 바꿉니다.
단계별 조건과 보충 설명
-
진입 경로
요청은 middleware에서 시작해 guard의 접근 판정과 interceptor의 사전 구간을 통과합니다.
-
인자와 handler
pipe가 route 인자를 변환·검증한 뒤 controller가 provider의 use case를 호출합니다.
-
성공 되감기
provider 결과는 controller에서 interceptor post 구간으로 돌아가 변환된 응답이 됩니다.
-
실패 되감기
pipe·controller·service의 오류를 interceptor의 catchError가 처리할 수 있습니다. 미처리 예외는 route → controller → global 순서에서 가장 가까운 matching filter 하나가 처리합니다.
그림의 아래 오류 분기는 handler 내부 오류의 예입니다. guard·pipe 등 다른 위치의 미처리 예외도 예외 계층으로 이동하며, middleware 예외에는 global filter만 적용됩니다. filter끼리 같은 예외를 다시 전달하지 않습니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | 클라이언트 요청이 middleware와 guard를 통과하고 interceptor pre와 pipe를 거쳐 controller와 provider로 들어간다. 성공 결과는 interceptor post를 거쳐 응답한다. 예외는 적용 가능한 exception filter가 응답으로 변환한다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 진입과 guard | 실행: 진입/guard / 거절: 업무 실행 안 함 | middleware 뒤 guard가 요청 허용 여부를 판정한다 | guard는 pipe 앞이며 각 hook은 등록된 확장 모델을 전제한다 |
| 업무 실행 | 입력: 검증/변환 값 / 현재: controller/provider | interceptor pre와 pipe 뒤 controller/provider를 실행한다 | 허용 경로만 실제 업무에 도달한다 |
| 반환 또는 오류 | 성공: 응답 변환 / 오류: 적용 filter | 성공은 interceptor post, 처리되지 않은 오류는 가장 가까운 적용 filter로 간다 | filter와 성공 post를 모든 요청의 연속 단계로 그리지 않는다 |
Nest 요청은 성공과 실패가 서로 다른 되감기 경로를 가진다 — 상태 추적
요청이 guard/pipe/controller와 interceptor 반환 또는 exception filter로 이동하며 현재 실행 위치·허용 여부가 달라진다. 등록되지 않은 hook은 확장 모델임을 명시한다.
NEST REQUEST · EXECUTION ORDER
Nest 요청은 성공과 실패가 서로 다른 되감기 경로를 가진다
일반 생명주기: 원문에 interceptor와 filter가 모두 등록된 것은 아닙니다. 성공은 interceptor를 역순으로 돌아오며, pipe·handler·service 오류는 catchError로 처리할 수 있습니다. 미처리 예외만 가장 가까운 matching filter가 응답으로 바꿉니다.
단계별 조건과 보충 설명
-
진입 경로
요청은 middleware에서 시작해 guard의 접근 판정과 interceptor의 사전 구간을 통과합니다.
-
인자와 handler
pipe가 route 인자를 변환·검증한 뒤 controller가 provider의 use case를 호출합니다.
-
성공 되감기
provider 결과는 controller에서 interceptor post 구간으로 돌아가 변환된 응답이 됩니다.
-
실패 되감기
pipe·controller·service의 오류를 interceptor의 catchError가 처리할 수 있습니다. 미처리 예외는 route → controller → global 순서에서 가장 가까운 matching filter 하나가 처리합니다.
그림의 아래 오류 분기는 handler 내부 오류의 예입니다. guard·pipe 등 다른 위치의 미처리 예외도 예외 계층으로 이동하며, middleware 예외에는 global filter만 적용됩니다. filter끼리 같은 예외를 다시 전달하지 않습니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | 클라이언트 요청이 middleware와 guard를 통과하고 interceptor pre와 pipe를 거쳐 controller와 provider로 들어간다. 성공 결과는 interceptor post를 거쳐 응답한다. 예외는 적용 가능한 exception filter가 응답으로 변환한다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 진입과 guard | 실행: 진입/guard / 거절: 업무 실행 안 함 | middleware 뒤 guard가 요청 허용 여부를 판정한다 | guard는 pipe 앞이며 각 hook은 등록된 확장 모델을 전제한다 |
| 업무 실행 | 입력: 검증/변환 값 / 현재: controller/provider | interceptor pre와 pipe 뒤 controller/provider를 실행한다 | 허용 경로만 실제 업무에 도달한다 |
| 반환 또는 오류 | 성공: 응답 변환 / 오류: 적용 filter | 성공은 interceptor post, 처리되지 않은 오류는 가장 가까운 적용 filter로 간다 | filter와 성공 post를 모든 요청의 연속 단계로 그리지 않는다 |
요청은 미들웨어를 거친 뒤 가드가 허용할지를 판단하고, 인터셉터(interceptor)의 앞부분을 지나 파이프가 인수를 바꾸고 검사하면, 처리 메서드와 그 안의 프로바이더가 실행됩니다. 성공한 결과는 인터셉터를 들어온 순서의 반대로 거쳐 응답이 됩니다. 이 글의 예제에는 인터셉터와 예외 필터를 직접 등록하지 않았지만, 응답의 모양을 바꾸거나 처리 시간을 기록하는 일은 인터셉터가, 예외를 응답으로 바꾸는 일은 예외 필터가 맡습니다.
가드의 거부나 파이프의 검증 실패, 처리 메서드에서 던진 예외처럼 처리되지 않은 예외는 예외 필터(exception filter)로 갑니다. 라우트, 컨트롤러, 전역 순서로 가장 가까이 등록된 필터 하나가 그 예외를 응답으로 바꾸며, 직접 등록한 필터가 없으면 NestJS의 기본 필터가 HttpException 계열은 그 상태 코드로, 알 수 없는 오류는 500과 Internal server error로 응답합니다. 앞의 400, 403, 404 응답이 모두 이 기본 필터가 만든 것입니다.
정리
- NestJS는 모듈, 컨트롤러, 프로바이더로 서버를 나누며, 예전 방식의 데코레이터와 데코레이터 메타데이터를 켠 설정으로 컴파일해 실행합니다.
- 컨트롤러는 생성자 매개변수의 타입으로 프로바이더를 주입받고, 주입할 수 있는 것은 파일의
import가 아니라 모듈의providers,imports,exports가 정합니다. - 파이프는 처리 메서드 직전에 인수를 바꾸고 검사하며, 내장
ParseIntPipe는 음수도 받으므로 ID의 규칙이 다르면PipeTransform으로 직접 만듭니다. - 요청 본문은
class-validator의 규칙을 적은 클래스와ValidationPipe로 검사하며, 인터페이스로는 실행 중에 검사할 수 없습니다. - 가드는
false를 돌려주어 403으로 요청을 막지만, 헤더가 있는지만 보는 가드는 인증이 아닙니다. - 요청은 미들웨어, 가드, 인터셉터, 파이프, 처리 메서드의 순서로 진행하고, 처리되지 않은 예외는 가장 가까운 예외 필터가 응답으로 바꿉니다.
연습 문제
1. 경로 값의 변환
ParseIntPipe를 쓰는 findOne에 /users/1, /users/-2, /users/abc를 보내면 각각 어떤 상태 코드가 돌아오나요? ParsePositiveIntPipe로 바꾸면 어떻게 달라지나요?
해설 보기
ParseIntPipe에서 1은 Alice와 함께 200, -2는 정수로 바뀌지만 그런 사용자가 없어 404, abc는 정수 모양이 아니어서 400입니다. ParsePositiveIntPipe에서는 1은 그대로 200이고, -2와 abc는 모두 양의 정수가 아니어서 400이 됩니다. 형식이 틀린 요청은 처리 메서드에 닿지 않습니다.
2. 인터페이스로 적은 본문
CreateUserDto를 클래스가 아니라 인터페이스로 바꾸고, app.controller.ts에서는 import type { CreateUserDto } from "./create-user.dto";로 가져오게 했습니다. ValidationPipe는 그대로 두었습니다. {"name":12}를 보내면 어떻게 되나요?
해설 보기
인터페이스는 컴파일하면 사라지므로 ValidationPipe가 읽을 규칙이 없어, 본문은 검사되지 않은 채 create에 들어갑니다. 그 결과 이름이 숫자 12이고 이메일이 없는 사용자가 만들어져 201이 돌아옵니다. 실행 중에 검사하려면 규칙을 데코레이터로 적은 클래스가 필요합니다. 가져오는 줄을 import type으로 바꾼 것은, 템플릿의 tsconfig.json이 isolatedModules와 emitDecoratorMetadata를 함께 켜 두어서 데코레이터가 붙은 메서드의 매개변수에 쓴 인터페이스를 일반 import로 가져오면 TS1272 오류로 컴파일되지 않기 때문입니다.
3. 모듈에 등록하지 않은 가드와 컨트롤러
AdminController를 만들고 파일도 app.module.ts에서 가져왔지만 controllers 배열에는 넣지 않았습니다. GET /admin/dashboard를 보내면 무엇이 돌아오나요? 넣은 뒤 Authorization: Bearer invalid 헤더로 보내면 어떻게 되나요?
해설 보기
모듈에 등록하지 않은 컨트롤러는 경로가 만들어지지 않으므로, 기본 예외 필터가 404를 돌려줍니다. 등록한 뒤에는 헤더가 문자열로 있으므로 가드가 true를 돌려주어 Welcome to the Admin Dashboard!가 돌아옵니다. 이 가드는 토큰이 유효한지 검사하지 않으므로, 잘못된 토큰도 통과한다는 점을 보여 줍니다.
다음 글 타입 안전 REST API에서는 Zod 스키마로 요청을 실행 중에 검증하면서 같은 스키마에서 타입을 얻고, 저장 모델과 공개 응답을 나눈 뒤, 오류 응답의 모양과 OpenAPI 문서로 클라이언트와의 약속을 정리합니다.