Express에서는 미들웨어 하나로 인증, 로깅, 데이터 변환을 모두 처리했습니다. NestJS는 이 역할을 Middleware, Guard, Interceptor, Pipe, ExceptionFilter로 나눠놓았습니다. 각각 언제 실행되고, 어떤 역할인지 헷갈리면 요청 처리 흐름 전체가 안 보입니다.


요청 라이프사이클 전체 흐름

NestJS에서 HTTP 요청이 들어오면 아래 순서로 처리됩니다.

PLAINTEXT
요청 수신


① Middleware          ← Express 미들웨어와 동일


② Guard              ← 인증/인가 판단 (true/false)


③ Interceptor (전)   ← 요청 가공, 로깅 시작


④ Pipe               ← 데이터 변환 + 검증


⑤ Route Handler      ← 컨트롤러 메서드 실행


⑥ Interceptor (후)   ← 응답 가공, 로깅 완료


⑦ ExceptionFilter    ← 예외 발생 시 에러 응답 처리


응답 반환

이 순서에서 핵심은 Guard가 Pipe보다 먼저 실행된다 는 것입니다. 인증되지 않은 요청의 데이터를 검증할 이유가 없으니까요.


Middleware — 가장 먼저 실행, Express와 동일

Express의 미들웨어를 그대로 사용합니다. req, res, next에 접근할 수 있고, NestJS의 DI 시스템 바깥 에서 동작합니다.

TYPESCRIPT
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    console.log(`[${req.method}] ${req.url}`);
    next();
  }
}

// 모듈에서 등록
@Module({})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware)
      .forRoutes('*');  // 모든 경로에 적용
  }
}

**언제 쓰나 **: CORS 설정, 요청 로깅, 쿠키 파싱 등 ** 프레임워크 레벨 **의 공통 처리. NestJS 고유 기능(Guard, DI 등)이 필요 없는 단순한 작업에 적합합니다.


Guard — "이 요청을 처리할 것인가?"

Guard는 단 하나의 질문에 답합니다: ** 이 요청을 통과시킬 것인가, 말 것인가.**

TYPESCRIPT
@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private readonly authService: AuthService) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const token = request.headers.authorization;

    if (!token) return false;  // → 403 Forbidden

    const user = this.authService.validateToken(token);
    request.user = user;       // 요청 객체에 사용자 정보 부착
    return true;
  }
}

Guard와 Middleware의 결정적 차이는 ExecutionContext에 접근할 수 있다 는 점입니다. 어떤 컨트롤러의 어떤 메서드가 실행될 예정인지 알 수 있기 때문에 역할 기반 접근 제어 가 가능합니다.

TYPESCRIPT
// 커스텀 데코레이터 + Guard 조합
@SetMetadata('roles', ['admin'])
@UseGuards(AuthGuard, RolesGuard)
@Delete(':id')
remove(@Param('id') id: string) {
  return this.userService.remove(id);
}

Spring Security의 @PreAuthorize("hasRole('ADMIN')")와 같은 패턴입니다.


Interceptor — 요청 전후를 감싸는 래퍼

Interceptor는 컨트롤러 메서드 실행 전과 후 모두 개입할 수 있습니다. Spring AOP의 @Around 어드바이스와 같은 개념입니다.

TYPESCRIPT
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const now = Date.now();
    const request = context.switchToHttp().getRequest();

    console.log(`[요청] ${request.method} ${request.url}`);

    return next.handle().pipe(
      tap(() => {
        console.log(`[응답] ${request.method} ${request.url}${Date.now() - now}ms`);
      }),
    );
  }
}

Interceptor가 유용한 경우를 정리하면 이렇습니다.

용도설명
로깅요청/응답 시간 측정, 요청 본문 기록
응답 변환모든 응답을 { data: ..., timestamp: ... } 형태로 래핑
캐싱동일 요청에 캐시된 응답 반환
타임아웃일정 시간 내 응답이 없으면 에러

응답 래핑 Interceptor 예시

TYPESCRIPT
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
    return next.handle().pipe(
      map(data => ({
        data,
        statusCode: context.switchToHttp().getResponse().statusCode,
        timestamp: new Date().toISOString(),
      })),
    );
  }
}

모든 API 응답이 일관된 포맷으로 나갑니다.


ExceptionFilter — 예외를 HTTP 응답으로 변환

NestJS는 기본적으로 HttpException을 던지면 적절한 HTTP 응답으로 변환합니다. 커스텀 필터를 만들면 에러 응답 형식을 통일할 수 있습니다.

TYPESCRIPT
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const status = exception.getStatus();

    response.status(status).json({
      statusCode: status,
      message: exception.message,
      timestamp: new Date().toISOString(),
      path: ctx.getRequest<Request>().url,
    });
  }
}

Spring의 @ExceptionHandler@ControllerAdvice를 합쳐놓은 것과 비슷합니다.


주의할 점

실행 순서를 모르면 디버깅이 불가능하다

가장 흔한 실수는 Pipe에서 검증 에러가 나는데, Interceptor의 로깅에서 잡으려고 하는 것 입니다. Pipe는 Interceptor(전) 이후에 실행되므로, Pipe 에러는 Interceptor(후)에서 잡을 수 없습니다. ExceptionFilter에서 처리해야 합니다.

PLAINTEXT
실수하는 패턴:
  Interceptor(전) → Pipe에서 에러! → Interceptor(후) 실행 안 됨
                                    → ExceptionFilter에서 처리됨

올바른 패턴:
  에러 로깅이 필요하면 ExceptionFilter에서 하거나,
  Interceptor에서 catchError 오퍼레이터를 사용

Guard에서 req.body를 읽지 마라

Guard는 Pipe보다 먼저 실행되므로, req.body의 데이터가 아직 검증되지 않은 상태 입니다. Guard에서 body 데이터를 기반으로 인가 판단을 하면 검증되지 않은 데이터를 신뢰하는 것이 됩니다.

인가에 필요한 정보는 토큰, 헤더, URL 파라미터 에서 가져오는 것이 안전합니다.

전역 vs 컨트롤러 vs 메서드 레벨

TYPESCRIPT
// 전역 — 모든 요청에 적용
app.useGlobalGuards(new AuthGuard());

// 컨트롤러 — 해당 컨트롤러의 모든 메서드에 적용
@UseGuards(AuthGuard)
@Controller('users')
export class UserController {}

// 메서드 — 특정 메서드에만 적용
@UseGuards(RolesGuard)
@Delete(':id')
remove() {}

전역으로 등록한 Guard/Interceptor에서 DI가 필요하면 app.useGlobalGuards()가 아니라 모듈의 providers에서 등록 해야 합니다. APP_GUARD, APP_INTERCEPTOR 토큰을 사용합니다.


정리

단계역할Spring 대응
Middleware요청/응답 원시 처리Servlet Filter
Guard인증/인가 판단Spring Security Filter
Interceptor요청 전후 가공AOP @Around
Pipe데이터 변환 + 검증@Valid + Converter
ExceptionFilter예외 → HTTP 응답@ExceptionHandler

순서를 외우는 팁: 미(Middleware) → 가(Guard) → 인(Interceptor) → 파(Pipe) → 핸들러 → 인(Interceptor) → 익(ExceptionFilter). 요청이 들어올수록 NestJS의 기능에 가까워지고, Middleware가 가장 바깥, Route Handler가 가장 안쪽입니다.

댓글 로딩 중...