NestJS ExceptionFilter 에러 처리

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-validatorValidationPipe가 던지는 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 외에 WebSocketGraphQL 컨텍스트에서도 동작합니다. 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 에러 포맷 통일: 필드별 에러 배열로 반환하여 프론트엔드 폼 검증과 매핑하세요
위로 스크롤
WordPress Appliance - Powered by TurnKey Linux