비밀번호는 암호화가 아니라 해싱이다
평문·양방향 암호화가 왜 위험한지, 단방향 해시와 솔트·work factor가 레인보우 테이블과 GPU 무차별 대입을 어떻게 막는지 bcrypt로 정리한다.
계정 보안을 다룬 주였다. 비밀번호 저장에서 가장 흔한 오해는 “암호화해서 저장한다”는 표현 그 자체다. 비밀번호는 암호화(encryption)하지 않는다. 해싱(hashing)한다. 둘의 차이를 이해하는 것이 보안의 출발점이다.
왜 암호화가 아니라 해싱인가
암호화는 키만 있으면 원문을 복원할 수 있는 양방향 변환이다. 비밀번호를 암호화해 저장하면, 그 키가 유출되는 순간 모든 비밀번호가 평문으로 풀린다. 애초에 서버는 사용자의 비밀번호 원문을 알 필요가 없다 — 로그인 시 “입력값이 저장된 것과 같은가”만 판정하면 된다.
그래서 단방향 해시를 쓴다. 해시는 원문을 복원할 수 없는 변환이다. 저장은 hash(password)로 하고, 검증은 입력값을 같은 방식으로 해싱해 비교한다. DB가 통째로 유출돼도 원문은 나오지 않는다 — 이상적으로는.
솔트 — 같은 비밀번호를 다르게
단순 해시만으로는 부족하다. 같은 비밀번호는 같은 해시가 나오므로, 공격자는 “흔한 비밀번호 → 해시” 대응표(레인보우 테이블)를 미리 만들어 한 방에 역추적한다. 또 DB에서 같은 해시값을 가진 계정끼리 “비밀번호가 같다”는 사실도 드러난다.
해법이 솔트(salt)다. 사용자마다 무작위 솔트를 붙여 hash(salt + password)로 저장한다. 같은 비밀번호라도 솔트가 달라 해시가 전부 달라진다. 미리 만든 레인보우 테이블은 무력화된다. 솔트는 비밀이 아니어도 되므로 해시와 함께 저장한다.
work factor — 일부러 느리게
SHA-256 같은 범용 해시는 너무 빠른 게 문제다. GPU로 초당 수십억 번 시도하는 무차별 대입에 취약하다. 그래서 비밀번호 전용 해시(bcrypt, scrypt, argon2)는 계산을 일부러 느리고 비싸게 만든다. bcrypt의 cost(work factor)는 반복 횟수를 2^cost로 늘린다. cost를 1 올리면 계산량이 2배다. 정상 로그인엔 수십 ms로 무시할 수준이지만, 수억 번 시도하는 공격엔 치명적이다.
1
2
3
4
// bcrypt: 솔트 생성·내장·cost를 모두 자동 처리. 검증은 matches로.
String hash = BCrypt.hashpw(rawPassword, BCrypt.gensalt(12)); // cost=12
// 저장값 예: $2a$12$<22자 솔트><31자 해시> ← 알고리즘·cost·솔트가 한 문자열에 포함
boolean ok = BCrypt.checkpw(inputPassword, hash); // true/false
bcrypt 해시 문자열은 알고리즘·cost·솔트·해시를 한 줄에 담는다. 그래서 별도 솔트 컬럼이 필요 없고, 나중에 cost를 올려도 기존 해시 검증이 그대로 된다.
운영 함정
- 직접 비교 금지:
storedHash.equals(hash(input))처럼 직접 구현하면 솔트·타이밍 처리를 틀리기 쉽다. 라이브러리의checkpw/matches를 쓴다(상수시간 비교 포함). - cost를 안 올림: 하드웨어가 빨라지면 cost도 주기적으로 올려야 한다. 로그인 성공 시 기존 해시의 cost가 낮으면 재해싱하는 전략이 흔하다.
- 빠른 해시 사용: MD5/SHA-1/단일 SHA-256으로 비밀번호를 저장하면 안 된다. 반드시 bcrypt/scrypt/argon2 같은 느린 전용 해시를 쓴다.
핵심 요약
- 비밀번호는 복원 가능한 암호화가 아니라 단방향 해시로 저장한다.
- 솔트는 레인보우 테이블과 동일 해시 노출을 막고, work factor는 무차별 대입을 비싸게 만든다.
- bcrypt 같은 전용 해시를 라이브러리 그대로 쓰고, cost를 시간에 따라 올린다.