"사용자 비밀번호를 데이터베이스에 정확히 어떻게 저장해야 하지?" — 만드는 사람이라면 한 번은 부딪히는 질문입니다. 답은 명확합니다. 여기 안전하게 저장하는 법을 순서대로, 공격 절차 없이 정리합니다.
평문·암호화·생 해시가 모두 실패하는 이유
언젠가 데이터베이스가 샌다고 가정하세요. 그때 피해는 저장 방식에 따라 천차만별입니다.
- 평문: 새는 순간 모든 비밀번호가 노출됩니다. 더 나쁜 것은, 비밀번호 재사용이 침해를 사용자의 다른 서비스로 연쇄시킵니다. 최악입니다.
- 암호화(가역): 키가 그것을 되돌립니다 — 그래서 키가 함께 새면 평문으로 돌아옵니다. 비밀번호는 되읽을 필요가 없으니, 가역성은 아무 이득도 주지 않습니다.
- 생 해시(MD5/SHA-256): 빠른 해시는 공격자가 추측을 빠르게 시험하게 합니다. 흔한 비밀번호는 레인보우 테이블과 무차별 대입에 무너집니다.
안전한 저장까지의 네 단계
약한 방식에 한 번에 하나씩 보강을 더해 가는 것으로 이해하면 가장 빠릅니다.
핵심 통찰: 솔트와 느린 해시는 서로 다른 일을 합니다. 솔트는 사전 계산(레인보우 테이블)과 대량 재사용 크래킹을 무력화합니다. 느린 해시는 무차별 대입을 비현실적인 속도로 떨어뜨립니다. 안전해지려면 둘 다 필요합니다(→ 해싱이란 무엇인가).
실전: 지금 무엇을 할까
Argon2id(또는 bcrypt)를 쓴다
신규 시스템이라면 Argon2id를 1순위로 — 메모리까지 요구할 수 있어 GPU 무차별 대입에 저항합니다. 검증된 구현을 선호한다면 bcrypt도 실전에서 충분합니다. 어느 쪽이든 표준 라이브러리 구현을 그대로 쓰세요.
솔트는 라이브러리가 생성하게 한다
bcrypt/Argon2는 사용자별 솔트를 생성해 해시 문자열 안에 삽입합니다. 별도의 솔트 컬럼을 둘 필요가 없습니다. MD5(salt + password)를 손수 짜는 것은 전형적인 하지 말아야 할 일입니다.
환경에 맞게 비용을 튜닝한다
bcrypt의 비용 인자, 또는 Argon2의 메모리/반복/병렬도를 정상 로그인이 여전히 즉각적으로 느껴지는 한도에서 최대한 높게 설정하세요. 서버가 빨라지면 공격자도 빨라지므로 — 매년 다시 점검해 올리세요.
표준 비교 함수로 검증한다
로그인 시 입력을 저장된 해시와 라이브러리의 검증 함수로 대조하세요(대부분 타이밍 안전한 상수 시간 비교를 씁니다). 직접 문자열 동등 비교를 짜지 마세요.
약한 해시는 로그인 시 재해싱으로 이전한다
이미 MD5/SHA-256을 저장 중이라면, 사용자가 로그인에 성공하는 순간 새 방식으로 재해싱해 저장하세요. 로그인하지 않은 사용자는 기존 해시를 bcrypt로 한 번 더 해싱해(레이어드 해시) 그동안 보호를 강화할 수 있습니다.
흔한 실수 vs 올바른 구현
흔한 실수
- 저장용으로 비밀번호를 암호화(키가 새면 → 끝)
MD5(password)나SHA-256(password)를 그대로 저장- 모든 사용자에게 단일 공유 솔트 사용
hash(salt + password)를 손수 짜기
올바른 구현
- Argon2id / bcrypt로 일방향 해시
- 사용자별 솔트(라이브러리가 자동으로 추가)
- 괜찮게 느껴지는 한도에서 높은 비용, 주기적으로 상향
- 표준 해시/검증 함수를 그대로 사용
해싱 vs 암호화 vs 인코딩
이 셋은 자주 혼동되지만, 되돌릴 수 있는지가 전혀 다릅니다. 비밀번호 저장에 쓸 수 있는 것은 해싱뿐입니다.
| 해싱 | 암호화 | 인코딩(Base64 등) | |
|---|---|---|---|
| 되돌릴 수 있나 | 없음(일방향) | 키가 있으면 가능 | 누구나 가능 |
| 키 | 없음 | 필요 | 없음 |
| 주된 용도 | 비밀번호 저장, 변조 탐지 | 데이터나 통신의 기밀 유지 | 데이터 형식 변환 |
| 비밀번호 저장에 | 쓴다(솔트 + 느린 해시) | 쓰지 않는다 | 쓰지 않는다(평문과 같음) |
권장 설정(OWASP)
OWASP Password Storage Cheat Sheet의 최소 설정입니다. 이를 하한으로 보고 서버에 맞춰 올리세요.
| 알고리즘 | 최소 설정 | 쓰는 경우 |
|---|---|---|
| Argon2id | 메모리 19 MiB, 반복 2회, 병렬도 1 | 새 시스템의 1순위 |
| scrypt | N=2^17(128 MiB), r=8, p=1 | Argon2id를 쓸 수 없을 때 |
| bcrypt | 비용 10 이상. 입력은 72바이트까지 | 기존 시스템, 오랜 실적 |
| PBKDF2-HMAC-SHA256 | 반복 600,000회 이상 | FIPS 140 준수가 필요할 때 |
플랫폼의 내장 기능도 확인하세요. PHP의 password_hash()는 기본으로 bcrypt(PASSWORD_DEFAULT)를 쓰며, PHP 8.4에서 기본 비용이 10에서 12로 올라갔습니다. Django는 기본으로 PBKDF2를 쓰며, django[argon2]를 설치하고 PASSWORD_HASHERS의 맨 앞에 Argon2를 두면 바뀝니다. Django는 이전 알고리즘으로 저장된 비밀번호를 사용자가 로그인할 때 자동으로 재해싱합니다.
내 서비스를 확인하는 법
저장된 해시 문자열의 앞부분을 보면 대개 방식을 알 수 있습니다. 운영 데이터를 다른 곳에 복사하지 말고, 관리하는 환경 안에서 몇 행만 보세요.
| 해시의 모양 | 방식 | 조치 |
|---|---|---|
$argon2id$v=19$m=…,t=…,p=…$ | Argon2id | m, t, p가 OWASP 최소치를 충족하는지 확인 |
$2y$12$… / $2b$10$… | bcrypt(숫자가 비용) | 10 미만이면 올린다 |
pbkdf2_sha256$1000000$… | PBKDF2(숫자가 반복 횟수) | 600,000 이상인지 확인 |
| 16진수 32글자 | MD5일 가능성이 높음 | 로그인 시 재해싱으로 이전 |
| 16진수 40 / 64글자 | SHA-1 / SHA-256일 가능성이 높음 | 위와 같음 |
| 로그인 문자열 그대로 | 평문 | 가장 먼저 이전. 유출됐을 때의 영향을 평가 |
다음도 확인하세요.
- 입력된 비밀번호가 앱이나 웹 서버의 로그, 오류 보고, 분석 도구에 기록되지 않는다
- bcrypt를 쓴다면 72바이트를 넘는 입력을 어떻게 다룰지 정해 두었다
- 백업이나 테스트용으로 만든 데이터베이스 사본에도 같은 종류의 해시만 들어 있다
실제 사례: 평문 저장이 피해를 키웠다
Takufile-bin 사건(2019년)에서는 서버에 대한 무단 접근으로 약 480만 건의 정보가 유출됐고, 그중 로그인 비밀번호는 평문으로 저장되어 있었습니다. 솔트를 쓴 느린 해시로 저장했다면, 유출된 비밀번호가 다른 서비스에서 그대로 쓰일 위험은 훨씬 낮았을 것입니다. 모든 침입을 막을 수는 없더라도, 비밀번호를 저장하는 방식이 유출 피해의 크기를 바꿉니다.
본 사이트의 견해: 비밀번호 저장에는 검증된 표준 구현을 쓰라
비밀번호 저장은 창의력을 발휘할 자리가 아닙니다. Argon2/bcrypt의 널리 검증된 표준 구현을 쓰는 것이 가장 안전하고 운영도 가장 쉽습니다. 우리의 입장: 여기서는 혁신하지 마세요. 그 노력은 대신 비밀번호 의존 자체를 줄이는 데 쓰세요 — 철저한 다단계 인증(MFA), 그리고 결국에는 패스키(패스워드리스)로의 이동입니다. 아무리 강한 해시도 약하고 재사용된 비밀번호를 구할 수는 없습니다.
다음으로 읽기
- 용어집: 비밀번호 해싱이란 무엇인가 / 솔트란 무엇인가
- 기초: 비밀번호를 올바르게 보관하기(사용자 측) / 비밀번호 관리자 고르기
- 방어: MFA를 제대로 고르기(비밀번호 의존 줄이기)
- 용어집: 패스키란 무엇인가(패스워드리스로 가기)
- 사례: Takufile-bin 정보 유출 사건(2019)(비밀번호 평문 보관의 실제 사례)
출처
- OWASP, "Password Storage Cheat Sheet": cheatsheetseries.owasp.org
- NIST, "SP 800-63B-4"(2025년 8월), 비밀번호 저장(솔트와 해싱): pages.nist.gov
- RFC 9106, "Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications": rfc-editor.org
- PHP Manual, "password_hash": php.net
- Django documentation, "Password management in Django"(5.2): docs.djangoproject.com