Programmers

[36일차]Book Market API 인증 모듈 및 주요 기능 분석

PARKpatchnotes 2025. 10. 30. 08:10

 

** 인증 로직 모듈화: 유틸과 미들웨어의 역할 분리**

프로젝트의 인증 로직은 toker.utils.jsauthorize.middleware.js 두 파일로 명확하게 분리되어 있다. 이는 단일 책임 원칙(Single Responsibility Principle)에 따라 코드의 재사용성과 유지보수성을 높이기 위한 설계이다.

** toker.utils.js: 토큰 생성의 책임 (도구의 역할)**

  • 역할: Access Token과 Refresh Token을 생성(Generation)하는 책임을 가진다.
  • 분리 이유:
    1. 순수 함수: 토큰 생성 로직은 사용자 정보를 입력받아 JWT 문자열을 반환하는 순수 함수에 가깝다. 이는 HTTP 요청-응답 사이클(req, res, next)에 의존하지 않는다.
    2. 재사용성: 로그인(login), 토큰 갱신(refresh-token) 등 토큰 생성이 필요한 여러 곳에서 쉽게 가져와 사용할 수 있는 '도구(utility)'의 성격을 가진다.
    3. 관심사 분리: 토큰을 '만드는' 로직과 '검증하는' 로직을 분리하여 코드의 복잡성을 낮춘다.
// toker.utils.js - 토큰을 '만드는' 공장

function generateAccessToken(user) {
  // ...
  return jwt.sign(payload, process.env.ACCESS_SECRET_KEY, { expiresIn: "1h" });
}

function generateRefreshToken(user) {
  // ...
  return jwt.sign(payload, process.env.REFRESH_SECRET_KEY, { expiresIn: "7d" });
}

** authorize.middleware.js: 토큰 검증의 책임 (문지기의 역할)**

  • 역할: API 요청 헤더에 담긴 토큰을 검증(Verification)하여 접근 권한을 제어하는 책임을 가진다.
  • 분리 이유:
    1. HTTP 요청 의존성: 토큰 검증은 req.headers에서 토큰을 추출하고, 검증 결과에 따라 next()를 호출하거나 에러를 반환해야 하므로 반드시 Express의 미들웨어 구조를 따라야 한다.
    2. 라우트 보호: 라우터에 간단하게 적용하여 특정 경로들을 보호하는 '문지기(Gatekeeper)' 역할을 수행한다. 코드가 선언적이고 직관적으로 변한다.
    3. 인증 정책 구현: authenticateJWT(필수 인증)와 authenticateIfPresent(선택적 인증)처럼 다양한 인증 정책을 모듈화하여 관리하기 용이하다.
// authorize.middleware.js - API의 접근을 제어하는 '문지기'

// 로그인이 반드시 필요한 API를 위한 미들웨어
function authenticateJWT(req, res, next) {
  // ... 토큰 추출 및 검증 ...
  try {
    req.user = jwt.verify(...);
    next();
  } catch (error) {
    return next(new CustomError(...)); // 실패 시 접근 차단
  }
}

결론적으로, '생성'은 재사용 가능한 순수 기능이므로 utils에, '검증'은 HTTP 요청 흐름에 개입해야 하므로 middleware에 배치함으로써 각 모듈의 책임이 명확해지고 프로젝트 전체의 구조적 안정성이 향상되었다.


API별 인증 구현 흐름 분석

** 필수 인증 API: 장바구니 및 구매(주문)**

  • 대상 API:
    • 장바구니 담기 (POST /carts)
    • 장바구니 조회 (GET /carts)
    • 장바구니 상품 삭제 (DELETE /carts/:cartItemId)
    • 주문하기 (POST /orders)
    • 주문 내역 조회 (GET /orders)
  • 적용 미들웨어: authenticateJWT (필수 인증)

구현 흐름

  1. 클라이언트Authorization: Bearer <accessToken> 헤더와 함께 API를 요청한다.
  2. carts.js 라우터는 요청을 컨트롤러로 보내기 전에 authenticateJWT 미들웨어를 실행한다.
  3. authenticateJWT 미들웨어:
    • 성공 시: 토큰을 성공적으로 검증하고, 디코딩된 사용자 정보를 req.user에 저장한 뒤 next()를 호출하여 컨트롤러로 제어를 넘긴다.
    • 실패 시: 토큰이 없거나 유효하지 않으면, 401/403 에러를 생성하여 next(error)로 에러 핸들러에 전달하고 요청을 즉시 중단시킨다.
  4. cart.controller.js: 미들웨어를 통과한 요청이므로 req.user 객체가 항상 존재한다고 신뢰할 수 있다. req.user.id를 사용하여 특정 사용자의 장바구니나 주문을 처리한다.

