ToolPal
노트북 키보드 위에 놓인 자물쇠 — 디지털 보안과 비밀번호 보호를 상징합니다.

Bcrypt 비밀번호 해싱: 왜 중요하며 어떻게 사용하는가

📷 Pixabay / Pexels

Bcrypt 비밀번호 해싱: 왜 중요하며 어떻게 사용하는가

SHA-256으로 저장된 비밀번호는 몇 분 만에 해독될 수 있습니다. Bcrypt는 의도적으로 느리게 설계되었으며, 그것이 핵심입니다. Bcrypt의 작동 원리, 비용 인수 선택 방법, Node.js, Python, PHP에서의 구현 방법을 알아보세요.

2026년 4월 7일11분 소요

비밀번호 저장 문제

로그인 시스템을 구축한다고 상상해 보세요. 사용자가 hunter2라는 비밀번호로 계정을 만듭니다. 다음 주에 로그인할 때 올바른 비밀번호를 입력했는지 확인할 수 있도록 데이터베이스에 무언가를 저장해야 합니다.

가장 단순한 해결책은 hunter2를 직접 저장하는 것입니다. 이것은 재앙입니다. 데이터베이스 침해, SQL 인젝션, 잘못 구성된 S3 버킷에 남겨진 백업 등 어떤 경우에도 공격자는 모든 사용자의 실제 비밀번호를 얻게 됩니다.

다음 단계는 해싱입니다. SHA-256("hunter2")는 되돌릴 수 없는 고정 길이 문자열을 제공합니다. 대신 그것을 저장하세요. 더 낫죠?

더 낫지만 — 충분하지 않습니다.

문제는 SHA-256이 빠르게 설계되었다는 것입니다. 현대 GPU는 초당 수십억 개의 SHA-256 해시를 계산할 수 있습니다. SHA-256으로 해싱된 비밀번호 데이터베이스와 좋은 GPU를 가진 공격자는 사전 계산된 테이블(레인보우 테이블)이나 사전 공격을 사용하여 몇 시간, 때로는 몇 분 만에 일반적인 비밀번호의 상당 부분을 해독할 수 있습니다.

Bcrypt는 이를 해결하기 위해 특별히 설계되었습니다. 의도적으로 느리며, 속도를 설정할 수 있습니다.

Bcrypt Hash Generator를 사용하여 bcrypt 해시를 직접 실험해 보세요 — 설정이 필요 없습니다.

Bcrypt란 무엇인가

Bcrypt는 1999년 Niels Provos와 David Mazieres가 Blowfish 암호를 기반으로 설계한 비밀번호 해싱 함수입니다. SHA-256이나 MD5(범용 암호화 해시 함수)와 달리, bcrypt는 처음부터 비밀번호 저장을 위해 구축되었습니다.

이를 위해 세 가지 특성이 있습니다:

1. 의도적으로 느립니다

Bcrypt는 설정 가능한 비용 인수(작업 인수 또는 솔트 라운드라고도 함)를 포함합니다. 함수는 내부적으로 2^비용 반복을 수행합니다. 비용 인수를 1씩 늘리면 계산 시간이 두 배로 늘어납니다. 즉, 하드웨어에 맞게 속도를 조정할 수 있고, 하드웨어가 빨라질수록 비용 인수를 높여 앞서 나갈 수 있습니다.

SHA-256 해시는 마이크로초가 걸립니다. 비용 12의 bcrypt 해시는 약 200-300밀리초가 걸립니다. 그 차이는 작게 들리지만, 공격자의 경제성을 극적으로 변화시킵니다.

2. 자동으로 솔팅을 처리합니다

솔트는 해싱 전에 비밀번호에 추가되는 랜덤 값입니다. 솔팅은 같은 비밀번호를 가진 두 사용자가 서로 다른 해시를 얻도록 보장하고, 사전 계산된 레인보우 테이블 공격을 방어합니다.

Bcrypt는 암호학적으로 랜덤한 128비트 솔트를 자동으로 생성하고 출력 해시에 포함시킵니다. 솔트를 직접 관리할 필요가 없습니다.

3. 해시가 자기 완결적입니다

bcrypt의 출력에는 알고리즘 버전, 비용 인수, 솔트, 해시가 모두 하나의 문자열에 포함됩니다. 즉, 사용자당 하나의 문자열만 저장하면 되고, 솔트를 별도로 검색하지 않고도 비밀번호를 검증할 수 있습니다.

일반적인 bcrypt 해시는 다음과 같습니다:

