NestJS Exception Filter

NestJS Exception Filter란? 예외 처리 계층의 핵심

API 서버에서 예외 처리는 단순히 에러를 잡는 것이 아닙니다. “클라이언트에게 어떤 형태로 에러를 전달할 것인가”, “민감한 내부 정보가 노출되지 않는가”, “에러 발생 시 로깅과 모니터링이 자동화되어 있는가” — 이 모든 것을 체계적으로 관리하는 것이 NestJS의 Exception Filter입니다.

NestJS는 기본적으로 처리되지 않은 예외를 잡아 JSON 응답으로 변환하는 내장 필터를 제공하지만, 실무에서는 이것만으로 부족합니다. 이 글에서는 내장 예외 계층 구조부터 커스텀 Exception Filter 설계, HTTP/WebSocket/GraphQL 멀티 프로토콜 대응, 에러 응답 표준화, Sentry 연동, 그리고 Interceptor의 catchError와의 역할 분담까지 운영 수준에서 완전히 다룹니다.

NestJS 내장 예외 계층: HttpException과 파생 클래스

NestJS의 모든 HTTP 예외는 HttpException을 상속합니다. 내장 예외 클래스는 상태 코드별로 제공됩니다:

예외 클래스 HTTP 상태 용도
BadRequestException 400 잘못된 요청 파라미터
UnauthorizedException 401 인증 실패
ForbiddenException 403 권한 부족
NotFoundException 404 리소스 미존재
ConflictException 409 중복 리소스
UnprocessableEntityException 422 비즈니스 규칙 위반
InternalServerErrorException 500 서버 내부 오류
ServiceUnavailableException 503 서비스 일시 중단

HttpException 응답 구조 커스터마이징

// 기본 사용
throw new NotFoundException('주문을 찾을 수 없습니다.');
// → { "statusCode": 404, "message": "주문을 찾을 수 없습니다." }

// 상세 응답 객체 전달
throw new BadRequestException({
  statusCode: 400,
  message: '유효성 검증 실패',
  errors: [
    { field: 'email', message: '이메일 형식이 올바르지 않습니다.' },
    { field: 'age', message: '나이는 0보다 커야 합니다.' },
  ],
});
// → 객체 그대로 응답 body에 포함

기본 동작: NestJS 내장 Exception Filter

아무런 커스텀 필터를 등록하지 않으면 NestJS의 BaseExceptionFilter가 동작합니다:

// HttpException인 경우 → 해당 상태 코드 + 메시지 반환
{
  "statusCode": 404,
  "message": "Cannot GET /unknown",
  "error": "Not Found"
}

// HttpException이 아닌 경우 → 500 Internal Server Error
// ⚠️ 내부 에러 메시지가 클라이언트에 노출될 수 있음!
{
  "statusCode": 500,
  "message": "Internal server error"
}

문제점: 기본 필터는 에러 형태가 일관되지 않고, 디버깅 정보(requestId, timestamp, path)가 없으며, 로깅/모니터링 연동이 없습니다. 이것이 커스텀 Exception Filter가 필요한 이유입니다.

커스텀 Exception Filter 구현: @Catch 데코레이터

import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
  Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { v4 as uuidv4 } from 'uuid';

@Catch() // 모든 예외 캐치 (인자 없으면 전체)
export class GlobalExceptionFilter implements ExceptionFilter {
  private readonly logger = new Logger('ExceptionFilter');

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const request = ctx.getRequest<Request>();
    const response = ctx.getResponse<Response>();

    // 에러 정보 추출
    const { status, message, errors } = this.extractError(exception);
    const errorId = uuidv4();

    // 에러 응답 구조 통일
    const errorResponse = {
      success: false,
      error: {
        id: errorId,
        status,
        message,
        ...(errors && { errors }),
        timestamp: new Date().toISOString(),
        path: request.url,
        method: request.method,
      },
    };

    // 서버 에러만 상세 로깅
    if (status >= 500) {
      this.logger.error(
        `[${errorId}] ${request.method} ${request.url} → ${status}`,
        exception instanceof Error ? exception.stack : String(exception),
      );
    } else {
      this.logger.warn(
        `[${errorId}] ${request.method} ${request.url} → ${status}: ${message}`,
      );
    }