코드 리뷰

// book_market/routes/carts.js
// 라우트 정의 시점에 authenticateJWT를 적용하여 이 라우트 전체에 인증을 강제한다.
router.get("/", authenticateJWT, cartController.getCartItems);
router.delete("/:cartItemId", authenticateJWT, cartController.removeCartItem);

// book_market/modules/carts/cart.controller.js
exports.getCartItems = async (req, res, next) => {
  try {
    // 미들웨어가 보장해 준 req.user.id를 사용하여 비즈니스 로직을 수행한다.
    const userId = req.user.id;
    const cartItems = await cartService.getCartItems(userId);
    res.status(200).json(cartItems);
  } catch (err) {
    next(err);
  }
};

이 구조는 인증 로직이 컨트롤러의 비즈니스 로직과 완벽하게 분리되어 코드의 가독성과 테스트 용이성을 높인다.

** 선택적 인증 API: 개별 도서 조회 및 좋아요**

  • 대상 API:
    • 개별 도서 조회 (GET /books/:bookId)
    • 도서 좋아요 토글 (POST /books/:bookId/like)
  • 적용 미들웨어:
    • GET /books/:bookId: authenticateIfPresent (선택적 인증)
    • POST /books/:bookId/like: authenticateJWT (필수 인증)

구현 흐름: 개별 도서 조회 (GET /books/:bookId)

  1. authenticateIfPresent 미들웨어:
    • 로그인 사용자: 토큰이 유효하면 req.user를 설정하고 next()를 호출한다.
    • 비로그인 사용자: 토큰이 없거나 유효하지 않아도 에러를 발생시키지 않고 req.user를 설정하지 않은 채 next()를 호출한다.
  2. book.controller.js: req.user의 존재 여부를 확인하여 userId 변수를 설정한다.
    • 로그인 시: userIdreq.user.id가 된다.
    • 비로그인 시: userIdnull이 된다.
  3. book.repository.js: userId 값을 EXISTS 서브쿼리에 전달하여 사용자의 '좋아요' 여부(isLiked)를 계산한다.
    • userIdnull이면, user_id = NULL 조건은 항상 false가 되므로, isLiked0으로 반환된다. 즉, 로그인 상태가 아니면 liked 정보가 제외(거부)되는 효과를 가진다.

코드 리뷰

// book.controller.js
exports.getBookById = async (req, res, next) => {
  try {
    const { bookId } = req.params;
    // req.user의 존재 여부에 따라 userId를 동적으로 설정한다.
    const userId = req.user ? req.user.id : null;
    const book = await bookService.getBookById(bookId, userId);
    res.status(200).json(book);
  } catch (err) {
    next(err);
  }
};

// book.repository.js
exports.findBookWithDetailById = async (bookId, userId) => {
  const sql = `
    SELECT 
        ...,
        -- userId 값에 따라 '좋아요' 여부가 결정된다.
        EXISTS(SELECT 1 FROM book_likes bl WHERE bl.book_id = b.id AND bl.user_id = ?) AS isLiked
    FROM books b ...
    WHERE b.id = ? ...`;
  const [result] = await dbPool.query(sql, [userId, bookId]);
  return result[0];
};

이처럼 선택적 인증 미들웨어를 활용함으로써, 단일 API 엔드포인트가 사용자의 로그인 상태에 따라 다른 데이터를 동적으로 제공할 수 있게 되어 API의 유연성과 재사용성이 극대화되었다.

** 페이지네이션(Pagination) 관련 코드 리뷰 및 구현 흐름**

대용량 데이터를 효율적으로 클라이언트에 전달하기 위해 페이지네이션은 필수적이다. 본 프로젝트는 클라이언트의 부담을 최소화하고 명확한 API 응답을 제공하기 위해 서버 사이드 페이지네이션을 구현하였다.

** 최종 응답 형식 (Response Format)**

API는 도서 목록(books)과 페이지네이션 정보(pagination)를 포함하는 일관된 JSON 객체를 반환한다.