$2b$12$LJ3m6gEwO/fSFqCVXWLwOeR/dYtTVkRDCwoGLBE0Fg6voFEOB5viy

분석하면:

$2b$     -- 알고리즘 버전 (2b가 현재 표준)
12$      -- 비용 인수 (2^12 = 4,096 키 스케줄 반복)
LJ3m6gEwO/fSFqCVXWLwOe  -- 22자 base64 인코딩 솔트 (128비트)
R/dYtTVkRDCwoGLBE0Fg6voFEOB5viy  -- 31자 base64 인코딩 해시

해싱과 암호화: 핵심 차이점

이 차이점은 명확히 말할 만큼 충분히 중요합니다.

해싱은 단방향 함수입니다. 비밀번호를 넣으면 해시가 나옵니다. 키도 없고 역방향 연산도 없습니다. 비밀번호가 해시와 일치하는지 확인하는 유일한 방법은 후보 비밀번호를 해싱하고 결과를 비교하는 것입니다.

암호화는 양방향 함수입니다. 키로 데이터를 암호화하고 같은(또는 관련된) 키로 원본으로 복호화할 수 있습니다.

비밀번호는 항상 암호화가 아닌 해싱해야 합니다. 비밀번호를 암호화하면 시스템 어딘가에 복호화 키가 있고, 그 키를 얻은 사람은 모든 사용자의 비밀번호를 갖게 됩니다. 해싱의 경우, 데이터베이스 침해는 비밀번호가 아닌 해시를 노출하고, bcrypt를 사용하면 그 해시를 해독하기 매우 어렵습니다.

예외: 애플리케이션이 원래 비밀번호를 검색할 합당한 이유가 있는 경우(매우 드물음 — 보통 레거시 통합), 해싱 대신 암호화할 수 있습니다. 그러나 거의 모든 표준 인증 시나리오에서 해싱이 올바른 방법입니다.

비용 인수: 적절한 느림 선택하기

비용 인수는 bcrypt가 얼마나 많은 계산을 하는지 직접 제어합니다. 각 증가는 작업을 두 배로 늘립니다.

비용 인수반복 횟수대략적인 시간 (일반 서버)
101,024~65ms
112,048~130ms
124,096~250ms
138,192~500ms
1416,384~1,000ms

위의 시간은 하드웨어에 따라 크게 다릅니다. 결정하기 전에 실제 프로덕션 하드웨어에서 벤치마크를 실행하세요.

OWASP의 현재 권장사항은 최소 비용 인수 10으로, 해시 시간 100ms 이상을 목표로 합니다. 대부분의 실무자는 오늘날 합리적인 기본값으로 12를 사용합니다.

절충점:

  • 너무 낮음 (8 이하): 해시가 충분히 빨라 좋은 GPU를 가진 공격자가 유출된 데이터베이스에 대해 빠르게 진행할 수 있습니다.
  • 너무 높음 (15+): 합법적인 로그인 요청이 1초 이상 걸려 사용자가 알아챌 수 있고, 공격자가 로그인 엔드포인트를 두드리면 서비스 거부 가능성이 생깁니다.
  • 적정 구간 (~12): 해시당 ~250ms는 공격자에게 의미 있는 방해를 줄 만큼 느리고, 사용자가 알아채지 못할 만큼 빠릅니다.

하드웨어가 향상됨에 따라, 몇 년 전에 비용 12로 해싱된 비밀번호는 현재 하드웨어에서 다소 더 쉽게 해독될 수 있습니다. 다음 로그인 시(평문 비밀번호로 검증 가능할 때) 활성 사용자의 비밀번호를 더 높은 비용 인수로 주기적으로 재해싱하는 것이 좋은 관행입니다.

Bcrypt 구현: 코드 예시

Node.js

bcrypt 패키지(와 순수 JS 대안인 bcryptjs)가 가장 일반적인 선택입니다.

import bcrypt from 'bcrypt';

const COST_FACTOR = 12;

// 비밀번호 해싱
async function hashPassword(plaintext) {
  const hash = await bcrypt.hash(plaintext, COST_FACTOR);
  return hash;
  // "$2b$12$..." -- 이 문자열을 데이터베이스에 저장
}

// 로그인 시 비밀번호 검증
async function verifyPassword(plaintext, storedHash) {
  const match = await bcrypt.compare(plaintext, storedHash);
  return match; // true 또는 false
}

// 사용 예시
const hash = await hashPassword('hunter2');
console.log(hash); // $2b$12$...

const valid = await verifyPassword('hunter2', hash);
console.log(valid); // true

const invalid = await verifyPassword('wrongpassword', hash);
console.log(invalid); // false

