NestJS Exception Filter
NestJS 애플리케이션에서 에러가 발생하면 어딘가에서 이걸 잡아서 클라이언트에게 적절한 HTTP 응답으로 변환해야 한다. 컨트롤러마다 try-catch를 넣는 건 반복적이고 지저분하다. 서비스 레이어에서 던진 예외, ORM에서 발생한 데이터베이스 에러, 심지어 예상하지 못한 런타임 에러까지 — 이 모든 걸 한 곳에서 일관되게 처리할 수 있는 메커니즘이 Exception Filter다.
NestJS의 기본 예외 처리
NestJS는 내장 예외 레이어(Exception Layer)를 가지고 있다. 아무런 설정 없이도 HttpException을 상속한 예외는 자동으로 적절한 HTTP 응답으로 변환된다.
// 컨트롤러에서 직접 예외를 던지면
throw new NotFoundException('사용자를 찾을 수 없습니다');
이렇게 던지면 NestJS가 알아서 404 응답을 만들어준다:
{
"statusCode": 404,
"message": "사용자를 찾을 수 없습니다",
"error": "Not Found"
}
NestJS가 기본 제공하는 HTTP 예외 클래스들이 있다:
| 클래스 | 상태 코드 | 용도 |
|---|---|---|
BadRequestException | 400 | 잘못된 요청 (유효성 검증 실패) |
UnauthorizedException | 401 | 인증 실패 |
ForbiddenException | 403 | 권한 부족 |
NotFoundException | 404 | 리소스 없음 |
ConflictException | 409 | 충돌 (중복 데이터 등) |
InternalServerErrorException | 500 | 서버 내부 오류 |
이 클래스들은 모두 HttpException을 상속한다. HttpException은 두 가지 인자를 받는다:
// 문자열만 전달
throw new HttpException('금지된 접근', HttpStatus.FORBIDDEN);
// 객체로 커스텀 응답 구조 전달
throw new HttpException(
{ status: HttpStatus.FORBIDDEN, error: '접근 권한이 없습니다' },
HttpStatus.FORBIDDEN,
);
여기까지는 문제가 없다. 하지만 실제 프로젝트에서는 HttpException이 아닌 예외들이 훨씬 많이 발생한다. ORM이 던지는 UniqueConstraintViolationException, JavaScript 런타임의 TypeError, 외부 API 호출 실패 등. 이런 예외들은 기본 예외 레이어에서 처리하지 못하고 500 에러로 빠진다. 응답 형식도 의도와 다르게 나올 수 있다.
Exception Filter의 동작 위치
NestJS의 요청 처리 파이프라인은 다음 순서로 동작한다:
요청 → Middleware → Guard → Interceptor(전) → Pipe → Controller → Interceptor(후) → 응답
↓
예외 발생 시 → Exception Filter
Exception Filter는 파이프라인 어디에서든 예외가 발생하면 그걸 가로챈다. Guard에서 던진 UnauthorizedException이든, Pipe에서 던진 BadRequestException이든, Controller에서 발생한 예외든 상관없이 Exception Filter가 최종적으로 응답을 결정한다.
중요한 점: Interceptor의 catchError 연산자로도 에러를 처리할 수 있지만, Exception Filter는 Interceptor보다 바깥에서 동작한다. Interceptor에서 잡지 못한 에러도 Exception Filter가 잡는다.
커스텀 Exception Filter 만들기
ExceptionFilter 인터페이스를 구현하고 @Catch() 데코레이터를 붙이면 된다.
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message: exception.message,
});
}
}
@Catch() 데코레이터
@Catch()에 넘기는 인자가 이 필터가 잡을 예외 타입을 결정한다.
@Catch(HttpException) // HttpException과 그 하위 클래스만 잡음
@Catch(NotFoundException) // NotFoundException만 잡음
@Catch(TypeError, RangeError) // 여러 타입 지정 가능
@Catch() // 인자 없으면 모든 예외를 잡음
@Catch()에 아무것도 안 넘기면 모든 예외를 잡는 글로벌 캐치 필터가 된다. 이게 실전에서 가장 많이 쓰이는 패턴이다.
ArgumentsHost
ArgumentsHost는 현재 실행 컨텍스트에 대한 추상화다. NestJS는 HTTP뿐만 아니라 WebSocket, gRPC 같은 다양한 전송 계층을 지원하는데, ArgumentsHost가 이 차이를 추상화해준다.
// HTTP 컨텍스트
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();
// WebSocket 컨텍스트
const wsCtx = host.switchToWs();
const client = wsCtx.getClient();
const data = wsCtx.getData();
// 어떤 컨텍스트인지 확인
const type = host.getType(); // 'http' | 'ws' | 'rpc'
하나의 Exception Filter에서 HTTP와 WebSocket 예외를 모두 처리하려면 getType()으로 분기하면 된다.
글로벌 캐치올(Catch-All) 필터
실전 프로젝트에서 가장 핵심적인 패턴이다. 모든 예외를 하나의 필터에서 잡아서, HttpException이면 원래 상태 코드를 사용하고, 아니면 500으로 처리한다.
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
catch(exception: unknown, host: ArgumentsHost) {
const { httpAdapter } = this.httpAdapterHost;
const ctx = host.switchToHttp();
const httpStatus =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message =
exception instanceof HttpException
? exception.message
: 'Internal server error';
// 500 에러는 로깅 (디버깅용)
if (httpStatus === HttpStatus.INTERNAL_SERVER_ERROR) {
this.logger.error(
`Unexpected error: ${exception}`,
exception instanceof Error ? exception.stack : undefined,
);
}
const responseBody = {
statusCode: httpStatus,
timestamp: new Date().toISOString(),
path: httpAdapter.getRequestUrl(ctx.getRequest()),
message,
};
httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus);
}
}
여기서 HttpAdapterHost를 주입받는 이유가 있다. @Catch()로 모든 예외를 잡으면 Express의 Response 객체에 직접 접근하는 대신 HttpAdapterHost를 통해 응답을 보내는 게 안전하다. Fastify로 교체해도 코드를 바꿀 필요가 없기 때문이다. httpAdapter.reply()는 플랫폼에 관계없이 동일하게 동작하는 추상화된 응답 메서드다.
ORM 예외 매핑 — 실전 핵심 패턴
데이터베이스 작업에서 발생하는 예외를 HTTP 응답으로 매핑하는 건 거의 모든 백엔드 프로젝트에서 필요하다. MikroORM을 예로 들면:
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpStatus,
Logger,
} from '@nestjs/common';
import {
UniqueConstraintViolationException,
NotFoundError,
ForeignKeyConstraintViolationException,
ValidationError,
} from '@mikro-orm/core';
import { HttpAdapterHost } from '@nestjs/core';
@Catch(
UniqueConstraintViolationException,
NotFoundError,
ForeignKeyConstraintViolationException,
ValidationError,
)
export class MikroOrmExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(MikroOrmExceptionFilter.name);
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
catch(exception: Error, host: ArgumentsHost) {
const { httpAdapter } = this.httpAdapterHost;
const ctx = host.switchToHttp();
let status: number;
let message: string;
if (exception instanceof UniqueConstraintViolationException) {
status = HttpStatus.CONFLICT;
message = '이미 존재하는 데이터입니다';
} else if (exception instanceof NotFoundError) {
status = HttpStatus.NOT_FOUND;
message = '요청한 리소스를 찾을 수 없습니다';
} else if (exception instanceof ForeignKeyConstraintViolationException) {
status = HttpStatus.BAD_REQUEST;
message = '참조 중인 데이터가 있어 삭제할 수 없습니다';
} else if (exception instanceof ValidationError) {
status = HttpStatus.BAD_REQUEST;
message = '데이터 유효성 검증에 실패했습니다';
} else {
status = HttpStatus.INTERNAL_SERVER_ERROR;
message = '서버 내부 오류';
}
this.logger.warn(`${exception.constructor.name}: ${exception.message}`);
httpAdapter.reply(
ctx.getResponse(),
{
statusCode: status,
message,
timestamp: new Date().toISOString(),
path: httpAdapter.getRequestUrl(ctx.getRequest()),
},
status,
);
}
}
이 패턴의 핵심은 서비스 레이어에서 ORM 예외를 직접 잡지 않아도 된다는 것이다. 서비스에서는 비즈니스 로직에만 집중하고, ORM이 던지는 예외는 필터에서 일괄 처리한다.
// 서비스에서 try-catch 없이 깔끔하게 작성
async createUser(dto: CreateUserDto) {
const user = this.em.create(User, dto);
await this.em.flush(); // UniqueConstraintViolation은 필터가 처리
return user;
}
TypeORM을 쓴다면 QueryFailedError를 잡고 driverError.code로 분기하는 식으로 비슷하게 구현할 수 있다:
@Catch(QueryFailedError)
export class TypeOrmExceptionFilter implements ExceptionFilter {
catch(exception: QueryFailedError, host: ArgumentsHost) {
const driverError = exception.driverError;
// PostgreSQL 에러 코드로 분기
if (driverError.code === '23505') {
// unique_violation → 409
} else if (driverError.code === '23503') {
// foreign_key_violation → 400
}
}
}
필터 적용 범위
Exception Filter는 세 가지 범위에서 적용할 수 있다.
1. 메서드 레벨
특정 라우트 핸들러에만 적용한다.
@Post()
@UseFilters(new HttpExceptionFilter())
create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
2. 컨트롤러 레벨
해당 컨트롤러의 모든 라우트에 적용한다.
@Controller('users')
@UseFilters(new HttpExceptionFilter())
export class UsersController {}
3. 글로벌 레벨
애플리케이션 전체에 적용한다. 두 가지 방법이 있다:
// 방법 1: main.ts에서 직접 등록
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new AllExceptionsFilter(app.get(HttpAdapterHost)));
// 방법 2: 모듈에서 Provider로 등록 (DI 가능 — 권장)
@Module({
providers: [
{
provide: APP_FILTER,
useClass: AllExceptionsFilter,
},
],
})
export class AppModule {}
방법 1은 main.ts에서 직접 인스턴스를 생성하기 때문에 의존성 주입이 안 된다(수동으로 app.get()으로 꺼내야 한다). 방법 2는 NestJS DI 컨테이너가 관리하므로 생성자에서 HttpAdapterHost든 Logger든 자유롭게 주입받을 수 있다. 실전에서는 거의 항상 방법 2를 쓴다.
여러 필터의 실행 순서
글로벌 필터와 컨트롤러/메서드 레벨 필터를 동시에 등록하면, 가장 좁은 범위의 필터가 먼저 실행된다. 메서드 레벨 → 컨트롤러 레벨 → 글로벌 레벨 순이다. 어떤 필터에서 예외를 처리하면 그 다음 필터는 실행되지 않는다.
실전에서의 일반적인 구성:
@Module({
providers: [
// ORM 예외 전용 필터 (먼저 매칭 시도)
{
provide: APP_FILTER,
useClass: MikroOrmExceptionFilter,
},
// 나머지 모든 예외 캐치 (위에서 안 잡힌 것)
{
provide: APP_FILTER,
useClass: AllExceptionsFilter,
},
],
})
export class AppModule {}
APP_FILTER를 여러 번 등록하면 NestJS가 등록 순서대로 체인을 만든다. 특정 예외 타입을 잡는 필터가 먼저 시도되고, 거기서 안 잡히면 다음 필터로 넘어간다.
커스텀 비즈니스 예외 클래스
프로젝트가 커지면 HttpException의 서브클래스만으로는 비즈니스 의미를 충분히 전달하기 어렵다. 도메인에 특화된 예외 클래스를 만들면 코드의 의도가 명확해진다.
// 도메인 예외 정의
export class InsufficientBalanceException extends HttpException {
constructor(required: number, current: number) {
super(
{
statusCode: HttpStatus.BAD_REQUEST,
error: 'Insufficient Balance',
message: `잔액이 부족합니다. 필요: ${required}, 현재: ${current}`,
},
HttpStatus.BAD_REQUEST,
);
}
}
export class BookingConflictException extends HttpException {
constructor(slotId: string) {
super(
{
statusCode: HttpStatus.CONFLICT,
error: 'Booking Conflict',
message: `해당 슬롯(${slotId})은 이미 예약되었습니다`,
},
HttpStatus.CONFLICT,
);
}
}
// 서비스에서 사용
async processPayment(userId: string, amount: number) {
const balance = await this.getBalance(userId);
if (balance < amount) {
throw new InsufficientBalanceException(amount, balance);
}
// 결제 처리...
}
이렇게 하면 서비스 코드만 봐도 어떤 상황에서 어떤 에러가 발생하는지 바로 알 수 있다. throw new BadRequestException('잔액 부족') 같은 제네릭한 예외보다 훨씬 의미가 명확하다.
HttpException이 아닌 예외 처리 전략
때로는 HttpException을 상속하지 않는 순수한 도메인 예외를 만들고 싶을 수 있다. HTTP 계층에 의존하지 않는 깔끔한 도메인 레이어를 원할 때 이 방식을 쓴다.
// HTTP에 의존하지 않는 도메인 예외
export class DomainException extends Error {
constructor(
message: string,
public readonly code: string,
) {
super(message);
}
}
export class EntityNotFoundException extends DomainException {
constructor(entity: string, id: string) {
super(`${entity}(${id})를 찾을 수 없습니다`, 'ENTITY_NOT_FOUND');
}
}
export class BusinessRuleViolationException extends DomainException {
constructor(rule: string) {
super(`비즈니스 규칙 위반: ${rule}`, 'BUSINESS_RULE_VIOLATION');
}
}
// 도메인 예외 → HTTP 응답 매핑 필터
@Catch(DomainException)
export class DomainExceptionFilter implements ExceptionFilter {
private readonly statusMap: Record<string, HttpStatus> = {
ENTITY_NOT_FOUND: HttpStatus.NOT_FOUND,
BUSINESS_RULE_VIOLATION: HttpStatus.UNPROCESSABLE_ENTITY,
DUPLICATE_ENTITY: HttpStatus.CONFLICT,
};
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
catch(exception: DomainException, host: ArgumentsHost) {
const { httpAdapter } = this.httpAdapterHost;
const ctx = host.switchToHttp();
const status = this.statusMap[exception.code] ?? HttpStatus.INTERNAL_SERVER_ERROR;
httpAdapter.reply(
ctx.getResponse(),
{
statusCode: status,
code: exception.code,
message: exception.message,
timestamp: new Date().toISOString(),
},
status,
);
}
}
이 패턴을 쓰면 서비스/도메인 레이어는 HTTP를 전혀 몰라도 된다. 예외에 담긴 code로 필터가 적절한 HTTP 상태 코드를 결정한다. 아키텍처가 깔끔해지지만, 간단한 프로젝트에서는 오버엔지니어링이 될 수 있으니 프로젝트 규모에 맞게 판단하면 된다.
에러 응답 직렬화 통일
프론트엔드와 협업할 때 에러 응답 형식이 일관되지 않으면 클라이언트 쪽에서 에러 처리가 지저분해진다. 모든 에러 응답이 동일한 구조를 따르도록 필터에서 통일하면 프론트엔드 개발자가 편해진다.
// 통일된 에러 응답 인터페이스
interface ErrorResponse {
statusCode: number;
message: string;
error: string;
timestamp: string;
path: string;
}
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
catch(exception: unknown, host: ArgumentsHost) {
const { httpAdapter } = this.httpAdapterHost;
const ctx = host.switchToHttp();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = '서버 내부 오류가 발생했습니다';
let error = 'Internal Server Error';
if (exception instanceof HttpException) {
status = exception.getStatus();
const res = exception.getResponse();
// getResponse()가 문자열일 수도, 객체일 수도 있음
if (typeof res === 'string') {
message = res;
} else if (typeof res === 'object' && res !== null) {
message = (res as any).message ?? exception.message;
error = (res as any).error ?? error;
}
}
const body: ErrorResponse = {
statusCode: status,
message,
error,
timestamp: new Date().toISOString(),
path: httpAdapter.getRequestUrl(ctx.getRequest()),
};
httpAdapter.reply(ctx.getResponse(), body, status);
}
}
HttpException.getResponse()의 반환값이 문자열일 수도 있고 객체일 수도 있다는 점을 주의해야 한다. throw new BadRequestException('잘못된 요청')처럼 문자열만 넘기면 getResponse()가 객체 { statusCode, message, error }를 반환하지만, 객체를 직접 넘기면 그 객체가 그대로 나온다. 필터에서 이 두 경우를 모두 처리해야 안전하다.
테스트
Exception Filter도 단위 테스트가 가능하다. ArgumentsHost를 모킹하면 된다.
describe('AllExceptionsFilter', () => {
let filter: AllExceptionsFilter;
const mockJson = jest.fn();
const mockStatus = jest.fn().mockReturnValue({ json: mockJson });
const mockGetResponse = jest.fn().mockReturnValue({ status: mockStatus });
const mockGetRequest = jest.fn().mockReturnValue({ url: '/test' });
const mockArgumentsHost: ArgumentsHost = {
switchToHttp: jest.fn().mockReturnValue({
getResponse: mockGetResponse,
getRequest: mockGetRequest,
}),
getType: jest.fn().mockReturnValue('http'),
} as unknown as ArgumentsHost;
const mockHttpAdapterHost = {
httpAdapter: {
reply: jest.fn(),
getRequestUrl: jest.fn().mockReturnValue('/test'),
},
} as unknown as HttpAdapterHost;
beforeEach(() => {
filter = new AllExceptionsFilter(mockHttpAdapterHost);
});
it('HttpException이면 해당 상태 코드를 사용한다', () => {
const exception = new NotFoundException('없음');
filter.catch(exception, mockArgumentsHost);
expect(mockHttpAdapterHost.httpAdapter.reply).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ statusCode: 404 }),
404,
);
});
it('일반 에러는 500으로 처리한다', () => {
const exception = new Error('알 수 없는 에러');
filter.catch(exception, mockArgumentsHost);
expect(mockHttpAdapterHost.httpAdapter.reply).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ statusCode: 500 }),
500,
);
});
});
정리
Exception Filter는 NestJS에서 에러 처리를 중앙화하는 핵심 메커니즘이다. 기본 제공되는 예외 레이어로 충분하지 않을 때 — ORM 예외 매핑, 응답 형식 통일, 에러 로깅 등이 필요할 때 — 커스텀 필터를 만들어서 해결한다. 글로벌 캐치올 필터 하나와 ORM 전용 필터 하나, 이 조합이면 대부분의 프로젝트에서 충분하다.