BioPlayground

🧬
목록으로

다중 사용자 인증 구현

회원가입, 비밀번호 해싱(bcrypt), 역할 기반 접근 제어를 구현하는 방법을 단계별로 배웁니다.

중급
|
12
|
검증 완료 (2026-07)
다중 사용자회원가입bcrypt비밀번호 해싱인증 구현
진행률0/23 (0%)

다중 사용자 인증 구현

이 토픽을 마치면

회원가입 시 비밀번호를 안전하게 해싱하고, bcrypt의 원리를 이해하며, 역할(role) 기반 접근 제어를 구현할 수 있습니다.


비밀번호를 그대로 저장하면 안 되는 이유

javascript
// NEVER do this
const users = [
  { username: 'alice', password: 'mySecret123' }
];

데이터베이스가 유출되면 모든 사용자의 비밀번호가 노출됩니다. 2012년 LinkedIn 해킹, 2013년 Adobe 해킹 — 수억 건의 비밀번호가 평문(또는 약한 해시)으로 저장되어 있었습니다.

비밀번호는 절대 평문으로 저장하지 않습니다. 해싱(hashing)해서 저장합니다.


해싱이란

해싱은 단방향 변환입니다. 원본 → 해시는 가능하지만, 해시 → 원본은 불가능합니다.

text
"mySecret123" → 해싱 → "$2b$10$X7kG..."  (가능)
"$2b$10$X7kG..." → ???  → "mySecret123"   (불가능)

로그인 시에는 입력된 비밀번호를 해싱해서 저장된 해시와 비교합니다.

text
사용자 입력: "mySecret123"
저장된 해시: "$2b$10$X7kG..."

"mySecret123" → 해싱 → "$2b$10$X7kG..."  (일치 → 인증 성공)
"wrongPass"   → 해싱 → "$2b$10$Qm1Y..."  (불일치 → 인증 실패)

bcrypt — 비밀번호 해싱의 표준

bash
npm install bcrypt

bcrypt가 SHA-256 같은 일반 해시 함수보다 비밀번호에 적합한 이유:

  1. 느리게 설계됨: 해커가 초당 수십억 개의 해시를 시도하는 것을 방지
  2. Salt 자동 포함: 같은 비밀번호라도 매번 다른 해시가 생성됨
  3. 비용 조절 가능: rounds 값으로 속도/보안 트레이드오프 조절
javascript
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 10;

// 해싱 (회원가입 시)
const hash = await bcrypt.hash('mySecret123', SALT_ROUNDS);
console.log(hash);
// $2b$10$X7kG.fDlUqVnPq5RvWEYq.ZM3Y4Fs8qD1vSrKLhwEcFjnp2fW9Xm.

// 비교 (로그인 시)
const isMatch = await bcrypt.compare('mySecret123', hash);
console.log(isMatch);  // true

const isWrong = await bcrypt.compare('wrongPass', hash);
console.log(isWrong);  // false

SALT_ROUNDS = 10은 2^10 = 1,024번 반복 연산을 의미합니다. 값이 높을수록 안전하지만 느립니다. 10~12가 현재 권장 범위입니다.


회원가입 구현

javascript
const express = require('express');
const bcrypt = require('bcrypt');
const app = express();
app.use(express.json());

const users = [];  // 실제로는 DB 사용

app.post('/register', async (req, res) => {
  const { username, password, displayName } = req.body;

  // 입력 검증
  if (!username || !password) {
    return res.status(400).json({ error: 'Username and password required' });
  }
  if (password.length < 8) {
    return res.status(400).json({ error: 'Password must be at least 8 characters' });
  }

  // 중복 확인
  if (users.find(u => u.username === username)) {
    return res.status(409).json({ error: 'Username already exists' });
  }

  // 비밀번호 해싱
  const hashedPassword = await bcrypt.hash(password, 10);

  // 저장
  const newUser = {
    id: users.length + 1,
    username,
    password: hashedPassword,
    displayName: displayName || username,
    role: 'user',
    createdAt: new Date().toISOString()
  };
  users.push(newUser);

  res.status(201).json({
    message: 'Registration successful',
    user: { id: newUser.id, username: newUser.username }
  });
});