    response.status(status).json(errorResponse);
  }

  private extractError(exception: unknown): {
    status: number;
    message: string;
    errors?: any[];
  } {
    // HttpException (NestJS 내장 예외)
    if (exception instanceof HttpException) {
      const response = exception.getResponse();
      const status = exception.getStatus();

      if (typeof response === 'string') {
        return { status, message: response };
      }

      return {
        status,
        message: (response as any).message || exception.message,
        errors: (response as any).errors,
      };
    }

    // 일반 Error
    if (exception instanceof Error) {
      return {
        status: HttpStatus.INTERNAL_SERVER_ERROR,
        message: 'Internal server error', // 내부 메시지 노출 방지!
      };
    }

    // 알 수 없는 예외
    return {
      status: HttpStatus.INTERNAL_SERVER_ERROR,
      message: 'An unexpected error occurred',
    };
  }
}

통일된 에러 응답 예시

// 404 Not Found
{
  "success": false,
  "error": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "status": 404,
    "message": "주문을 찾을 수 없습니다.",
    "timestamp": "2026-02-22T15:00:00.000Z",
    "path": "/api/orders/999",
    "method": "GET"
  }
}

// 400 Validation Error
{
  "success": false,
  "error": {
    "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
    "status": 400,
    "message": "유효성 검증 실패",
    "errors": [
      { "field": "email", "message": "이메일 형식이 올바르지 않습니다." },
      { "field": "age", "message": "나이는 0보다 커야 합니다." }
    ],
    "timestamp": "2026-02-22T15:00:00.000Z",
    "path": "/api/users",
    "method": "POST"
  }
}

Exception Filter 등록: 3가지 스코프

// 1. 메서드 레벨
@Post()
@UseFilters(CustomExceptionFilter)
create(@Body() dto: CreateOrderDto) { ... }

// 2. 클래스 레벨
@Controller('orders')
@UseFilters(CustomExceptionFilter)
export class OrderController { ... }

// 3. 글로벌 레벨 — DI 지원 (권장!)
@Module({
  providers: [
    {
      provide: APP_FILTER,
      useClass: GlobalExceptionFilter,
    },
  ],
})
export class AppModule {}

// 글로벌 레벨 — main.ts (DI 불가)
app.useGlobalFilters(new GlobalExceptionFilter());

실행 순서: 여러 필터가 등록되면 글로벌 → 클래스 → 메서드 순으로 평가되며, 가장 먼저 매칭되는 필터가 예외를 처리합니다. @Catch()의 인자가 더 구체적인 예외 타입일수록 우선 매칭됩니다.

@Catch 데코레이터: 특정 예외 타입만 처리

// 모든 예외 캐치
@Catch()
export class AllExceptionsFilter implements ExceptionFilter { ... }

// HttpException만 캐치
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter { ... }

// 특정 커스텀 예외만 캐치
@Catch(BusinessException)
export class BusinessExceptionFilter implements ExceptionFilter { ... }

// 여러 타입 동시 캐치
@Catch(EntityNotFoundError, QueryFailedError)
export class DatabaseExceptionFilter implements ExceptionFilter { ... }

예외 타입별 필터 분리 패턴

// DB 예외 전용 필터
@Catch(QueryFailedError)
export class DatabaseExceptionFilter implements ExceptionFilter {
  catch(exception: QueryFailedError, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();

    // PostgreSQL 에러 코드별 매핑
    switch (exception.driverError?.code) {
      case '23505': // unique_violation
        response.status(409).json({
          success: false,
          error: { status: 409, message: '이미 존재하는 리소스입니다.' },
        });
        break;
      case '23503': // foreign_key_violation
        response.status(400).json({
          success: false,
          error: { status: 400, message: '참조하는 리소스가 존재하지 않습니다.' },
        });
        break;
      default:
        response.status(500).json({
          success: false,
          error: { status: 500, message: 'Database error' },
        });
    }
  }
}

