junyeokk
Blog
Express·2024. 09. 04

Express 에러 처리 미들웨어

Express 앱에서 에러가 발생하면 어떻게 될까? 라우트 핸들러에서 예외가 던져지거나, next(err)가 호출되면 Express는 에러 처리 미들웨어를 찾아서 실행한다. 별도로 정의하지 않으면 Express 기본 에러 핸들러가 동작하는데, 이건 단순히 스택 트레이스를 HTML로 응답하는 게 전부다. API 서버에서 HTML 에러 페이지를 보내면 클라이언트가 파싱할 수 없고, 스택 트레이스가 그대로 노출되면 보안상 문제가 된다.

그래서 커스텀 에러 처리 미들웨어를 만들어야 한다. 에러 응답 포맷을 JSON으로 통일하고, 환경에 따라 노출하는 정보량을 조절하는 게 핵심이다.


Express 에러 미들웨어의 특별한 점

일반 미들웨어는 (req, res, next) 세 개의 인자를 받지만, 에러 처리 미들웨어는 네 개의 인자 (err, req, res, next)를 받는다. Express는 인자 개수로 에러 미들웨어를 구분하기 때문에 반드시 네 개를 선언해야 한다. 하나라도 빠지면 일반 미들웨어로 취급된다.

javascript
// ❌ 이건 일반 미들웨어로 인식됨 (인자 3개)
app.use((err, req, res) => {
  res.status(500).json({ message: err.message });
});

// ✅ 에러 미들웨어 (인자 4개)
app.use((err, req, res, next) => {
  res.status(500).json({ message: err.message });
});

next를 사용하지 않더라도 반드시 선언해야 한다. ESLint에서 unused parameter 경고가 뜰 수 있는데, 이 경우 해당 라인만 예외 처리하면 된다.


에러 미들웨어 등록 위치

에러 처리 미들웨어는 모든 라우트와 미들웨어 뒤에 등록해야 한다. Express는 미들웨어를 등록 순서대로 실행하기 때문에, 에러 미들웨어가 앞에 있으면 뒤에 등록된 라우트에서 발생한 에러를 잡을 수 없다.

javascript
const express = require("express");
const app = express();

// 일반 미들웨어
app.use(express.json());

// 라우트
app.use("/api/users", userRouter);
app.use("/api/posts", postRouter);

// 404 처리 (모든 라우트에 매칭되지 않은 요청)
app.all("*", (req, res, next) => {
  next(new AppError(`${req.originalUrl} 경로를 찾을 수 없습니다.`, 404));
});

// 에러 처리 미들웨어 (맨 마지막)
app.use(errorHandler);

404 처리도 주목할 부분이다. app.all("*")은 어떤 라우트에도 매칭되지 않은 모든 요청을 잡아낸다. 여기서 next()에 에러 객체를 전달하면 Express가 자동으로 에러 미들웨어로 넘겨준다. next()"route" 문자열이 아닌 값을 전달하면 Express는 이후의 일반 미들웨어를 건너뛰고 에러 미들웨어만 실행한다.


환경별 에러 응답 분기

에러 처리에서 가장 중요한 설계 포인트는 개발 환경과 프로덕션 환경에서 다른 정보를 보여주는 것이다.

  • 개발 환경: 디버깅을 위해 최대한 많은 정보를 보여줘야 한다. 에러 메시지, 상태 코드, 전체 에러 객체, 스택 트레이스까지 전부 포함한다.
  • 프로덕션 환경: 사용자에게는 최소한의 정보만 보여주고, 내부 구현 세부사항은 숨겨야 한다. 스택 트레이스가 노출되면 공격자에게 코드 구조, 파일 경로, 사용 중인 라이브러리 정보를 제공하는 셈이다.
javascript
const sendErrorDev = (err, res) => {
  res.status(err.statusCode).json({
    status: err.status,
    message: err.message,
    error: err,
    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: "서버 오류가 발생했습니다.",
    });
  }
};

