프로그래머스 데브코스TIL북 스토어 프로젝트

[week6] 프로젝트 : Node.js 기반의 Rest API 구현 (5)

이규현2026-02-12
[week6] 프로젝트 : Node.js 기반의 Rest API 구현 (5)

로그인/회원가입 API 구현하기

저번시간에 간단하게 모듈화해서 구현해놨던 user.js를 제대로 구현해보려고 합니다.

👉🏻 지난시간 API 구현 확인하기

1. 데이터베이스 접속 코드 작성

우선 데이터베이스부터 연결하는 코드를 작성해보겠습니다.

import mysql from 'mysql2';
import dotenv from 'dotenv';

dotenv.config();

const conn = mysql.createConnection({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASS,
  database: process.env.DB_NAME,
  dateStrings: true,
});

conn.connect((err) => {
  if (err) console.error('❌ DB 연결 실패:', err.message);
  else console.log('✅ DB 연결 성공!');
});

module.exports = conn;

host, user, password, database같은 정보들은 env파일에 작성하여 불러오게 작성하였습니다.

# env_sample.txt
# 데이터베이스 설정
DB_HOST=localhost
DB_USER=root
DB_PASS=your_password
DB_NAME=your_database_name

2. http-status-codes

코드를 작성하다 보면 200, 404, 500 같은 숫자가 무엇을 의미하는지 바로 떠오르지 않을 때가 있는데, http-status-codes는 숫자 대신 영어 단어로 상태 코드를 적게 해줘서 코드를 읽기 편하게 만드는 도구입니다.

자주 사용하는 코드들을 정리해보았습니다.

HTTP 상태 코드라이브러리 상수명 (StatusCodes)의미 및 사용 상황
200OK성공: 데이터 조회, 수정, 삭제가 성공했을 때
201CREATED생성됨: 회원가입, 게시글 등록 등 새로운 데이터 생성 성공 시
400BAD_REQUEST잘못된 요청: 필수 파라미터 누락, 유효성 검사 실패 시
401UNAUTHORIZED인증 필요: 로그인이 안 되어 있거나 토큰이 유효하지 않을 때
403FORBIDDEN권한 없음: 로그인은 했지만 해당 리소스에 접근 권한이 없을 때
404NOT_FOUND찾을 수 없음: 요청한 페이지나 DB에 해당 ID의 데이터가 없을 때
409CONFLICT충돌: 이미 존재하는 이메일로 가입을 시도하는 등 중복 발생 시
500INTERNAL_SERVER_ERROR서버 에러: 서버 내부 로직 오류나 DB 연결 오류 발생 시

라이브러리 설치

npm install http-status-codes

Express.js의 라우터-미들웨어-컨트롤러 패턴 구조화

전에 실습에서는 Router 파일에 모든 로직을 다 넣고 진행하였는데 이번에는 분리하여 설계해보았습니다.

각 폴더의 역할을 이렇게 되어있습니다.

  • Router 경로를 안내하는 이정표 역할을 합니다.

어떤 URL 요청이 들어왔을 때, 어떤 유효성 검증(body)을 거쳐 어떤 컨트롤러 함수로 보낼지 결정합니다.

  • Middleware 요청 데이터의 유효성을 사전에 검증하는 문지기 역할을 합니다.

express-validatorvalidationResult를 사용하여 라우터에서 정의한 규칙을 위반한 데이터가 발견되면 컨트롤러로 넘어가기 전 400 Bad Request 응답을 보냅니다.

  • Controller 실제 비즈니스 로직과 DB 상호작용을 담당하는 실행부 역할을 합니다.

검증이 완료된 데이터를 바탕으로 SQL 쿼리를 실행(conn.query)하고, 그 결과에 따라 적절한 HTTP 상태 코드(StatusCodes)를 클라이언트에 반환합니다.

이렇게 분리를 다음과 같은 장점이 있습니다.

  • 가독성 향상 한 파일의 코드 길이가 획기적으로 줄어들어 전체적인 흐름을 파악하기 쉽습니다.

  • 유지보수 효율성 유효성 검사 규칙만 수정하고 싶을 때는 라우터를, DB 로직을 고치고 싶을 때는 컨트롤러만 확인하면 되므로 관리가 용이합니다.

  • 재사용성 동일한 validator 미들웨어를 여러 라우트에서 공유하여 사용할 수 있어 코드 중복이 제거됩니다.

Middleware

클라이언트가 보낸 데이터가 유효한지 검사하는 **'공통 검증 미들웨어'**입니다.

export 키워드를 사용하여 이 미들웨어를 외부 파일에서도 자유롭게 가져다 쓸 수 있도록 만들었습니다.

// midleware/validator.js

