NestJS ExceptionFilter란?
NestJS의 ExceptionFilter는 애플리케이션에서 발생하는 예외를 가로채어 클라이언트에 일관된 에러 응답을 반환하는 계층입니다. 기본 내장 필터가 HttpException을 처리하지만, 실무에서는 커스텀 예외 체계, 로깅 통합, 도메인별 에러 코드 등 정교한 에러 처리가 필요합니다. ExceptionFilter를 제대로 설계하면 에러 응답 포맷 통일, 디버깅 효율 향상, 운영 안정성 확보가 가능합니다.
기본 ExceptionFilter 구조
NestJS는 @Catch() 데코레이터와 ExceptionFilter 인터페이스로 커스텀 필터를 구현합니다.
import {
ExceptionFilter, Catch, ArgumentsHost,
HttpException, HttpStatus, Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch() // 모든 예외 캐치
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();
const { status, message, code } = this.extractError(exception);
const errorResponse = {
statusCode: status,
code,
message,
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
};
// 5xx 에러만 상세 로깅
if (status >= 500) {
this.logger.error(
`${request.method} ${request.url} ${status}`,
exception instanceof Error ? exception.stack : String(exception),
);
} else {
this.logger.warn(`${request.method} ${request.url} ${status} - ${message}`);
}
response.status(status).json(errorResponse);
}
private extractError(exception: unknown) {
if (exception instanceof HttpException) {
const response = exception.getResponse();
return {
status: exception.getStatus(),
message: typeof response === 'string'
? response
: (response as any).message || exception.message,
code: `HTTP_${exception.getStatus()}`,
};
}
return {
status: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Internal server error',
code: 'INTERNAL_ERROR',
};
}
}
도메인 예외 계층 설계
실무에서는 HttpException을 직접 던지기보다, 도메인 고유 예외 클래스를 정의하고 ExceptionFilter에서 HTTP 응답으로 매핑하는 패턴이 권장됩니다. 서비스 계층이 HTTP 컨텍스트에 의존하지 않게 됩니다.
// 도메인 예외 기본 클래스
export abstract class DomainException extends Error {
abstract readonly code: string;
abstract readonly statusCode: number;
constructor(
message: string,
public readonly metadata?: Record<string, unknown>,
) {
super(message);
this.name = this.constructor.name;
}
}
// 구체적 도메인 예외들
export class EntityNotFoundException extends DomainException {
readonly code = 'ENTITY_NOT_FOUND';
readonly statusCode = 404;
constructor(entity: string, id: string | number) {
super(`${entity} with id ${id} not found`, { entity, id });
}
}
export class BusinessRuleViolationException extends DomainException {
readonly code = 'BUSINESS_RULE_VIOLATION';
readonly statusCode = 422;
}
export class DuplicateEntityException extends DomainException {
readonly code = 'DUPLICATE_ENTITY';
readonly statusCode = 409;
constructor(entity: string, field: string, value: string) {
super(`${entity} with ${field} '${value}' already exists`, { entity, field, value });
}
}
export class InsufficientPermissionException extends DomainException {
readonly code = 'INSUFFICIENT_PERMISSION';
readonly statusCode = 403;
}
// 서비스에서 사용
@Injectable()
export class OrderService {
async findOrder(id: number): Promise<Order> {
const order = await this.orderRepo.findOne({ where: { id } });
if (!order) {
throw new EntityNotFoundException('Order', id);
}
return order;
}
async createOrder(dto: CreateOrderDto): Promise<Order> {
if (dto.quantity <= 0) {
throw new BusinessRuleViolationException(
'Order quantity must be positive',
{ field: 'quantity', value: dto.quantity },
);
}
// ...
}
}
도메인 예외 전용 필터
도메인 예외를 캐치하여 일관된 에러 응답으로 변환하는 전용 필터를 구현합니다.
@Catch(DomainException)
export class DomainExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(DomainExceptionFilter.name);
catch(exception: DomainException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();
const errorResponse = {
statusCode: exception.statusCode,
code: exception.code,
message: exception.message,
metadata: exception.metadata,
timestamp: new Date().toISOString(),
path: request.url,
};
this.logger.warn(
`[${exception.code}] ${request.method} ${request.url} - ${exception.message}`,
);
response.status(exception.statusCode).json(errorResponse);
}
}
Validation 에러 커스터마이징
class-validator의 ValidationPipe가 던지는 BadRequestException을 가로채어 필드별 에러 메시지를 정리된 포맷으로 반환할 수 있습니다.
// ValidationPipe에서 커스텀 예외 던지기
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
exceptionFactory: (errors) => {
const formattedErrors = errors.map((error) => ({
field: error.property,
constraints: Object.values(error.constraints || {}),
value: error.value,
}));
return new ValidationException(formattedErrors);
},
}),
);
// 커스텀 Validation 예외
export class ValidationException extends HttpException {
constructor(public readonly errors: ValidationError[]) {
super({ message: 'Validation failed', errors }, HttpStatus.BAD_REQUEST);
}
}
interface ValidationError {
field: string;
constraints: string[];
value: unknown;
}
// Validation 전용 필터
@Catch(ValidationException)
export class ValidationExceptionFilter implements ExceptionFilter {
catch(exception: ValidationException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
response.status(400).json({
statusCode: 400,
code: 'VALIDATION_ERROR',
message: 'Validation failed',
errors: exception.errors,
timestamp: new Date().toISOString(),
});
}
}
// 응답 예시:
// {
// "statusCode": 400,
// "code": "VALIDATION_ERROR",
// "message": "Validation failed",
// "errors": [
// { "field": "email", "constraints": ["email must be a valid email"], "value": "invalid" },
// { "field": "age", "constraints": ["age must be a positive number"], "value": -1 }
// ]
// }
필터 등록 순서와 스코프
ExceptionFilter는 글로벌, 컨트롤러, 메서드 세 레벨에서 등록할 수 있으며, 실행 순서가 중요합니다.
// 1. 글로벌 등록 — main.ts (DI 미지원)
app.useGlobalFilters(
new GlobalExceptionFilter(), // 최후방 — 모든 예외 캐치
);
// 2. 글로벌 등록 — 모듈 방식 (DI 지원 ✅ 권장)
@Module({
providers: [
{ provide: APP_FILTER, useClass: DomainExceptionFilter },
{ provide: APP_FILTER, useClass: ValidationExceptionFilter },
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
],
})
export class AppModule {}
// 3. 컨트롤러 스코프
@Controller('orders')
@UseFilters(OrderExceptionFilter)
export class OrderController {}
// 4. 메서드 스코프
@Post()
@UseFilters(SpecificExceptionFilter)
async createOrder(@Body() dto: CreateOrderDto) {}
| 등록 방식 | DI 지원 | 스코프 | 우선순위 |
|---|---|---|---|
메서드 @UseFilters |
인스턴스만 | 해당 메서드 | 최우선 |
컨트롤러 @UseFilters |
인스턴스만 | 해당 컨트롤러 | 중간 |
APP_FILTER 프로바이더 |
✅ | 전역 | 후순위 |
app.useGlobalFilters |
❌ | 전역 | 최후순위 |
핵심: 더 구체적인 @Catch(SpecificException) 필터가 먼저 매칭됩니다. @Catch()(모든 예외)는 다른 필터가 처리하지 못한 예외의 최종 안전망 역할을 합니다.
WebSocket·GraphQL 예외 처리
ExceptionFilter는 HTTP 외에 WebSocket과 GraphQL 컨텍스트에서도 동작합니다. ArgumentsHost의 타입에 따라 응답 방식을 분기합니다.
@Catch()
export class UnifiedExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(UnifiedExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const contextType = host.getType<string>();
switch (contextType) {
case 'http':
return this.handleHttp(exception, host);
case 'ws':
return this.handleWs(exception, host);
case 'graphql':
return this.handleGraphQL(exception, host);
default:
this.logger.error(`Unknown context type: ${contextType}`);
}
}
private handleHttp(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status = exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
response.status(status).json({
statusCode: status,
message: this.getMessage(exception),
timestamp: new Date().toISOString(),
});
}
private handleWs(exception: unknown, host: ArgumentsHost) {
const client = host.switchToWs().getClient();
client.emit('error', {
code: 'WS_ERROR',
message: this.getMessage(exception),
});
}
private handleGraphQL(exception: unknown, host: ArgumentsHost) {
// GraphQL은 예외를 다시 던져서 Apollo/Mercurius가 처리
if (exception instanceof HttpException) throw exception;
throw new InternalServerErrorException('Internal server error');
}
private getMessage(exception: unknown): string {
if (exception instanceof Error) return exception.message;
return 'Unknown error';
}
}
운영 베스트 프랙티스
- 도메인 예외 분리: 서비스 계층에서
HttpException대신 도메인 예외를 던져 HTTP 의존성을 제거하세요 - APP_FILTER 프로바이더 사용: DI가 필요한 필터는
app.useGlobalFilters대신 모듈 프로바이더로 등록하세요 - 에러 코드 체계: 문자열 코드(
ENTITY_NOT_FOUND)를 사용해 클라이언트가 에러를 프로그래밍적으로 처리할 수 있게 하세요 - 5xx vs 4xx 로깅 분리: 서버 에러는 ERROR 레벨 + 스택 트레이스, 클라이언트 에러는 WARN 레벨로 구분하세요
- 민감 정보 노출 방지: 프로덕션에서는 내부 에러 메시지나 스택 트레이스를 응답에 포함하지 마세요
- Validation 에러 포맷 통일: 필드별 에러 배열로 반환하여 프론트엔드 폼 검증과 매핑하세요