참고: bcrypt.compare()는 내부적으로 타이밍 안전 비교를 사용합니다. 이는 공격자가 비교에 걸리는 시간을 측정하여 정보를 추론하는 타이밍 공격을 방지합니다.

Express.js 등록 및 로그인 라우트

import express from 'express';
import bcrypt from 'bcrypt';
import { db } from './database.js';

const router = express.Router();
const COST_FACTOR = 12;

// 등록
router.post('/register', async (req, res) => {
  const { email, password } = req.body;

  // 기본 검증 -- 프로덕션에서는 더 철저한 검사 추가
  if (!email || !password || password.length < 8) {
    return res.status(400).json({ error: 'Invalid input' });
  }

  try {
    const hash = await bcrypt.hash(password, COST_FACTOR);

    await db.query(
      'INSERT INTO users (email, password_hash) VALUES ($1, $2)',
      [email, hash]
    );

    res.status(201).json({ message: 'Account created' });
  } catch (error) {
    if (error.code === '23505') { // 고유 제약 위반
      return res.status(409).json({ error: 'Email already registered' });
    }
    res.status(500).json({ error: 'Registration failed' });
  }
});

// 로그인
router.post('/login', async (req, res) => {
  const { email, password } = req.body;

  const user = await db.query(
    'SELECT id, password_hash FROM users WHERE email = $1',
    [email]
  );

  // 사용자를 찾지 못해도 항상 해싱하여 타이밍 기반 사용자 열거 방지
  const hash = user.rows[0]?.password_hash ?? '$2b$12$invalidhashfortimingprotection';
  const match = await bcrypt.compare(password, hash);

  if (!user.rows[0] || !match) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // 세션 / JWT 발급
  res.json({ message: 'Logged in', userId: user.rows[0].id });
});

Python (bcrypt 패키지 사용)

import bcrypt

COST_FACTOR = 12

def hash_password(plaintext: str) -> str:
    """비밀번호를 해싱하고 bcrypt 해시 문자열을 반환합니다."""
    password_bytes = plaintext.encode('utf-8')
    salt = bcrypt.gensalt(rounds=COST_FACTOR)
    hashed = bcrypt.hashpw(password_bytes, salt)
    return hashed.decode('utf-8')

def verify_password(plaintext: str, stored_hash: str) -> bool:
    """평문 비밀번호를 저장된 bcrypt 해시와 검증합니다."""
    password_bytes = plaintext.encode('utf-8')
    hash_bytes = stored_hash.encode('utf-8')
    return bcrypt.checkpw(password_bytes, hash_bytes)

# 사용
hash_value = hash_password('hunter2')
print(hash_value)  # $2b$12$...

print(verify_password('hunter2', hash_value))       # True
print(verify_password('wrongpassword', hash_value)) # False

Python with FastAPI

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import bcrypt

app = FastAPI()

class RegisterRequest(BaseModel):
    email: str
    password: str

class LoginRequest(BaseModel):
    email: str
    password: str

@app.post("/register")
async def register(request: RegisterRequest):
    if len(request.password) < 8:
        raise HTTPException(status_code=400, detail="Password too short")

    password_hash = bcrypt.hashpw(
        request.password.encode('utf-8'),
        bcrypt.gensalt(rounds=12)
    ).decode('utf-8')

    # 이메일과 password_hash를 데이터베이스에 저장
    return {"message": "Account created"}

@app.post("/login")
async def login(request: LoginRequest):
    # 데이터베이스에서 사용자 검색
    user = get_user_by_email(request.email)  # DB 조회

    placeholder_hash = b'$2b$12$invalidhashforunknownusers000000000'
    stored_hash = user['password_hash'].encode('utf-8') if user else placeholder_hash

    is_valid = bcrypt.checkpw(request.password.encode('utf-8'), stored_hash)

    if not user or not is_valid:
        raise HTTPException(status_code=401, detail="Invalid credentials")

    return {"message": "Logged in"}

PHP

<?php

// 비밀번호 해싱
function hashPassword(string $plaintext): string {
    return password_hash($plaintext, PASSWORD_BCRYPT, ['cost' => 12]);
}

// 비밀번호 검증
function verifyPassword(string $plaintext, string $storedHash): bool {
    return password_verify($plaintext, $storedHash);
}

// 해시를 재해싱해야 하는지 확인 (예: 비용 인수를 높인 경우)
function needsRehash(string $storedHash): bool {
    return password_needs_rehash($storedHash, PASSWORD_BCRYPT, ['cost' => 12]);
}

