
NestJS 심화: Exception Filters와 예외 경계 설계
1) 버전 기준
- NestJS 릴리즈 기준: v11.1.14 (2026-02-17, GitHub Releases)
- 공식 문서 기준: Exception Filters
2) 핵심 개념
NestJS 공식 문서에서 Exception Filter는 예외를 가로채고 응답 형태를 일관되게 만드는 메커니즘으로 설명됩니다. 기본적으로는 프레임워크 내장 예외 계층을 사용하고, 커스텀 필터를 통해 HTTP 응답 바디/코드/로깅 정책을 통제할 수 있습니다.
@Catch()로 특정 예외 타입을 대상으로 필터를 선언할 수 있습니다.- 메서드/컨트롤러/글로벌 범위로 적용 범위를 선택할 수 있습니다.
- 플랫폼(Express/Fastify)에 따라 응답 객체 접근은
ArgumentsHost를 통해 분기합니다.
3) 트레이드오프
- 글로벌 필터 1개 집중: 일관성은 높지만, 도메인별 세밀한 오류 표현이 어려워질 수 있습니다.
- 컨트롤러별 필터 분리: 표현력은 높지만 정책 중복과 관리 비용이 커질 수 있습니다.
- 예외를 광범위하게 200 응답으로 변환: 클라이언트 호환성은 좋아질 수 있으나, 모니터링/알람 정확도가 떨어질 수 있습니다.
4) 장애 재현-해결
재현 시나리오
- 서비스 계층에서 일반
Error를 throw. - HTTP 계층에서 필터가 없거나,
HttpException만 처리하도록 잘못 제한. - 클라이언트가 엔드포인트별로 다른 에러 포맷을 받음.
해결 절차
- 공식 문서 패턴대로 커스텀 Exception Filter를 작성합니다.
ArgumentsHost에서 HTTP 컨텍스트를 꺼내 공통 에러 스키마를 강제합니다.- 글로벌 적용(
app.useGlobalFilters()) 후, 도메인 특수 케이스만 로컬 필터로 보강합니다. HttpException과 일반Error를 분리 처리하여 상태코드/메시지 손실을 방지합니다.
5) 체크리스트
- [ ] 전역 에러 응답 스키마(코드/메시지/타임스탬프/경로)가 고정되어 있는가?
- [ ]
HttpException과 일반Error가 구분 처리되는가? - [ ] 글로벌 필터와 로컬 필터의 책임 경계가 문서화되어 있는가?
- [ ] Fastify/Express 중 실제 어댑터 기준으로 테스트했는가?
- [ ] 예외 응답이 로깅/모니터링 태그와 연계되는가?
6) 공식 링크
- NestJS Exception Filters: https://docs.nestjs.com/exception-filters
- NestJS Releases: https://github.com/nestjs/nest/releases
7) 실전 코드: 커스텀 Exception Filter 구현
가장 많이 쓰이는 패턴은 모든 예외를 잡는 글로벌 필터입니다. HttpException과 일반 Error를 분기 처리하여 일관된 에러 응답을 만듭니다.
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
interface ErrorResponse {
statusCode: number;
message: string;
error: string;
timestamp: string;
path: string;
}
@Catch() // 모든 예외를 잡음
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
let error = 'Internal Server Error';
if (exception instanceof HttpException) {
statusCode = exception.getStatus();
const exceptionResponse = exception.getResponse();
message = typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as any).message || message;
error = exception.name;
} else if (exception instanceof Error) {
message = exception.message;
error = exception.name;
}
const errorResponse: ErrorResponse = {
statusCode,
message,
error,
timestamp: new Date().toISOString(),
path: request.url,
};
// 5xx 에러만 스택 트레이스 로깅
if (statusCode >= 500) {
this.logger.error(
`${request.method} ${request.url} ${statusCode}`,
exception instanceof Error ? exception.stack : String(exception),
);
} else {
this.logger.warn(`${request.method} ${request.url} ${statusCode}: ${message}`);
}
response.status(statusCode).json(errorResponse);
}
}
글로벌 필터 등록 (main.ts)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './filters/all-exceptions.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new AllExceptionsFilter());
await app.listen(3000);
}
bootstrap();
DI를 통한 등록이 필요한 경우(다른 서비스 주입 등) APP_FILTER 토큰을 사용합니다:
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { AllExceptionsFilter } from './filters/all-exceptions.filter';
@Module({
providers: [
{
provide: APP_FILTER,
useClass: AllExceptionsFilter,
},
],
})
export class AppModule {}
8) 도메인별 예외 계층 설계
실무에서는 비즈니스 로직별로 커스텀 예외 클래스를 만들어 에러 코드와 메시지를 표준화합니다. 이렇게 하면 프론트엔드에서 에러 코드 기반으로 분기 처리가 가능해집니다.
// 비즈니스 예외 베이스 클래스
export class BusinessException extends HttpException {
constructor(
public readonly errorCode: string,
message: string,
statusCode: HttpStatus = HttpStatus.BAD_REQUEST,
) {
super({ errorCode, message, statusCode }, statusCode);
}
}
// 도메인별 예외
export class UserNotFoundException extends BusinessException {
constructor(userId: string) {
super('USER_NOT_FOUND', `사용자를 찾을 수 없습니다: ${userId}`, HttpStatus.NOT_FOUND);
}
}
export class InsufficientBalanceException extends BusinessException {
constructor(required: number, current: number) {
super(
'INSUFFICIENT_BALANCE',
`잔액이 부족합니다. 필요: ${required}, 현재: ${current}`,
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
}
9) 마이크로서비스 예외 처리 (RPC/WebSocket)
HTTP가 아닌 전송 계층에서는 RpcException이나 WsException을 사용해야 합니다. ArgumentsHost.getType()으로 컨텍스트 타입을 판별하면 하이브리드 앱에서도 하나의 필터로 처리 가능합니다.
@Catch()
export class HybridExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const type = host.getType(); // 'http' | 'rpc' | 'ws'
switch (type) {
case 'http':
return this.handleHttp(exception, host);
case 'rpc':
return this.handleRpc(exception, host);
case 'ws':
return this.handleWs(exception, host);
}
}
private handleHttp(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const status = exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
response.status(status).json({ error: String(exception) });
}
private handleRpc(exception: unknown, host: ArgumentsHost) {
return throwError(() => exception);
}
private handleWs(exception: unknown, host: ArgumentsHost) {
const client = host.switchToWs().getClient();
client.emit('error', { message: String(exception) });
}
}
10) 관련 글
- NestJS Interceptor 6가지 패턴 — 예외 발생 전 요청을 가로채는 Interceptor와 함께 사용하면 에러 처리 파이프라인이 완성됩니다.
- NestJS Pipe 유효성 검증 심화 — Validation Pipe에서 던진 예외를 Exception Filter가 받아 처리하는 흐름을 이해하세요.
- NestJS Middleware 실전 패턴 — Middleware → Guard → Interceptor → Pipe → Filter의 실행 순서를 정리합니다.