// ValidationPipe 에러 전용 필터
@Catch(BadRequestException)
export class ValidationExceptionFilter implements ExceptionFilter {
  catch(exception: BadRequestException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const exceptionResponse = exception.getResponse() as any;

    // class-validator 에러를 필드별로 정리
    const errors = Array.isArray(exceptionResponse.message)
      ? exceptionResponse.message.map((msg: string) => ({
          field: this.extractField(msg),
          message: msg,
        }))
      : [{ message: exceptionResponse.message }];

    response.status(400).json({
      success: false,
      error: {
        status: 400,
        message: '유효성 검증 실패',
        errors,
      },
    });
  }

  private extractField(message: string): string {
    // "email must be an email" → "email"
    return message.split(' ')[0];
  }
}

비즈니스 예외 설계: 도메인 에러 계층 구조

// 1. 비즈니스 예외 기본 클래스
export class BusinessException extends Error {
  constructor(
    public readonly code: string,       // "ORDER_001"
    public readonly statusCode: number, // HTTP 상태 코드
    message: string,
  ) {
    super(message);
    this.name = 'BusinessException';
  }
}

// 2. 도메인별 예외
export class InsufficientStockException extends BusinessException {
  constructor(productId: number, requested: number, available: number) {
    super(
      'STOCK_001',
      422,
      `상품(${productId}) 재고 부족: 요청=${requested}, 잔여=${available}`,
    );
  }
}

export class OrderAlreadyCancelledException extends BusinessException {
  constructor(orderId: number) {
    super('ORDER_002', 409, `주문(${orderId})은 이미 취소되었습니다.`);
  }
}

export class PaymentDeclinedException extends BusinessException {
  constructor(reason: string) {
    super('PAYMENT_001', 402, `결제 거부: ${reason}`);
  }
}

// 3. 비즈니스 예외 전용 필터
@Catch(BusinessException)
export class BusinessExceptionFilter implements ExceptionFilter {
  catch(exception: BusinessException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const request = ctx.getRequest();
    const response = ctx.getResponse();

    response.status(exception.statusCode).json({
      success: false,
      error: {
        code: exception.code,
        status: exception.statusCode,
        message: exception.message,
        timestamp: new Date().toISOString(),
        path: request.url,
      },
    });
  }
}

// 4. Service에서 사용
@Injectable()
export class OrderService {
  async cancelOrder(orderId: number) {
    const order = await this.orderRepo.findOneOrFail(orderId);

    if (order.status === OrderStatus.CANCELLED) {
      throw new OrderAlreadyCancelledException(orderId);
    }

    if (order.status === OrderStatus.SHIPPED) {
      throw new BusinessException(
        'ORDER_003',
        422,
        '배송 시작된 주문은 취소할 수 없습니다.',
      );
    }

    order.cancel();
    await this.orderRepo.flush();
  }
}

Sentry/에러 모니터링 연동

import * as Sentry from '@sentry/node';

@Catch()
export class SentryExceptionFilter implements ExceptionFilter {
  private readonly logger = new Logger('ExceptionFilter');

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const request = ctx.getRequest();
    const response = ctx.getResponse();

    const { status, message } = this.extractError(exception);

    // 5xx 에러만 Sentry에 보고
    if (status >= 500) {
      Sentry.withScope((scope) => {
        scope.setTag('url', request.url);
        scope.setTag('method', request.method);
        scope.setUser({
          id: request.user?.id,
          email: request.user?.email,
        });
        scope.setExtra('body', request.body);
        scope.setExtra('query', request.query);
        scope.setExtra('params', request.params);

        if (exception instanceof Error) {
          Sentry.captureException(exception);
        } else {
          Sentry.captureMessage(String(exception));
        }
      });

      this.logger.error(
        `${request.method} ${request.url} → ${status}`,
        exception instanceof Error ? exception.stack : undefined,
      );
    }

    // 4xx는 WARN 레벨만 로깅 (Sentry 미보고)
    if (status >= 400 && status < 500) {
      this.logger.warn(`${request.method} ${request.url} → ${status}: ${message}`);
    }

    response.status(status).json({
      success: false,
      error: { status, message, timestamp: new Date().toISOString(), path: request.url },
    });
  }

  private extractError(exception: unknown) {
    if (exception instanceof HttpException) {
      return { status: exception.getStatus(), message: exception.message };
    }
    return { status: 500, message: 'Internal server error' };
  }
}

멀티 프로토콜 대응: HTTP, WebSocket, GraphQL

