Backend
NestJS에서 예외 처리 구조 개선하기: 에러는 잡았는데, 어디서 터진 걸까?
내가 널 얼마나 찾았는데

1. 예외 처리는 하고 있었는데
프로젝트에서 API를 개발하다 보면 예외 처리는 자연스럽게 늘어난다. 요청값이 잘못됐을 때, 데이터 조회에 실패했을 때, 외부 API가 응답하지 않을 때 등 예외가 발생할 상황은 생각보다 많다.
NestJS는 기본적으로 HttpException, BadRequestException, InternalServerErrorException과 같은 예외 클래스를 제공한다.

덕분에 예외를 발생시키고 HTTP 응답으로 변환하는 작업은 어렵지 않다. 문제는 프로젝트 규모가 커졌을 때였다.
throw new InternalServerErrorException(
'데이터 저장에 실패했습니다.',
);응답은 정상적으로 반환된다.
그런데 로그를 확인하면 몇 가지 궁금증이 생긴다.

- 실제로 DB 저장에 실패한 걸까?
- 저장 전에 수행하는 비즈니스 로직에서 실패한 걸까?
- 외부 API에서 오류가 발생한 걸까?
- 같은 오류가 반복되는 건지, 서로 다른 원인인지 어떻게 구분할까?
HTTP 상태 코드와 메시지만으로는 예외의 발생 지점과 책임을 구분하기 어려웠다. 특히 하나의 API가 여러 서비스와 저장소를 호출하는 구조에서는 단순한 500 에러만으로 원인을 파악하기가 쉽지 않다.
물론 스택 트레이스를 따라가면 된다. 다만 오류가 발생할 때마다 스택 트레이스 전체를 읽는 방식이 효율적인 관측 체계라고 보기는 어려웠다. 그래서 기존 예외 처리 구조를 조금 더 명확하게 정리하기로 했다.
2. 이번 개선에서 지키고 싶었던 것
예외 처리 구조를 개선한다고 해서 기존 클라이언트의 응답 형식까지 변경하고 싶지는 않았다. 기존 API는 다음과 같은 오류 응답을 사용하고 있었다.
{
"errorCode": "PAGE_APPLY_FAILED",
"statusCode": 500,
"message": "페이지 적용에 실패했습니다."
}클라이언트는 errorCode, statusCode, message를 기준으로 오류를 처리한다. 따라서 기존 응답 형식은 그대로 유지하고, 서버 내부에서 예외를 분류할 수 있는 정보를 추가하기로 했다.
설계 목표는 다음과 같았다.
- 기존 HTTP 오류 응답 형식을 유지한다.
- 예외가 발생한 아키텍처 계층을 구분한다.
- 어떤 논리적 작업에서 실패했는지 식별한다.
- 사용자에게 노출하는 메시지와 내부 로그를 분리한다.
- 프로젝트 전체에서 동일한 예외 처리 규칙을 사용한다.
핵심은 클라이언트가 알아야 할 오류 정보와 개발자가 분석해야 할 오류 정보를 분리하는 것이었다.
3. 예외를 계층별로 구분하기
프로젝트는 HTTP 요청을 처리하는 계층부터 비즈니스 로직, 도메인 모델, 외부 시스템 연동까지 여러 책임으로 구성되어 있었다.
이를 기준으로 예외를 네 가지로 분류했다.
3.1 HTTP 경계: BaseException
Controller, Guard, Middleware, DTO 검증 등 HTTP 요청을 처리하는 경계에서 발생하는 오류다. 대표적으로 인증 헤더가 누락됐거나 요청값이 유효하지 않은 경우가 있다.
throw new BaseException(
ErrorCode.UNAUTHORIZED,
HttpStatus.UNAUTHORIZED,
'인증 정보가 유효하지 않습니다.',
);HTTP 요청 자체가 올바르지 않은 경우에는 별도의 비즈니스 계층 예외로 분류하지 않았다.
3.2 Application: ApplicationBaseException
Application 계층은 하나의 유스케이스를 실행하기 위해 여러 작업을 조합하는 역할을 한다. 예를 들어 페이지를 저장하는 기능이 있다고 가정해 보자.
페이지 저장 요청
↓
요청 데이터 검증
↓
기존 페이지 조회
↓
변경사항 반영
↓
데이터 저장
↓
저장 결과 반환이 과정에서 유스케이스를 정상적으로 완료하지 못했다면 Application 계층의 예외로 표현할 수 있다.
throw new ApplicationBaseException(
ErrorCode.PAGE_SAVE_FAILED,
HttpStatus.INTERNAL_SERVER_ERROR,
'페이지 저장에 실패했습니다.',
'page/save',
);단, 하위 계층에서 발생한 모든 예외를 무조건 Application 예외로 감싸지는 않는다. DB 연결 실패처럼 이미 원인이 구체적으로 분류된 예외라면 그 정보를 유지하는 편이 장애 분석에 유리하다.
3.3 Domain: DomainBaseException
Domain 계층은 비즈니스 규칙을 표현하는 영역이다. 예를 들어 이미 게시된 콘텐츠는 삭제할 수 없다는 규칙이 있다고 가정해 보자.
class Content {
constructor(
private readonly isPublished: boolean,
) {}
validateDeletion(): void {
if (this.isPublished) {
throw new DomainBaseException(
ErrorCode.CONTENT_DELETE_NOT_ALLOWED,
HttpStatus.CONFLICT,
'게시된 콘텐츠는 삭제할 수 없습니다.',
'content/delete',
);
}
}
}이 오류는 DB 조회나 외부 API 호출에 실패한 것이 아니다. 현재 콘텐츠 상태에서 삭제를 허용하지 않는다는 도메인 규칙을 위반한 것이다.
따라서 Domain 예외로 분류한다. 여기서 중요하게 생각한 부분은 Repository의 위치였다. Repository 인터페이스가 Domain 계층에 정의되어 있더라도 실제 DB 쿼리를 수행하는 구현체는 Infrastructure에 있다.
즉, Repository에서 DB 연결이나 쿼리에 실패했다면 Domain 오류가 아니라 Infrastructure 오류로 처리한다. 폴더 위치보다 실제 실패 책임을 기준으로 분류하는 것이 중요했다.
3.4 Infrastructure: InfrastructureBaseException
Infrastructure 계층은 외부 시스템과 직접 통신하는 영역이다.
예를 들면 다음과 같다.
- 데이터베이스 조회 및 저장
- Redis 캐시 처리
- 외부 REST API 호출
- 파일 업로드
- 외부 스토리지 연동
try {
return await this.httpClient.get('/contents');
} catch (error) {
throw new InfrastructureBaseException(
ErrorCode.EXTERNAL_CONTENT_FETCH_FAILED,
HttpStatus.BAD_GATEWAY,
'콘텐츠 정보를 조회하지 못했습니다.',
'external/content/fetch',
);
}실제 적용 시에는 내부 로그에 원본 오류를 cause로 연결하는 방식도 고려할 수 있다. 원본 예외를 모두 숨기면 정작 장애가 발생했을 때 원인을 추적하기 어려워지기 때문이다. 사용자에게는 안전한 메시지를 반환하되, 서버 내부에서는 원본 오류를 추적할 수 있도록 하는 것이 목적이다.
4. 공통 예외 클래스 설계
계층별 예외를 만들더라도 공통적인 응답 필드는 동일하게 유지하고 싶었다.
이를 위해 기본 예외 클래스에서 errorCode, statusCode, message를 관리하도록 했다.
4.1 BaseException
import { HttpException, HttpStatus } from '@nestjs/common';
export class BaseException extends HttpException {
readonly layer: string = 'base';
readonly operation?: string;
constructor(
public readonly errorCode: ErrorCode,
public readonly statusCode: HttpStatus,
message: string,
) {
super(
{
errorCode,
statusCode,
message,
},
statusCode,
);
}
}기본 예외 클래스는 기존 API 응답 형식을 그대로 유지한다.
4.2 계층별 공통 예외
각 계층의 예외는 공통적으로 layer와 operation을 가진다.
type ExceptionLayer =
| 'base'
| 'application'
| 'domain'
| 'infrastructure';
abstract class LayeredBaseException extends BaseException {
abstract override readonly layer: ExceptionLayer;
constructor(
errorCode: ErrorCode,
statusCode: HttpStatus,
message: string,
public override readonly operation: string,
) {
super(errorCode, statusCode, message);
}
}Application 예외는 다음과 같이 정의할 수 있다.
export class ApplicationBaseException
extends LayeredBaseException {
readonly layer = 'application' as const;
}Domain과 Infrastructure도 같은 구조로 구현한다.
export class DomainBaseException
extends LayeredBaseException {
readonly layer = 'domain' as const;
}
export class InfrastructureBaseException
extends LayeredBaseException {
readonly layer = 'infrastructure' as const;
}이렇게 구성하면 공통 필드는 유지하면서 계층별로 예외를 구분할 수 있다. 또한 클래스 이름만으로 예외의 책임을 파악할 수 있다.
5. Operation으로 실패한 작업 식별하기
계층을 구분한 다음에는 어떤 작업에서 오류가 발생했는지도 기록할 필요가 있었다.
이를 위해 operation이라는 필드를 추가했다.
Operation은 HTTP URL이 아니라 실패가 발생한 논리적인 작업을 식별하는 값이다.
예를 들어 다음과 같이 정의한다.
content/page/get
content/page/save
content/page/apply
content/skin/create
content/skin/delete
external/content/fetch
storage/file/upload
database/page/findHTTP 경로를 그대로 사용하지 않는 이유가 있다. 하나의 논리 작업이 여러 HTTP 경로에서 호출될 수 있고, 반대로 하나의 HTTP 요청에서 여러 작업이 실행될 수도 있기 때문이다. 또한 요청별로 달라지는 ID나 쿼리 스트링을 operation에 포함하면 로그를 집계하기 어려워진다.
따라서 다음 원칙을 적용했다.
- 소문자 kebab-case를 사용한다.
- 서비스 또는 기능 영역, 도메인, 작업을 구분한다.
- ID, 토큰, 요청 파라미터처럼 변하는 값은 포함하지 않는다.
- 같은 논리 작업에서는 같은 operation을 사용한다.
Operation 값이 일관되면 특정 작업에서 오류가 얼마나 자주 발생하는지 집계하기도 쉬워진다.
6. ErrorCode는 HTTP 상태 코드보다 구체적으로
HTTP 상태 코드는 오류의 종류를 대략적으로 나타낸다.
하지만 500 Internal Server Error만으로는 실제 문제가 무엇인지 알 수 없다.
그래서 별도의 ErrorCode를 유지했다.
export enum ErrorCode {
INVALID_REQUIRED = 'INVALID_REQUIRED',
INVALID_FORMAT = 'INVALID_FORMAT',
UNAUTHORIZED = 'UNAUTHORIZED',
FORBIDDEN = 'FORBIDDEN',
PAGE_SAVE_FAILED = 'PAGE_SAVE_FAILED',
PAGE_APPLY_FAILED = 'PAGE_APPLY_FAILED',
CONTENT_NOT_FOUND = 'CONTENT_NOT_FOUND',
CONTENT_DELETE_NOT_ALLOWED = 'CONTENT_DELETE_NOT_ALLOWED',
EXTERNAL_CONTENT_FETCH_FAILED =
'EXTERNAL_CONTENT_FETCH_FAILED',
FILE_UPLOAD_FAILED = 'FILE_UPLOAD_FAILED',
}예외를 작성할 때는 가능하면 의미가 구체적인 코드를 사용하도록 했다. 예를 들어 페이지 적용에 실패했다면 단순히 INTERNAL_SERVER_ERROR보다는 PAGE_APPLY_FAILED가 더 많은 정보를 제공한다.
반면 인증 실패나 요청값 누락처럼 범용적인 의미가 적합한 경우에는 공통 코드를 사용한다. 여기서 주의할 점은 ErrorCode와 message의 역할이 다르다는 것이다. ErrorCode는 시스템이 구분하는 식별자이고, message는 사용자에게 전달할 수 있는 설명이다.
메시지에 SQL 오류, 액세스 토큰, 외부 시스템의 원본 응답 등을 포함하지 않도록 했다.
7. GlobalExceptionFilter로 예외 처리 일원화하기
각 계층에서 예외를 구분하더라도 최종 HTTP 응답은 한 곳에서 처리하는 편이 관리하기 쉽다. NestJS의 ExceptionFilter를 사용하면 예외를 공통 형식으로 변환할 수 있다. 구조는 다음과 같다.
Controller / Guard
│
Application Service
│
Domain Model
│
Infrastructure
│
▼
Exception 발생
│
▼
GlobalExceptionFilter
│
├─ 클라이언트 응답
│ errorCode
│ statusCode
│ message
│
└─ 서버 로그
requestId
layer
operation
errorCode
statusCode7.1 전역 필터 구현 예시
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
@Catch()
export class GlobalExceptionFilter
implements ExceptionFilter {
private readonly logger = new Logger(
GlobalExceptionFilter.name,
);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const isBaseException =
exception instanceof BaseException;
const isHttpException =
exception instanceof HttpException;
const statusCode = isBaseException
? exception.statusCode
: isHttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const errorCode = isBaseException
? exception.errorCode
: 'INTERNAL_SERVER_ERROR';
const message = isBaseException
? exception.message
: statusCode >= 500
? '요청 처리 중 오류가 발생했습니다.'
: '요청을 처리할 수 없습니다.';
const layer = isBaseException
? exception.layer
: 'unknown';
const operation = isBaseException
? exception.operation
: undefined;
this.logger.error({
requestId: request.id,
layer,
operation,
errorCode,
statusCode,
message,
});
response.status(statusCode).json({
errorCode,
statusCode,
message,
});
}
}예시는 핵심 구조를 보여주기 위해 단순화한 코드다. 실제 구현에서는 ValidationPipe에서 발생한 오류의 매핑, HTTP 어댑터, 로깅 정책 등을 함께 고려해야 한다.
requestId 역시 요청에 미리 부여돼 있어야 하므로 별도의 미들웨어나 요청 추적 기능을 통해 관리해야 한다.
7.2 클라이언트 응답은 그대로 유지
Application 계층에서 다음 예외가 발생했다고 가정하자.
throw new ApplicationBaseException(
ErrorCode.PAGE_APPLY_FAILED,
HttpStatus.INTERNAL_SERVER_ERROR,
'페이지 적용에 실패했습니다.',
'content/page/apply',
);클라이언트는 기존과 동일한 응답을 받는다.
{
"errorCode": "PAGE_APPLY_FAILED",
"statusCode": 500,
"message": "페이지 적용에 실패했습니다."
}반면 서버 로그에는 추가 정보가 기록된다.
{
"requestId": "req-123456",
"layer": "application",
"operation": "content/page/apply",
"errorCode": "PAGE_APPLY_FAILED",
"statusCode": 500
}이제 로그만 확인해도 Application 계층의 페이지 적용 작업에서 오류가 발생했다는 사실을 알 수 있다. 특히 requestId와 함께 기록하면 하나의 요청에서 발생한 여러 로그를 연결해 분석할 수 있다.
8. 예상하지 못한 예외는 어떻게 처리할까?
모든 오류를 사전에 정의할 수는 없다. 개발자가 예상하지 못한 런타임 오류가 발생할 수도 있고, NestJS 내부에서 기본 HttpException이 발생할 수도 있다. 그래서 모든 예외를 억지로 계층별 예외로 변환하지 않기로 했다.
대신 다음과 같은 기준을 적용했다.
예상하지 못한 오류에는 사용자에게 내부 오류 내용을 노출하지 않고, 서버 로그에 원본 스택 트레이스를 남기도록 구성하는 것이 중요하다. 또한 하위 계층 예외를 상위 계층에서 다시 감쌀 때는 원본 예외 정보를 잃지 않도록 해야 한다.
예를 들어 Redis 연결 실패가 발생했는데 상위 서비스에서 이를 모두 PAGE_SAVE_FAILED로 변경하면 실제 원인이 사라질 수 있다. 사용자에게 보여주는 오류는 단순하더라도 내부적으로는 어떤 의존성에서 실패했는지 추적할 수 있어야 한다.
9. 예외 처리 구조를 개선하며 고려한 점
9.1 모든 오류에 새로운 클래스를 만들 필요는 없다
계층별로 예외를 구분한다고 해서 오류 종류마다 클래스를 새로 만들 필요는 없다.
PageSaveException, PageDeleteException, PageApplyException처럼 클래스를 계속 추가하면 관리 대상만 늘어날 수 있다. 공통 예외 클래스와 ErrorCode를 조합하면 필요한 정보를 충분히 표현할 수 있다.
9.2 Domain 예외와 Infrastructure 예외는 명확하게 구분한다
가장 혼동하기 쉬운 부분이었다. 도메인 Repository 인터페이스를 통해 DB를 조회한다고 해서 DB 오류가 Domain 예외가 되는 것은 아니다. 도메인 규칙을 위반한 경우에만 Domain 예외를 사용한다.
외부 시스템과의 통신이나 데이터 저장 실패는 Infrastructure 책임이다. 이 기준이 명확해야 같은 종류의 오류가 개발자마다 다르게 분류되는 문제를 줄일 수 있다.
9.3 Operation은 로그 분석을 위한 식별자다
Operation에 요청마다 달라지는 값을 넣으면 로그를 그룹화하기 어려워진다.
# 지양
content/page/12345/apply
# 권장
content/page/apply페이지 ID처럼 추가적인 분석에 필요한 값은 별도의 로그 필드로 관리하는 편이 낫다. 단, 민감 정보가 로그에 남지 않도록 주의해야 한다.
9.4 예외 처리를 구조화한다고 오류가 해결되는 것은 아니다
이번 개선은 오류를 없애기 위한 작업이 아니다. 오류가 발생했을 때 원인을 더 빠르게 파악하고, 일관된 방식으로 처리하기 위한 작업이다. 구조화된 로그가 생겼다고 해서 장애가 자동으로 해결되는 것은 아니지만, 적어도 오류가 발생한 위치부터 다시 추측할 필요는 줄어든다.
10. 마무리
처음에는 예외 처리 방식을 통일하는 단순한 리팩토링이라고 생각했다. 하지만 구조를 정리하다 보니 예외 처리는 단순히 throw와 catch를 작성하는 문제가 아니었다. 어느 계층이 어떤 실패를 책임지는지, 클라이언트에는 무엇을 보여줄지, 서버에서는 어떤 정보를 남겨야 하는지까지 함께 결정해야 했다.
이번 설계에서는 기존 응답 형식을 유지하면서 계층별 예외와 operation 정보를 추가하는 방향을 선택했다. 이를 통해 예외의 종류뿐 아니라 실패가 발생한 책임 영역과 논리적 작업을 구분할 수 있는 기반을 마련했다.

결국 좋은 예외 처리 구조는 에러 메시지를 많이 만드는 것이 아니라, 오류가 발생했을 때 필요한 정보를 일관되게 남기는 구조라고 생각한다. 에러는 어차피 발생한다. 중요한 건 다음에 같은 에러를 만났을 때 처음부터 다시 고생하지 않는 것이라고 생각한다.
댓글
익명 캐릭터로 가볍게 이야기를 남겨 주세요.