[REST API] 6편. Fastify 스키마 검증, 전역 에러 처리, 로그 설계 : try/catch 지옥 벗어나기
6편. Fastify 스키마 검증, 전역 에러 처리, 로그 설계 : try/catch 지옥 벗어나기
📚 목차
1. 전역 에러 핸들러(ErrorHandler): 아키텍처의 중심이 되는 이유
2. Schema 검증을 통한 에러 자동 처리 설계 : TypeBox Validation
3. BusinessError · PrismaError · ValidationError 통합 설계
4. 로그는 '부가 기능'이 아닌 '시스템의 증거'입니다

📂 [GitHub 코드 보러가기] : https://github.com/cericube/nodejs-practice-lab/tree/main/fastify-api-rest
1. 전역 에러 핸들러(ErrorHandler): 아키텍처의 중심이 되는 이유
백엔드 개발에서 에러 처리는 단순히 '예외를 잡는 것' 이상의 의미를 갖습니다.
에러 처리를 어떻게 설계하느냐에 따라 비즈니스 로직의 순수성과 전체 코드의 유지보수성이 결정됩니다.
1. 흔히 마주하는 문제: try/catch 지옥
많은 프로젝트에서 관습적으로 사용하는 아래와 같은 코드는 시간이 흐를수록 부채가 됩니다.
try {
const user = await service.createUser(dto);
reply.send(user);
} catch (e) {
// 매번 반복되는 에러 처리
reply.status(500).send({ message: 'Internal Server Error' });
}
이 방식의 주요 문제점
▸ 코드 중복: 모든 컨트롤러와 핸들러에 유사한 try/catch 블록이 반복됩니다.
▸ 응답의 일관성 결여: 개발자마다, 혹은 엔드포인트마다 에러 응답 포맷이 미세하게 달라져 클라이언트 대응이 어려워집니다.
▸ 로깅 정책의 파편화: 에러 로그를 어디서, 어떤 수준(Level)으로 남길지에 대한 규칙을 일괄적으로 적용하기 힘듭니다.
▸ 비즈니스 의도 결여: 코드만 봐서는 이 에러가 왜 400(Bad Request)인지, 404(Not Found)인지 직관적으로 파악하기 어렵습니다.
2. Fastify의 에러 흐름 이해하기
Fastify는 에러가 발생했을 때 이를 상위로 전파(Propagate)하여 한곳에서 처리할 수 있는 강력한 메커니즘을 제공합니다.
▸ Validation 실패: Fastify 내부(AJV 등)에서 자동으로 에러를 던집니다.
▸ 계층별 에러 발생: Handler, Service, Repository 등 어느 계층에서든 throw를 통해 에러를 발생시킵니다.
▸ 전역 핸들러 도달: setErrorHandler()로 등록된 핸들러가 이 모든 에러를 최종적으로 수집합니다.
// app.ts
fastify.setErrorHandler(errorHandler);
이러한 흐름을 활용하면 각 계층은 본연의 역할에만 집중할 수 있습니다.
| 계층 | 역할 |
| Service | 비즈니스 규칙 위반 시 의미 있는 BusinessError를 정의하여 던짐 |
| Repository | 데이터베이스 에러를 그대로 위로 던지거나, 기술적인 에러를 정의함 |
| ErrorHandler | 수집된 모든 에러를 해석하여 적절한 HTTP 응답으로 번역함 |
3. 전역 에러 핸들러 구현 예시
모든 에러를 표준화된 포맷으로 변환해주는 전역 핸들러의 예시입니다.
// error.handler.ts
export function errorHandler(error: unknown, request: FastifyRequest, reply: FastifyReply) {
// 1. 어떤 분기에도 걸리지 않으면 무조건 500으로 처리
let statusCode = 500;
let errorCode = ErrorCode.INTERNAL_SERVER_ERROR;
let message = '서버에서 오류가 발생했습니다.';
let details: unknown = null;
// 2. 비즈니스 에러 (의도적인 예외)
if (error instanceof BusinessError) {
statusCode = error.statusCode;
errorCode = error.errorCode;
message = error.message;
}
// 3. Fastify Validation 에러 (Schema 검증 실패)
else if (error && typeof error === 'object' && 'validation' in error) {
statusCode = 400;
errorCode = ErrorCode.VALIDATION_ERROR;
message = '입력 형식이 올바르지 않습니다.';
details = (error as any).validation;
}
// 4. Prisma DB 에러 (데이터베이스 제약 조건 등)
else if (error instanceof Prisma.PrismaClientKnownRequestError) {
const mapping = mapPrismaError(error);
statusCode = mapping.status;
errorCode = mapping.code;
message = mapping.message;
}
// 구조화된 로깅 (Observability 확보)
request.log[statusCode >= 500 ? 'error' : 'info']({
traceId: request.id,
path: request.url,
error: { code: errorCode, message, details },
});
// 클라이언트에게는 표준화된 포맷으로 응답
return reply.status(statusCode).send({
success: false,
code: errorCode,
message,
});
}
4. 결과: API 계약 기반의 간결한 컨트롤러
전역 핸들러가 도입되면 컨트롤러의 코드는 간결해집니다.
// controller.ts
const result = await userController.createUser(request.body);
return reply.code(200).send(success(result));
✔️ 왜 이 코드가 더 우수한가요?
🔸 관심사의 분리: 컨트롤러는 "성공했을 때 무엇을 줄 것인가"라는 것에만 집중합니다.
🔸 가독성 향상: 불필요한 try/catch가 제거되어 비즈니스 로직의 흐름이 한눈에 들어옵니다.
🔸 API 계약 준수: 어떤 에러가 발생하더라도 클라이언트는 항상 동일한 구조의 에러 객체를 받게 되어, 프런트엔드와의 협업 효율이 극대화됩니다.
2. Schema 검증을 통한 에러 자동 처리 설계 : TypeBox Validation
Fastify와 TypeBox를 결합하면 이 계약을 코드 수준에서 강제하고, 런타임 안정성과 문서화라는 두 마리 토끼를 한 번에 잡을 수 있습니다.
1. RouteShorthandOptions: API 계약의 단일 진실원 (SSOT)
RouteShorthandOptions의 역할
Fastify에서 RouteShorthandOptions는 단순한 설정 객체가 아니라, 해당 엔드포인트의 요청 검증, 응답 형태, 런타임 동작, 문서 계약을 동시에 결정하는 API 계약의 단일 진실원(SSOT)이다.
Fastify에서 경로(Route)를 설정할 때 사용하는 schema 객체는 단순한 옵션이 아닙니다. 이 API가 어떻게 작동해야 하는지를 정의하는 최종 설계도입니다.
// route.ts
fastify.post('/', {
schema: {
// 1. 들어오는 문(Input)을 검사하는 기준
body: UserCreateBodySchema,
// 2. 나가는 문(Output)을 보장하는 기준
response: {
200: SuccessResponseSchema(UserResponseSchema),
},
},
handler: async (request, reply) => {
// 설계도를 통과한 안전한 데이터만 이곳에 도달합니다.
}
});
이 설정 하나로 세 가지 강력한 기능이 자동으로 작동합니다.
▸ 요청 검문 (Validation): 잘못된 데이터가 들어오지 못하게 입구에서 컷(Cut)합니다.
▸ 응답 보증 (Serialization): 나가는 데이터가 약속된 형식을 지켰는지 최종 확인합니다.
▸ 자동 설명서 (OpenAPI): 별도의 작업 없이 Swagger 문서를 최신 상태로 만들어줍니다.
2. Request Schema: "검증된 데이터만 입장 가능합니다"
body, params, querystring 스키마는 API의 든든한 '보안 요원' 역할을 합니다.
데이터 형식이 틀렸다면, 요청은 비즈니스 로직(Handler) 근처에도 가지 못합니다.
덕분에 개발자는 핸들러 내부에서 "데이터가 왔나?", "숫자가 맞나?" 같은 구차한 체크 로직을 짤 필요가 없습니다.
검증에 실패하면 서버가 알아서 "무엇이 틀렸는지" 친절하게 알려주는 에러 핸들러로 전달됩니다.
// error.handler.ts
// 3. Fastify Validation 에러 (Schema 검증 실패)
else if (error && typeof error === 'object' && 'validation' in error) {
statusCode = 400;
errorCode = ErrorCode.VALIDATION_ERROR;
message = '입력 형식이 올바르지 않습니다.';
details = (error as any).validation;
}
결과적으로 비즈니스 로직은 오직 깨끗하고 올바른 데이터만 처리하게 되어, 코드가 간결해지고 버그가 줄어듭니다.
3. Response Schema: "나가는 데이터도 끝까지 책임집니다"
Fastify의 response 스키마는 응답을 단순히 설명하는 것을 넘어 강제로 형식을 맞춥니다.
응답 스키마를 사용하면, 개발 과정에서 계약을 어기는 순간 바로 에러가 발생하므로 실수를 즉시 수정할 수 있습니다.
클라이언트에게 데이터를 보내기 직전, 스키마라는 필터를 거치며 다음을 점검합니다.
▸ 깜빡한 데이터: 꼭 있어야 할 정보가 빠졌다면 서버가 먼저 에러를 냅니다.
▸ 숨겨야 할 데이터: 비밀번호처럼 노출되면 안 되는 정보가 포함되어도, 스키마에 없다면 자동으로 걸러냅니다.
▸ 잘못된 형식: 약속과 다른 타입의 데이터가 나가는 것을 원천 차단합니다.
3. BusinessError · PrismaError · ValidationError 통합 설계
백엔드 애플리케이션에서 에러 처리는 단순히 "예외를 던지고 잡는 문제"가 아닙니다.
에러는 도메인의 의도, 인프라의 한계, 그리고 클라이언트와의 계약이 만나는 지점입니다.
이 세 가지 관점을 명확히 구분하지 않으면 코드 베이스와 API 설계는 순식간에 복잡해집니다.
1. Service 계층에서는 HTTP가 아닌 비즈니스 규칙만 표현
가장 중요한 원칙은 "서비스 계층은 도메인의 규칙을 말해야 하며, HTTP나 프레임워크의 언어에 종속되어서는 안 된다"는 것입니다. 이를 위해 도메인 전용 에러 타입인 BusinessError를 정의합니다.
// business.error.ts
export class BusinessError extends Error {
constructor(
public readonly statusCode: number, // HTTP로 번역될 기본 제안 값
public readonly errorCode: string, // 서비스 고유 에러 코드
message: string,
public readonly details?: unknown,
) {
super(message);
this.name = 'BusinessError';
}
}
이 구조의 핵심은 의미 중심이라는 점입니다. "이미 존재함", "권한 없음", "비정상 상태" 등 비즈니스 상황을 명확히 정의하면서도, 기술 구현(DB, API 엔진 등)과는 철저히 분리되어 있습니다.
Service 계층에서의 사용 예시
// service.ts
if (userExists) {
throw new BusinessError(
409,
ErrorCode.ALREADY_EXISTS,
'이미 등록된 이메일입니다.',
);
}
여기서 서비스 코드는 HTTP Response 객체를 생성하지 않습니다.
단지 "충돌(Conflict)이 발생했고, 원인은 중복 리소스다"라는 상태 정보를 던질 뿐입니다.
이 정보는 나중에 번역 계층에 의해 처리됩니다.
2. Prisma 에러를 그대로 노출하면 안 되는 이유
Prisma와 같은 ORM은 P2002(Unique constraint failed)나 P2025(Record not found) 같은 상세한 에러 코드를 제공합니다.
이는 개발자에게는 유용하지만, 다음과 같은 이유로 클라이언트에게 그대로 전달되어서는 안 됩니다.
▸ 내부 구조 유출: 에러 메시지에 특정 컬럼명이나 인덱스 정보가 포함되어 DB 스키마가 노출될 수 있습니다.
▸ 모호한 의미: P2002가 사용자 중복인지, 게시글 제목 중복인지 클라이언트는 파악하기 어렵습니다.
▸ 높은 결합도: ORM을 교체하거나 버전을 업그레이드할 때 에러 형식이 바뀌면 API 계약(Contract)까지 깨지게 됩니다.
3. 에러 번역 계층(Error Handler)의 역할
이 문제를 해결하기 위해 각기 다른 형태(도메인, DB, 입력 검증)의 에러들을 하나의 표준 API 응답으로 맞추기 위해 '에러 번역 계층'을 둡니다.
Service
└─ BusinessError (도메인 의미)
└─ Prisma Error (DB 의미)
└─ Validation Error (입력 의미)
↓
Error Handler (번역 계층)
↓
HTTP Response (API 계약)
예를 들어 Prisma의 known error는 다음과 같이 매핑합니다.
function mapPrismaError(
error: Prisma.PrismaClientKnownRequestError,
) {
switch (error.code) {
case 'P2002':
return { status: 409, code: ErrorCode.ALREADY_EXISTS };
case 'P2025':
return { status: 404, code: ErrorCode.NOT_FOUND };
default:
return { status: 500, code: ErrorCode.DB_ERROR };
}
}
✔️ 이 전략이 가져다주는 이점
▸ Service 계층의 순수성: 비즈니스 로직이 HTTP 상태 코드나 외부 라이브러리의 에러 객체에 오염되지 않습니다.
테스트 코드를 작성할 때도 HTTP 컨텍스트를 모킹할 필요가 없어집니다.
▸ 안정적인 API 계약: 내부 DB 구조가 바뀌거나 ORM을 변경하더라도 클라이언트가 받는 응답 포맷은 일정하게 유지됩니다.
▸ 중앙 집중식 관리: 에러 로깅, 추적(Tracing), 메시지 다국어 처리 등을 특정 계층에서 한 번에 관리할 수 있어 유지보수 효율이 극대화됩니다.
4. 로그는 '부가 기능'이 아닌 '시스템의 증거'입니다
로그는 단순한 기록을 넘어, 장애 발생 시 시스템의 행적을 증명하는 유일한 수단입니다.
효율적인 장애 대응과 시스템 분석을 위해 ErrorHandler 중심의 구조화된 로그 설계가 반드시 필요합니다.
1. 로그 기록의 단일화 (Single Point of Logging)
에러는 비즈니스 로직, 데이터베이스 접근, 외부 API 호출 등 다양한 위치에서 발생할 수 있습니다.
하지만 해당 에러의 최종적인 의미를 판단하고 처리 방향을 결정하는 곳은 중앙 ErrorHandler입니다.
분산 로그의 문제점
▸ 로그 포맷 파편화: 계층마다 남기는 방식이 달라 분석이 어려워집니다.
▸ 중복 기록: 동일한 에러가 여러 계층을 거치며 불필요하게 반복 기록됩니다.
▸ 추적의 어려움: 특정 로그가 시스템의 최종 판단 결과인지 확인하기 어렵습니다.
따라서 로그는 에러가 처리되는 최종 관문인 ErrorHandler에서만 남기는 것이 가장 일관성 있는 방식입니다.
설계 예시
// error.handler.ts
const logPayload = {
traceId: request.id, // 요청 간 상관관계 추적을 위한 ID
path: request.url, // 장애 발생 경로
method: request.method, // HTTP 메소드
error: {
code: errorCode, // 내부 관리 에러 코드
message: message, // 에러 요약
details: details, // 스택 트레이스 및 디버깅 데이터
},
};
2. 클라이언트 응답과 내부 로그의 분리
사용자에게 전달하는 정보와 운영자가 확인해야 하는 정보는 목적이 다릅니다.
이 둘을 명확히 분리하는 것이 안정적인 서비스 운영의 기초입니다.
1) 클라이언트용 응답 (Client Response)
▸ 목적: 사용자의 가이드 및 보안 유지
▸ 특징: 최소한의 정보만 노출하여 시스템 내부 구조(DB 스키마, 라이브러리 버전 등)가 유출되지 않도록 합니다.
{
"success": false,
"code": "VALIDATION_ERROR",
"message": "입력 형식이 올바르지 않습니다. 다시 확인해 주세요."
}
2) 운영용 로그 (System Log)
▸ 목적: 원인 분석 및 장애 복구
▸ 특징: 분석에 필요한 모든 문맥(Context)을 포함합니다. 상세한 파라미터, 쿼리 결과, 스택 트레이스 등이 여기에 해당합니다.
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid email format: 'user@@example.com'",
"details": { "field": "email", "value": "user@@example.com", "constraint": "email-regex" }
}
}
3. 운영을 고려한 구조화 로그 설계 (Structured Logging) 예시
현대적인 운영 환경에서는 텍스트 형태의 로그보다 기계가 읽기 쉬운 구조화된 로그(JSON 등)가 필수적입니다.
const logPayload = {
level: statusCode >= 500 ? 'error' : 'info', // 에러 등급 결정
timestamp: new Date().toISOString(),
service: env.SERVICE_NAME,
traceId: request.id,
context: {
path: request.url,
method: request.method,
},
error: {
code: errorCode,
message: message,
details: details, // 분석용 상세 데이터
},
request: {
body: request.body, // ※ 개인정보 마스킹 처리 필수
query: request.query,
},
};
const app = Fastify({
logger: {
level: env.LOG_LEVEL,
transport: {
targets: [
env.NODE_ENV === 'development'
? {
// 개발 환경: 사람이 읽기 편한 Pretty Log
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'yyyy-mm-dd HH:MM:ss',
ignore: 'pid,hostname',
},
}
: {
// 운영 환경: 수집 시스템 전송을 위한 JSON 파일 로그
target: 'pino/file',
options: {
destination: env.LOG_PATH,
mkdir: true,
},
},
],
},
},
});
※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.