
bcrypt 비밀번호 해싱: 개발자를 위한 실용 가이드
📷 Pixabay / Pexelsbcrypt 비밀번호 해싱: 개발자를 위한 실용 가이드
비밀번호 보안을 위한 bcrypt의 모든 것 — 비용 인수, Node.js/Python/PHP 구현, 그리고 피해야 할 일반적인 실수들.
비밀번호를 저장해야 했다면, "bcrypt를 사용하라"는 말을 들어본 적이 있을 것입니다. 20년 이상 표준 권장사항이었고, 그럴 만한 이유가 있습니다. 하지만 왜 작동하는지, 그리고 올바르게 사용하는 방법을 이해하는 것이 안전한 구현과 안전해 보이기만 하는 구현의 차이를 만듭니다.
이 가이드는 실용적인 모든 것을 다룹니다: bcrypt가 실제로 어떻게 작동하는지, 어떤 비용 인수를 선택해야 하는지, 여러 언어에서 구현하는 방법, 그리고 비밀번호 보안을 조용히 약화시키는 실수들.
bcrypt란 무엇이며 왜 존재하는가
bcrypt는 1999년 Niels Provos와 David Mazières가 비밀번호 해싱을 위해 특별히 설계했습니다. 그 맥락이 중요합니다. 빠르게 만들어진 것이 아니라 — 제어되고 조정 가능한 방식으로 느리게 만들어졌습니다.
bcrypt 뒤의 핵심 통찰은 빠른 해싱이 비밀번호에 있어 취약점이라는 것입니다. SHA-256과 같은 범용 해시는 현대 하드웨어에서 초당 수십억 개의 해시를 계산할 수 있습니다. 그것은 체크섬과 데이터 무결성에는 좋지만, 공격자가 비밀번호 데이터베이스를 훔치면 보조 GPU로 초당 수십억 번의 추측을 시도할 수 있어 비밀번호에는 끔찍합니다.
bcrypt는 비용 인수를 도입하여 이를 해결합니다 — 알고리즘이 얼마나 많은 작업을 하는지 제어하는 매개변수. 해싱이 얼마나 느린지 결정합니다. 하드웨어는 매년 빨라지므로, 비용 인수를 시간이 지남에 따라 높여 따라잡을 수 있습니다.
bcrypt가 실제로 어떻게 작동하는가
bcrypt.hash("mypassword", 10)을 호출하면, 내부적으로 여러 일이 일어납니다:
- 랜덤 16바이트 솔트가 생성됩니다. 이 솔트는 각 해시 작업에 고유합니다.
- 비밀번호와 솔트가 Eksblowfish 키 설정에 입력됩니다. 이것이 bcrypt의 핵심 알고리즘 — 값비싼 키 확장 단계를 가진 Blowfish 암호의 수정 버전입니다.
- 키 설정이 2^비용 번 반복됩니다. 비용 인수 10에서는 1,024번입니다. 비용 12에서는 4,096번입니다. 각 증분은 작업을 두 배로 늘립니다.
- 결과 해시는 알고리즘 버전, 비용 인수, 솔트, 해시가 모두 하나에 포함된 60자 문자열로 인코딩됩니다.
출력은 다음과 같습니다:
$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
분해하면:
$2b$— bcrypt 버전10— 비용 인수- 다음 22자 — base64 인코딩된 솔트
- 나머지 문자 — 해시
솔트가 해시 문자열에 내장되어 있으므로 별도로 저장할 필요가 없습니다. bcrypt.compare() 함수는 저장된 해시에서 솔트를 추출하고 입력 비밀번호를 해싱하는 데 사용합니다 — 수동 솔트 관리가 필요 없습니다.
빠른 해시 함수가 잘못된 도구인 이유
개발자들은 때때로 MD5, SHA-1, SHA-256으로 비밀번호를 저장합니다. 이러한 알고리즘은 널리 사용 가능하고 잘 이해되므로 선택이 합리적으로 보입니다. 그렇지 않습니다.
문제는 이러한 알고리즘이 의도된 목적에 깨진 것이 아닙니다(MD5와 SHA-1은 충돌 취약점이 있지만). 문제는 빠르게 설계되었으며, 빠른 것이 비밀번호 저장에 정확히 잘못된 것이라는 점입니다.
2012년, 보안 연구자 Jeremi Gosney는 64,000,000개의 SHA-1 비밀번호 해시 데이터베이스의 90%를 1시간 이내에 해독했습니다. 현대 하드웨어와 Hashcat과 같은 도구로, 좋은 GPU를 가진 공격자는 초당 100억 번 이상의 MD5 해시를 테스트할 수 있습니다. SHA-256은 크게 나아지지 않습니다 — 초당 약 40억 번입니다.
비용 10의 bcrypt는 이를 초당 약 20,000번의 시도로 낮춥니다. 이것이 핵심입니다.
올바른 비용 인수 선택하기
비용 인수는 bcrypt로 내릴 가장 중요한 튜닝 결정입니다.
비용 10이 널리 권장되는 기본값입니다. 현대 서버에서 약 100-300ms의 해시를 생성합니다. 로그인하는 사용자에게는 눈에 띄지 않을 만큼 느리지만, 공격자는 코어당 초당 수천 개의 비밀번호만 테스트할 수 있습니다.
비용 12는 10에 비해 계산 시간을 약 4배로 늘립니다. 높은 보안 요구사항의 애플리케이션 — 은행, 의료, 민감한 금융 데이터를 처리하는 모든 것 — 에 합리적인 선택으로, 400-1000ms의 로그인 시간을 수용할 수 있을 때 적합합니다.
비용 14 이상은 보통 웹 애플리케이션에서는 과도합니다. 비용 14에서 해싱은 몇 초가 걸려 사용자 경험에 눈에 띄게 영향을 미칩니다.
프로덕션에서는 절대 8 미만으로 내리지 마세요. 일부 오래된 튜토리얼은 예시로 비용 5나 6을 보여줍니다. 그 값들은 너무 빠르고 부적절한 보호를 제공합니다.
실용적인 규칙: 실제 하드웨어에서 벤치마크하고, 예상 동시 로드에서 로그인 시간을 250ms 이하로 유지하는 가장 높은 비용 인수를 선택하세요. 그런 다음 하드웨어가 향상됨에 따라 2~3년마다 재검토하세요.
Node.js에서 bcrypt 구현
bcryptjs와 bcrypt 패키지 모두 널리 사용됩니다. bcrypt는 네이티브 애드온으로 약간 더 빠르고, bcryptjs는 순수 JavaScript로 네이티브 컴파일 없이 어디서나 작동합니다.
const bcrypt = require('bcrypt');
const COST_FACTOR = 10;
// 비밀번호 해싱
async function hashPassword(plaintext) {
const hash = await bcrypt.hash(plaintext, COST_FACTOR);
return hash; // 이 문자열을 데이터베이스에 저장
}
// 로그인 시 검증
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$10$...
const valid = await verifyPassword('hunter2', hash);
console.log(valid); // true
const invalid = await verifyPassword('wrong', hash);
console.log(invalid); // false
주목할 만한 점: bcrypt.hash()는 비동기로, 스레드 풀에서 실행되어 이벤트 루프를 차단하지 않습니다. 프로덕션에서는 비동기 버전을 사용하세요 — 동기 bcrypt.hashSync()는 부하 시 서버를 멈출 것입니다.
Python에서 bcrypt 구현
bcrypt 라이브러리가 표준 선택입니다:
import bcrypt
COST_FACTOR = 10
def hash_password(plaintext: str) -> bytes:
password_bytes = plaintext.encode('utf-8')
hashed = bcrypt.hashpw(password_bytes, bcrypt.gensalt(rounds=COST_FACTOR))
return hashed # 바이트로 저장하거나 문자열로 디코딩
def verify_password(plaintext: str, stored_hash: bytes) -> bool:
password_bytes = plaintext.encode('utf-8')
return bcrypt.checkpw(password_bytes, stored_hash)
# 사용
hashed = hash_password("hunter2")
print(hashed) # b'$2b$10$...'
print(verify_password("hunter2", hashed)) # True
print(verify_password("wrong", hashed)) # False
PHP에서 bcrypt 구현
PHP의 내장 password_hash() 함수는 기본적으로 bcrypt를 사용하며 올바른 접근 방식입니다:
<?php
$options = ['cost' => 10];
// 해싱
$hash = password_hash($plaintext, PASSWORD_BCRYPT, $options);
// 검증
$valid = password_verify($plaintext, $hash);
// 해시를 업그레이드해야 하는지 확인 (예: 비용 인수를 올린 경우)
if (password_needs_rehash($hash, PASSWORD_BCRYPT, $options)) {
$newHash = password_hash($plaintext, PASSWORD_BCRYPT, $options);
// $newHash를 데이터베이스에 저장
}
?>
password_needs_rehash() 함수는 알아둘 가치가 있습니다. 비용 인수를 높이면, 각 성공적인 로그인 시 이를 호출하고 더 높은 비용으로 조용히 재해싱할 수 있습니다. 사용자는 중단 없이 더 많은 보안을 얻습니다.
Go에서 bcrypt 구현
golang.org/x/crypto/bcrypt 패키지가 이를 깔끔하게 처리합니다:
import "golang.org/x/crypto/bcrypt"
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 10)
return string(bytes), err
}
func CheckPasswordHash(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
bcrypt를 약화시키는 일반적인 실수
매 로그인 검증 시 비밀번호를 다시 해싱하는 것. 이는 중요한 성능 실수입니다. 사용자가 로그인하면 bcrypt.compare()를 사용하여 저장된 해시에 대해 비밀번호를 비교해야 합니다. 비밀번호를 다시 해싱하고 두 해시를 비교하지 마세요 — 매번 새로운 랜덤 솔트가 생성되므로 절대 일치하지 않습니다.
평문 비밀번호 저장. 명백하지만 발생합니다. 몇 년 전 비밀번호 관리자 침해는 "복구 목적으로" 평문으로 비밀번호를 저장했습니다. 그 위험을 감수할 복구 목적은 없습니다.
비밀번호 저장에 MD5 또는 SHA 사용. 수동으로 솔트를 추가해도, 빠른 해시는 공격자에게 너무 많은 처리량을 줍니다. MD5 해시를 사용한 레거시 시스템을 마이그레이션하는 경우, 로그인 시 MD5 해시를 확인한 다음 bcrypt로 재해싱하고 새 해시를 저장하여 기존 사용자를 처리하세요.
72바이트 제한을 처리하지 않는 것. bcrypt는 72바이트에서 입력을 자동으로 잘라냅니다. 표준 해결책은 비밀번호를 먼저 SHA-256으로 해싱한 다음 16진수 또는 바이너리 출력을 bcrypt로 해싱하는 것입니다.
너무 낮은 비용 인수를 사용하는 것. 비용 4가 더 빠르다고 해서 사용하고 있다면, 핵심을 놓치고 있습니다. 느림은 보안 속성이지 버그가 아닙니다.
bcrypt 구현 테스트
서버를 구성하기 전에 해시가 올바르게 보이는지 확인하거나 비용 인수를 실험하려면, ToolBox Hub의 bcrypt 생성기 도구를 사용하여 브라우저에서 직접 문자열을 해싱하고 해시를 검증할 수 있습니다.
대신 Argon2를 사용할 때
bcrypt는 25년간 안전한 기본값이었지만, 최신 최고 선택은 아닙니다. Argon2 — 구체적으로 Argon2id — 는 2015년 Password Hashing Competition에서 우승했으며 bcrypt의 주요 약점을 해결합니다.
bcrypt는 CPU 바인딩입니다: GPU를 가진 공격자는 알고리즘이 많은 메모리를 필요로 하지 않기 때문에 비교적 저렴하게 크래킹을 병렬화할 수 있습니다. Argon2는 메모리 집약적입니다: 해시 작업마다 설정 가능한 양의 RAM을 필요로 합니다. GPU는 CPU보다 컴퓨트 코어당 메모리가 훨씬 적으므로, 메모리 집약적 알고리즘이 경쟁을 크게 평준화합니다.
요약
- bcrypt의 강점은 의도적인 느림에서 나오며, 암호화 복잡성이 아닙니다
- 검증에는 항상
bcrypt.compare()(또는 언어의 동등한 함수)를 사용하세요 — 절대 해싱하고 비교하지 마세요 - 비용 인수 10이 대부분의 애플리케이션에 올바른 기본값입니다; 몇 년마다 재검토하세요
- bcrypt는 자동으로 솔팅을 처리합니다 — 솔트를 관리할 필요가 없습니다
- 비정상적으로 긴 비밀번호에 대해 72바이트 잘림 제한을 인식하세요
- 새 프로젝트에는 Argon2id를 고려할 가치가 있습니다; 기존 bcrypt 시스템에서는 유지하세요
비밀번호 저장은 안전한 옵션 — 합리적인 비용 인수의 bcrypt — 이 또한 올바르게 구현하기 쉬운 몇 안 되는 영역 중 하나입니다. 이 공간에서 대부분의 취약점은 알고리즘의 결함이 아닌 조언을 무시하는 데서 나옵니다.