요청이 컨트롤러에 도달하기까지 — Guard, Interceptor, Pipe, Filter (5편)
4편까지 NestJS가 앱을 조립하는 과정을 봤습니다. 모듈이 스캔되고, DI 컨테이너가 인스턴스를 만들고, 서버가 listen합니다. 그런데 HTTP 요청이 들어오면 실제로 어떤 경로를 거쳐서 컨트롤러 메서드에 도달하는 걸까요?
Middleware, Guard, Interceptor, Pipe, ExceptionFilter — 비슷해 보이는 이것들이 각각 무슨 역할이고 어떤 순서로 실행되는지 추적해보겠습니다.
전체 요청 라이프사이클
먼저 큰 그림부터 보겠습니다. NestJS에서 HTTP 요청 하나가 거치는 전체 경로입니다.
HTTP 요청
│
▼
┌─────────────────────────────────────────────────────────┐
│ 1. Middleware │
│ Express/Fastify 호환 계층 │
│ (cors, helmet, logger, body-parser...) │
└─────────────┬───────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 2. Guard │
│ "이 요청을 처리해도 되는가?" │
│ CanActivate → true / false / throw │
└─────────────┬───────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 3. Interceptor (before) │
│ 요청 전처리 — 로깅 시작, 캐시 확인 │
└─────────────┬───────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 4. Pipe │
│ 데이터 변환 + 유효성 검증 │
│ @Body(), @Param(), @Query() 값 처리 │
└─────────────┬───────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 5. Controller Method │
│ 실제 비즈니스 로직 실행 │
└─────────────┬───────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 6. Interceptor (after) │
│ 응답 후처리 — 로깅 완료, 응답 변환 │
└─────────────┬───────────────────────────────────────────┘
│
▼
HTTP 응답
※ 어디서든 에러 발생 시 → ExceptionFilter
이 다이어그램이 NestJS 공식 문서에서도 볼 수 있는 요청 라이프사이클입니다. 하지만 "왜 이 순서인가"는 소스코드를 봐야 알 수 있습니다. RouterExecutionContext가 이 파이프라인을 조립하는 핵심 클래스입니다. 각 레이어를 하나씩 추적해보겠습니다.
1. Middleware — Express/Fastify 호환 계층
NestJS의 미들웨어는 사실 Express 미들웨어와 동일합니다. (req, res, next) 시그니처를 그대로 씁니다. 다만 NestJS는 여기에 DI를 얹을 수 있게 해줍니다.
// 함수형 미들웨어 — Express 미들웨어와 완전히 동일
export function logger(req: Request, res: Response, next: NextFunction) {
console.log(`[${req.method}] ${req.url}`);
next();
}
// 클래스형 미들웨어 — DI 가능
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
constructor(private readonly someService: SomeService) {} // DI!
use(req: Request, res: Response, next: NextFunction) {
console.log(`[${req.method}] ${req.url}`);
next();
}
}
미들웨어를 등록하는 방법은 두 가지입니다.
// 1. 모듈에서 configure()로 등록 — 라우트별 적용 가능
@Module({})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LoggerMiddleware)
.forRoutes('blog-posts'); // 특정 라우트에만 적용
}
}
// 2. main.ts에서 전역 미들웨어
app.use(helmet());
app.use(cors());
핵심 포인트가 있습니다. 미들웨어는 라우트 핸들러가 결정되기 전에 실행됩니다. Express/Fastify 레벨에서 동작하기 때문에, NestJS의 Guard나 Interceptor보다 먼저 실행됩니다. 이 말은 미들웨어 안에서는 "어떤 컨트롤러의 어떤 메서드가 처리할 것인지" 알 수 없다는 뜻입니다.
그래서 미들웨어에서는 ExecutionContext를 받을 수 없습니다. getClass()나 getHandler()로 컨트롤러 정보를 참조하는 것은 Guard부터 가능합니다.
2. Guard — "이 요청을 처리해도 되는가?"
Guard는 단 하나의 질문에 답합니다: "이 요청을 처리해도 되는가?" 인증/인가를 담당하는 레이어입니다.
CanActivate 인터페이스
export interface CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean>;
}
반환값이 boolean | Promise<boolean> | Observable<boolean>입니다. 동기, 비동기, RxJS Observable — 세 가지 모두 지원합니다. 이 세 가지를 어떻게 처리하는지 GuardsConsumer 소스를 보겠습니다.
GuardsConsumer 소스코드
packages/core/guards/guards-consumer.ts — Guard를 실행하는 핵심 클래스입니다.
// packages/core/guards/guards-consumer.ts
export class GuardsConsumer {
public async tryActivate<TContext extends string = ContextType>(
guards: CanActivate[],
args: unknown[],
instance: Controller,
callback: (...args: unknown[]) => unknown,
type?: TContext,
): Promise<boolean> {
if (!guards || isEmpty(guards)) {
return true; // Guard가 없으면 무조건 통과
}
const context = this.createContext(args, instance, callback);
context.setType<TContext>(type!);
for (const guard of guards) {
const result = guard.canActivate(context);
if (typeof result === 'boolean') {
if (!result) {
return false; // 동기적으로 false → 즉시 차단
}
continue;
}
// Promise나 Observable이면 await
if (await this.pickResult(result)) {
continue;
}
return false;
}
return true; // 모든 Guard 통과
}
public async pickResult(
result: boolean | Promise<boolean> | Observable<boolean>,
): Promise<boolean> {
if (result instanceof Observable) {
return lastValueFrom(result); // Observable → Promise로 변환
}
return result;
}
}
주목할 부분이 있습니다.
첫째, Guard가 없으면 즉시 return true입니다. Guard를 등록하지 않은 라우트는 이 단계를 통과합니다.
둘째, Guard는 순서대로 실행됩니다. for...of 루프로 하나씩 실행하고, 하나라도 false를 반환하면 뒤의 Guard는 실행되지 않습니다.
셋째, Observable도 처리합니다. lastValueFrom()으로 Observable을 Promise로 변환합니다. NestJS가 RxJS에 의존하는 이유 중 하나입니다.
Guard가 false를 반환하면?
RouterExecutionContext.createGuardsFn()에서 이 결과를 처리합니다.
// packages/core/router/router-execution-context.ts
public createGuardsFn<TContext extends string = ContextType>(
guards: CanActivate[],
instance: Controller,
callback: (...args: any[]) => any,
contextType?: TContext,
): ((args: any[]) => Promise<void>) | null {
const canActivateFn = async (args: any[]) => {
const canActivate = await this.guardsConsumer.tryActivate<TContext>(
guards, args, instance, callback, contextType,
);
if (!canActivate) {
throw new ForbiddenException(FORBIDDEN_MESSAGE);
}
};
return guards.length ? canActivateFn : null;
}
tryActivate()가 false를 반환하면 ForbiddenException을 던집니다. Guard에서 직접 다른 예외를 던질 수도 있습니다. 예를 들어 UnauthorizedException을 던지면 403 대신 401이 됩니다.
실전 예시: AuthGuard
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException('토큰이 없습니다');
}
try {
const payload = await this.jwtService.verifyAsync(token);
request.user = payload; // 요청 객체에 사용자 정보 부착
} catch {
throw new UnauthorizedException('유효하지 않은 토큰입니다');
}
return true;
}
private extractToken(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
Guard에서 request.user = payload를 하는 이유가 여기 있습니다. Guard가 Interceptor, Pipe, Controller보다 먼저 실행되기 때문에, Guard에서 부착한 request.user를 이후 모든 레이어에서 사용할 수 있습니다.
3. Interceptor — 요청 전후를 감싸는 래퍼
Guard를 통과하면 다음은 Interceptor입니다. Interceptor는 Guard와 달리 요청 전후 모두에 개입할 수 있습니다. "AOP(Aspect-Oriented Programming)"를 NestJS에서 구현하는 핵심 메커니즘입니다.
NestInterceptor 인터페이스
export interface NestInterceptor<T = any, R = any> {
intercept(
context: ExecutionContext,
next: CallHandler<T>,
): Observable<R> | Promise<Observable<R>>;
}
export interface CallHandler<T = any> {
handle(): Observable<T>;
}
CallHandler.handle()을 호출하면 다음 단계(최종적으로 컨트롤러 메서드)가 실행됩니다. handle() 전에 코드를 넣으면 요청 전처리, handle()이 반환한 Observable을 파이프하면 응답 후처리가 됩니다.
InterceptorsConsumer 소스코드
packages/core/interceptors/interceptors-consumer.ts — 여기서 Interceptor 체인이 만들어집니다.
// packages/core/interceptors/interceptors-consumer.ts
export class InterceptorsConsumer {
public async intercept<TContext extends string = ContextType>(
interceptors: NestInterceptor[],
args: unknown[],
instance: Controller,
callback: (...args: unknown[]) => unknown,
next: () => Promise<unknown>,
type?: TContext,
): Promise<unknown> {
if (isEmpty(interceptors)) {
return next(); // Interceptor 없으면 바로 다음 단계
}
const context = this.createContext(args, instance, callback);
context.setType<TContext>(type!);
const nextFn = async (i = 0) => {
if (i >= interceptors.length) {
// 모든 Interceptor를 거쳤으면 실제 핸들러 실행
return defer(
AsyncResource.bind(() => this.transformDeferred(next))
);
}
const handler: CallHandler = {
handle: () =>
defer(
AsyncResource.bind(() => nextFn(i + 1))
).pipe(mergeAll()),
};
return interceptors[i].intercept(context, handler);
};
return defer(() => nextFn()).pipe(mergeAll());
}
}
이 코드에서 가장 중요한 패턴은 재귀적 체이닝입니다.
nextFn(0)
→ interceptors[0].intercept(context, { handle: () → nextFn(1) })
→ interceptors[1].intercept(context, { handle: () → nextFn(2) })
→ ...
→ nextFn(n) → 실제 핸들러 실행 (next())
각 Interceptor의 handle()이 다음 Interceptor를 호출하는 구조입니다. 마지막 Interceptor의 handle()이 실제 컨트롤러 메서드를 실행합니다. 이것은 Express 미들웨어의 next() 패턴과 비슷하지만, RxJS Observable로 감싸져 있어서 응답을 스트림으로 다룰 수 있다는 차이가 있습니다.
왜 Observable인가?
Interceptor가 Observable을 반환하는 이유가 있습니다. handle().pipe()를 통해 응답 스트림을 변환, 필터링, 타임아웃 처리할 수 있습니다. Promise로는 "이미 완료된 값"만 다룰 수 있지만, Observable로는 "응답이 나올 때까지의 흐름" 자체를 제어할 수 있습니다.
실전 예시: LoggingInterceptor
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const { method, url } = request;
const start = Date.now();
// handle() 전 = 요청 전처리
this.logger.log(`→ ${method} ${url}`);
return next.handle().pipe(
// handle() 후 = 응답 후처리
tap(() => {
const elapsed = Date.now() - start;
this.logger.log(`← ${method} ${url} ${elapsed}ms`);
}),
);
}
}
실전 예시: TransformInterceptor
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
return next.handle().pipe(
map(data => ({
success: true,
data,
timestamp: new Date().toISOString(),
})),
);
}
}
컨트롤러가 { id: 1, name: 'hello' }를 반환하면, 이 Interceptor가 { success: true, data: { id: 1, name: 'hello' }, timestamp: '...' }로 감싸줍니다. 모든 API 응답 포맷을 통일할 때 유용합니다.
4. Pipe — 데이터 변환과 유효성 검증
Interceptor의 "before" 단계 이후, 컨트롤러 메서드를 호출하기 직전에 Pipe가 실행됩니다. Pipe는 두 가지 역할을 합니다: 변환(transformation)과 검증(validation).
PipeTransform 인터페이스
export interface PipeTransform<T = any, R = any> {
transform(value: T, metadata: ArgumentMetadata): R;
}
export interface ArgumentMetadata {
type: 'body' | 'query' | 'param' | 'custom';
metatype?: Type<unknown>;
data?: string;
}
value는 실제 값(예: req.body), metadata는 이 값이 어디서 온 것인지(@Body()인지, @Param()인지)를 알려줍니다.
PipesConsumer 소스코드
packages/core/pipes/pipes-consumer.ts — Pipe를 순서대로 실행하는 클래스입니다.
// packages/core/pipes/pipes-consumer.ts
export class PipesConsumer {
private readonly paramsTokenFactory = new ParamsTokenFactory();
public async apply<TInput = unknown>(
value: TInput,
{ metatype, type, data }: ArgumentMetadata,
pipes: PipeTransform[],
) {
const token = this.paramsTokenFactory.exchangeEnumForString(
type as any as RouteParamtypes,
);
return this.applyPipes(value, { metatype, type: token, data }, pipes);
}
public async applyPipes<TInput = unknown>(
value: TInput,
{ metatype, type, data }: { metatype: any; type?: any; data?: any },
transforms: PipeTransform[],
) {
return transforms.reduce(async (deferredValue, pipe) => {
const val = await deferredValue;
const result = pipe.transform(val, { metatype, type, data });
return result;
}, Promise.resolve(value));
}
}
reduce 패턴이 핵심입니다. 여러 Pipe가 등록되어 있으면 이전 Pipe의 출력값이 다음 Pipe의 입력값이 됩니다. 체인처럼 연결되는 것입니다.
원본 값 → Pipe[0].transform() → Pipe[1].transform() → ... → 최종 값
│
컨트롤러 메서드 파라미터로 전달
실전 예시: ValidationPipe와 ParseIntPipe
// 변환 Pipe — 문자열을 숫자로
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
// id는 이미 number 타입
return this.service.findOne(id);
}
// 검증 Pipe — DTO 유효성 검사
@Post()
create(@Body(ValidationPipe) createDto: CreateBlogPostDto) {
// createDto는 이미 검증 완료
return this.service.create(createDto);
}
// 전역 Pipe — 모든 라우트에 ValidationPipe 적용
// main.ts
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // DTO에 없는 속성 제거
forbidNonWhitelisted: true, // 알 수 없는 속성이 있으면 에러
transform: true, // 타입 자동 변환
}));
ValidationPipe는 내부적으로 class-validator와 class-transformer를 사용합니다. DTO 클래스의 데코레이터(@IsString(), @IsNumber() 등)를 읽어서 검증하고, 실패하면 BadRequestException을 던집니다.
5. ExceptionFilter — 에러 응답 포맷팅
지금까지 본 Middleware, Guard, Interceptor, Pipe — 이 중 어디서든 에러가 발생하면 ExceptionFilter가 처리합니다. NestJS는 기본적으로 BaseExceptionFilter를 제공합니다.
BaseExceptionFilter 소스코드
packages/core/exceptions/base-exception-filter.ts
// packages/core/exceptions/base-exception-filter.ts
export class BaseExceptionFilter<T = any> implements ExceptionFilter<T> {
private static readonly logger = new Logger('ExceptionsHandler');
catch(exception: T, host: ArgumentsHost) {
const applicationRef =
this.applicationRef ||
(this.httpAdapterHost && this.httpAdapterHost.httpAdapter)!;
if (!(exception instanceof HttpException)) {
// HttpException이 아니면 → 알 수 없는 에러
return this.handleUnknownError(exception, host, applicationRef);
}
// HttpException이면 → 상태 코드와 메시지 추출
const res = exception.getResponse();
const message = isObject(res)
? res
: {
statusCode: exception.getStatus(),
message: res,
};
const response = host.getArgByIndex(1);
if (!applicationRef.isHeadersSent(response)) {
applicationRef.reply(response, message, exception.getStatus());
} else {
applicationRef.end(response);
}
}
public handleUnknownError(exception: T, host: ArgumentsHost, applicationRef) {
const body = this.isHttpError(exception)
? { statusCode: exception.statusCode, message: exception.message }
: {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: MESSAGES.UNKNOWN_EXCEPTION_MESSAGE,
};
const response = host.getArgByIndex(1);
if (!applicationRef.isHeadersSent(response)) {
applicationRef.reply(response, body, body.statusCode);
} else {
applicationRef.end(response);
}
if (!(exception instanceof IntrinsicException)) {
BaseExceptionFilter.logger.error(exception);
}
}
}
두 가지 분기가 있습니다.
1) HttpException인 경우: getStatus()와 getResponse()로 상태 코드와 응답 본문을 추출합니다. NotFoundException, BadRequestException, ForbiddenException 등이 모두 HttpException을 상속합니다.
2) HttpException이 아닌 경우: 500 Internal Server Error를 반환하고, 로거에 에러를 기록합니다. "Internal server error"라는 메시지만 클라이언트에 보내서 내부 구현을 노출하지 않습니다.
또 하나 중요한 점: isHeadersSent(response)를 체크합니다. 응답 헤더가 이미 전송된 후에는 새로운 응답을 보낼 수 없으므로, 그 경우 end()로 연결만 종료합니다.
커스텀 ExceptionFilter
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger('HttpException');
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
const errorResponse = {
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
message: exception.message,
};
this.logger.warn(`${request.method} ${request.url} → ${status}`);
response.status(status).json(errorResponse);
}
}
@Catch(HttpException)은 이 필터가 HttpException과 그 서브클래스만 처리한다는 뜻입니다. @Catch()에 아무것도 넣지 않으면 모든 예외를 잡습니다.
각 레이어 비교
6개 레이어를 한눈에 비교해보겠습니다.
| 레이어 | 인터페이스 | 역할 | 실행 시점 | ExecutionContext | DI 지원 | 실전 예시 |
|---|---|---|---|---|---|---|
| Middleware | NestMiddleware | 범용 전처리 | 가장 먼저 | ❌ | ⭕ | cors, helmet, logger |
| Guard | CanActivate | 인가/권한 확인 | Middleware 후 | ⭕ | ⭕ | AuthGuard, RolesGuard |
| Interceptor | NestInterceptor | 전후 래핑 (AOP) | Guard 후 | ⭕ | ⭕ | Logging, Cache, Transform |
| Pipe | PipeTransform | 변환/검증 | Interceptor 후 (before) | ❌ (ArgumentMetadata) | ⭕ | ValidationPipe, ParseIntPipe |
| Controller | — | 비즈니스 로직 | Pipe 후 | — | ⭕ | 라우트 핸들러 |
| ExceptionFilter | ExceptionFilter | 에러 응답 포맷팅 | 에러 발생 시 | ❌ (ArgumentsHost) | ⭕ | HttpExceptionFilter |
이 표에서 ExecutionContext 지원 여부가 중요합니다. Middleware와 Pipe, ExceptionFilter는 ExecutionContext를 받지 않습니다. 즉, "어떤 컨트롤러의 어떤 메서드인지" 알 수 없습니다. Guard와 Interceptor만 이 정보를 알 수 있고, 그래서 @SetMetadata()로 설정한 메타데이터를 읽을 수 있는 것도 Guard와 Interceptor뿐입니다.
"Middleware로 할까, Guard로 할까?"를 고민할 때 기준이 됩니다. 라우트 핸들러의 메타데이터를 읽어야 하면 Guard, 아니면 Middleware.
RouterExecutionContext — 파이프라인을 조립하는 곳
지금까지 각 레이어를 개별적으로 봤습니다. 이제 이것들을 하나의 파이프라인으로 연결하는 곳을 보겠습니다. packages/core/router/router-execution-context.ts입니다.
생성자 — 모든 Consumer를 주입받습니다
// packages/core/router/router-execution-context.ts
constructor(
private readonly paramsFactory: IRouteParamsFactory,
private readonly pipesContextCreator: PipesContextCreator,
private readonly pipesConsumer: PipesConsumer,
private readonly guardsContextCreator: GuardsContextCreator,
private readonly guardsConsumer: GuardsConsumer,
private readonly interceptorsContextCreator: InterceptorsContextCreator,
private readonly interceptorsConsumer: InterceptorsConsumer,
readonly applicationRef: HttpServer,
)
GuardsConsumer, InterceptorsConsumer, PipesConsumer — 앞서 본 세 클래스가 모두 여기에 주입됩니다. 이 클래스가 파이프라인의 "오케스트레이터"입니다.
실행 순서가 결정되는 곳
create() 메서드가 각 레이어를 조립합니다. 실행 순서를 간략화하면 이렇습니다.
create() 메서드 내부 (간략화)
│
├── 1. fnCanActivate = createGuardsFn(guards, ...)
│ → GuardsConsumer.tryActivate() 호출
│
├── 2. fnApplyPipes = createPipesFn(pipes, ...)
│ → PipesConsumer.apply() 호출
│
└── 3. 반환하는 핸들러 함수:
async (req, res, next) => {
await fnCanActivate([req, res, next]); // Guard
const result = await interceptorsConsumer // Interceptor
.intercept(interceptors, [req, res, next], instance, callback,
async () => { // 실제 핸들러
const args = await fnApplyPipes(req, res, next); // Pipe
return callback(...args); // Controller Method
}
);
await fnHandleResponse(result, res); // 응답 전송
}
코드로 보면 복잡하지만, 흐름은 명확합니다.
1단계: Guard 실행 — 실패하면 ForbiddenException
2단계: Interceptor 시작 — intercept()의 next 콜백 안에 3~4단계가 들어있습니다
3단계: Pipe 실행 — 파라미터 변환/검증
4단계: Controller 메서드 호출
5단계: Interceptor의 Observable 파이프라인을 통해 응답 후처리
6단계: 응답 전송
Interceptor의 next 콜백 안에 Pipe와 Controller가 감싸져 있다는 것이 핵심입니다. 이것이 Interceptor가 "전후를 감싼다"는 말의 정체입니다.
RouterExplorer — 라우트를 Express에 등록하는 곳
마지막으로, 이 파이프라인이 어떻게 Express/Fastify의 실제 라우트에 연결되는지 보겠습니다. packages/core/router/router-explorer.ts입니다.
// packages/core/router/router-explorer.ts (간략화)
// 생성자에서 RouterExecutionContext를 만듭니다
this.executionContextCreator = new RouterExecutionContext(
routeParamsFactory,
pipesContextCreator,
pipesConsumer,
guardsContextCreator,
guardsConsumer,
interceptorsContextCreator,
interceptorsConsumer,
container.getHttpAdapterRef(),
);
RouterExplorer는 컨트롤러의 각 메서드를 순회하면서, @Get(), @Post() 같은 데코레이터의 메타데이터를 읽고, RouterExecutionContext.create()로 파이프라인을 만들어서 Express의 router.get(), router.post()에 등록합니다.
RouterExplorer가 하는 일 (부트스트랩 시)
@Controller('blog-posts') @Get(':slug')
│ │
▼ ▼
라우트 경로 결정 RouterExecutionContext.create()
'/blog-posts' Guard + Interceptor + Pipe + Handler 조립
│ │
└──────────────┬────────────────┘
▼
Express: router.get('/blog-posts/:slug', handler)
이 등록은 부트스트랩 시에 한 번만 일어납니다. 매 요청마다 파이프라인을 새로 조립하는 것이 아닙니다. NestFactory.create() → 모듈 스캔 → DI 해결 → RouterExplorer가 라우트 등록. 이후 요청이 들어오면 이미 조립된 파이프라인이 실행되는 것입니다.
다만 예외가 있습니다. Request-scoped 프로바이더를 사용하면 매 요청마다 인스턴스를 새로 만들어야 하므로, DI 컨테이너에서 동적으로 인스턴스를 가져오는 별도 경로를 탑니다. createRequestScopedHandler()가 이 역할을 합니다. 이것이 Request-scoped 프로바이더의 성능 오버헤드 원인입니다.
전역 vs 컨트롤러 vs 메서드 레벨
각 레이어는 세 가지 범위로 등록할 수 있습니다.
// 1. 전역 — 모든 라우트에 적용
app.useGlobalGuards(new AuthGuard());
app.useGlobalInterceptors(new LoggingInterceptor());
app.useGlobalPipes(new ValidationPipe());
app.useGlobalFilters(new HttpExceptionFilter());
// 2. 컨트롤러 레벨 — 해당 컨트롤러의 모든 메서드에 적용
@UseGuards(AuthGuard)
@UseInterceptors(LoggingInterceptor)
@Controller('blog-posts')
export class BlogPostsController { ... }
// 3. 메서드 레벨 — 해당 메서드에만 적용
@UseGuards(RolesGuard)
@UsePipes(ValidationPipe)
@Post()
create(@Body() dto: CreateDto) { ... }
실행 순서는: 전역 → 컨트롤러 → 메서드입니다. 같은 레벨 안에서는 등록 순서대로 실행됩니다.
정리 — 요청 하나가 거치는 6단계
HTTP 요청 하나가 NestJS 서버에 도달했을 때 일어나는 일을 정리하겠습니다.
1. Middleware — Express 호환 계층. req/res/next만 알고, 컨트롤러는 모릅니다
2. Guard — "처리해도 되는가?" canActivate()로 판단. ExecutionContext 사용 가능
3. Interceptor — 요청 전처리. handle() 호출 전까지가 "before"
4. Pipe — 파라미터 변환/검증. reduce 패턴으로 체이닝
5. Controller — 비즈니스 로직 실행
6. Interceptor — 응답 후처리. handle()이 반환한 Observable을 pipe()
(에러 시) → ExceptionFilter가 에러를 잡아서 응답 포맷팅
이 순서를 결정하는 것은 RouterExecutionContext.create()입니다. Guard → Interceptor(next 콜백 안에 Pipe + Controller) → 응답 처리. 이 구조가 부트스트랩 시에 조립되어 Express 라우트에 등록됩니다.
소스코드를 보면 각 Consumer(GuardsConsumer, InterceptorsConsumer, PipesConsumer)가 놀라울 정도로 단순합니다. Guard는 for...of 루프, Pipe는 reduce, Interceptor는 재귀적 nextFn. 복잡해 보이는 NestJS의 요청 처리도 결국 이 세 가지 패턴의 조합이라는 걸 확인할 수 있었습니다.
다음 6편에서는 @CurrentUser(), @Roles() 같은 커스텀 데코레이터를 만들 때 내부에서 무슨 일이 일어나는지 추적하겠습니다. createParamDecorator와 SetMetadata가 메타데이터를 저장하고, 이번 편에서 본 Guard와 Pipe가 그 메타데이터를 읽어서 실행하는 — 데코레이터와 라이프사이클이 연결되는 지점을 보겠습니다.
관련 글
Custom Decorator 만들기 — createParamDecorator의 내부 (6편)
NestJS에서 @CurrentUser(), @Roles() 같은 커스텀 데코레이터가 내부에서 어떻게 동작하는지 소스코드로 추적합니다. createParamDecorator가 메타데이터를 저장하고, Pipe가 값을 추출하고, SetMetadata + Reflector + Guard가 연결되는 전체 흐름을 확인합니다.
NestFactory.create()를 호출하면 무슨 일이 일어나는가 — NestJS 소스코드 추적 (1편)
NestJS로 서버를 만들 때마다 실행하는 NestFactory.create(AppModule) 한 줄. 이 한 줄이 내부에서 DI 컨테이너 생성, 모듈 스캔, 인스턴스 로딩, Express 바인딩까지 5단계를 거친다는 사실을 NestJS 소스코드를 직접 추적하며 확인합니다.
NestJS 서버로 추적하는 이벤트 루프 — 이론에서 실전으로 (5편)
이벤트 루프 이론을 실제 NestJS 서버 코드에 대입합니다. 서버 시작부터 HTTP 요청 처리까지, 6개 페이즈가 실제로 어떤 역할을 하는지 추적합니다.