// 사용
$hash = hashPassword('hunter2');
echo $hash; // $2y$12$...

var_dump(verifyPassword('hunter2', $hash));       // bool(true)
var_dump(verifyPassword('wrongpassword', $hash)); // bool(false)

// 로그인 시: 필요하면 재해싱
if (needsRehash($storedHash)) {
    $newHash = hashPassword($plaintextPassword);
    // 데이터베이스에서 해시 업데이트
}
?>

PHP의 내장 password_hash()PASSWORD_BCRYPT와 함께 솔트 생성을 자동으로 처리합니다. PHP는 버전 접두사로 $2y$를 사용하는 반면 $2b$도 있지만 -- 두 가지 모두 기능적으로 동일하며 다른 bcrypt 구현과 교차 호환됩니다.

일반적인 실수와 피하는 방법

평문이나 가역 인코딩으로 비밀번호 저장

Base64는 인코딩이지 해싱이 아닙니다. base64("hunter2")aHVudGVyMg==입니다. 이것을 보는 공격자는 한 줄로 디코딩할 수 있습니다. 마찬가지로, 가역 암호화 방식은 키를 저장해야 하고, 그 키가 단일 실패 지점이 됩니다.

비밀번호에 빠른 해시 함수 사용

SHA-256, SHA-512, MD5, SHA-1 -- 이 모두는 빠르게 설계되었습니다. 빠른 것은 비밀번호 해싱에 잘못된 속성입니다. 체크섬, 디지털 서명, 데이터 무결성에는 완전히 적절합니다. 비밀번호에는 잘못된 선택입니다.

솔팅하지 않음 (레거시 또는 사용자 정의 구현)

적절한 라이브러리를 사용하지 않고 직접 구현하는 경우, 비밀번호마다 고유한 랜덤 솔트를 생성하고 해시와 함께 저장해야 합니다. 솔트 없는 해시는 레인보우 테이블 공격에 취약합니다 — 공격자가 즉시 수백만 개의 해시를 역산할 수 있는 일반 비밀번호 해시 값의 사전 계산된 테이블입니다.

Bcrypt는 이를 자동으로 처리합니다. bcrypt 라이브러리를 올바르게 사용하면 솔팅에 대해 별도로 생각할 필요가 없습니다.

72바이트 잘림 문제

원래 bcrypt 사양은 입력의 처음 72바이트만 처리합니다. 일부 라이브러리는 72바이트보다 긴 입력을 자동으로 잘립니다.

실제로 대부분의 실제 사용자 비밀번호는 72바이트 미만입니다. 그러나 긴 패스프레이즈를 지원하거나 bcrypt를 API 키나 긴 문자열을 해싱하는 데 사용하는 경우 이 문제가 발생할 수 있습니다. 표준 해결책은 입력을 먼저 SHA-256으로 해싱한 다음 해시를 bcrypt로 해싱하는 것입니다:

import crypto from 'crypto';
import bcrypt from 'bcrypt';

async function hashLongPassword(plaintext) {
  // SHA-256은 32바이트 -- bcrypt의 72바이트 제한 내에 안전하게 포함
  const prehashed = crypto
    .createHash('sha256')
    .update(plaintext)
    .digest('base64'); // 출력 가능한 문자를 위한 base64

  return bcrypt.hash(prehashed, 12);
}

타이밍 안전 비교를 사용하지 않음

라이브러리의 compare 또는 verify 함수 대신 해시를 수동으로 비교하는 경우, 상수 시간 비교 함수를 사용하세요. 대부분의 언어에서 문자열 비교는 첫 번째 차이에서 단락 처리되어 얼마나 많은 문자가 일치했는지에 대한 정보를 누출합니다. Bcrypt의 내장 비교 함수는 이를 올바르게 처리합니다.

// 잘못된 방법 -- 타이밍 누출
if (userHash === storedHash) { ... }

// 올바른 방법 -- 라이브러리의 compare 함수 사용
if (await bcrypt.compare(plaintext, storedHash)) { ... }

위협 모델에 비해 비용 인수를 너무 낮게 설정

비용 10은 바닥이지, 목표가 아닙니다. 애플리케이션이 중요한 가치를 저장하는 경우 — 금융 데이터, 건강 기록, 결제 방법 — 더 높은 비용 인수를 사용하고 실제 하드웨어에서 벤치마크를 실행하세요.

기존 비밀번호 해시 업그레이드

