⚠️ 07. 에러 처리 — 고객에게 친절한 '불만 처리 전담반' 만들기
📋 개요
서버가 뻗지 않도록 예방하고, 클라이언트에게 항상 친절하고 일관된 에러 응답을 내려주는 전역 예외 필터(Exception Filter)를 마스터합니다.
📋 목차
- 📌 이 문서를 읽기 전에
- 🤔 왜 알아야 하는가
- 🏗️ 비유로 먼저 이해하기
- 🧩 내장 예외와 커스텀 예외 🟡
- 🧩 전역 예외 필터 (Global Exception Filter) 🟢
- 🧪 따라해보기: 궁극의 글로벌 필터 깎아보기
- 💼 베스트 프랙티스와 실무 팁
- ⚠️ 에러 해결 카탈로그
- 🗂️ 치트시트
- 📝 마무리 퀴즈
📌 이 문서를 읽기 전에
⏱️ 예상 읽기 시간: 15분
🧳 전제 지식 체크리스트
- HTTP 상태 코드(400, 401, 404, 500 등)의 대략적인 의미를 알고 있다.
- JavaScript의
try-catch문의 역할과throw new Error()문법을 이해한다.
🗺️ 이 문서의 흐름
NestJS 내장 에러 뱉기 → 나만의 비즈니스 에러로 포장하기 → 서버 급증을 막는 전역 그물망(Filter) 던지기 → 실무 로깅 및 에러 코드 관리법
🎯 이 문서를 다 읽으면 할 수 있는 것
- 컨트롤러에서 지저분한
try-catch도배를 걷어내고 코드 가독성을 2배 높일 수 있다. - 프론트엔드 개발자가 파싱하기 쉬운 "일관된 JSON 에러 응답 포맷"을 규격화할 수 있다.
- 알 수 없는 치명적 500 에러 발생 시 지정된 채널(Slack 등)로 즉시 알림을 쏘는 구조를 짤 수 있다.
🤔 왜 알아야 하는가
초보 시절 백엔드 코드의 가장 큰 특징은 라우터마다 try { ... } catch (e) { res.status(500).send(e) } 가 도배되어 있다는 거야.
이렇게 짜면 프론트엔드 입장에서는 어떤 API는 에러를 텍스트로 주고, 어떤 API는 JSON으로 주고, 응답 형태가 제멋대로라 에러 팝업을 띄우는 것 자체가 고통이 돼.
NestJS의 예외 필터(Exception Filter) 를 배우면, 에러 처리를 한 곳으로 싹 몰아서(중앙 집중화) 우아하게 프론트엔드에게 일관된 양식을 넘겨줄 수 있어.
🏗️ 비유로 먼저 이해하기
🧒 5살에게 설명한다면?
나쁜 식당 (컨트롤러에서 다 처리함):
주방장(Service)이 요리를 태웠어. 홀서빙 직원(Controller)이 주방에 들어가서 "아이고 망했네!" 하고 직접 손님한테 달려가 "요리가 타서 못 먹습니다, 500원 돌려드릴게요"라고 사과해. 직원이 100명이면 사과법도 100가지야.
좋은 식당 (예외 필터 사용):
주방장은 그냥 요리를 태우면 주방 바닥에 불량 스티커(Exception)를 탁 던지고 쿨하게 돌아서 버려!
홀서빙 직원도 신경 안 써. 대신 식당에는 '고객 불만 처리 전담반(Exception Filter)' 이라는 부서가 숨어있지. 주방에서 누가 불량 스티커를 던지는 순간 0.1초 만에 전담팀이 출동해서 낚아챈 다음, "고객님, 메뉴가 품절입니다(깔끔한 JSON 형태)" 라며 매우 친절하고 통일된 톤으로 응대하는 거야.
🧩 내장 예외와 커스텀 예외 🟡
🎯 이 섹션을 읽고 나면:
- 상황에 맞는 HTTP 예외 클래스를 던질(
throw) 줄 안다.
NestJS가 준비해 둔 기본 예외들 (무기고)
NestJS는 HttpException을 상속받은 수많은 상태코드별 무기들을 이미 다 준비해놨어.
import { NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
@Injectable()
export class UsersService {
async getProfile(id: string) {
const user = await this.db.findById(id);
// 유저가 없다면 필터에게 위험 요소(예외)을 던짐!
if (!user) {
// 404 상태코드와 메시지가 담긴 예외 발생
throw new NotFoundException(`회원번호 ${id}를 찾을 수 없습니다.`);
}
return user;
}
}[주요 내장 예외 목록]
BadRequestException(400) - 파라미터나 바디값이 틀렸음 (04장 유효성 검사 파이프가 뱉는 것도 이거야)UnauthorizedException(401) - 토큰 없음/틀림 (06장 가드가 뱉음)NotFoundException(404) - 리소스를 못 찾음ConflictException(409) - 이메일 중복가입, 이미 좋아요 누름 등 로직 충돌InternalServerErrorException(500) - DB가 죽었거나 내 코드에 버그가 있거나
나만의 커스텀 예외 만들기
"잔액 부족" 같은 비즈니스 특화 에러는 400 에러지만, 평범한 400 에러와는 구분 지어주고 싶어!
// common/exceptions/business.exception.ts
export class InsufficientBalanceException extends BadRequestException {
constructor(required: number, current: number) {
// 프론트엔드에게 줄 JSON 데이터를 상세하게 마음대로 조립!
super({
errorCode: 'INSUFFICIENT_BALANCE',
message: '돈이 부족합니다 .',
details: { required, current } // 남은 잔액을 알려줌
});
}
}🧩 전역 예외 필터 (Global Exception Filter) 🟢
이제 주방(서비스/컨트롤러)에서 던지는 모든 에러를 허공에서 낚아채서, 예쁜 포장지에 담아주는 '불만 처리 전담반'을 만들 차례야.
// common/filters/http-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { Response } from 'express'; // Fastify 쓰면 FastifyReply 적용
@Catch() // 👈 아무 괄호도 안 적으면 '에러나는 모든 것'을 다 잡아채겠다는 뜻!
export class GlobalExceptionFilter implements ExceptionFilter {
// 에러가 트리거되는 순간 여기가 반드시 실행됨
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
// 1. 상태 코드 판별 (내가 던진 에러면 그 코드, 아니면 반드시 500 처리)
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
// 2. 최종 프론트엔드에게 내려갈 통일된 JSON 포맷!
response.status(status).json({
success: false, // 반드시 들어가는 공통 상태
timestamp: new Date().toISOString(),
path: request.url,
// 내가 던진 에러라면 알맹이를 빼오고, 알 수 없는 에러면 임의 메세지
error: exception instanceof HttpException
? exception.getResponse()
: '앗! 서버 내부 네트워크망 오류입니다!',
});
}
}이 필터를 건물 전체에 활성화시키면 끝이야. main.ts 로 간다!
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 글로벌 필터 장착! 이제 365일 에러 안 놓침.
app.useGlobalFilters(new GlobalExceptionFilter());
await app.listen(3000);
}🧪 따라해보기: 궁극의 글로벌 필터 깎아보기
실무에서는 NestJS 유효성 검사 에러, Zod 라이브러리 검사 에러, 알 수 없는 치명적 500 에러 등을 필터 안에서 분기 처리해야 해. 거기에 500 에러 발생 시 터미널 로깅까지 붙여보자.
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { ZodError } from 'zod'; // Zod 쓸 때
@Catch()
export class MegaGlobalFilter implements ExceptionFilter {
// 로거 장착
private readonly logger = new Logger(MegaGlobalFilter.name);
// HttpAdapterHost를 쓰면 Express든 Fastify든 프레임워크 종속성 없이 응답(Reply) 가능!
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
catch(exception: unknown, host: ArgumentsHost) {
const { httpAdapter } = this.httpAdapterHost;
const ctx = host.switchToHttp();
const request = ctx.getRequest();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = '알 수 없는 에러가 발생했습니다.';
let errorCode = 'UNKNOWN_ERROR';
// 분기 1: 내가 알고 던진 HTTP 에러 (400, 401, 404 등)
if (exception instanceof HttpException) {
status = exception.getStatus();
const res = exception.getResponse() as any;
message = typeof res === 'string' ? res : (res.message || 'Error');
errorCode = res.errorCode || 'HTTP_ERROR';
}
// 분기 2: Zod 스키마 검증 실패 에러
else if (exception instanceof ZodError) {
status = HttpStatus.BAD_REQUEST;
message = '입력값이 올바르지 않습니다.';
errorCode = 'ZOD_VALIDATION_ERROR';
}
// 분기 3: 진짜 서버가 발생하는 버그 (null.abc 접근, DB 접속 지연 등) => 500
else {
// 🚨 치명적 에러는 반드시 자세하게 로깅! (또는 여기서 슬랙 API 호출)
this.logger.error(
`[${request.method}] ${request.url} - ${exception}`,
(exception as Error)?.stack,
);
}
// 통일된 응답 규격
const responseBody = {
statusCode: status,
errorCode,
message,
timestamp: new Date().toISOString(),
path: httpAdapter.getRequestUrl(request),
};
httpAdapter.reply(ctx.getResponse(), responseBody, status);
}
}앱에 등록할 때는 HttpAdapterHost 주입에 치사하게 주의해야 해:
// main.ts
const httpAdapterHost = app.get(HttpAdapterHost); // 어댑터 가져오기!
app.useGlobalFilters(new MegaGlobalFilter(httpAdapterHost));💼 베스트 프랙티스와 실무 팁 🟡
1. 컨트롤러 안에서 명시적인 try-catch는 최대한 지운다.
가장 많이 하는 초보의 실수가 모든 라우터마다 try-catch를 덧씌우는 거야.
"만약 로직 짜다 에러 나면 어떡해요?" -> 어차피 글로벌 필터가 다 잡아! 그냥 서비스 함수 쭉 쓰고 만약 이상하면 서비스 안에서 throw new NotFoundException() 만 던져 버려. 컨트롤러는 세상 편하게 로직만 호출하는 게 가장 안정적인 형태야.
2. 에러 코드는 한 곳에 모아 상수로 관리하라
하드코딩된 에러 코드를 마구 쓰면, 나중에 프론트엔드 개발자가 분기 처리할 때 고통스러워.
// common/error-codes.ts
export const ErrorCodes = {
USER_NOT_FOUND: 'USER_NOT_FOUND',
EMAIL_EXISTS: 'EMAIL_EXISTS',
TOKEN_EXPIRED: 'TOKEN_EXPIRED',
} as const;
// 사용할 때
throw new BadRequestException({ errorCode: ErrorCodes.EMAIL_EXISTS });⚠️ 에러 해결 카탈로그
에러 메시지가 뜨면 Ctrl+F로 검색해봐.
❌ UnhandledPromiseRejectionWarning (서버 다운)
현상:
서버가 완전히 푹 꺼짐. 무언가 처리되지 않은 예외라며 뻘건 글씨 장문이 터미널에 뜸.
원인:
놀랍게도 글로벌 예외 필터를 거치지 않은 에러야. 이 경우는 비동기 함수 (Promise) 안에서 에러가 났는데 await를 안 걸어줘서 타이밍이 어긋난 채 우주 밖 미아로 에러가 터져버린 경우야.
해결책:
에러가 난 Promise를 리턴하는 함수(예: DB 저장 로직) 앞에 await를 잘 기재했는지 체크!
❌ TypeError: Cannot read properties of undefined (reading 'switchToHttp')
원인:
필터에서 ArgumentsHost를 써야 하는데 뭔가 객체 구조 인식을 잘못했음. 주로 WebSocket이나 GraphQL 같이 HTTP가 아닌 환경에서 강제로 .switchToHttp()를 무리하게 꺼내려 할 때 발생하기도 해.
해결책:
만약 HTTP 전용 앱이 아니라면 host.getType() === 'http' 로 분기 처리를 해줘야 안전해.
🗂️ 치트시트 — 실무 요약 카드
| 상황 | 사용 예시 | 비고 |
|---|---|---|
| 리소스 못 찾음 | throw new NotFoundException() | HTTP 404 내려감 |
| 비즈니스 에러 | throw new BadRequestException() | 대부분의 에러 (400) |
| 파이프/가드 밖에서 다 잡기 | @Catch() 달린 클래스 생성 | 글로벌 필터 완성 |
| 전역 필터 적용 | app.useGlobalFilters(new MyFilter()) | main.ts 필수 기재 |
📝 마무리 퀴즈
Q1. NestJS에서 좋은 아키텍처를 만들기 위해 권장하는 에러 처리 방식 중 가장 옳은 것은?
- A) 모든 컨트롤러의 라우터 내부에
try { } catch() { }블록을 작성하여 직접 500 상태 코드를res.send()한다. - B) 서비스(Service) 레이어의 모든 반환값을 에러인지 확인하는
if (result.error)검사를 촘촘히 짠다. - C) 컨트롤러에서 예외 처리 고민을 덜고, 로직 중 문제가 생기면 예외(
Exception)를throw하여 전역 필터가 처리하게 맡긴다.
✅ 정답: C
💡 설명: 필터의 존재 이유야. 홀서빙 직원(Controller)은 요리 주문만 받고 갖다주고 편하게 일해야 해. 돌발 상황 처리는 불만 처리반(Global Exception Filter)에 중앙집권화 시켜서 통일되게 응답하는 것이 최고야.
Q2. 프론트엔드 개발자가 API 응답을 받았는데 에러 형태가 파싱하기 너무 힘들다고 항의한다. 이를 완전히 규격화(예: { success: false, msg: "...", code: "..." }) 해서 한 방에 처리하고 싶은데, 꼭 작성해야 하는 클래스의 데코레이터와 상속 타입은 무엇인가?
✅ 정답: @Catch() 데코레이터를 붙이고, ExceptionFilter 인터페이스를 구현(implements)한 필터 객체를 작성한다!
Q3. 디버깅 퀴즈: 아래 코드에서 HttpAdapterHost를 다룰 때 빼먹어선 안 될 main.ts 등록 과정을 서술해봐.
@Catch()
export class HttpFilter implements ExceptionFilter {
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
}✅ 정답: 빌드된 app 객체에서 어댑터를 가져와 인자로 넣어줘야 한다.
설명: const httpAdapterHost = app.get(HttpAdapterHost); 이렇게 app.get() 명령어로 컨테이너에서 꺼내온 뒤에 app.useGlobalFilters(new HttpFilter(httpAdapterHost)) 와 같이 넣어줘야 의존성이 깨지지 않아.
🐣 영철이의 퇴근 일기
오늘은 에러 처리가 try-catch를 많이 쓰는 일이 아니라 실패 응답의 계약을 통일하는 일이라는 걸 배웠다. 컨트롤러마다 제각각 res.status().send()를 하면 프론트는 매번 다른 모양의 에러를 해석해야 한다.
영숙 님이 "사용자는 실패했을 때 더 불안하다"고 말했는데, 전역 예외 필터가 왜 필요한지 바로 이해됐다.
💡 "좋은 에러 처리는 서버를 숨기는 일이 아니라, 실패를 예측 가능한 형태로 번역하는 일이다."
내일은 예외 필터를 보면 응답 포맷이 프론트와 합의된 형태인지까지 확인해야겠다.