응답에 비밀번호(해시 포함)를 절대 포함하지 않습니다.


Passport와 연동

javascript
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;

passport.use(new LocalStrategy(
  async (username, password, done) => {
    const user = users.find(u => u.username === username);
    if (!user) {
      return done(null, false, { message: 'User not found' });
    }

    // bcrypt로 비교
    const isValid = await bcrypt.compare(password, user.password);
    if (!isValid) {
      return done(null, false, { message: 'Wrong password' });
    }

    return done(null, user);
  }
));

passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser((id, done) => {
  const user = users.find(u => u.id === id);
  done(null, user);
});

이전 토픽의 Strategy에서 user.password !== password 대신 bcrypt.compare()를 사용하는 것이 유일한 차이입니다.


역할 기반 접근 제어 (RBAC)

javascript
function requireRole(...roles) {
  return (req, res, next) => {
    if (!req.isAuthenticated()) {
      return res.status(401).json({ error: 'Login required' });
    }
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    next();
  };
}

// 모든 로그인 사용자
app.get('/profile', requireRole('user', 'admin'), (req, res) => {
  res.json({ user: req.user.displayName });
});

// 관리자만
app.get('/admin/users', requireRole('admin'), (req, res) => {
  const safeUsers = users.map(u => ({
    id: u.id, username: u.username, role: u.role
  }));
  res.json(safeUsers);
});

// 관리자 — 역할 변경
app.patch('/admin/users/:id/role', requireRole('admin'), (req, res) => {
  const user = users.find(u => u.id === parseInt(req.params.id));
  if (!user) return res.status(404).json({ error: 'User not found' });

  user.role = req.body.role;
  res.json({ message: `${user.username} role changed to ${user.role}` });
});

requireRole(...roles)은 고차 함수(함수를 반환하는 함수)입니다. 허용할 역할을 인자로 받아서, 해당 역할이 아니면 403을 반환하는 미들웨어를 만듭니다.


보안 체크리스트

항목설명
비밀번호 해싱bcrypt, SALT_ROUNDS 10+
최소 길이8자 이상 (NIST 권장)
응답에 해시 미포함API 응답에 password 필드 절대 포함 금지
에러 메시지 일반화"잘못된 사용자명 또는 비밀번호" (어느 쪽이 틀렸는지 알려주지 않음)
Rate limiting로그인 시도 횟수 제한 (brute force 방지)
HTTPS비밀번호가 평문으로 네트워크를 타지 않도록

에러 메시지를 일반화하는 이유: "사용자명이 없습니다"라고 하면, 공격자가 존재하는 계정을 알아낼 수 있습니다. 이것을 **사용자 열거 공격(User Enumeration)**이라고 합니다.


실전 패턴 — 회원 탈퇴

javascript
app.delete('/account', requireRole('user', 'admin'), async (req, res) => {
  const valid = await bcrypt.compare(req.body.password, req.user.password);
  if (!valid) {
    return res.status(401).json({ error: 'Password confirmation failed' });
  }

  const index = users.findIndex(u => u.id === req.user.id);
  users.splice(index, 1);

  req.logout((err) => {
    if (err) return res.status(500).json({ error: 'Logout failed' });
    res.json({ message: 'Account deleted' });
  });
});

계정 삭제는 위험한 작업이므로 비밀번호 재확인을 거칩니다. 삭제 후 반드시 req.logout()으로 세션을 정리합니다.


핵심 정리

개념정리
해싱단방향 변환. 원본 → 해시 가능, 해시 → 원본 불가능
bcrypt비밀번호 전용 해시. 느리고 salt 포함
Salt같은 비밀번호도 다른 해시를 생성하는 랜덤 값
RBAC역할(role) 기반으로 접근 권한을 분리
bcrypt.hash()회원가입 시 비밀번호 → 해시
bcrypt.compare()로그인 시 입력 vs 저장된 해시 비교

인증은 "비밀번호가 맞는가"를 넘어서 "비밀번호를 어떻게 안전하게 다루는가"가 핵심입니다. 평문 저장은 사고, 약한 해시(MD5, SHA-1)는 무의미, bcrypt는 현재 표준입니다.