{
    "books": [
        {
            "id": 1,
            "title": "모던 자바스크립트 Deep Dive",
            "author": "이웅모",
            "likes": 1,
            ...
        },
        ...
    ],
    "pagination": {
        "currentPage": 1,
        "totalCount": 100,
        "totalPages": 13
    }
}

이 구조는 클라이언트가 목록 데이터와 함께 페이지네이션 UI를 렌더링하는 데 필요한 모든 정보를 한 번의 요청으로 얻을 수 있게 하여 효율성을 극대화한다.

** 구현 흐름**

  1. Controller (book.controller.js):
    • 클라이언트로부터 page, limit 등의 쿼리 파라미터를 받는다.
    • safeParseInt와 같은 유틸 함수를 사용하여 파라미터의 유효성을 검증하고 기본값을 설정한다.
    • 모든 필터링 및 페이지네이션 정보를 객체로 묶어 Service 계층에 전달한다.
  2. Service (book.service.js):
    • 핵심 역할: 데이터 조회와 전체 개수 계산을 조율(Orchestration)한다.
    • Promise.all을 사용하여 Repository도서 목록 조회 함수전체 개수 조회 함수동시에 호출하여 성능을 최적화한다.
    • 조회된 totalCount와 요청된 limit 값을 바탕으로 totalPages (전체 페이지 수)를 계산한다.
    • 최종적으로 { books, pagination } 객체를 생성하여 Controller에 반환한다.
  3. Repository (book.repository.js):
    • 도서 목록 조회: LIMITOFFSET 절을 사용하여 요청된 페이지에 해당하는 데이터만 데이터베이스에서 조회한다.
    • 전체 개수 조회: SELECT COUNT(*) 쿼리를 실행하여 필터링 조건에 맞는 전체 데이터의 개수를 반환한다.

이러한 계층적 구조는 각 모듈의 책임을 명확히 분리하여, 데이터베이스 로직(Repository), 비즈니스 로직(Service), 요청/응답 처리(Controller)가 서로에게 미치는 영향을 최소화하고 코드의 유지보수성을 높인다.


API 응답 네이밍 컨벤션: Camel Case vs Snake Case

  • Snake Case (snake_case): 단어 사이를 밑줄(_)로 연결하는 방식. (예: current_page)
  • Camel Case (camelCase): 첫 단어를 제외한 각 단어의 첫 글자를 대문자로 표기하는 방식. (예: currentPage)

본 프로젝트의 API 응답은 Camel Case를 일관되게 사용하고 있다.

  • 선택 이유:
    1. 자바스크립트 생태계 표준: 자바스크립트에서는 변수와 함수명에 Camel Case를 사용하는 것이 일반적인 컨벤션이다. API 응답 형식을 이에 맞추면 프론트엔드(React, Vue 등)에서 데이터를 변환 없이 바로 사용할 수 있어 개발 생산성이 향상된다.
    2. JSON과의 호환성: JSON 형식 자체는 특정 컨벤션을 강제하지 않지만, 자바스크립트 객체 표기법에서 유래했기 때문에 Camel Case가 자연스럽게 사용된다.
    3. 일관성: 프로젝트 내외부에서 사용되는 데이터 구조의 네이밍을 통일하여 혼동을 줄인다.

데이터베이스 컬럼명은 snake_case를 사용하는 경우가 많지만(예: published_date), Repository 계층에서 데이터를 조회한 후 Service나 응답 직전에 camelCase로 매핑하여 API의 일관성을 유지하는 것이 바람직하다.


중앙 집중식 에러 핸들링 시스템

프로젝트는 CustomError 클래스, 에러 상수, 그리고 최종 에러 핸들링 미들웨어를 통해 강력하고 체계적인 에러 처리 시스템을 구축하였다.

