Programmers

[과제]회원가입, 로그인 API 구현 흐름, 결과

PARKpatchnotes 2025. 10. 17. 13:37

1. 회원가입 (Registration) 흐름

회원가입은 새로운 사용자의 정보를 데이터베이스에 안전하게 저장하는 과정이다.

회원가입 실행 간단 흐름도

1. 클라이언트 → 서버:
   POST /register (email, password, name, ...)

2. Controller:
   - 요청 데이터 추출
   - userService.register 호출

3. Service:
   - 이메일 중복 확인 (userRepository.findUserByEmail)
   - 비밀번호 해싱 (bcrypt.hash)
   - 새로운 사용자 정보 저장 요청 (userRepository.createUser)

4. Repository:
   - DB에 `INSERT` 쿼리 실행
   - 생성된 사용자 ID 반환

5. Controller:
   - 201 Created 상태 코드와 함께 성공 메시지 응답

1단계: 클라이언트 요청 및 데이터 검증 (Controller)

사용자가 이메일, 비밀번호 등의 정보를 입력하여 /register 엔드포인트로 POST 요청을 보낸다. Controller는 이 요청을 받아 서비스 계층으로 전달한다.

// user.controller.js
exports.register = async (req, res, next) => {
  try {
    // 1. 서비스 계층에 사용자 등록 로직을 위임한다.
    const newUser = await userService.register(req.body);

    // 2. 성공 시, 201 Created 상태와 함께 응답한다.
    res.status(201).json({ message: "User registered successfully", userId: newUser.id });
  } catch (err) {
    next(err); // 에러 발생 시 중앙 에러 핸들러로 전달
  }
};

2단계: 비즈니스 로직 처리 (Service)

Service 계층에서는 회원가입의 핵심 로직이 처리된다.

// user.service.js
exports.register = async ({ email, password, name, address, phone_number }) => {
  // 1. 이메일 중복 확인 (Repository 호출)
  const existingUser = await userRepository.findUserByEmail(email);
  if (existingUser) {
    throw new CustomError(USER_ALREADY_EXISTS.statusCode, USER_ALREADY_EXISTS.message);
  }

  // 2. 비밀번호를 bcrypt로 해싱한다. (saltRounds=10)
  const hashedPassword = await bcrypt.hash(password, 10);

  // 3. 해싱된 비밀번호와 함께 사용자 정보를 DB에 저장 요청한다.
  const userId = await userRepository.createUser({
    email,
    hashedPassword, // 평문 비밀번호가 아닌 해시 값을 전달
    name,
    address,
    phone_number,
  });
  return { id: userId };
};

가장 중요한 부분은 사용자의 비밀번호를 절대 평문으로 저장하지 않고, bcrypt.hash를 통해 안전하게 암호화된 상태로 변환하는 것이다.

3단계: 데이터베이스 저장 (Repository)

Repository 계층은 Service로부터 받은 데이터를 실제 데이터베이스에 저장(INSERT)하는 역할을 담당한다.

// user.repository.js
exports.createUser = async ({ email, hashedPassword, name, address, phone_number }) => {
  const result = await dbPool.query(
    "INSERT INTO users (email, password, name, address, phone_number) VALUES (?, ?, ?, ?, ?)",
    [email, hashedPassword, name, address, phone_number]
  );
  // 생성된 사용자의 ID를 반환한다.
  return result[0].insertId;
};

4단계: 회원가입 API 결과

성공

 


2. 로그인 (Login) 흐름

로그인은 저장된 사용자 정보를 바탕으로 신원을 확인하고, 인증 상태를 유지할 수 있는 토큰을 발급하는 과정이다.

로그인 실행 간단 흐름도

1. 클라이언트 → 서버:
   POST /login (email, password)

2. Controller:
   - 요청 데이터 추출, userService.login 호출

3. Service:
   - 사용자 조회 (userRepository.findUserByEmail)
   - 비밀번호 검증 (bcrypt.compare)
   - 인증 성공 시 Controller로 사용자 정보 반환

4. Controller:
   - Access/Refresh Token 생성
   - Refresh Token DB 저장 요청 (userRepository.saveRefreshToken)
   - Refresh Token을 httpOnly 쿠키에 설정
   - Access Token을 JSON으로 클라이언트에 응답

