API 에러 처리 표준화: withErrorHandler 패턴으로 중앙집중화하기
API 엔드포인트가 늘어나면서 에러 처리 코드가 중복되고 응답 형식도 제각각이었습니다. withErrorHandler 래퍼와 세분화된 에러 클래스로 문제를 해결한 경험을 공유합니다.
1. 문제 상황
API 엔드포인트가 늘어나면서 에러 처리 코드가 중복되고, 응답 형식도 제각각이었습니다.
문제점들
// ❌ Before: 각 API마다 중복된 에러 처리
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: '로그인 필요' }, { status: 401 });
}
const body = await request.json();
// Zod 검증...
const result = await prisma.item.create({ data: body });
return NextResponse.json(result);
} catch (error) {
if (error instanceof ZodError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
return NextResponse.json({ error: '중복' }, { status: 409 });
}
}
console.error(error);
return NextResponse.json({ error: '서버 오류' }, { status: 500 });
}
}
문제:
- 모든 API에서 동일한 try-catch 패턴 반복
- 에러 응답 형식이 일관되지 않음 (
error,message,msg혼재) - 로깅이 누락되거나 일관되지 않음
- Request ID로 디버깅 추적 불가
2. 해결 방법: withErrorHandler 래퍼
Higher-Order Function 패턴으로 에러 처리를 중앙집중화합니다.
2.1 기본 구조
// src/lib/core/api-handler.ts
import { NextRequest, NextResponse } from 'next/server';
import { ZodError } from 'zod';
import { Prisma } from '@prisma/client';
import * as Sentry from '@sentry/nextjs';
import { AppError } from './errors';
import { logger, createErrorContext } from './logger';
type ApiHandler<TParams = Record<string, string>> = (
request: NextRequest,
context: { params?: Promise<TParams> }
) => Promise<NextResponse>;
/**
* 요청에서 Request ID 추출
*/
function getRequestId(request: NextRequest): string {
return request.headers.get('X-Request-ID') || crypto.randomUUID().slice(0, 8);
}
/**
* Response에 Request ID 헤더 추가
*/
function withRequestId(response: NextResponse, requestId: string): NextResponse {
response.headers.set('X-Request-ID', requestId);
return response;
}
/**
* 에러 응답 생성 헬퍼
*/
function errorResponse(
body: Record<string, unknown>,
status: number,
requestId: string,
headers?: Record<string, string>
): NextResponse {
const response = withRequestId(NextResponse.json(body, { status }), requestId);
if (headers) {
Object.entries(headers).forEach(([key, value]) => {
if (value) response.headers.set(key, value);
});
}
return response;
}
2.2 withErrorHandler 구현
// src/lib/core/api-handler.ts (계속)
/**
* 전역 에러 핸들러 래퍼
*
* @example
* export const GET = withErrorHandler(async (request) => {
* const session = await auth();
* requireAuth(session);
*
* const items = await prisma.item.findMany();
* return NextResponse.json({ items });
* });
*/
export function withErrorHandler<TParams = Record<string, string>>(
handler: ApiHandler<TParams>
): ApiHandler<TParams> {
return async (request, context) => {
const requestId = getRequestId(request);
const startTime = Date.now();
const path = new URL(request.url).pathname;
const method = request.method;
try {
const response = await handler(request, context);
return withRequestId(response, requestId);
} catch (error) {
const duration = Date.now() - startTime;
// 1. Zod 검증 에러 (400)
if (error instanceof ZodError) {
const firstError = error.issues[0];
logger.warn('Validation error', {
requestId,
method,
path,
duration,
error: createErrorContext(error, 'VALIDATION_ERROR', 400),
});
return errorResponse(
{
error: firstError?.message || '유효성 검사 실패',
code: 'VALIDATION_ERROR',
errors: error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
})),
},
400,
requestId
);
}
// 2. Prisma 에러
if (error instanceof Prisma.PrismaClientKnownRequestError) {
// P2002: Unique constraint violation
if (error.code === 'P2002') {
logger.warn('Conflict error (duplicate)', {
requestId, method, path, duration,
error: createErrorContext(error, 'CONFLICT', 409),
});
return errorResponse(
{ error: '이미 존재하는 데이터입니다.', code: 'CONFLICT' },
409,
requestId
);
}
// P2025: Record not found
if (error.code === 'P2025') {
logger.warn('Not found error', {
requestId, method, path, duration,
error: createErrorContext(error, 'NOT_FOUND', 404),
});
return errorResponse(
{ error: '요청한 데이터를 찾을 수 없습니다.', code: 'NOT_FOUND' },
404,
requestId
);
}
// 기타 Prisma 에러 → 500
logger.error('Prisma error', {
requestId, method, path, duration,
error: createErrorContext(error, 'INTERNAL_ERROR', 500),
prismaCode: error.code,
});
Sentry.captureException(error, {
tags: { requestId, path, prismaCode: error.code },
});
return errorResponse(
{ error: '서버 오류가 발생했습니다.', errorId: requestId },
500,
requestId
);
}
// 3. AppError (커스텀 에러)
if (error instanceof AppError) {
if (error.status >= 500) {
logger.error(`AppError: ${error.message}`, {
requestId, method, path, duration,
error: createErrorContext(error, error.code, error.status),
});
Sentry.captureException(error);
} else {
logger.warn(`AppError: ${error.message}`, {
requestId, method, path, duration,
error: createErrorContext(error, error.code, error.status),
});
}
// toJSON() 메서드가 에러별 고유 필드 포함
return errorResponse(error.toJSON(), error.status, requestId);
}
// 4. 예상치 못한 에러 (500)
logger.error('Unhandled API error', {
requestId, method, path, duration,
error: createErrorContext(error, 'INTERNAL_ERROR', 500),
});
Sentry.captureException(error, {
tags: { requestId, path },
});
return errorResponse(
{ error: '서버 오류가 발생했습니다.', errorId: requestId },
500,
requestId
);
}
};
}
3. 세분화된 에러 클래스 (E6 패턴)
9개의 에러 타입으로 명확한 의미 전달과 일관된 처리가 가능합니다.
3.1 기본 AppError 클래스
// src/lib/core/errors.ts
export type ErrorCode =
| 'VALIDATION_ERROR'
| 'AUTHENTICATION_ERROR'
| 'AUTHORIZATION_ERROR'
| 'NOT_FOUND'
| 'GONE'
| 'CONFLICT'
| 'RATE_LIMIT_EXCEEDED'
| 'TIMEOUT'
| 'INTERNAL_ERROR';
/**
* 기본 에러 클래스
*/
export class AppError extends Error {
public readonly code: ErrorCode;
public readonly status: number;
public readonly details?: Record<string, unknown>;
constructor(
code: ErrorCode,
status: number,
message: string,
details?: Record<string, unknown>
) {
super(message);
this.name = this.constructor.name;
this.code = code;
this.status = status;
this.details = details;
Object.setPrototypeOf(this, new.target.prototype);
}
/**
* JSON 직렬화 - API 응답용
*/
toJSON(): Record<string, unknown> {
return {
error: this.message,
code: this.code,
...(this.details && { details: this.details }),
};
}
}
3.2 세분화된 에러 클래스들
// src/lib/core/errors.ts (계속)
/**
* 입력값 검증 실패 (400)
*/
export class ValidationError extends AppError {
public readonly errors?: FieldError[];
constructor(message: string, errors?: FieldError[]) {
super('VALIDATION_ERROR', 400, message);
this.errors = errors;
}
static field(field: string, message: string): ValidationError {
return new ValidationError(message, [{ field, message }]);
}
override toJSON() {
return {
error: this.message,
code: this.code,
...(this.errors && { errors: this.errors }),
};
}
}
/**
* 인증 필요 (401)
*/
export class AuthenticationError extends AppError {
constructor(message = '인증이 필요합니다.') {
super('AUTHENTICATION_ERROR', 401, message);
}
static sessionExpired(): AuthenticationError {
return new AuthenticationError('세션이 만료되었습니다.');
}
static invalidCredentials(): AuthenticationError {
return new AuthenticationError('이메일 또는 비밀번호가 올바르지 않습니다.');
}
}
/**
* 권한 없음 (403)
*/
export class AuthorizationError extends AppError {
public readonly requiredRoles?: string[];
constructor(message = '접근 권한이 없습니다.', requiredRoles?: string[]) {
super('AUTHORIZATION_ERROR', 403, message);
this.requiredRoles = requiredRoles;
}
static roleRequired(roles: string[]): AuthorizationError {
return new AuthorizationError(
`이 작업을 수행하려면 다음 역할이 필요합니다: ${roles.join(', ')}`,
roles
);
}
override toJSON() {
return {
error: this.message,
code: this.code,
...(this.requiredRoles && { requiredRoles: this.requiredRoles }),
};
}
}
/**
* 리소스 없음 (404)
*/
export class NotFoundError extends AppError {
public readonly resource: string;
public readonly resourceId?: string;
constructor(resource: string, resourceId?: string) {
super('NOT_FOUND', 404, `${resource}을(를) 찾을 수 없습니다.`);
this.resource = resource;
this.resourceId = resourceId;
}
override toJSON() {
return {
error: this.message,
code: this.code,
resource: this.resource,
...(this.resourceId && { resourceId: this.resourceId }),
};
}
}
/**
* 데이터 충돌 (409)
*/
export class ConflictError extends AppError {
public readonly conflictField?: string;
constructor(message: string, conflictField?: string) {
super('CONFLICT', 409, message);
this.conflictField = conflictField;
}
static duplicate(field: string): ConflictError {
return new ConflictError(`이미 존재하는 ${field}입니다.`, field);
}
}
/**
* 리소스 만료 (410)
*/
export class GoneError extends AppError {
constructor(message: string) {
super('GONE', 410, message);
}
static invitationExpired(): GoneError {
return new GoneError('초대 링크가 만료되었습니다.');
}
static tokenExpired(): GoneError {
return new GoneError('토큰이 만료되었습니다.');
}
}
/**
* 요청 한도 초과 (429)
*/
export class RateLimitError extends AppError {
public readonly retryAfter?: number;
constructor(retryAfter?: number) {
super('RATE_LIMIT_EXCEEDED', 429, '요청 한도를 초과했습니다.');
this.retryAfter = retryAfter;
}
override toJSON() {
return {
error: this.message,
code: this.code,
...(this.retryAfter && { retryAfter: this.retryAfter }),
};
}
}
/**
* 처리 시간 초과 (504)
*/
export class TimeoutError extends AppError {
constructor(message = '처리 시간이 초과되었습니다.') {
super('TIMEOUT', 504, message);
}
static database(): TimeoutError {
return new TimeoutError('데이터베이스 연결 시간 초과');
}
}
/**
* 서버 오류 (500) - requestId로 추적
*/
export class InternalError extends AppError {
public readonly requestId: string;
constructor(requestId: string) {
super('INTERNAL_ERROR', 500, '서버 오류가 발생했습니다.');
this.requestId = requestId;
}
override toJSON() {
return {
error: this.message,
code: this.code,
errorId: this.requestId, // ← 사용자가 에러 ID로 문의 가능
};
}
}
4. 인증/권한 헬퍼
// src/lib/core/api-handler.ts (계속)
/**
* 인증 체크 헬퍼
*
* @example
* const session = await auth();
* requireAuth(session); // 401 throw 또는 타입 보장
*/
export function requireAuth(
session: { user?: { id: string } } | null
): asserts session is { user: { id: string } } {
if (!session?.user) {
throw new AuthenticationError();
}
}
/**
* 역할 체크 헬퍼
*
* @example
* requireRole(session, ['ADMIN', 'SUPER_ADMIN']);
*/
export function requireRole(
session: { user: { role?: string } },
allowedRoles: string[]
): void {
if (!session.user.role || !allowedRoles.includes(session.user.role)) {
throw AuthorizationError.roleRequired(allowedRoles);
}
}
/**
* 회사 ID 체크 헬퍼 (멀티테넌트)
*/
export function requireCompanyId(
session: { user: { companyId?: string | null } }
): string {
if (!session.user.companyId) {
throw AuthorizationError.companyRequired();
}
return session.user.companyId;
}
5. 사용 예시
5.1 기본 사용
// ✅ After: 깔끔한 핸들러, 에러 처리는 자동
// src/app/api/items/route.ts
import { NextResponse } from 'next/server';
import { withErrorHandler, requireAuth, requireRole } from '@/lib/core/api-handler';
import { auth } from '@/lib/core/auth';
import { prisma } from '@/lib/core/db';
import { itemSchema } from '@/lib/validations';
export const GET = withErrorHandler(async (request) => {
const session = await auth();
requireAuth(session);
const items = await prisma.item.findMany({
where: { companyId: session.user.companyId },
});
return NextResponse.json({ items });
});
export const POST = withErrorHandler(async (request) => {
const session = await auth();
requireAuth(session);
requireRole(session, ['ADMIN', 'SUPER_ADMIN']);
const body = await request.json();
const data = itemSchema.parse(body); // Zod 검증 → 실패 시 400 자동
const item = await prisma.item.create({
data: {
...data,
companyId: session.user.companyId,
},
});
return NextResponse.json({ item }, { status: 201 });
});
5.2 동적 라우트
// src/app/api/items/[id]/route.ts
import { NotFoundError } from '@/lib/core/errors';
export const GET = withErrorHandler(async (request, { params }) => {
const { id } = await params; // Next.js 15+ 비동기 params
const session = await auth();
requireAuth(session);
const item = await prisma.item.findUnique({
where: {
id,
companyId: session.user.companyId, // 테넌트 격리
},
});
if (!item) {
throw new NotFoundError('아이템', id); // ← 명확한 에러
}
return NextResponse.json({ item });
});
5.3 커스텀 에러 throw
export const PUT = withErrorHandler(async (request, { params }) => {
const { id } = await params;
const session = await auth();
requireAuth(session);
const item = await prisma.item.findUnique({ where: { id } });
if (!item) {
throw new NotFoundError('아이템', id);
}
if (item.status === 'LOCKED') {
throw new ValidationError('잠긴 아이템은 수정할 수 없습니다.');
}
if (item.expiresAt < new Date()) {
throw new GoneError('만료된 아이템입니다.');
}
// ... 업데이트 로직
});
6. 응답 형식 표준화
모든 API 에러 응답은 동일한 형식을 갖습니다.
에러 응답 예시
// 400 Bad Request (ValidationError)
{
"error": "이메일 형식이 올바르지 않습니다.",
"code": "VALIDATION_ERROR",
"errors": [
{ "field": "email", "message": "이메일 형식이 올바르지 않습니다." }
]
}
// 401 Unauthorized (AuthenticationError)
{
"error": "인증이 필요합니다.",
"code": "AUTHENTICATION_ERROR"
}
// 403 Forbidden (AuthorizationError)
{
"error": "이 작업을 수행하려면 다음 역할이 필요합니다: ADMIN, SUPER_ADMIN",
"code": "AUTHORIZATION_ERROR",
"requiredRoles": ["ADMIN", "SUPER_ADMIN"]
}
// 404 Not Found (NotFoundError)
{
"error": "아이템을(를) 찾을 수 없습니다.",
"code": "NOT_FOUND",
"resource": "아이템",
"resourceId": "item-123"
}
// 409 Conflict (ConflictError)
{
"error": "이미 존재하는 이메일입니다.",
"code": "CONFLICT",
"conflictField": "email"
}
// 429 Too Many Requests (RateLimitError)
{
"error": "요청 한도를 초과했습니다.",
"code": "RATE_LIMIT_EXCEEDED",
"retryAfter": 60
}
// Header: Retry-After: 60
// 500 Internal Server Error
{
"error": "서버 오류가 발생했습니다.",
"errorId": "abc12345"
}
// Header: X-Request-ID: abc12345
7. 핵심 개념 정리
| 에러 클래스 | HTTP 상태 | 사용 사례 |
|---|---|---|
ValidationError |
400 | 입력값 검증 실패 |
AuthenticationError |
401 | 로그인 필요 |
AuthorizationError |
403 | 권한 부족 |
NotFoundError |
404 | 리소스 없음 |
ConflictError |
409 | 중복 데이터 |
GoneError |
410 | 리소스 만료 |
RateLimitError |
429 | 요청 제한 초과 |
TimeoutError |
504 | 처리 시간 초과 |
InternalError |
500 | 서버 내부 오류 |
8. 베스트 프랙티스
체크리스트
- [ ] 모든 API Route에
withErrorHandler래퍼 적용 - [ ] 인증 체크 시
requireAuth()헬퍼 사용 - [ ] 권한 체크 시
requireRole()헬퍼 사용 - [ ] 비즈니스 로직 에러는 적절한 에러 클래스 사용
- [ ] 500 에러는 Sentry로 자동 전송 확인
- [ ] 모든 응답에
X-Request-ID헤더 포함
Sentry 연동
// sentry.server.config.ts
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 0.1, // 10% 샘플링
beforeSend(event) {
// 4xx 에러는 Sentry에 보내지 않음 (클라이언트 문제)
if (event.level === 'warning') {
return null;
}
return event;
},
});
9. FAQ
Q: 왜 try-catch를 API마다 쓰지 않나요?
A: 코드 중복을 줄이고, 에러 처리 로직을 한 곳에서 관리하기 위해서입니다. 새로운 에러 타입 추가 시 한 곳만 수정하면 됩니다.
Q: toJSON() 메서드는 왜 필요한가요?
A: 각 에러 타입별로 고유한 필드(errors, requiredRoles, retryAfter 등)를 응답에 포함시키기 위해서입니다. withErrorHandler에서 error.toJSON()을 호출하면 자동으로 적절한 형식이 됩니다.
Q: Zod 에러와 ValidationError를 왜 분리하나요?
A: Zod는 입력 데이터 파싱에 특화되어 있고, ValidationError는 비즈니스 로직 검증에 사용합니다. 예를 들어 "잠긴 아이템은 수정 불가"는 Zod로 검증할 수 없습니다.
Q: errorId와 requestId의 차이는 무엇인가요?
A: 동일합니다. 500 에러 응답에서는 errorId로, 헤더에서는 X-Request-ID로 표시합니다. 사용자가 "에러 ID: abc123"을 보고 지원팀에 문의하면 로그에서 추적할 수 있습니다.
Q: 미들웨어에서도 이 패턴을 쓸 수 있나요?
A: 미들웨어는 Edge Runtime에서 실행되어 일부 제약이 있습니다. 미들웨어에서는 간단한 리다이렉트만 처리하고, 복잡한 에러 처리는 API Route에서 하는 것을 권장합니다.
10. 참고 자료
11. 다음 단계
API 에러 처리를 표준화했다면, 민감 데이터 접근에 대한 감사 로깅도 필수입니다.
시리즈 목차:
- Prisma Extension으로 민감 데이터 암호화 자동화하기
- Next.js에서 2FA/TOTP 인증 구현하기
- API 에러 처리 표준화: withErrorHandler 패턴 ← 현재 글
- 민감 데이터 접근 추적: Audit Logging 구현