bcrypt 비밀번호 해싱
비밀번호를 데이터베이스에 평문으로 저장하면 안 된다는 건 누구나 안다. 그래서 해싱을 한다. 그런데 단순히 SHA-256 같은 해시 함수로 비밀번호를 변환하면 충분할까?
결론부터 말하면 아니다. 일반적인 해시 함수는 비밀번호 저장용으로 설계되지 않았다. bcrypt는 이 문제를 해결하기 위해 만들어진 전용 알고리즘이다.
일반 해시 함수의 문제
SHA-256이나 MD5 같은 해시 함수의 설계 목적은 빠르게 해시를 계산하는 것이다. 파일 무결성 검증이나 디지털 서명에서는 속도가 중요하니까. 그런데 비밀번호 해싱에서 "빠르다"는 건 오히려 약점이다.
공격자가 해시된 비밀번호 데이터베이스를 손에 넣었다고 하자. SHA-256은 초당 수십억 개의 해시를 계산할 수 있다. 흔히 쓰이는 비밀번호 수백만 개를 전부 해싱해서 비교하는 데 몇 초면 충분하다. 이걸 레인보우 테이블 공격이라고 한다. 미리 계산해둔 해시 테이블과 대조하는 방식이다.
salt를 추가하면 레인보우 테이블은 무력화된다. 각 비밀번호에 랜덤한 문자열을 붙여서 해싱하니까, 사전 계산된 테이블이 소용없다. 하지만 무차별 대입(brute force)에는 여전히 취약하다. SHA-256이 너무 빨라서 salt가 있어도 하나씩 대입해보는 게 현실적으로 가능하기 때문이다.
bcrypt의 핵심: 의도적으로 느리게
bcrypt는 1999년 Niels Provos와 David Mazières가 Blowfish 암호 알고리즘을 기반으로 설계했다. 핵심 아이디어는 단순하다: 해시 계산을 일부러 느리게 만들어서 무차별 대입을 비현실적으로 만든다.
bcrypt 해시 결과는 이런 형태다:
$2b$12$LJ3m4ys3Lg7Gy5cFOKgbhePJAkhGr1KNSoJmlf.aOMq3tSI/C6U/6
이걸 분해하면:
| 부분 | 의미 |
|---|---|
$2b$ | bcrypt 버전 (2b가 현재 표준) |
12$ | cost factor (2^12 = 4096번 반복) |
LJ3m4ys3Lg7Gy5cFOKgbhe | 22자리 salt (Base64 인코딩) |
PJAkhGr1KNSoJmlf.aOMq3tSI/C6U/6 | 31자리 해시 값 |
salt와 해시가 하나의 문자열에 다 들어있다. 별도로 salt를 저장할 필요가 없다.
Cost Factor (Work Factor)
bcrypt의 가장 중요한 파라미터는 cost factor다. 이 값이 n이면 내부적으로 2^n번의 반복 연산을 수행한다.
- cost 10: 2^10 = 1,024번 반복 → 약 100ms
- cost 12: 2^12 = 4,096번 반복 → 약 300ms
- cost 14: 2^14 = 16,384번 반복 → 약 1초
1 올릴 때마다 시간이 2배가 된다. 로그인할 때 300ms 정도 기다리는 건 사용자에게 체감이 안 되지만, 공격자가 100만 개의 비밀번호를 대입하려면 300ms × 1,000,000 = 약 3.5일이 필요하다. SHA-256이면 1초도 안 걸릴 작업이다.
이게 bcrypt가 "적응형(adaptive)" 알고리즘이라고 불리는 이유다. 하드웨어가 빨라지면 cost factor를 올리면 된다. 알고리즘을 바꿀 필요 없이 숫자 하나만 조정하면 보안 강도가 유지된다.
내부 동작 원리
bcrypt는 Blowfish 블록 암호의 키 스케줄링을 변형해서 사용한다. 일반 Blowfish는 키 스케줄링을 한 번 수행하지만, bcrypt는 이걸 2^cost번 반복한다.
간략하게 정리하면:
- 비밀번호와 salt로 Blowfish 키 스케줄링 수행
- "OrpheanBeholderScryDoubt"라는 고정 문자열을 64번 암호화
- 위 과정을 2^cost번 반복
- 최종 결과가 해시 값
핵심은 2번과 3번이다. 고정 문자열을 반복적으로 암호화하되, 매 반복마다 키 스케줄링을 다시 수행한다. 이 과정에서 비밀번호와 salt가 계속 혼합되면서 해시 결과에 반영된다. GPU 병렬화로 가속하기도 어려운 구조인데, Blowfish가 4KB의 내부 상태(S-box)를 유지하기 때문이다. GPU는 각 스레드에 이만큼의 메모리를 할당하기 어렵다.
비밀번호 검증 과정
비밀번호를 검증할 때는 저장된 해시에서 salt와 cost factor를 추출하고, 입력된 비밀번호에 같은 salt와 cost를 적용해서 해시를 다시 계산한 뒤 비교한다.
// 저장
const hash = await bcrypt.hash('mypassword', 12);
// → $2b$12$LJ3m4ys3Lg7Gy5cFOKgbhe...
// 검증
const isMatch = await bcrypt.compare('mypassword', hash);
// → salt와 cost를 hash에서 추출 → 다시 해싱 → 비교
compare 함수는 단순 문자열 비교가 아니라 상수 시간(constant-time) 비교를 수행한다. 문자열을 앞에서부터 비교하면 "어디까지 일치하는지"가 실행 시간으로 노출될 수 있는데(타이밍 공격), 상수 시간 비교는 항상 같은 시간이 걸려서 이 공격을 방지한다.
실무에서의 cost factor 선택
cost factor는 "로그인에 허용할 수 있는 최대 지연 시간"으로 결정한다. 보통 비밀번호 해싱에 200500ms가 걸리는 수준이 적절하다. 2026년 기준으로 대부분의 서버에서 cost 1213이 이 범위에 해당한다.
주의할 점은 cost factor를 너무 높게 잡으면 DoS 공격에 취약해질 수 있다는 것이다. 공격자가 로그인 요청을 대량으로 보내면, 서버가 해시 계산에 CPU를 다 쓰면서 다운될 수 있다. 그래서 로그인 시도 횟수 제한(rate limiting)을 반드시 함께 적용해야 한다.