NestJS Exception Filter란?
NestJS의 Exception Filter는 애플리케이션에서 발생하는 예외를 가로채 클라이언트에게 일관된 에러 응답을 반환하는 레이어다. 기본 내장 필터가 HttpException을 처리하지만, 실무에서는 커스텀 예외 체계, 로깅, 도메인별 에러 코드가 필요하다.
1. 기본 Exception Filter 동작
NestJS는 HttpException과 그 하위 클래스를 자동으로 JSON 응답으로 변환한다.
// 내장 예외 사용
throw new NotFoundException('사용자를 찾을 수 없습니다');
// → { statusCode: 404, message: '사용자를 찾을 수 없습니다', error: 'Not Found' }
throw new BadRequestException({
message: '유효성 검증 실패',
errors: [{ field: 'email', reason: '형식이 올바르지 않습니다' }],
});
// → { statusCode: 400, message: '유효성 검증 실패', errors: [...] }
// HttpException이 아닌 예외는 500으로 처리됨
throw new Error('DB 연결 실패');
// → { statusCode: 500, message: 'Internal server error' }
2. 커스텀 Exception Filter 구현
@Catch() 데코레이터와 ExceptionFilter 인터페이스로 예외 처리를 완전히 제어한다.
// 모든 예외를 잡는 글로벌 필터
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const { status, body } = this.buildResponse(exception, request);
this.logger.error(
`[${request.method}] ${request.url} → ${status}`,
exception instanceof Error ? exception.stack : String(exception),
);
response.status(status).json(body);
}
private buildResponse(exception: unknown, request: Request) {
if (exception instanceof HttpException) {
const status = exception.getStatus();
const exResponse = exception.getResponse();
return {
status,
body: {
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
...(typeof exResponse === 'string'
? { message: exResponse }
: (exResponse as object)),
},
};
}
// 예상치 못한 예외
return {
status: 500,
body: {
statusCode: 500,
timestamp: new Date().toISOString(),
path: request.url,
message: 'Internal server error',
},
};
}
}
3. 도메인 예외 체계 설계
실무에서는 비즈니스 에러 코드를 포함한 도메인 예외 체계가 필요하다.
// 비즈니스 에러 코드 정의
export enum ErrorCode {
USER_NOT_FOUND = 'USER_001',
USER_ALREADY_EXISTS = 'USER_002',
ORDER_LIMIT_EXCEEDED = 'ORDER_001',
PAYMENT_FAILED = 'PAYMENT_001',
INSUFFICIENT_STOCK = 'INVENTORY_001',
}
// 도메인 예외 기본 클래스
export class DomainException extends HttpException {
constructor(
public readonly errorCode: ErrorCode,
message: string,
statusCode: HttpStatus = HttpStatus.BAD_REQUEST,
public readonly details?: Record<string, unknown>,
) {
super({ errorCode, message, details }, statusCode);
}
}
// 구체 예외
export class UserNotFoundException extends DomainException {
constructor(userId: string) {
super(
ErrorCode.USER_NOT_FOUND,
`사용자(${userId})를 찾을 수 없습니다`,
HttpStatus.NOT_FOUND,
{ userId },
);
}
}
export class InsufficientStockException extends DomainException {
constructor(productId: string, requested: number, available: number) {
super(
ErrorCode.INSUFFICIENT_STOCK,
`재고 부족: ${available}개 남음`,
HttpStatus.CONFLICT,
{ productId, requested, available },
);
}
}
4. 도메인 예외 전용 필터
@Catch(DomainException)
export class DomainExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(DomainExceptionFilter.name);
catch(exception: DomainException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
const body = {
statusCode: status,
errorCode: exception.errorCode,
message: exception.message,
details: exception.details,
timestamp: new Date().toISOString(),
path: request.url,
};
this.logger.warn(
`[${exception.errorCode}] ${request.method} ${request.url}: ${exception.message}`,
);
response.status(status).json(body);
}
}
// 응답 예시
// POST /api/orders → 409
// {
// "statusCode": 409,
// "errorCode": "INVENTORY_001",
// "message": "재고 부족: 3개 남음",
// "details": { "productId": "P001", "requested": 5, "available": 3 },
// "timestamp": "2026-03-01T17:00:00.000Z",
// "path": "/api/orders"
// }
5. 필터 적용 범위와 우선순위
| 범위 | 적용 방법 | 우선순위 |
|---|---|---|
| 메서드 | @UseFilters(Filter) |
가장 높음 |
| 컨트롤러 | @UseFilters(Filter) |
중간 |
| 전역 | APP_FILTER 또는 useGlobalFilters |
가장 낮음 |
// 전역 등록 (DI 지원 — 권장)
@Module({
providers: [
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
{ provide: APP_FILTER, useClass: DomainExceptionFilter },
],
})
export class AppModule {}
// 컨트롤러 레벨
@UseFilters(DomainExceptionFilter)
@Controller('orders')
export class OrdersController {}
// 메서드 레벨
@UseFilters(new CustomValidationFilter())
@Post()
create(@Body() dto: CreateOrderDto) {}
⚠️ 여러 필터가 등록된 경우, 가장 구체적인 예외를 잡는 필터가 먼저 실행된다. @Catch(DomainException)이 @Catch()보다 우선한다. NestJS의 요청 파이프라인 전체 흐름은 Interceptor 6가지 패턴 글을 참고하자.
6. WebSocket·GraphQL 예외 처리
// WebSocket 예외 필터
@Catch(WsException)
export class WsExceptionFilter implements ExceptionFilter {
catch(exception: WsException, host: ArgumentsHost) {
const client = host.switchToWs().getClient();
client.emit('error', {
errorCode: 'WS_ERROR',
message: exception.message,
});
}
}
// GraphQL 예외 — 기본 HttpException이 GraphQL 에러로 자동 변환
// 커스텀이 필요하면 GqlExceptionFilter 사용
@Catch()
export class GqlExceptionFilter implements GqlExceptionFilter {
catch(exception: unknown) {
if (exception instanceof DomainException) {
return new GraphQLError(exception.message, {
extensions: {
code: exception.errorCode,
details: exception.details,
},
});
}
return new GraphQLError('Internal server error');
}
}
7. Validation 에러 포맷팅
// class-validator 에러를 깔끔하게 변환
@Catch(BadRequestException)
export class ValidationExceptionFilter implements ExceptionFilter {
catch(exception: BadRequestException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const exResponse = exception.getResponse() as any;
const errors = Array.isArray(exResponse.message)
? exResponse.message.map((msg: string) => {
const [field, ...rest] = msg.split(' ');
return { field: field.toLowerCase(), message: rest.join(' ') };
})
: [{ field: 'unknown', message: exResponse.message }];
response.status(400).json({
statusCode: 400,
errorCode: 'VALIDATION_ERROR',
message: '입력값 검증에 실패했습니다',
errors,
timestamp: new Date().toISOString(),
});
}
}
Pipe 기반 유효성 검증 심화는 NestJS Pipe 유효성 검증 글을 참고하자.
마무리
Exception Filter는 NestJS 에러 처리의 최종 방어선이다. 도메인 예외 체계를 설계하고, 에러 코드 기반 응답을 반환하며, 전송 계층별 필터를 분리하면 클라이언트에게 일관되고 디버깅 가능한 에러 응답을 제공할 수 있다.