** 시스템 구성 요소**

  1. constants/errors.js (에러 사전):
    • 애플리케이션에서 발생할 수 있는 모든 예측 가능한 에러의 statusCodemessage를 상수로 정의한다.
    • 이를 통해 에러 코드와 메시지를 중앙에서 관리하고, 코드 전체에서 일관된 에러를 발생시킬 수 있다.
  2. utils/errorHandler.util.js (CustomError 클래스):
    • 자바스크립트의 기본 Error 클래스를 상속받아 statusCode 프로퍼티를 추가한 커스텀 에러 클래스이다.
    • throw new CustomError(404, "페이지를 찾을 수 없습니다")와 같이, 비즈니스 로직 어디서든 상태 코드를 포함한 명시적인 에러를 발생시킬 수 있게 한다.
  3. middleware/errorHandler.middleware.js (최종 처리기):
    • Express 애플리케이션의 가장 마지막에 위치하는 미들웨어로, next(err)를 통해 전달된 모든 에러를 최종적으로 처리한다.
    • err.statusCodeerr.message를 사용하여 일관된 JSON 형식의 에러 응답을 클라이언트에 전송한다.
    • 이를 통해 컨트롤러 내에서 res.status(...).json(...)과 같은 에러 응답 코드가 반복되는 것을 방지하고, 에러 처리 로직을 한 곳으로 집중시킨다.

** 구현 흐름**

  1. Service/Controller: 특정 조건(예: 유효성 검사 실패)을 만족하면 throw new CustomError(STATUS_CODE, MESSAGE)를 통해 에러를 던진다.
  2. Controller의 catch 블록: try-catch 문이 던져진 에러를 잡아 next(err)를 호출하여 다음 미들웨어로 전달한다.
  3. errorHandler 미들웨어: 전달받은 err 객체를 사용하여 res.status(err.statusCode).json(...) 형식으로 클라이언트에 최종 응답한다.

이 구조는 에러의 발생, 전달, 처리를 명확하게 분리하여 애플리케이션의 안정성과 예측 가능성을 크게 향상시킨다.


랜덤 데이터 API 구현 방안 (Faker.js 활용)

개발 및 테스트 단계에서 현실적인 대량의 데이터는 필수적이다. faker-js 라이브러리를 사용하여 가짜 사용자를 대량으로 생성하는 API를 구현할 수 있다.

** faker-js 라이브러리**

  • 이름, 이메일, 주소, 비밀번호 등 매우 현실적인 가짜 데이터를 생성해주는 라이브러리이다.
  • npm install @faker-js/faker --save-dev 명령어로 개발 의존성으로 설치한다.

** 가짜 사용자 생성 API 구현 흐름**

  1. 라우트 정의 (/dev/users.js):
    • 운영 환경에서 노출되지 않도록 /dev와 같은 개발용 프리픽스를 사용하여 라우트를 정의한다.
    • POST /dev/users/seed?count=100: 지정된 count만큼 사용자를 생성하는 API를 설계한다.
  2. Controller:
    • req.query에서 count 값을 가져온다.
    • faker를 사용하여 count만큼의 가짜 사용자 객체 배열을 생성한다.
    • 생성된 사용자 배열을 Service 계층으로 전달한다.
  3. Service:
    • 전달받은 사용자 배열을 순회하며 각 사용자의 비밀번호를 bcrypt로 해싱하는 등 필요한 전처리 작업을 수행한다.
    • 전처리가 완료된 사용자 데이터를 Repository의 사용자 대량 생성(Bulk Insert) 함수로 호출한다.
  4. Repository:
    • INSERT INTO users (...) VALUES ?, ?, ... 구문을 사용하여 여러 사용자 데이터를 한 번의 쿼리로 데이터베이스에 삽입하여 성능을 최적화한다.

코드 리뷰 (Controller 예시)

// /dev/users.controller.js
const { faker } = require('@faker-js/faker');
const userService = require('../../modules/users/user.service');

exports.seedUsers = async (req, res, next) => {
  try {
    const count = parseInt(req.query.count) || 10;
    const users = [];

    for (let i = 0; i < count; i++) {
      users.push({
        email: faker.internet.email(),
        password: faker.internet.password(), // 이 비밀번호는 Service에서 해싱될 것이다.
        name: faker.person.fullName(),
      });
    }

    // 서비스 계층에서 비밀번호 해싱 등 전처리를 수행하고 DB에 저장한다.
    await userService.createMultipleUsers(users);

    res.status(201).json({ message: `${count}명의 가짜 사용자가 성공적으로 생성되었습니다.` });
  } catch (err) {
    next(err);
  }
};

이러한 시딩(Seeding) API를 통해 개발자는 언제든지 깨끗하고 현실적인 테스트 환경을 구축할 수 있어, 개발 및 테스트의 효율성이 크게 향상된다.