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
ArgumentsHost의 getType()으로 프로토콜을 판별하여 각각 다르게 응답할 수 있습니다:
@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는 최종 응답 형태를 결정하는 역할입니다. 이 두 계층을 명확히 분리하면 에러 처리 코드가 깔끔하게 정리됩니다.