isOperational 플래그

프로덕션에서 sendErrorProdisOperational 플래그를 확인하는 이유가 있다. 서버에서 발생하는 에러는 크게 두 종류다:

  1. Operational Error (예상 가능한 에러): 잘못된 입력, 인증 실패, 리소스 없음 같은 에러. 클라이언트에게 구체적인 메시지를 보내도 괜찮다.
  2. Programming Error (버그): 타입 에러, null 참조, 라이브러리 충돌 같은 에러. 내부 구현 문제이므로 구체적인 정보를 노출하면 안 된다.

isOperationaltrue인 에러만 클라이언트에게 원본 메시지를 전달하고, 그 외에는 "서버 오류가 발생했습니다." 같은 일반적인 메시지를 보낸다. 이 패턴은 커스텀 에러 클래스와 함께 사용된다.

javascript
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true;
  }
}

AppError로 생성된 에러는 자동으로 isOperational = true가 된다. 개발자가 의도적으로 던진 에러라는 의미다. 반면 예상하지 못한 런타임 에러는 isOperational이 없으므로 일반 메시지로 대체된다.


에러 핸들러 조립

환경 분기를 하나의 핸들러 함수로 묶는다.

javascript
const handleError = (err, req, res, next) => {
  err.statusCode = err.statusCode || 500;
  err.status = err.status || "error";

  if (process.env.NODE_ENV === "development") {
    sendErrorDev(err, res);
  } else if (process.env.NODE_ENV === "production") {
    sendErrorProd(err, res);
  }
};

module.exports = handleError;

err.statusCodeerr.status에 기본값을 할당하는 부분이 중요하다. 커스텀 에러 클래스가 아닌 일반 Error가 던져질 수도 있기 때문이다. 일반 Error에는 statusCode가 없으므로 기본값 500을 지정해서 Internal Server Error로 처리한다.

err.status는 응답의 status 필드에 들어가는 문자열이다. HTTP 상태 코드가 4xx면 "fail", 5xx면 "error"로 구분하는 경우도 있다.

javascript
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.status = `${statusCode}`.startsWith("4") ? "fail" : "error";
    this.isOperational = true;
  }
}

실제 사용 흐름

전체 흐름을 한번 따라가 보자.

javascript
// 라우트에서 에러 발생
app.get("/api/users/:id", async (req, res, next) => {
  const user = await User.findById(req.params.id);

  if (!user) {
    return next(new AppError("해당 사용자를 찾을 수 없습니다.", 404));
  }

  res.json({ status: "success", data: user });
});
  1. 존재하지 않는 사용자 ID로 요청이 들어온다
  2. usernull이므로 AppError를 생성하고 next()에 전달한다
  3. Express는 이후의 일반 미들웨어를 건너뛰고 에러 미들웨어로 이동한다
  4. handleError가 실행되어 환경에 따라 다른 응답을 반환한다

개발 환경 응답:

json
{
  "status": "fail",
  "message": "해당 사용자를 찾을 수 없습니다.",
  "error": {
    "statusCode": 404,
    "status": "fail",
    "isOperational": true
  },
  "stack": "AppError: 해당 사용자를 찾을 수 없습니다.\n    at /src/routes/users.js:12:18\n    ..."
}

프로덕션 환경 응답:

json
{
  "status": "fail",
  "message": "해당 사용자를 찾을 수 없습니다."
}

스택 트레이스와 에러 객체가 사라진 걸 볼 수 있다. 클라이언트는 메시지만 보고 무엇이 잘못됐는지 파악하면 된다.


비동기 에러 처리와의 관계

Express 4에서는 비동기 함수(async/await)에서 발생한 에러를 자동으로 잡지 못한다. async 함수 안에서 예외가 발생하면 Promise가 reject되는데, Express는 이 rejected Promise를 처리하지 않는다.