애플리케이션이 이미 MD5, SHA-1, 또는 솔트 없는 SHA-256 해시로 비밀번호를 저장하고 있다면, 원래 비밀번호 없이는 단순히 재해싱할 수 없습니다. 평문 비밀번호를 가지는 유일한 시점은 로그인 시입니다.

표준 마이그레이션 전략:

  1. 새 해시 형식을 위한 열을 users 테이블에 추가 (또는 마이그레이션 상태를 나타내는 플래그)
  2. 각 성공적인 로그인 시: 이전 해시에 대해 검증한 다음 즉시 bcrypt 해시로 재해싱하고 저장
  3. 전환 기간 이후, 로그인하지 않았고 마이그레이션되지 않은 계정을 강제 만료할 수 있습니다
async function migratingLogin(email, plaintext) {
  const user = await db.getUserByEmail(email);

  // 먼저 bcrypt 시도 (이미 마이그레이션됨)
  if (user.password_hash.startsWith('$2')) {
    return bcrypt.compare(plaintext, user.password_hash);
  }

  // 레거시 SHA-256 경로
  const sha256Hash = crypto.createHash('sha256').update(plaintext).digest('hex');
  const legacyMatch = sha256Hash === user.password_hash;

  if (legacyMatch) {
    // 성공적인 로그인 시 bcrypt로 마이그레이션
    const newHash = await bcrypt.hash(plaintext, 12);
    await db.updatePasswordHash(user.id, newHash);
    return true;
  }

  return false;
}

Bcrypt 해시 테스트

Bcrypt Hash Generator는 다음과 같은 용도로 유용합니다:

  • 프로젝트 설정 없이 개발 중에 테스트 해시 생성
  • 구현이 유효한 bcrypt 출력을 생성하는지 검증
  • 코드에서 해시-비밀번호 검증을 빠르게 테스트
  • 해시 형식 이해 -- 비용 인수를 조정하여 출력 접두사가 어떻게 변하는지 확인

도구는 브라우저에서 완전히 실행되며 서버로 데이터를 전송하지 않습니다. 전체 애플리케이션 없이 프로토타입을 만들거나 디버깅할 때 유용합니다.

Bcrypt와 대안 비교

Bcrypt는 검증되고 널리 지원되지만, 전체 상황을 아는 것이 좋습니다:

알고리즘메모리 집약적GPU 저항성OWASP 상태참고
Bcrypt아니오부분적권장됨검증됨, 72바이트 제한
Argon2id선호됨PHC 우승, 최신
scrypt허용됨메모리 집약적, 복잡한 매개변수
PBKDF2아니오아니오허용됨NIST 승인, FIPS 준수
SHA-256 (일반)아니오아니오사용 금지비밀번호 해시 아님

새 프로젝트에서는 Argon2id가 현재 모범 사례입니다. 메모리 집약적이어서 GPU 기반 공격을 훨씬 비용이 많이 들게 하며, 비용 프로필을 더 많이 제어할 수 있는 세 가지 매개변수(메모리, 시간, 병렬성)가 있습니다.

그렇더라도 bcrypt는 25년 이상의 실제 사용 경험이 있고, 거의 모든 언어와 프레임워크에서 사용 가능하며, 72바이트 잘림 문제는 관리 가능합니다. 팀이 bcrypt에 더 익숙하거나 프레임워크가 이에 대한 일급 지원이 있다면, 비용 12 이상의 bcrypt는 충분히 방어할 수 있는 선택입니다.

결론

비밀번호 해싱은 "동작함"과 "올바르게 동작함"의 차이가 매우 중요한 영역 중 하나입니다. SHA-256 해시를 저장하는 로그인 폼은 데이터베이스 침해가 발생하고 모든 사용자 비밀번호가 몇 시간 내에 해독 가능하다는 것이 밝혀질 때까지 안전해 보일 수 있습니다.

Bcrypt는 세 가지 중요한 것을 제공합니다: 의도적인 느림, 자동 솔팅, 자기 완결적 출력 형식. 최신 옵션은 아니지만 잘 테스트되고, 보편적으로 지원되며, 올바릅니다.

비용 인수 12를 시작점으로 사용하세요. 실제 하드웨어에서 벤치마크를 실행하세요. 서버가 200ms보다 훨씬 빠르게 해싱할 수 있다면 높이세요. 처음부터 시작한다면 Argon2id를 살펴보세요.

테스트나 개발용 bcrypt 해시를 생성하려면 Bcrypt Hash Generator를 사용하세요 -- 브라우저에서 실행되며 입력을 어디에도 전송하지 않습니다.

관련 도구 및 리소스

자주 묻는 질문

이 글 공유하기

XLinkedIn

관련 글