Programmers

[35일차]Node.js 에러 핸들링: 기본부터 실전 프로젝트 적용까지

PARKpatchnotes 2025. 10. 29. 07:58

자바스크립트의 기본적인 에러 객체와 처리 방식(try-catch)부터, 실제 Express 프로젝트에서 JWT 인증 에러와 비즈니스 로직 에러를 어떻게 효과적으로 관리하는지에 대해 설명한다.


1. 자바스크립트 에러의 기본

에러 객체의 종류

자바스크립트는 다양한 유형의 내장 에러 객체를 제공하여, 에러의 원인을 명확히 알려준다.

  • Error(): 가장 일반적인 런타임 에러 객체이다. 개발자가 직접 에러를 생성할 때 주로 사용된다.
  • SyntaxError(): 코드의 구문이 자바스크립트 문법에 맞지 않을 때 발생한다. (예: 괄호 불일치)
  • ReferenceError(): 존재하지 않는 변수나 함수를 참조하려고 할 때 발생한다.
  • TypeError(): 값이 예상된 타입이 아닐 때 발생한다. (예: null의 프로퍼티에 접근 시도)

try-catch를 이용한 예외 처리

try-catch 문은 런타임에 발생할 수 있는 에러를 처리하기 위한 핵심 구문이다.

  • try 블록: 에러가 발생할 가능성이 있는 코드를 감싼다.
  • catch 블록: try 블록에서 에러가 발생(throw)하면, 실행이 즉시 catch 블록으로 점프하고 에러 객체를 인자로 받는다.
  • finally 블록: 에러 발생 여부와 관계없이 항상 실행되는 코드 블록이다. 주로 데이터베이스 연결을 해제(conn.release())하거나 파일을 닫는 등 자원 정리 작업에 사용된다.
  • throw 키워드: 개발자가 의도적으로 에러를 발생시킬 때 사용한다. throw new Error("메시지")와 같이 에러 객체를 생성하여 던지는 것이 일반적이다.

if-elsetry-catch의 차이점

  • if-else: 예측 가능한 '조건 분기'에 사용된다. 예를 들어, 사용자가 입력한 값이 유효한지 검사하는 것은 예측 가능한 로직이므로 if-else가 적합하다.
  • try-catch: 예측 불가능한 '런타임 예외'를 처리하는 데 사용된다. 외부 API 호출 실패, 데이터베이스 연결 끊김, 파일 시스템 접근 오류 등 실행 시점에 발생할 수 있는 돌발 상황을 처리하는 데 적합하다.

2. 프로젝트의 JWT 에러 핸들링 전략

프로젝트에서는 jsonwebtoken 라이브러리를 사용하여 JWT 기반 인증을 구현하고 있으며, 관련 에러는 미들웨어를 통해 중앙에서 효과적으로 처리한다.

JWT 인증 미들웨어 (authorize.middleware.js)

필수 인증: authenticateJWT

이 미들웨어는 로그인이 반드시 필요한 API(예: 장바구니 담기, 도서 '좋아요' 토글)에 사용된다.

// book_market/middleware/authorize.middleware.js

function authenticateJWT(req, res, next) {
  const authHeader = req.headers.authorization;

  // 1. 토큰이 없는 경우
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    return next(
      new CustomError(UNAUTHORIZED.statusCode, "Access token is required.")
    );
  }
  const token = authHeader.split(" ")[1];

  try {
    // 2. 토큰 검증 시도
    const decoded = jwt.verify(token, process.env.ACCESS_SECRET_KEY);
    req.user = decoded; // 성공 시 요청 객체에 사용자 정보 저장
    next();
  } catch (error) {
    // 3. 토큰 검증 실패 시 (만료, 위변조 등)
    return next(
      new CustomError(FORBIDDEN.statusCode, "Invalid or expired token.")
    );
  }
}
  1. 요청 헤더에서 Authorization 토큰을 확인하고, 없으면 401 Unauthorized 에러를 next()로 전달한다.
  2. jwt.verify()를 사용하여 토큰의 유효성을 검증한다.
  3. verify 과정에서 에러가 발생하면 catch 블록이 실행된다. jsonwebtoken 라이브러리는 다음과 같은 특정 에러를 던진다:
    • TokenExpiredError: 토큰이 만료되었을 때 발생한다.
    • JsonWebTokenError: 토큰이 유효하지 않거나(위변조 등) 형식이 잘못되었을 때 발생한다.
    • 현재 코드는 이 모든 에러를 하나의 catch 블록에서 잡아, "Invalid or expired token"이라는 통일된 메시지와 403 Forbidden 상태 코드를 가진 CustomError를 생성하여 next()로 전달한다.