javascript
// ❌ 에러가 잡히지 않음 — Express 4에서는 unhandled rejection
app.get("/api/data", async (req, res) => {
  const data = await someAsyncOperation(); // 여기서 에러 발생
  res.json(data);
});

이 문제를 해결하려면 모든 async 핸들러를 try-catch로 감싸야 한다.

javascript
// ✅ try-catch로 에러를 잡아서 next()에 전달
app.get("/api/data", async (req, res, next) => {
  try {
    const data = await someAsyncOperation();
    res.json(data);
  } catch (err) {
    next(err);
  }
});

하지만 모든 라우트마다 try-catch를 작성하면 보일러플레이트가 심해진다. 이걸 해결하는 패턴이 asyncHandler 래퍼다. 래퍼 함수가 try-catch를 대신 처리해주므로 라우트 핸들러는 비즈니스 로직에만 집중할 수 있다.

javascript
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

// 깔끔하게 사용
app.get("/api/data", asyncHandler(async (req, res) => {
  const data = await someAsyncOperation();
  res.json(data);
}));

참고로 Express 5에서는 async 함수의 rejected Promise를 자동으로 next(err)로 전달해주기 때문에 이 래퍼가 필요 없어진다.


특정 에러 타입별 처리

데이터베이스나 라이브러리에서 발생하는 에러는 고유한 에러 타입을 가지고 있다. 이런 에러들을 에러 미들웨어에서 변환해주면 클라이언트에게 더 의미 있는 응답을 보낼 수 있다.

javascript
const handleError = (err, req, res, next) => {
  let error = { ...err, message: err.message };

  // MySQL duplicate entry
  if (err.code === "ER_DUP_ENTRY") {
    error = new AppError("이미 존재하는 데이터입니다.", 409);
  }

  // JSON 파싱 에러
  if (err.type === "entity.parse.failed") {
    error = new AppError("잘못된 JSON 형식입니다.", 400);
  }

  // Validation 에러
  if (err.name === "ValidationError") {
    const messages = Object.values(err.errors).map((e) => e.message);
    error = new AppError(`유효하지 않은 입력: ${messages.join(", ")}`, 400);
  }

  error.statusCode = error.statusCode || 500;
  error.status = error.status || "error";

  if (process.env.NODE_ENV === "development") {
    sendErrorDev(error, res);
  } else {
    sendErrorProd(error, res);
  }
};

이렇게 하면 프로덕션에서 MySQL의 ER_DUP_ENTRY 같은 내부 에러 코드 대신 "이미 존재하는 데이터입니다."라는 사용자 친화적인 메시지를 보낼 수 있다.


에러 로깅

프로덕션에서 에러를 숨기기만 하면 디버깅할 방법이 없어진다. 클라이언트에게는 최소한의 정보만 보내되, 서버 측에서는 에러를 기록해야 한다.

가장 단순한 방법은 console.error()로 출력하는 것이지만, 실제 프로덕션에서는 로깅 라이브러리(winston, pino 등)를 사용해서 파일이나 외부 서비스에 기록한다.

javascript
const sendErrorProd = (err, res) => {
  // 서버 로그에는 전체 정보를 기록
  console.error("ERROR: ", err);

  if (err.isOperational) {
    res.status(err.statusCode).json({
      status: err.status,
      message: err.message,
    });
  } else {
    res.status(500).json({
      status: "error",
      message: "서버 오류가 발생했습니다.",
    });
  }
};

핵심 정리

개념설명
에러 미들웨어 시그니처(err, req, res, next) — 반드시 4개 인자
등록 위치모든 라우트/미들웨어 뒤, 맨 마지막
환경 분기dev는 상세 정보, prod는 최소 정보
isOperational예상된 에러 vs 예상하지 못한 에러 구분
기본값 처리statusCode || 500, status || "error"
비동기 에러Express 4는 수동 next(err) 필요, 5는 자동

관련 문서