ArgumentsHostgetType()으로 프로토콜을 판별하여 각각 다르게 응답할 수 있습니다:

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const type = host.getType<string>();

    switch (type) {
      case 'http':
        return this.handleHttp(exception, host);
      case 'ws':
        return this.handleWebSocket(exception, host);
      case 'graphql':
        return this.handleGraphQL(exception, host);
      default:
        this.handleHttp(exception, host);
    }
  }

  private handleHttp(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const status = exception instanceof HttpException
      ? exception.getStatus()
      : 500;

    response.status(status).json({
      success: false,
      error: { status, message: this.getMessage(exception) },
    });
  }

  private handleWebSocket(exception: unknown, host: ArgumentsHost) {
    const client = host.switchToWs().getClient();
    client.emit('error', {
      event: 'error',
      data: { message: this.getMessage(exception) },
    });
  }

  private handleGraphQL(exception: unknown, host: ArgumentsHost) {
    // GraphQL은 예외를 그대로 throw하면
    // Apollo Server가 errors 배열로 변환
    throw exception;
  }

  private getMessage(exception: unknown): string {
    if (exception instanceof HttpException) return exception.message;
    if (exception instanceof Error) return 'Internal server error';
    return 'Unknown error';
  }
}

BaseExceptionFilter 상속: 기본 동작 확장

NestJS의 기본 동작을 유지하면서 추가 로직만 넣고 싶다면 BaseExceptionFilter를 상속합니다:

import { BaseExceptionFilter } from '@nestjs/core';

@Catch()
export class ExtendedExceptionFilter extends BaseExceptionFilter {
  private readonly logger = new Logger('ExceptionFilter');

  catch(exception: unknown, host: ArgumentsHost) {
    // 추가 로직: 로깅
    if (exception instanceof Error) {
      this.logger.error(exception.message, exception.stack);
    }

    // 추가 로직: 메트릭 수집
    this.incrementErrorCounter(exception);

    // 기본 NestJS 동작 위임
    super.catch(exception, host);
  }

  private incrementErrorCounter(exception: unknown) {
    const status = exception instanceof HttpException
      ? exception.getStatus()
      : 500;
    // Prometheus counter 증가 등
  }
}

// main.ts에서 사용 시 httpAdapter 전달 필요
const { httpAdapter } = app.get(HttpAdapterHost);
app.useGlobalFilters(new ExtendedExceptionFilter(httpAdapter));

Exception Filter vs Interceptor catchError: 역할 분담

특성 Exception Filter Interceptor (catchError)
실행 시점 예외가 Guard/Interceptor/Pipe/Controller 어디서든 발생 시 Controller 실행 중 발생한 예외만
응답 제어 response 객체 직접 조작 예외를 다른 예외로 변환
주요 용도 최종 에러 응답 형태 결정 DB 에러 → HTTP 에러 변환
복수 처리 하나의 필터만 처리 (first match) 체인으로 여러 Interceptor 순차 처리

실무 권장 패턴:

// Interceptor: DB 에러를 비즈니스 에러로 변환
@Injectable()
export class ErrorMappingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler) {
    return next.handle().pipe(
      catchError((error) => {
        if (error.code === '23505') {
          return throwError(() => new ConflictException('이미 존재'));
        }
        return throwError(() => error); // 나머지는 통과
      }),
    );
  }
}

// Exception Filter: 최종 응답 형태 통일 + 로깅 + Sentry
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    // ConflictException이 여기까지 도착 → 통일된 에러 응답 반환
  }
}

환경별 에러 응답 분기: 개발 vs 프로덕션

@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  constructor(private readonly configService: ConfigService) {}

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();
    const status = exception instanceof HttpException
      ? exception.getStatus() : 500;

    const isProd = this.configService.get('NODE_ENV') === 'production';

    const errorResponse: any = {
      success: false,
      error: {
        status,
        message: status >= 500 && isProd
          ? 'Internal server error'  // 프로덕션: 내부 메시지 숨김
          : this.getMessage(exception),
        timestamp: new Date().toISOString(),
        path: request.url,
      },
    };

    // 개발 환경에서만 스택 트레이스 포함
    if (!isProd && exception instanceof Error) {
      errorResponse.error.stack = exception.stack;
      errorResponse.error.name = exception.name;
    }

    response.status(status).json(errorResponse);
  }
}