import { validationResult } from 'express-validator';

export const validator = (req, res, next) => {
  const errors = validationResult(req);
  if (errors.isEmpty()) {
    return next();
  }
  return res.status(400).json({ message: errors.array()[0].msg });
};

Controller

모든 검증을 마친 데이터를 전달받아 실제 데이터베이스(MySQL)와 상호작용하고, 그 결과에 따라 클라이언트에게 최종 응답을 보내는 역할을 합니다.

userController.js 파일에는 router/users.js에 모든 로직이 담길 예정입니다.

// controller/userController.js
import conn from '../db/mysql_connect.js';
import { StatusCodes } from 'http-status-codes';

export const join = (req, res) => {
  const { email, name, password } = req.body;
  const sql = `INSERT INTO Users (email, name, password) VALUES (?, ?, ?)`;
  const values = [email, name, password];

  conn.query(sql, values, function (err, results) {
    if (err) {
      console.error('회원가입 DB 에러:', err);
      return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json(err);
    }
    res.status(StatusCodes.CREATED).json({
      message: '회원가입 성공!',
      result: results,
    });
  });
};

export const login = (req, res) => {};

Router에 적용해보기 (회원가입 API 구현)

위에서 만들었던 validator.jsuserController.js 파일을 가져와서 회원가입 API에 적용해보겠습니다.

import express from 'express';
import { body } from 'express-validator';
import { validator } from '../middleware/validator.js';
import { join } from '../controller/userController.js';

const router = express.Router();

router.post(
  '/join',
  [
    body('email').notEmpty().isEmail().withMessage('이메일을 확인해주세요.'),
    body('name').notEmpty().isString().withMessage('이름을 확인해주세요.'),
    body('password').notEmpty().isString().withMessage('비밀번호를 확인해주세요.'),
  ],
  validator,
  join,
);

1. 외부 모듈 호출

  • import { body } from "express-validator" 사용자가 입력한 값(email, name 등)이 올바른 형식인지 검사하기 위한 도구를 가져옵니다.

  • import validator from "../middleware/validator" 검증 결과 에러가 있는지 확인하여 통과 여부를 결정하는 미들웨어를 가져옵니다.

  • import userHandler from "../controller/userController" 검증을 마친 후 실제 DB에 데이터를 저장하는 컨트롤러를 가져옵니다.

2. 실행 순서

  • 규칙 선언 (Router) body()를 이용해 이메일 형식, 필수 입력값 등 데이터 검증 규칙을 정의합니다.

  • 데이터 검사 (Middleware) middleware/validator.js가 실행되어 앞서 정의한 규칙에 따라 에러 유무를 판단합니다. 에러가 있으면 여기서 즉시 400 응답을 보내고 요청을 종료합니다.

  • 로직 실행 (Controller) 검증을 통과한 데이터만 controller/userController.js로 전달되어 실제 DB 저장(INSERT) 명령을 수행하고 최종 응답을 보냅니다.

비밀번호 암호화 (Hash)

개인 정보와 해킹 방지를 위하여 비밀번호는 암호화 해서 사용합다. Node.js 내장함수인 crypto의 를 pbkdf2Sync 함수를 사용해서 강력한 단방향 해시 암호화를 구현할 수 있습니다.

Salt (소금): 같은 비밀번호라도 사용자마다 다른 결과값이 나오도록 추가하는 무작위 데이터입니다. randomBytes를 사용하여 생성합니다.

user 테이블 salt 컬럼 추가

ALTER TABLE users ADD salt VARCHAR(100);

userController.join() 수정

// 맨위에 추가
import crypto from 'crypto';

// join()안에 추가 및 수정
// 추가
const salt = crypto.randomBytes(10).toString('base64');

const hashPassword = crypto.pbkdf2Sync(password, salt, 10000, 10, 'sha512').toString('base64');

// 수정 전
const sql = `INSERT INTO Users (email, name, password) VALUES (?, ?, ?)`;
const values = [email, name, password];

// 수정 후
const sql = `INSERT INTO Users (email, name, password) VALUES (?, ?, ?, ?)`;
const values = [email, name, hashPassword, salt];

회원가입 결과 확인

로그인 API 구현

회원가입에 이어서 로그인도 구현해보겠습니다.

1. .env에 jwt token 추가

# env_sample.txt -> .env에 추가하세요!!
#JWT 토근
JWT_SECRET_KEY=your_secret_key

2. Controller

// jwt 토큰 생성을 위해 임포트 해줍니다.
import jwt from 'jsonwebtoken';

