비밀번호를 잊어버렸을 때, 어떤 서비스도 원래 비밀번호를 알려주지 않습니다. 항상 재설정 링크만 보냅니다.
불친절해서가 아닙니다. 서비스도 모르기 때문입니다.
이 글은 그 이유 하나에서 출발합니다. 왜 모르는지, 모르는데 어떻게 로그인을 통과시키는지, 그리고 그것만으로는 왜 부족한지 순서대로 따라갑니다.
암호화가 아니라 해시입니다
가장 흔한 오해가 "비밀번호를 암호화해서 저장한다"입니다. 정확히는 해시이고, 둘은 목적이 다릅니다.
| 암호화 | 해시 | |
|---|---|---|
| 되돌리기 | 가능 (키가 있으면) | 불가능 |
| 목적 | 나중에 원문을 다시 봐야 할 때 | 원문을 다시 볼 필요가 없을 때 |
| 예 | 주민번호, 계좌번호, 메시지 | 비밀번호 |
암호화는 금고입니다. 열쇠가 있으면 열립니다. 그래서 열쇠가 털리면 전부 털립니다.
해시는 고기를 갈아 다진 고기로 만드는 것에 가깝습니다. 같은 고기를 넣으면 항상 같은 결과가 나오지만, 다진 고기에서 원래 덩어리를 복원할 수는 없습니다.
"hunter2" → 해시 → a94a8fe5ccb19ba61c4c0873d391e987...원문을 못 되돌리는데 어떻게 로그인을 확인할까요. 비교를 원문이 아니라 해시로 하기 때문입니다.
1. 가입: "hunter2" 를 해시해서 a94a8f... 를 저장. 원문은 버린다.
2. 로그인: 입력받은 값을 다시 해시한다.
3. 저장된 a94a8f... 와 같으면 통과.DB가 통째로 유출돼도 공격자가 얻는 건 해시값입니다. 그것만으로는 로그인할 수 없습니다 — 로그인 창은 원문을 요구하니까요.
그런데 해시만으로는 부족합니다
여기까지가 절반입니다. 해시에는 결정적인 성질이 하나 있습니다.
같은 입력은 항상 같은 출력을 냅니다.
이게 검증에는 필수인데, 동시에 약점입니다. 유출된 테이블을 보면 이런 일이 벌어집니다.
| 사용자 | 해시 |
|---|---|
| kim | a94a8fe5ccb19ba6... |
| lee | b3d9a1f0e79c5f18... |
| park | a94a8fe5ccb19ba6... |
kim과 park의 해시가 같습니다. 둘이 같은 비밀번호를 쓴다는 걸 공격자가 알게 됩니다. 하나만 뚫으면 둘 다 뚫립니다.
더 큰 문제는 레인보우 테이블입니다. 흔한 비밀번호 수억 개를 미리 해시해 사전으로 만들어 두면, 유출된 해시값을 그 사전에서 찾기만 하면 됩니다. 계산이 아니라 조회입니다.
password, 123456, qwerty 같은 값은 이미 전부 사전에 있습니다.
솔트 — 사용자마다 다른 값을 섞는다
해결책은 간단합니다. 해시하기 전에 사용자마다 다른 무작위 값을 붙입니다. 이걸 솔트라고 합니다.
kim : 해시("hunter2" + "x7Fq2m") → 3d1f0a...
park : 해시("hunter2" + "Kp9zR4") → c8b71e...같은 비밀번호인데 저장된 해시가 달라집니다. 두 가지가 한꺼번에 해결됩니다.
- 유출 테이블에서 같은 비밀번호를 쓰는 사람을 찾아낼 수 없습니다
- 미리 만든 사전이 무력해집니다. 솔트마다 사전을 새로 만들어야 하는데, 그건 사실상 전수 계산입니다
여기서 자주 나오는 질문 하나. 솔트는 어디에 저장하나요?
답은 "해시 옆에, 평문으로"입니다. 숨기는 값이 아닙니다. 솔트의 목적은 비밀 유지가 아니라 미리 계산해 둘 수 없게 만드는 것이라, 공격자가 솔트를 알아도 목적은 그대로 달성됩니다.
실무에서는 이걸 직접 관리할 일도 거의 없습니다. bcrypt 같은 알고리즘은 솔트를 결과 문자열 안에 같이 담아 돌려줍니다.
$2b$12$X7Fq2mKp9zR4tVn3Lw8eOu.J5hQr1sYc0BdA6fGmZiKlNpTvWxYzC
└┬┘ └┬┘ └──────────┬─────────┘└───────────┬────────────────┘
알고리즘 비용 솔트 22자 해시 31자한 문자열에 다 들어 있어서, DB 컬럼 하나만 있으면 됩니다.
빠른 게 문제입니다
솔트까지 붙였으면 끝일까요. 아직 한 가지가 남았습니다.
SHA-256 같은 범용 해시는 빠르도록 설계됐습니다. 파일 무결성 검사 같은 용도에서는 그게 장점입니다. 그런데 비밀번호에서는 정확히 그 장점이 약점이 됩니다.
일반적인 GPU 한 장이면 SHA-256을 초당 수십억 회 계산합니다. 솔트가 있어서 미리 계산은 못 하더라도, 유출된 해시 하나를 붙잡고 흔한 비밀번호부터 차례로 대입하는 건 여전히 빠릅니다. 8자리 영숫자 정도는 오래 걸리지 않습니다.
그래서 비밀번호 전용 알고리즘은 일부러 느리게 만들어져 있습니다. 로그인 한 번에 0.2초는 사용자가 못 느끼지만, 수십억 번 시도해야 하는 공격자에게는 수백 배의 벽이 됩니다.
$2b$12$ 의 12가 그 조절 손잡이입니다. 이 값이 1 오를 때마다 계산량이 두 배가 됩니다. 하드웨어가 빨라지면 이 숫자를 올려서 따라갑니다.
무엇을 쓸까
| 알고리즘 | 상태 | 비고 |
|---|---|---|
| Argon2id | 현재 1순위 권장 | 메모리도 많이 쓰게 만들어 GPU 공격에 특히 강함 |
| bcrypt | 여전히 안전, 가장 널리 쓰임 | 라이브러리가 어디에나 있음. 입력 72바이트 제한 주의 |
| scrypt | 사용 가능 | Argon2 이전 세대의 메모리 강화 방식 |
| PBKDF2 | 규제 요건일 때 | FIPS 인증이 필요한 환경에서 주로 |
| SHA-256 / MD5 단독 | 쓰면 안 됨 | 빠른 게 문제. 솔트를 붙여도 마찬가지 |
새로 만든다면 Argon2id, 이미 bcrypt를 쓰고 있다면 그대로 두면 됩니다. bcrypt가 깨져서 Argon2가 나온 게 아니라, GPU 환경에 더 잘 대응하도록 나온 것입니다. 멀쩡히 돌아가는 bcrypt를 굳이 갈아엎을 이유는 없습니다.
구체적인 파라미터(Argon2의 메모리·반복 횟수, bcrypt의 비용 계수, PBKDF2의 반복 횟수)는 하드웨어가 빨라지면서 계속 상향됩니다. 글에 숫자를 박아두면 금방 낡기 때문에, 최신 값은 OWASP Password Storage Cheat Sheet에서 확인하시는 걸 권합니다.
기준을 잡는 실용적인 방법은 이렇습니다. 운영 서버에서 실제로 시간을 재서, 로그인 한 번이 0.2~0.5초 걸리도록 파라미터를 올리는 것입니다. 문서의 숫자보다 이쪽이 내 환경에 맞습니다.
// Spring Security — 비용 계수를 명시하고, 서버에서 실제 시간을 재서 조정한다
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}// Node.js — bcrypt
const hash = await bcrypt.hash(password, 12)
const ok = await bcrypt.compare(input, hash) // 직접 비교하지 말 것compare 같은 전용 함수를 쓰는 이유가 있습니다. 문자열을 === 로 비교하면 앞에서 몇 글자가 맞았는지에 따라 걸리는 시간이 미세하게 다릅니다. 그 차이를 측정해 한 글자씩 알아내는 공격이 있어서, 라이브러리는 항상 같은 시간이 걸리도록 비교합니다.
페퍼 — 한 겹 더
솔트가 DB에 함께 저장된다는 게 마음에 걸린다면, 페퍼라는 선택지가 있습니다. 전 사용자에게 공통으로 쓰는 비밀값을 하나 더 섞는데, 이건 DB가 아니라 애플리케이션 설정이나 KMS에 둡니다.
DB만 유출됐을 때 공격자는 페퍼를 모르므로 대입 공격 자체를 시작할 수 없습니다.
다만 필수는 아닙니다. 페퍼를 잃어버리면 모든 사용자가 로그인할 수 없게 되고, 교체하려면 전원 재설정이 필요합니다. 키 관리 체계가 갖춰진 조직이 아니라면 득보다 실이 클 수 있습니다.
실무에서 자주 틀리는 것
알고리즘을 제대로 골라도 주변에서 새는 경우가 많습니다.
로그에 평문이 찍힙니다. 요청 본문을 통째로 로깅하면 가입·로그인 요청에 비밀번호가 그대로 남습니다. 정작 DB는 잘 지켜놓고 로그 파일로 유출되는 사례가 흔합니다. 마스킹 대상에 반드시 넣으세요.
최대 길이를 짧게 막습니다. 해시 결과는 입력 길이와 무관하게 고정 길이라, 비밀번호가 길다고 DB가 커지지 않습니다. 짧은 상한은 보안을 낮추기만 합니다. 다만 bcrypt는 72바이트를 넘으면 뒤를 버리므로, 그 이상을 허용하려면 Argon2id를 쓰거나 사전 처리 방식을 따로 정해야 합니다.
로그인 실패 메시지가 힌트를 줍니다. "존재하지 않는 아이디"와 "비밀번호가 틀렸습니다"를 구분해 보여주면, 공격자가 가입된 아이디 목록을 먼저 수집할 수 있습니다. 둘 다 "아이디 또는 비밀번호가 올바르지 않습니다"로 통일하세요.
해시만 믿고 나머지를 안 합니다. 해시는 유출됐을 때의 피해를 줄이는 장치이지, 뚫리는 걸 막는 장치가 아닙니다. 비밀번호 자체가 123456이면 어떤 알고리즘도 소용없습니다. 이미 유출된 적 있는 비밀번호를 가입 시점에 걸러내고, 다중 인증을 함께 두는 쪽이 실질적인 방어입니다.
저장은 이 글의 이야기지만, 전달은 다른 이야기입니다. 비밀번호가 서버까지 가는 구간은 HTTPS 핸드셰이크가 지키고, 로그인 이후의 상태 유지는 JWT와 세션의 영역입니다. 셋은 각각 다른 지점을 맡습니다.
자주 묻는 질문 (FAQ)
비밀번호를 암호화해서 저장하면 안 되나요?
안 됩니다. 암호화는 되돌릴 수 있다는 뜻이고, 그 말은 키를 가진 사람은 전 사용자의 비밀번호를 볼 수 있다는 뜻입니다. 내부자든 침입자든 키만 얻으면 됩니다. 비밀번호는 서비스도 몰라야 하는 값이라, 되돌릴 수 없는 해시가 맞습니다.
솔트를 DB에 같이 저장하면 의미가 없지 않나요?
의미가 있습니다. 솔트의 목적은 숨기는 게 아니라 미리 계산해 둘 수 없게 만드는 것입니다. 공격자가 솔트를 알아도, 사용자마다 다르기 때문에 사전을 사용자 수만큼 새로 만들어야 합니다. 그게 비현실적이라 방어가 성립합니다.
SHA-256에 솔트를 붙이면 되지 않나요?
레인보우 테이블은 막지만 대입 공격은 못 막습니다. SHA-256은 너무 빨라서, 솔트가 있어도 흔한 비밀번호부터 차례로 넣어보는 공격이 여전히 유효합니다. 비밀번호에는 일부러 느린 알고리즘이 필요합니다.
이미 SHA-256으로 저장해 둔 게 있는데 어떻게 옮기나요?
원문을 모르니 한 번에 변환할 수는 없습니다. 두 가지 방법이 있습니다. 로그인할 때 옮기는 방법 — 검증에 성공한 순간에만 원문을 알 수 있으니, 그때 새 알고리즘으로 다시 해시해 덮어씁니다. 안 들어오는 계정은 오래 남습니다. 감싸는 방법 — 기존 해시값을 입력으로 삼아 bcrypt(sha256(pw)) 형태로 한 번 더 감싸면 전체를 즉시 옮길 수 있습니다. 다만 검증 로직이 그만큼 복잡해집니다.
비밀번호를 주기적으로 바꾸게 하는 게 좋나요?
지금은 권장되지 않습니다. 강제 주기 변경은 사용자가 pass01, pass02 식으로 규칙적인 변형을 쓰게 만들어 오히려 예측하기 쉬워집니다. 유출 정황이 있을 때 바꾸게 하는 쪽이 현재 권고입니다.
정리하며
- 비밀번호는 암호화가 아니라 해시로 저장합니다. 되돌릴 수 없어야 하니까요
- 검증은 원문끼리가 아니라 해시끼리 비교합니다. 그래서 서비스가 원문을 몰라도 로그인이 됩니다
- 솔트로 사용자마다 결과를 다르게 만들어, 미리 만든 사전을 무력화합니다
- 일부러 느린 알고리즘(Argon2id·bcrypt)을 씁니다. 빠른 SHA-256은 여기서 약점입니다
- 파라미터는 문서 숫자보다 내 서버에서 0.2~0.5초가 걸리도록 맞추는 게 실용적입니다
"비밀번호 찾기"가 없고 "재설정"만 있는 서비스는 불친절한 게 아닙니다. 제대로 만든 서비스라는 신호입니다.