실무 패턴: 요청 ID(Correlation ID) 추적

// 1. Middleware에서 요청 ID 생성
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    req['requestId'] = req.headers['x-request-id'] || uuidv4();
    res.setHeader('x-request-id', req['requestId']);
    next();
  }
}

// 2. Exception Filter에서 요청 ID 포함
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const request = ctx.getRequest();
    const response = ctx.getResponse();
    const requestId = request['requestId'];
    const status = exception instanceof HttpException
      ? exception.getStatus() : 500;

    // 로그에 요청 ID 포함 → 분산 시스템에서 추적 가능
    this.logger.error(`[${requestId}] ${request.method} ${request.url} → ${status}`);

    response.status(status).json({
      success: false,
      error: {
        requestId,  // 클라이언트가 이 ID로 문의 가능
        status,
        message: this.getMessage(exception),
        timestamp: new Date().toISOString(),
        path: request.url,
      },
    });
  }
}

테스트: Exception Filter 동작 검증

describe('GlobalExceptionFilter', () => {
  let filter: GlobalExceptionFilter;

  beforeEach(() => {
    filter = new GlobalExceptionFilter(mockConfigService);
  });

  it('should handle HttpException with correct status', () => {
    const mockJson = jest.fn();
    const mockStatus = jest.fn().mockReturnValue({ json: mockJson });
    const mockHost = createMockArgumentsHost({
      status: mockStatus,
      url: '/api/orders/999',
      method: 'GET',
    });

    filter.catch(new NotFoundException('주문 없음'), mockHost);

    expect(mockStatus).toHaveBeenCalledWith(404);
    expect(mockJson).toHaveBeenCalledWith(
      expect.objectContaining({
        success: false,
        error: expect.objectContaining({
          status: 404,
          message: '주문 없음',
        }),
      }),
    );
  });

  it('should mask internal error message in production', () => {
    mockConfigService.get.mockReturnValue('production');
    const mockJson = jest.fn();
    const mockStatus = jest.fn().mockReturnValue({ json: mockJson });
    const mockHost = createMockArgumentsHost({ status: mockStatus });

    filter.catch(new Error('DB connection failed'), mockHost);

    expect(mockStatus).toHaveBeenCalledWith(500);
    expect(mockJson).toHaveBeenCalledWith(
      expect.objectContaining({
        error: expect.objectContaining({
          message: 'Internal server error', // DB 에러 메시지 노출 안 됨
        }),
      }),
    );
  });
});

// E2E 테스트
describe('AppController (e2e)', () => {
  it('should return 404 with standard error format', () => {
    return request(app.getHttpServer())
      .get('/api/orders/99999')
      .expect(404)
      .expect((res) => {
        expect(res.body.success).toBe(false);
        expect(res.body.error.status).toBe(404);
        expect(res.body.error.requestId).toBeDefined();
        expect(res.body.error.timestamp).toBeDefined();
      });
  });
});

정리: Exception Filter 설계 체크리스트

항목 체크
글로벌 Exception Filter 등록 (APP_FILTER, DI 지원)
에러 응답 형태 통일 (success, error.status, error.message, timestamp)
5xx 에러의 내부 메시지 프로덕션에서 숨김
requestId/correlationId 포함
5xx만 Sentry 등 모니터링 도구 보고
비즈니스 예외 계층 구조 설계 (code, statusCode)
DB 에러는 Interceptor에서 HTTP 예외로 변환
ValidationPipe 에러 필드별 정리
개발 환경에서만 스택 트레이스 노출
E2E 테스트로 에러 응답 형태 검증

NestJS Exception Filter는 애플리케이션의 마지막 방어선입니다. 어디서 발생하든 모든 예외가 이 계층을 거쳐 클라이언트에게 전달됩니다. 핵심은 에러 응답을 통일하고, 민감 정보를 숨기며, 모니터링과 연동하는 것입니다. Interceptor가 에러를 변환하는 역할이라면, Exception Filter는 최종 응답 형태를 결정하는 역할입니다. 이 두 계층을 명확히 분리하면 에러 처리 코드가 깔끔하게 정리됩니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux