에러 핸들링 패턴 — 프로덕션 레벨의 에러 처리
개발 환경에서는 에러 스택 트레이스를 콘솔에 찍으면 되지만, 프로덕션에서는 에러 하나가 서버 전체를 죽일 수 있습니다. 어떻게 대비해야 할까요?
try-catch를 여기저기 붙이는 것은 에러 처리가 아닙니다. 프로덕션 레벨의 에러 처리는 에러를 분류하고, 적절히 응답하고, 기록하고, 서버가 죽지 않게 보호하는 체계적인 시스템입니다.
개념 정의
** 에러 핸들링 패턴 **은 애플리케이션에서 발생하는 에러를 예측 가능한 방식으로 처리하는 구조입니다. 핵심은 ** 운영 에러 **(예상된 에러)와 ** 프로그래밍 에러 **(버그)를 구분하는 것입니다.
에러의 두 가지 종류
| 종류 | 설명 | 예시 | 처리 방법 |
|---|---|---|---|
| ** 운영 에러** | 예상 가능한 실패 | 잘못된 입력, DB 커넥션 실패, 타임아웃 | 에러 응답 + 로깅 |
| ** 프로그래밍 에러** | 코드의 버그 | TypeError, null 참조, 잘못된 인덱스 | 프로세스 재시작 |
이 구분이 중요한 이유가 있습니다.
- 운영 에러는 ** 복구 가능 **합니다. 사용자에게 적절한 에러 메시지를 보내면 됩니다.
- 프로그래밍 에러는 ** 복구 불가능 **합니다. 애플리케이션 상태가 오염되었을 수 있으므로, 안전하게 프로세스를 재시작해야 합니다.
커스텀 에러 클래스
모든 에러를 new Error('...')로 던지면 에러 미들웨어에서 종류를 구분할 수 없습니다.
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true; // ← 운영 에러 표시
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404);
}
}
class ValidationError extends AppError {
constructor(message) {
super(message, 400);
}
}
isOperational 플래그가 핵심입니다. 에러 핸들러에서 이 플래그로 운영 에러와 프로그래밍 에러를 구분합니다.
라우트에서 사용하면 에러 처리가 깔끔해집니다.
app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError('User');
res.json(user);
});
글로벌 에러 핸들러
모든 에러를 한 곳에서 처리합니다. 에러 분류, 로깅, 응답을 통합합니다.
app.use((err, req, res, next) => {
// 1. 로깅
if (!err.isOperational) {
console.error('[CRITICAL]', err); // 프로그래밍 에러는 상세 로깅
} else {
console.warn('[WARN]', err.message);
}
// 2. 응답
const statusCode = err.statusCode || 500;
const message = err.isOperational
? err.message
: 'Internal Server Error'; // ← 내부 에러 메시지 노출 금지
res.status(statusCode).json({
error: message,
...(process.env.NODE_ENV === 'development' && {
stack: err.stack, // 개발 환경에서만 스택 트레이스
}),
});
});
위 코드에서 중요한 점은 isOperational이 아닌 에러(프로그래밍 에러)의 메시지를 클라이언트에 노출하지 않는 것입니다. 내부 구현 정보가 유출되면 보안 취약점이 됩니다.
process 레벨 에러 처리
Express 에러 미들웨어가 잡지 못하는 에러가 있습니다.
uncaughtException
process.on('uncaughtException', (err) => {
console.error('[UNCAUGHT EXCEPTION]', err);
// 상태가 오염되었을 수 있으므로 안전하게 종료
process.exit(1); // ← PM2가 자동으로 재시작
});
동기 코드에서 잡히지 않은 에러입니다. 이 시점에서 애플리케이션 상태를 신뢰할 수 없으므로, 로깅 후 프로세스를 종료하고 PM2 같은 프로세스 매니저가 재시작하게 합니다.
unhandledRejection
process.on('unhandledRejection', (reason) => {
console.error('[UNHANDLED REJECTION]', reason);
// Node.js 15+에서는 uncaughtException으로 처리됨
process.exit(1);
});
Promise에서 .catch()나 try-catch 없이 rejection이 발생한 경우입니다. Node.js 15부터는 이 이벤트가 발생하면 프로세스가 기본적으로 종료됩니다.
비동기 에러 처리 전략
Express 4에서 async 에러를 안전하게 처리하는 패턴입니다.
// 방법 1: 래퍼 함수
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
// 방법 2: express-async-errors (require만 하면 끝)
require('express-async-errors');
두 방법 모두 async 함수의 rejection을 잡아서 next(err)로 에러 미들웨어에 전달합니다.
에러 응답 형식 통일
API 소비자(프론트엔드)가 에러를 예측 가능하게 처리하려면 응답 형식이 일관되어야 합니다.
{
"error": "User not found",
"statusCode": 404,
"timestamp": "2026-05-02T12:00:00.000Z"
}
에러 응답 형식을 API 문서에 명시하고, 모든 에러가 같은 형식으로 나가도록 글로벌 에러 핸들러에서 통제합니다. 프론트엔드가
res.data.error로 항상 에러 메시지에 접근할 수 있어야 합니다.
주의할 점
에러 메시지로 내부 정보 노출
// 위험 — DB 스키마 정보 유출
res.status(500).json({
error: 'relation "users" does not exist',
});
// 안전 — 일반적인 메시지
res.status(500).json({
error: 'Internal Server Error',
});
uncaughtException에서 요청 처리 계속하지 않기
uncaughtException 핸들러에서 process.exit()을 호출하지 않고 서버를 계속 운영하면, 오염된 상태에서 요청을 처리하게 됩니다. 데이터 손상이나 보안 취약점으로 이어질 수 있습니다.
에러 로깅에 민감 정보 포함하지 않기
// 위험 — 비밀번호가 로그에 남음
console.error('Login failed', { email, password });
// 안전 — 필요한 정보만
console.error('Login failed', { email });
정리
| 항목 | 설명 |
|---|---|
| 에러 분류 | 운영 에러(복구 가능) vs 프로그래밍 에러(재시작 필요) |
| 커스텀 에러 | AppError + isOperational 플래그로 구분 |
| 글로벌 핸들러 | 에러 분류 → 로깅 → 응답을 한 곳에서 처리 |
| process 이벤트 | uncaughtException, unhandledRejection 반드시 등록 |
| 보안 | 프로그래밍 에러 메시지를 클라이언트에 노출하지 않기 |
| 프로덕션 원칙 | 에러 형식 통일, 상세 로깅, 안전한 프로세스 재시작 |