개요
API 에러 응답을 일관된 JSON 포맷으로 통일하고, 비즈니스 예외와 프레임워크 예외를 한 경로로 수집하는 전역 예외 필터 설계와 구현 정리 핵심은 표준 에러 코드 정의, AppError 기반의 명시적 예외 던지기, GlobalExceptionFilter에서의 단일 포맷 출력, ValidationPipe 결과의 구조화, traceId 기반 상관관계 추적
요청 흐름 개요
- 요청 진입 → ValidationPipe 검증
- 검증 실패 → 전역 필터에서 표준 응답 변환
- 검증 통과 → Controller/Service 실행
- 비즈니스 로직 예외 → AppError 서브클래스 throw → 전역 필터 처리
- 알 수 없는 예외 → 500으로 표준 응답 변환
최종 응답 포맷은 단일 구조 유지
- { error: { code, message, details, traceId } } 형태
핵심 개념
에러 코드 중심 관리
- ErrorCode Enum으로 전역 에러 코드 집합 관리
- 문자열 하드코딩 제거 및 프런트엔드와의 계약 명확화
AppError 기반 계층
- HttpException 확장 베이스 클래스 AppError 정의
- 공통 필드 code, message, details 보유
- 상황별 편의 서브클래스 제공, 예를 들어 ValidationException, AuthRequiredException, PermissionDeniedException, ConflictException 등
표준 응답 스키마
- ErrorResponse 인터페이스를 통해 응답 형태 고정
- 필수 code, message, traceId와 선택 details로 확장 여지 확보
전역 예외 필터
- AppError, NestJS HttpException, 그 외 unknown 예외를 분기 처리
- ValidationPipe 에러 메시지 배열은 VALIDATION_FAILED로 승격하고 details에 원문 유지
- 404와 같은 공통 예외는 별도 코드 정책에 따라 매핑, 필요 시 NOT_FOUND 코드 도입 고려
- traceId를 매 요청 예외 응답에 포함해 서버 로그와 클라이언트 에러 상관관계 추적
관찰 가능성과 보안
- NODE_ENV가 production인 경우 스택 등 민감한 details 노출 금지
- 5xx는 error 로그, 4xx는 warn 로그 수준으로 구분 기록
동작 원리
1 요청이 애플리케이션에 진입하면 우선 전역 ValidationPipe에서 DTO 기반 입력 검증 수행 2 검증 실패 시 HttpException 형태로 올라오며, 전역 예외 필터에서 ValidationPipe 응답 구조를 해석해 code를 VALIDATION_FAILED로 변경, 메시지 정제, details에는 원인 리스트 보존 3 비즈니스 로직에서 AppError 혹은 서브클래스를 명시적으로 throw하면 전역 예외 필터가 해당 code, message, details를 그대로 사용해 응답 구성 4 프레임워크 기본 HttpException이나 기타 라이브러리에서 발생한 예외는 status를 유지하되 code를 정책에 따라 INVALID_INPUT 또는 기타 코드로 매핑, 알 수 없는 예외는 UNKNOWN_ERROR로 통일 5 모든 경우에 traceId를 생성해 응답과 로그에 포함, 서버 측 Logger로 상황별 로그 수준에 맞춰 기록 6 Express 어댑터 기준으로 Response 객체에 status와 JSON 바디를 설정해 반환
최소 스니펫
에러 코드 정의 예시
export enum ErrorCode {
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
INVALID_INPUT = 'INVALID_INPUT',
VALIDATION_FAILED = 'VALIDATION_FAILED',
UNAUTHORIZED = 'UNAUTHORIZED',
FORBIDDEN = 'FORBIDDEN',
USER_ALREADY_EXISTS = 'USER_ALREADY_EXISTS',
}AppError 베이스 클래스를 통한 일관 인터페이스
export class AppError extends HttpException {
constructor(
public readonly code: ErrorCode,
message: string,
status: HttpStatus = HttpStatus.BAD_REQUEST,
public readonly details?: unknown,
) {
super(message, status)
}
}전역 예외 필터 핵심 분기 골자
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse<Response>()
const req = host.switchToHttp().getRequest<Request>()
const traceId = uuidv4()
if (exception instanceof AppError) {
// AppError 그대로 변환
} else if (exception instanceof HttpException) {
// ValidationPipe와 404 등 매핑
} else {
// UNKNOWN_ERROR로 500 처리, 스택 비노출
}
// res.status(status).json({ error: { ... , traceId } })
}
}애플리케이션 등록 포인트
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
app.useGlobalFilters(new GlobalExceptionFilter())서비스 계층에서 명시적 예외 던지기
if (exists) throw new ConflictException(ErrorCode.USER_ALREADY_EXISTS, '이미 가입된 이메일', { email })클라이언트가 받는 표준 응답 예시
{
"error": {
"code": "USER_ALREADY_EXISTS",
"message": "이미 가입된 이메일",
"details": { "email": "test@example.com" },
"traceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}주의 사항과 베스트 프랙티스
- Express와 Fastify의 HTTP 어댑터 차이 고려 필요, Response 타입과 응답 전송 방식이 다름
- ValidationPipe 메시지는 다국어화나 사용자 노출 정책에 맞춰 재가공 추천, 내부 필드명 노출 최소화
- 404, 401, 403 등 공통 케이스는 별도 ErrorCode를 정의해 가독성과 검색성을 높이기 권장, NOT_FOUND 추가 고려
- AppError의 details는 디버깅과 클라이언트 UX에 꼭 필요한 최소 정보만 포함, 민감정보 금지
- 프로덕션에서 스택 등 내부 구현 노출 방지, traceId로 서버 로그와 상관관계 확보
- 로깅 레벨 정책 일관 유지, 5xx는 error, 4xx는 warn, 필수 컨텍스트만 포함
- API 문서와 동기화, 각 ErrorCode의 의미와 대응 HTTP 상태 코드 표기
- 기존 라이브러리나 ORM 에러를 AppError로 래핑해 상위 계층에 전파, 예외 전파 경로 단순화
- 분산 환경에서는 traceId를 요청 헤더로 전달받아 재사용하거나 미존재 시 생성, 서비스 간 연쇄 호출에도 동일 값 전파
간단 예시 흐름
1 클라이언트가 회원가입 요청을 전송 2 DTO 검증 실패 시 ValidationPipe가 400 HttpException 발생, 전역 필터가 VALIDATION_FAILED 코드와 메시지로 표준 응답 변환, details에는 필드별 에러 배열 유지 3 이메일 중복 시 서비스에서 ConflictException(AppError) throw, 전역 필터는 code와 details를 변경 없이 응답에 반영 4 알 수 없는 런타임 에러 발생 시 500 UNKNOWN_ERROR로 변환, 프로덕션에서는 스택 비노출, traceId로 서버 로그 상관관계 확보
마무리
- 에러 코드를 단일 Enum으로 집계해 팀 간 커뮤니케이션 비용 절감
- AppError를 통해 비즈니스 의도를 가진 예외만 상위로 전파, 예외 처리 비용 감소
- GlobalExceptionFilter로 모든 예외의 출력 포맷을 통일, 클라이언트 처리 로직 단순화
- traceId로 관찰 가능성 확보, 운영 단계 디버깅 효율 개선
- ValidationPipe와 프레임워크 기본 예외도 동일 포맷으로 정규화, API 일관성 유지
NestJS 구조화된 에러 핸들링의 목적은 예외의 의미를 잃지 않으면서도 응답은 표준화하는 것, 위 구조를 기반으로 서비스 상황에 맞는 코드 집합과 로깅 정책만 보강하면 확장 가능