bcrypt는 salt를 따로 안 저장하는데 어떻게 검증하나 — compare()의 실제 동작
비밀번호 해싱을 처음 배울 때 "salt는 레인보우 테이블 공격을 막기 위해 무작위로 생성해서 비밀번호와 별도로 저장해야 한다"고 배웁니다. 그런데 bcrypt를 실제로 쓰는 코드를 보면 salt 컬럼이 따로 없습니다. DB에는 password_hash 컬럼 하나만 있는데, 로그인 시 검증은 정확히 일어납니다. 이게 어떻게 가능한지는 bcrypt 해시 문자열 자체의 구조를 뜯어보면 바로 풀립니다.
1. bcrypt 해시 문자열의 정확한 구조
bcrypt.hash()가 만들어내는 결과는 하나의 문자열이지만, 실제로는 4개 부분이 이어붙은 것입니다.
| 구간 | 자릿수 | 예시 | 의미 |
|---|---|---|---|
| 버전 접두사 | 가변(보통 4자) | $2b$ | 알고리즘 버전(2, 2a, 2b, 2x, 2y) |
| cost factor | 2자 + 앞뒤 $ | 10$ | 2^cost 반복 횟수(예: 10 → 2^10회) |
| salt | 22자 | N9qo8uLOickgx2ZMRZoMye | 무작위 값, base64 유사 인코딩 |
| 해시 결과 | 31자 | IjZAgcfl7p92ldGxad68LJZdL17lhWy | 실제 해싱 연산 결과 |
이 네 조각을 이어붙이면 $2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy 같은 60자 문자열이 됩니다. 핵심은 salt가 이 문자열 안에 이미 평문 그대로(암호화되지 않고) 박혀 있다는 점입니다. 별도로 숨겨두거나 암호화할 필요가 없습니다 — salt의 역할은 비밀이 아니라 "같은 비밀번호라도 매번 다른 해시가 나오게" 만드는 것이기 때문입니다.
2. 자릿수를 실제 코드로 확인하기
Bcrypt 검증기의 검증 로직은 이 구조를 그대로 이용합니다. 해시 문자열에서 salt를 잘라내는 코드는 hash.slice(7, 29)입니다. 인덱스 0부터 6까지($2b$10$, 7글자)가 버전+cost 접두사이고, 그다음 인덱스 7부터 28까지(22글자)가 salt입니다. 나머지 인덱스 29부터 끝까지(31글자)가 해시 결과이며, 접두사 7자 + salt 22자를 합친 앞 29자가 "버전+cost+salt"를 모두 담은 덩어리입니다.
3. compare()는 무엇을 하는가
DB에 salt 컬럼이 없어도 검증이 가능한 이유가 여기서 드러납니다. bcrypt.compare(입력비밀번호, 저장된해시)가 내부에서 하는 일은 다음과 같습니다.
- 저장된 해시 문자열에서 앞 29자(버전+cost+salt)를 그대로 읽어들인다.
- 그 29자를 그대로 재사용해서, 입력된 평문 비밀번호를 동일한 cost·동일한 salt로 다시 해싱한다.
- 새로 계산된 해시 결과(뒤 31자에 해당하는 부분)를, 저장된 해시의 뒤 31자와 문자열 비교한다.
- 완전히 일치하면 매치, 한 글자라도 다르면 불일치로 판정한다.
즉 "평문 비밀번호끼리 비교"하는 것이 아니라 "같은 조건으로 다시 계산한 해시 결과끼리 비교"하는 방식입니다. salt를 별도 저장소에서 조회해 올 필요가 없는 이유는,애초에 검증에 필요한 salt가 비교 대상인 해시 문자열 자체 안에 이미 들어있기 때문입니다.
4. $2y$ 접두사와 실무에서 자주 겹치는 함정
버전 접두사 자리에는 $2, $2a$, $2b$, $2x$, $2y$ 다섯 가지가 올 수 있습니다. 이 중 $2y$는 PHP의 password_hash() 함수가 기본으로 생성하는 형식이라, Laravel이나 WordPress 같은 PHP 기반 서비스에서 뽑아온 해시를 다른 언어 스택에서 검증하려 할 때 자주 마주칩니다. Bcrypt 검증기는 이 다섯 접두사를 모두 유효한 형식으로 인식하도록 정규식(/^\$2[abxy]?\$\d{2}\$/)이 짜여 있어, PHP에서 넘어온 $2y$ 해시를 붙여넣어도 형식 오류 없이 그대로 검증됩니다.
구조를 알고 나면 "검증이 왜 실패했지"를 디버깅할 때도 훨씬 빨라집니다. 60자 중 단 한 글자만 복사 과정에서 누락되거나 순서가 바뀌어도 salt 재구성 자체가 어긋나 무조건 불일치로 나오므로, 실패 시 가장 먼저 확인할 것은 비밀번호가 아니라 해시 문자열이 정확히 60자인지입니다.
5. cost factor와 계산 비용의 관계
cost 자리의 숫자(예 10)는 내부적으로 Blowfish 기반 키 스케줄을 2^cost번 반복하라는 지시입니다. cost가 1 오를 때마다 연산량이 정확히 2배가 되므로, cost 10에서 약 100ms 걸리던 검증이 cost 12에서는 약 4배(약 400ms)로 늘어납니다. 이 값도 해시 문자열 안에 함께 저장되어 있기 때문에, DB의 cost 설정을 나중에 올리더라도 이미 저장된 기존 해시들은 각자 생성 당시의 cost 그대로 정확하게 검증됩니다 — 전체 마이그레이션 없이도 새 가입자부터 점진적으로 높은 cost를 적용할 수 있는 이유입니다.
자주 묻는 질문
Q. salt를 DB에 따로 저장 안 하면 보안에 문제 없나요?
문제없습니다. salt의 목적은 비밀 유지가 아니라 동일 비밀번호가 항상 동일 해시로 나오는 것을 막아 레인보우 테이블 공격을 무력화하는 것입니다. 해시 문자열에 salt가 그대로 노출되어 있어도, salt 자체는 무작위 값이라 공격자가 여러 계정에 재사용할 수 있는 사전 계산 테이블을 만들 수 없습니다.
Q. 같은 비밀번호를 두 번 해싱하면 결과가 매번 다른가요?
네. 매번 새로운 무작위 salt가 생성되므로 hash() 결과는 매번 다릅니다. 그래서 두 해시 문자열을 직접 비교해 "같은 비밀번호인지" 알 수는 없고, 반드시 compare() 함수로 검증해야 합니다.
Q. 해시 문자열의 정확한 길이는 얼마인가요?
표준 bcrypt 해시는 60자입니다(접두사 7자 + salt 22자 + 해시 결과 31자). DB 컬럼을 CHAR(60) 미만으로 잡으면 저장 중 잘려서 검증이 항상 실패하는 흔한 사고 원인이 됩니다.
Q. cost factor를 나중에 올리려면 기존 해시를 다 다시 만들어야 하나요?
즉시 그럴 필요는 없습니다. 기존 해시는 저장 당시의 cost로 계속 정상 검증되므로, 로그인에 성공한 시점에 그 사용자의 해시만 새 cost로 재생성해 저장하는 점진적 마이그레이션이 일반적입니다.