선택적 인증: authenticateIfPresent

이 미들웨어는 로그인을 하지 않아도 접근 가능하지만, 로그인 시 추가 정보를 제공하는 API(예: 도서 상세 조회 시 '좋아요' 여부 표시)에 사용된다.

// book_market/middleware/authorize.middleware.js

function authenticateIfPresent(req, res, next) {
  // ... 토큰 추출 로직 ...
  try {
    // 토큰이 유효하면 req.user에 사용자 정보를 저장한다.
    req.user = jwt.verify(token, process.env.ACCESS_SECRET_KEY);
  } catch (error) {
    // 토큰이 유효하지 않더라도 에러를 발생시키지 않고 그냥 넘어간다.
    // req.user는 설정되지 않은 상태로 유지된다.
  }
  next();
}
  • authenticateJWT와 달리, catch 블록에서 에러를 next()로 넘기지 않는다.
  • 따라서 토큰이 유효하지 않아도 서버가 중단되지 않고, 컨트롤러는 req.user의 존재 여부로 로그인 상태를 판별할 수 있다.
// book.controller.js의 getBookById
const userId = req.user ? req.user.id : null; // 로그인 상태에 따라 userId를 설정
const book = await bookService.getBookById(bookId, userId);

3. 프로젝트의 에러 처리 실제 사례

컨트롤러 계층의 try-catchnext(err)

프로젝트의 모든 컨트롤러 함수는 try-catch 블록으로 감싸져 있다.

// cart.controller.js
exports.addToCart = async (req, res, next) => {
  try {
    // ... 비즈니스 로직 ...
    // 성공 시 응답
    res.status(201).json({ message: "장바구니에 상품을 담았습니다." });
  } catch (err) {
    // 실패 시 에러 핸들링 미들웨어로 전달
    next(err);
  }
};
  • 이 패턴은 코드의 모든 동기/비동기 에러를 catch 블록에서 잡아, Express의 중앙 에러 핸들링 미들웨어로 일관되게 전달하는 역할을 한다.
  • 만약 이 구조가 없다면, 비동기 로직에서 발생한 에러는 서버를 다운시킬 수 있다.

res를 두 번 보내면 생기는 일

next(err)를 사용하지 않고 catch 블록 안에서 res.send()res.json()을 호출하면, try 블록 안의 res 호출과 중복되어 "Error: Cannot set headers after they are sent to the client" 라는 심각한 런타임 에러가 발생한다. next(err) 패턴은 이러한 문제를 방지하고 응답 로직을 한 곳으로 집중시킨다.

서비스 계층의 throw new CustomError()

비즈니스 로직 상의 에러(예: 존재하지 않는 상품 조회)는 서비스 계층에서 명시적으로 throw를 사용하여 발생시킨다.

// cart.service.js
exports.updateCartItem = async ({ cartItemId, quantity, userId }) => {
  const affectedRows = await cartRepository.updateCartItemQuantity(...);

  // 수정된 행이 0개이면, 상품이 없거나 내 소유가 아닌 경우이다.
  if (affectedRows === 0) {
    throw new CustomError( // 의도적으로 에러를 발생시킨다.
      NOT_FOUND.statusCode,
      "해당 상품을 장바구니에서 찾을 수 없거나, 수정할 권한이 없습니다."
    );
  }
};
  • cart.service.js에서 던져진 CustomErrorcart.controller.jscatch 블록에 의해 잡히고, 다시 next(err)를 통해 최종 에러 핸들러로 전달된다.
  • 이처럼 각 계층(Controller, Service, Repository)은 명확한 역할 분담을 통해 에러를 처리한다.
    • Controller: try-catch로 에러를 잡아 next()로 전달한다.
    • Service: 비즈니스 규칙 위반 시 CustomErrorthrow한다.
    • Repository: 데이터베이스 쿼리를 실행하고 결과를 반환한다.

이러한 구조 덕분에 프로젝트는 예측 불가능한 런타임 에러와 예측 가능한 비즈니스 로직 에러를 모두 안정적이고 일관된 방식으로 처리할 수 있다.