5. 클라이언트:
   - Access Token으로 API 요청, 만료 시 Refresh Token으로 재발급

1단계: 클라이언트 요청 및 인증 위임 (Controller)

사용자가 이메일과 비밀번호로 /login 엔드포인트에 POST 요청을 보낸다.

// user.controller.js
exports.login = async (req, res, next) => {
  try {
    const { email, password } = req.body;
    // 1. 서비스 계층에 인증 처리를 위임한다.
    const user = await userService.login(email, password);
    // ... (인증 성공 후 토큰 발급 로직)
  } catch (err) {
    next(err);
  }
};

2단계: 사용자 인증 (Service & Repository)

Service는 Repository를 통해 사용자를 조회하고, bcrypt.compare를 통해 비밀번호의 일치 여부를 검증한다.

// user.repository.js
exports.findUserByEmail = async (email) => {
  const result = await dbPool.query(
    "SELECT * FROM users WHERE email = ? AND deleted_at IS NULL",
    [email]
  );
  return result[0][0]; // MariaDB 결과 형식에 맞춰 실제 데이터 행 추출
};

// user.service.js
exports.login = async (email, password) => {
  const user = await userRepository.findUserByEmail(email);
  if (!user) { // 사용자 존재 여부 확인
    throw new CustomError(INVALID_CREDENTIALS.statusCode, INVALID_CREDENTIALS.message);
  }

  // bcrypt.compare는 입력된 평문과 DB의 해시를 비교한다.
  const isPasswordValid = await bcrypt.compare(password, user.password);
  if (!isPasswordValid) { // 비밀번호 불일치 시 에러
    throw new CustomError(INVALID_CREDENTIALS.statusCode, INVALID_CREDENTIALS.message);
  }

  // 보안을 위해 비밀번호를 제외한 정보만 반환
  const { password: _, ...userWithoutPassword } = user;
  return userWithoutPassword;
};

3단계: JWT 발급 및 저장 (Controller & Repository)

인증에 성공하면 Controller는 Access Token과 Refresh Token을 생성한다. Refresh Token은 탈취되더라도 서버에서 무효화할 수 있도록 데이터베이스에 저장한다.

// user.controller.js (login 함수 내부)
const payload = { id: user.id, email: user.email };
const accessToken = generateAccessToken(payload);
const refreshToken = generateRefreshToken(payload);

// 서비스 계층을 통해 Repository의 저장 함수를 호출
await userService.saveRefreshToken(user.id, refreshToken);

// user.repository.js
exports.saveRefreshToken = async (userId, token) => {
  const expiresAt = new Date();
  expiresAt.setDate(expiresAt.getDate() + 7);
  const sql = `
    INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, ?)
    ON DUPLICATE KEY UPDATE token = VALUES(token), expires_at = VALUES(expires_at)`;
  await dbPool.query(sql, [userId, token, expiresAt]);
};

4단계: 최종 응답 (Controller)

마지막으로 Controller는 클라이언트에게 토큰을 전달한다. Access Token은 API 요청 시 사용되며, Refresh Token은 안전하게 쿠키에 저장된다.

// user.controller.js (login 함수 마지막)
// Refresh Token은 httpOnly 쿠키에 담아 XSS 공격을 방지한다.
res.cookie("refreshToken", refreshToken, {
  httpOnly: true,
  secure: process.env.NODE_ENV === "production",
  sameSite: "Strict",
});

// Access Token은 JSON 본문에 담아 전달한다.
res.status(200).json({ message: "Login successful", accessToken });

5단계: 로그인 API 결과

엑세스 토큰 반환 성공

결론

  • 회원가입 시에는 bcrypt를 통해 비밀번호를 안전하게 해싱하여 저장하는 것이 핵심이다.
  • 로그인 시에는 bcrypt.compare로 비밀번호를 검증하고, JWT(Access/Refresh Token)를 발급하여 인증 상태를 관리한다.
  • 계층형 아키텍처(Controller, Service, Repository)를 통해 각 모듈의 역할을 명확히 분리함으로써 코드의 유지보수성과 확장성을 높일 수 있다.

이러한 흐름을 통해 안전하고 효율적인 사용자 인증 시스템을 구축할 수 있다.