실전 백엔드의 70% 는 에러 처리. 정상 흐름은 짧고 깔끔한데, "DB 끊김"·"외부 API 타임아웃"·"잘못된 입력"·"메모리 부족" 같은 비정상이 무한히 많다. 한 군데 빠뜨리면 사용자는 500 에러를 보고, 최악의 경우 프로세스가 통째로 죽는다.
Node 에서 에러를 다루는 도구는 4가지 — try/catch · 커스텀 에러 클래스 · Express 에러 미들웨어 · uncaughtException 안전망. 이번 편에서 네 층을 쌓아 진짜 단단한 서비스로 만든다.
1. try/catch — 기본 안전망
9편의 async/await 와 함께 가장 자주 쓰는 패턴.
async function fetchUserData(id) {
try {
const user = await db.users.findUnique({ where: { id } });
if (!user) throw new Error('사용자 없음');
return user;
} catch (err) {
console.error('[fetchUserData]', err);
throw err; // 호출자에게 다시 던짐 (또는 기본값 반환)
}
}
중요한 결정 — catch 안에서 throw 다시 할지, 기본값 반환할지, 로깅만 할지. 셋 다 정당한 선택. 단 무엇을 골랐는지 의식적으로.
흔한 안티패턴 — 빈 catch — catch (err) {} 처럼 비워두면 에러가 통째로 사라진다. 디버깅 지옥. 최소한 console.error 또는 로깅 호출이라도. 무시할 거면 명시적으로 // intentionally ignored 주석.
2. 커스텀 에러 클래스 — 분류와 status 매핑
모든 에러가 같지 않다. "찾을 수 없음"·"권한 없음"·"검증 실패" 는 서로 다른 status 와 메시지가 필요. Error 를 상속해 분류한다.
// errors.js
export class AppError extends Error {
constructor(message, status = 500, code = 'INTERNAL') {
super(message);
this.status = status;
this.code = code;
this.name = this.constructor.name;
}
}
export class NotFoundError extends AppError {
constructor(resource = '리소스') {
super(`${resource}을 찾을 수 없습니다`, 404, 'NOT_FOUND');
}
}
export class ValidationError extends AppError {
constructor(details) {
super('입력값이 잘못되었습니다', 400, 'VALIDATION_FAILED');
this.details = details;
}
}
export class UnauthorizedError extends AppError {
constructor() {
super('로그인이 필요합니다', 401, 'UNAUTHORIZED');
}
}
사용 — 핸들러 안에서 그냥 throw.
router.get('/posts/:id', async (req, res, next) => {
const post = await db.posts.findUnique({ where: { id: req.params.id } });
if (!post) throw new NotFoundError('글'); // ← 한 줄
res.json(post);
});
이 한 줄이 status 404 + 메시지 + code 까지 자동으로 전달한다. 중앙 에러 핸들러가 그걸 받아 응답.
3. Express 에러 미들웨어 — 중앙 처리
14편에서 잠깐 본 패턴. 인자 4개가 신호.
// server.js — 라우트 모두 등록 뒤에
app.use((err, req, res, next) => {
// 1. 로깅 (구조화된 형식으로)
console.error({
name: err.name,
code: err.code,
message: err.message,
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
path: req.path,
method: req.method,
});
// 2. 알려진 AppError 면 그대로 응답
if (err instanceof AppError) {
return res.status(err.status).json({
error: { code: err.code, message: err.message, details: err.details },
});
}
// 3. 모르는 에러는 500 + 안전 메시지
res.status(500).json({
error: { code: 'INTERNAL', message: '서버 오류가 발생했습니다' },
});
});
핵심 — 알려진 에러와 모르는 에러를 나눈다. AppError 는 사용자에게 그대로 보여줘도 안전한 메시지. 그 외(TypeError·RangeError 등)는 디버깅 정보가 들어있을 수 있으니 안전한 일반 메시지로 교체.
스택 트레이스 노출 금지 — production 에서 err.stack 을 응답에 포함하면 코드 경로·라이브러리 버전이 새 나간다. 보안 사고. 로깅엔 포함, 응답에선 제거. NODE_ENV === 'development' 분기가 표준.
4. async 미들웨어 함정 — Express 5 vs 4
14편에서도 짚었던 부분. 다시 한 번.
// ❌ Express 4 — async throw 자동 캐치 안 됨
router.get('/x', async (req, res) => {
throw new Error('won\\'t be caught!');
});
// ✅ Express 5 (2024 stable) — 자동 캐치
router.get('/x', async (req, res) => {
throw new Error('caught by error middleware');
});
// ✅ Express 4 호환 — 매 라우트 try/catch 또는 wrap 헬퍼
const asyncH = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
router.get('/x', asyncH(async (req, res) => {
throw new Error('caught');
}));
Express 5 면 그냥 throw, 4 면 wrap 헬퍼 또는 express-async-errors 패키지. 어느 버전인지 package.json 확인부터.
5. uncaughtException — 최후의 안전망
모든 try/catch 와 미들웨어가 잡지 못한 에러가 결국 여기로. 잡지 않으면 프로세스가 죽는다.
// server.js 맨 위
process.on('uncaughtException', (err) => {
console.error('[uncaughtException]', err);
// 외부 에러 추적 서비스로 보고 (Sentry 등)
// 권장: 안전하게 종료 후 PM2/Docker 가 재시작
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
console.error('[unhandledRejection]', reason);
// Node 22 부터 기본이 throw 라 결국 uncaughtException 으로
});
uncaughtException 후 process.exit(1) 권장 — 표면적으론 "어차피 잡았으니 계속 돌아도 되지 않나?" 싶지만, 그 시점에 메모리·소켓 상태가 깨졌을 가능성이 있다. 안전하게 종료 후 PM2·Docker·systemd 가 재시작하는 게 production 표준. 22편 PM2 에서 다시 다룬다.
요약 — 16편 좌표
여기까지 정리. 에러 처리 4층 — ① try/catch(직접 잡기, 빈 catch 금지), ② 커스텀 AppError(분류·status·code), ③ Express 에러 미들웨어(인자 4개, 알려진/모르는 에러 분기, 스택 노출 금지), ④ uncaughtException(최후 안전망, exit 1 + 재시작). Express 4 면 async wrap, 5 면 자동. 다음 편에서 환경변수 다루기.
다음 편 예고 — 환경변수와 dotenv
.env 로 비밀 설정 분리, NODE_ENV 활용. 17편.