커스텀 에러 클래스 설계
Express 앱에서 에러가 발생하면 기본적으로 Error 객체를 throw한다. 문제는 이 기본 Error에는 HTTP 상태 코드도 없고, 운영상 예상된 에러인지 프로그래밍 실수인지 구분할 방법도 없다는 것이다.
// 이렇게만 하면 에러 미들웨어에서 할 수 있는 게 별로 없다
throw new Error("사용자를 찾을 수 없습니다");
에러 미들웨어가 이 에러를 받았을 때, 클라이언트에 404를 보내야 하는지 500을 보내야 하는지 알 수 없다. 에러 메시지를 파싱해서 판단하는 건 당연히 나쁜 방법이다. 결국 에러 자체에 메타데이터를 담을 수 있는 구조가 필요하다.
기본 Error의 한계
JavaScript의 내장 Error는 message와 stack 두 가지 정보만 가지고 있다.
const err = new Error("잘못된 요청");
console.log(err.message); // "잘못된 요청"
console.log(err.stack); // 스택 트레이스
// err.statusCode → undefined
// err.isOperational → undefined
서버 에러 처리에서는 최소한 이런 정보가 추가로 필요하다:
- HTTP 상태 코드 — 클라이언트에 어떤 응답을 보낼지
- 운영 에러 여부 — 예상된 에러(404, 유효성 검증 실패)인지, 예상치 못한 버그인지
- 에러 코드 — 클라이언트가 에러 종류를 프로그래밍적으로 구분할 수 있는 식별자
이걸 매번 에러를 throw할 때마다 수동으로 붙이는 건 번거롭고 일관성을 유지하기 어렵다.
// 이런 식으로 하면 코드가 지저분해진다
const err = new Error("사용자를 찾을 수 없습니다");
err.statusCode = 404;
err.status = "fail";
err.isOperational = true;
throw err;
커스텀 에러 클래스 구현
Error를 상속받아서 필요한 메타데이터를 생성자에서 한 번에 설정하는 클래스를 만든다.
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith("4") ? "fail" : "error";
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
각 부분을 뜯어보자.
super(message)
부모 클래스인 Error의 생성자를 호출해서 message와 stack을 정상적으로 설정한다. 이걸 빼먹으면 err.message가 undefined가 된다.
statusCode와 status
statusCode는 숫자(404, 500 등)를 그대로 저장하고, status는 상태 코드의 첫 자리를 기준으로 자동 결정한다. 4xx 에러는 클라이언트 잘못이니까 "fail", 5xx 에러는 서버 잘못이니까 "error"로 설정한다. 이 분류는 JSend 스펙에서 가져온 관례다.
// 4xx → "fail"
new AppError("찾을 수 없습니다", 404); // status: "fail"
new AppError("권한이 없습니다", 403); // status: "fail"
// 5xx → "error"
new AppError("서버 내부 오류", 500); // status: "error"
isOperational
이 플래그가 커스텀 에러 클래스의 핵심이다. true로 설정된 에러는 예상된 운영 에러라는 뜻이다. 잘못된 입력, 인증 실패, 리소스 미존재 같은 것들이다. 반면 프로그래밍 실수(TypeError, ReferenceError 등)는 isOperational이 undefined이거나 false다.
왜 이 구분이 중요한가? 에러 처리 전략이 완전히 달라지기 때문이다:
| 운영 에러 (Operational) | 프로그래밍 에러 (Programmer) | |
|---|---|---|
| 예시 | 404 Not Found, 유효성 실패 | TypeError, null 참조 |
| 예측 가능? | O | X |
| 클라이언트 메시지 | 에러 메시지 그대로 전달 가능 | "서버 오류" 같은 일반 메시지 |
| 서버 동작 | 정상 계속 운영 | 로깅 + 심하면 프로세스 재시작 |
Error.captureStackTrace
Error.captureStackTrace(this, this.constructor);
이 줄은 스택 트레이스에서 AppError 생성자 자체를 제외시킨다. 스택 트레이스를 봤을 때 AppError의 생성자가 아니라, AppError를 throw한 위치가 최상단에 나오도록 만드는 것이다. 디버깅할 때 실제 에러 발생 지점을 바로 찾을 수 있어서 편하다.
참고로 Error.captureStackTrace는 V8 엔진(Node.js, Chrome) 전용이다. 브라우저 호환성이 필요한 라이브러리에서는 사용을 피하지만, Node.js 서버에서는 문제없이 쓸 수 있다.
사용 방법
이제 에러를 throw할 때 한 줄로 상태 코드와 메시지를 함께 전달할 수 있다.
// 라우터나 서비스 레이어에서
const user = await User.findById(id);
if (!user) {
throw new AppError("해당 사용자를 찾을 수 없습니다", 404);
}
// 인증 미들웨어에서
if (!req.session.userId) {
throw new AppError("로그인이 필요합니다", 401);
}
// 권한 검증에서
if (task.ownerId !== req.session.userId) {
throw new AppError("이 작업에 대한 권한이 없습니다", 403);
}
에러 미들웨어에서 활용
isOperational 플래그를 활용하면 에러 미들웨어에서 응답을 깔끔하게 분기할 수 있다.
// 에러 처리 미들웨어
app.use((err, req, res, next) => {
err.statusCode = err.statusCode || 500;
err.status = err.status || "error";
if (err.isOperational) {
// 예상된 에러 → 클라이언트에 상세 메시지 전달
res.status(err.statusCode).json({
status: err.status,
message: err.message,
});
} else {
// 예상치 못한 에러 → 로깅하고 일반 메시지 응답
console.error("ERROR 💥", err);
res.status(500).json({
status: "error",
message: "서버 내부 오류가 발생했습니다",
});
}
});
운영 에러는 에러 메시지를 클라이언트에 그대로 보내도 안전하다. "해당 사용자를 찾을 수 없습니다"는 사용자에게 보여줘도 괜찮은 메시지니까. 반면 프로그래밍 에러의 메시지("Cannot read property 'id' of undefined")를 클라이언트에 보내면 내부 구현이 노출되고, 사용자 입장에서도 이해할 수 없는 메시지다.
개발/운영 환경 분리
환경에 따라 에러 응답의 상세도를 다르게 하면 디버깅과 보안 두 마리 토끼를 잡을 수 있다.
const sendErrorDev = (err, res) => {
// 개발 환경: 모든 정보 노출
res.status(err.statusCode).json({
status: err.status,
error: err,
message: err.message,
stack: err.stack,
});
};
const sendErrorProd = (err, res) => {
if (err.isOperational) {
// 운영 에러: 메시지 전달
res.status(err.statusCode).json({
status: err.status,
message: err.message,
});
} else {
// 프로그래밍 에러: 상세 정보 숨김
console.error("ERROR 💥", err);
res.status(500).json({
status: "error",
message: "서버 내부 오류가 발생했습니다",
});
}
};
app.use((err, req, res, next) => {
err.statusCode = err.statusCode || 500;
err.status = err.status || "error";
if (process.env.NODE_ENV === "development") {
sendErrorDev(err, res);
} else {
sendErrorProd(err, res);
}
});
개발 환경에서는 스택 트레이스까지 전부 응답에 포함시켜서 디버깅을 쉽게 하고, 운영 환경에서는 isOperational로 분기해서 민감한 정보 노출을 막는다.
서브클래스로 확장하기
에러 종류가 많아지면 AppError를 다시 상속받아서 특화된 에러 클래스를 만들 수 있다.
class NotFoundError extends AppError {
constructor(resource = "리소스") {
super(`해당 ${resource}을(를) 찾을 수 없습니다`, 404);
}
}
class ValidationError extends AppError {
constructor(message) {
super(message, 400);
}
}
class UnauthorizedError extends AppError {
constructor(message = "인증이 필요합니다") {
super(message, 401);
}
}
class ForbiddenError extends AppError {
constructor(message = "접근 권한이 없습니다") {
super(message, 403);
}
}
사용하는 쪽에서는 더 명시적이고 간결해진다:
throw new NotFoundError("사용자"); // "해당 사용자을(를) 찾을 수 없습니다", 404
throw new ValidationError("이메일 형식이 올바르지 않습니다"); // 400
throw new UnauthorizedError(); // "인증이 필요합니다", 401
상태 코드를 매번 외울 필요가 없고, 에러를 throw하는 코드만 봐도 어떤 종류의 에러인지 바로 알 수 있다.
instanceof로 에러 타입 체크
커스텀 에러 클래스의 또 다른 장점은 instanceof로 에러 타입을 정확히 구분할 수 있다는 것이다.
try {
await someOperation();
} catch (err) {
if (err instanceof NotFoundError) {
// 404 처리
} else if (err instanceof ValidationError) {
// 400 처리
} else {
// 기타 에러
throw err;
}
}
문자열 비교보다 훨씬 안전하고 타입 체크도 정확하다. 에러 클래스 이름이 바뀌면 instanceof는 자연스럽게 따라가지만, 문자열은 일일이 찾아서 바꿔야 한다.
외부 라이브러리 에러 래핑
외부 라이브러리(데이터베이스 드라이버, HTTP 클라이언트 등)에서 발생하는 에러는 우리가 만든 AppError 형태가 아니다. 이런 에러를 AppError로 변환해서 일관된 에러 처리를 유지할 수 있다.
// 데이터베이스 중복 키 에러를 AppError로 변환
const handleDuplicateKeyError = (err) => {
const field = Object.keys(err.keyValue).join(", ");
const message = `중복된 값입니다: ${field}. 다른 값을 사용해주세요.`;
return new AppError(message, 400);
};
// JWT 에러 변환
const handleJWTError = () =>
new AppError("유효하지 않은 토큰입니다. 다시 로그인해주세요.", 401);
const handleJWTExpiredError = () =>
new AppError("토큰이 만료되었습니다. 다시 로그인해주세요.", 401);
에러 미들웨어에서 외부 에러를 감지해서 변환하면 된다:
app.use((err, req, res, next) => {
let error = { ...err, message: err.message };
if (error.code === 11000) error = handleDuplicateKeyError(error);
if (error.name === "JsonWebTokenError") error = handleJWTError();
if (error.name === "TokenExpiredError") error = handleJWTExpiredError();
// 변환된 에러로 응답 처리
sendError(error, res);
});
이렇게 하면 어떤 출처의 에러든 동일한 형태로 클라이언트에 전달된다.
설계 시 주의점
에러 메시지는 사용자 친화적으로 작성한다. isOperational이 true인 에러의 메시지는 클라이언트에 그대로 전달될 가능성이 높다. "Cannot read property 'x' of null" 같은 기술적 메시지가 아니라, "해당 리소스를 찾을 수 없습니다" 같이 사용자가 이해할 수 있는 메시지를 넣어야 한다.
isOperational 플래그를 남용하지 않는다. 정말 예상 가능한 에러에만 true를 설정해야 한다. 확신이 없으면 false(또는 미설정)로 두는 게 안전하다. 예상치 못한 에러를 운영 에러로 잘못 분류하면 심각한 버그를 놓칠 수 있다.
상태 코드를 정확히 사용한다. 400과 404의 차이, 401과 403의 차이를 명확히 하자:
- 400 Bad Request — 요청 자체가 잘못됨 (유효성 검증 실패)
- 401 Unauthorized — 인증되지 않음 (로그인 필요)
- 403 Forbidden — 인증됐지만 권한 없음
- 404 Not Found — 리소스 없음
- 409 Conflict — 충돌 (중복 데이터 등)
- 422 Unprocessable Entity — 요청 형식은 맞지만 처리 불가
정리
- Error를 상속받아 statusCode, status, isOperational 세 가지 메타데이터를 생성자에서 설정하면 에러 미들웨어에서 분기 처리가 깔끔해진다
- isOperational 플래그로 예상된 운영 에러와 프로그래밍 버그를 구분하고, 프로덕션에서 노출할 정보 수준을 다르게 제어한다
- NotFoundError, ValidationError 같은 서브클래스를 만들면 상태 코드를 외울 필요 없이 의미가 명확한 에러를 throw할 수 있다
관련 문서
- Express 에러 미들웨어 - 환경별 에러 응답 분기
- async 핸들러 래퍼 - try-catch 보일러플레이트 제거
- Zod 스키마 검증 - 런타임 유효성 검증과 타입 추론