// userController.js join()아래에 작성하면 됩니다.
export const login = (req, res) => {
  const { email, password } = req.body;
  const sql = `SELECT * FROM Users WHERE email = ?`;

  conn.query(sql, [email], (err, results) => {
    if (err) {
      console.error('로그인 DB 에러:', err);
      return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json(err);
    }

    const loginUser = results[0];

    if (loginUser) {
      const hashPassword = crypto
        .pbkdf2Sync(password, loginUser.salt, 10000, 10, 'sha512')
        .toString('base64');

      if (loginUser.password === hashPassword) {
        const token = jwt.sign(
          {
            email: loginUser.email,
            name: loginUser.name,
          },
          process.env.JWT_SECRET_KEY,
          {
            expiresIn: '3m',
            issuer: 'kyuhyun',
          },
        );

        res.cookie('token', token, {
          httpOnly: true,
        });

        return res.status(StatusCodes.OK).json({
          message: `${loginUser.name}님, 환영합니다!`,
        });
      }
    }
    return res.status(StatusCodes.UNAUTHORIZED).json({
      message: '아이디 또는 비밀번호가 틀렸습니다.',
    });
  });
};

로그인 로직 요약

  • 사용자 조회 (이메일 확인) 사용자가 보낸 이메일을 기준으로 DB에서 해당 유저 정보를 가져옵니다.

가입된 이메일이 없다면 바로 401(인증 실패) 응답을 보냅니다.

  • 비밀번호 검증 (Crypto 활용) DB에 저장된 해당 유저의 salt를 꺼내와서, 입력받은 비밀번호를 다시 해싱합니다.

이렇게 계산된 결과값(hashPassword)이 DB에 저장된 비밀번호와 일치하는지 확인합니다.

  • 증명서 발급 및 전달 (JWT & Cookie) 검증이 완료되면 유저 정보를 담은 JWT를 생성합니다. (이때 .env의 비밀키 사용)

생성된 토큰을 보안을 위해 httpOnly 옵션이 적용된 쿠키에 담아 클라이언트에 전달합니다.

로그인 결과 확인

비밀번호 초기화 요청 및 수정

비밀번호 초기화 요청을 할때는 이메일을 입력받아서 해당 유저가 회원인지 확인을 합니다.

해당 유저가 확인되면 새로운 비밀번호를 입력하여 전송하면 기존 Salt를 재사용하지 않고 새로운 Salt를 생성합니다.

현재는 비밀번호를 바꿀 때 이메일과 비밀번호를 입력해서 바꾸지만 추후 프론트엔드를 추가하게 되면 이메일로 본인인증 -> 새로운 비밀번호 입력으로 자연스럽게 이어지도록 개선할 예정입니다.

controller

// 비밀번호 초기화 요청
export const pwdResetReq = (req, res) => {
  const { email } = req.body;
  const sql = `SELECT * FROM Users WHERE email = ?`;

  conn.query(sql, [email], (err, results) => {
    if (err) {
      console.error('비밀번호 초기화 요청 DB 에러:', err);
      return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json(err);
    }

    const user = results[0];
    if (user) {
      return res.status(StatusCodes.OK).json({
        email: email,
        message: '비밀번호를 변경할 준비가 되었습니다.',
      });
    } else {
      return res.status(StatusCodes.UNAUTHORIZED).json({
        message: '해당 이메일로 가입된 정보가 없습니다.',
      });
    }
  });
};

// 비밀번호 초기화
export const pwdReset = (req, res) => {
  const { email, password } = req.body;

  const salt = crypto.randomBytes(10).toString('base64');

  const hashPassword = crypto.pbkdf2Sync(password, salt, 10000, 10, 'sha512').toString('base64');

  const sql = `UPDATE Users SET password = ?, salt = ? WHERE email = ?`;
  const values = [hashPassword, salt, email];

  conn.query(sql, values, (err, results) => {
    if (err) {
      console.error('비밀번호 변경 DB 에러:', err);
      return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json(err);
    }

    if (results.affectedRows === 0) {
      return res.status(StatusCodes.BAD_REQUEST).json({
        message: '비밀번호 변경에 실패했습니다.',
      });
    }

    return res.status(StatusCodes.OK).json({
      message: '비밀번호가 성공적으로 변경되었습니다.',
    });
  });
};

router (users.js)

//.post(/"login") 밑에 추가합니다.
 .post(
    "/reset",
    [body("email").notEmpty().isEmail().withMessage("이메일을 입력해주세요.")],
    validator,
    pwdResetReq,
  )

  .put(
    "/reset",
    [
      body("email").notEmpty().isEmail().withMessage("이메일을 입력해주세요."),
      body("password")
        .notEmpty()
        .isString()
        .withMessage("새로운 비밀번호를 입력해주세요."),
    ],
    validator,
    pwdReset,
  );

결과 화면