| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- 모바일앱개발
- 자바
- SWEA
- 인프런
- java
- 일본어공부
- 프로그래머스
- 삼성
- 백준
- 카카오
- 자바스크립트
- 일본어학습지
- 코딩
- 웹보안
- javascript
- js
- 코테
- 가벼운학습지
- 자료구조
- 일본어독학
- 마이라이트
- New Architecture
- 알고리즘
- 성인학습지
- 코딩테스트
- TurboModule
- 가벼운학습지후기
- React Native
- array
- 삼성소프트웨어아카데미
- Today
- Total
개발에 AtoZ까지
비밀번호는 어떻게 저장하나 — "암호화"도 "SHA-256"도 답이 아닌 이유 본문
😀 개요
안녕하세요 😀
자체 회원가입 기능을 만들 때, 비밀번호를 DB에 어떻게 넣을지 검색해 보면 조언이 갈립니다.
- "그냥 저장하면 안 되니까, 암호화해서 저장하면 되죠."
- "SHA-256으로 해시하고 salt만 붙이면 안전해요."
둘 다 그럴듯하지만, 둘 다 오늘날 기준으로는 위험한 방법입니다.
결론부터 말하면 두 가지입니다. 첫째, 비밀번호는 암호화(양방향)가 아니라 되돌릴 수 없는 단방향 해시로 저장합니다. 둘째, 그 해시는 SHA-256 같은 빠른 해시가 아니라, 일부러 느리게 만든 전용 해시(bcrypt·Argon2id) 를 salt와 함께 써야 합니다. 이 두 가지가 왜 그런지 짚고, 그다음 무엇을 어떤 값으로 쓸지까지 정리하겠습니다.
이 글은 로그인을 만드는 과정에서 세션 vs JWT 인증(상태를 어디 둘까), 소셜 로그인과 OAuth 2.0(외부 계정으로 로그인)에 이어, 자체 회원가입이면 비밀번호 자체는 어떻게 저장하나를 다루는 편입니다.
🎯 오늘 정리하는 것
이 글을 다 읽으면 아래를 스스로 판단할 수 있습니다.
- 비밀번호를 암호화하면 왜 안 되는지, 해싱과 무엇이 다른지 설명할 수 있습니다.
- SHA-256 + salt가 왜 부족한지, "느린 해시"가 무슨 뜻인지 말할 수 있습니다.
- salt와 pepper의 역할을 구분할 수 있습니다.
- 내 상황에 Argon2id·bcrypt·PBKDF2 중 무엇을 어떤 파라미터로 쓸지 고를 수 있습니다.
- 로그인 검증과 work factor 업그레이드, 그리고 비밀번호 정책의 기준을 잡습니다.
특정 라이브러리 사용법이 아니라, 무엇을 왜 고르는지 판단하는 기준을 손에 넣는 것이 목적입니다.
🧭 근거부터 짚고 갑니다
미리 말씀드리면, 이 글은 제 특정 서비스의 운영 경험담이 아니라 공식 표준과 보안 가이드에서 확인해 정리한 것입니다. 근거로 삼은 자료는 다음과 같습니다.
- OWASP — Password Storage / Authentication Cheat Sheet (알고리즘·파라미터의 실무 기준)
- NIST SP 800-63B — Digital Identity Guidelines (검증자 요구사항·비밀번호 정책)
- RFC 9106(Argon2) · RFC 7914(scrypt) · RFC 8018(PBKDF2) — 각 알고리즘 규격
- RFC 4648 — base64 등 인코딩의 정의
- 언어 표준 라이브러리 — PHP
password_hash, Pythonhashlib/hmac, Nodecrypto
개념과 원칙은 표준이라 잘 바뀌지 않지만, 권장 파라미터 수치는 하드웨어에 맞춰 갱신됩니다. 아래 수치는 확인일(2026년 7월 25일) OWASP 기준이며, 실제 적용 전에는 본인이 쓰는 라이브러리·OWASP 문서의 현재 값으로 확인하시는 편이 안전합니다.
🔐 비밀번호는 "암호화"하는 게 아닙니다
가장 먼저 풀 오해가 "암호화해서 저장"입니다. 세 가지 개념을 먼저 나누겠습니다.
- 인코딩(예: base64) — 쉽게 말하면 모양만 바꾸는 변환입니다. 키도 비밀도 없어 누구나 즉시 되돌립니다. RFC 4648도 "base 인코딩은 비밀번호 같은 정보를 눈에만 안 띄게 할 뿐, 어떤 기밀성도 주지 않는다"고 못박습니다. base64로 "숨긴" 비밀번호는 사실상 평문입니다.
- 암호화(encryption) — 양방향 변환입니다. 키가 있으면 원문으로 되돌릴 수 있습니다. 그래서 키 하나가 유출되면 저장된 비밀번호 전체가 원문으로 풀립니다.
- 해싱(hashing) — 단방향 함수입니다. 해시에서 원문을 되돌릴 수 없습니다.
핵심은 비밀번호 인증에는 원문을 되돌릴 필요가 아예 없다는 점입니다. 로그인은 "이 비밀번호가 맞나?"라는 예/아니오 비교일 뿐, 저장된 값을 다시 읽어 낼 일이 없습니다. 그래서 되돌릴 수 없는 단방향 해시가 오히려 정답입니다. OWASP도 이렇게 정리합니다.
"Passwords should never be stored in plain text. Instead, they must be protected using strong, slow hashing algorithms such as Argon2id, bcrypt, or PBKDF2." (비밀번호는 절대 평문으로 저장하면 안 되며, Argon2id·bcrypt·PBKDF2 같은 강하고 느린 해싱 알고리즘으로 보호해야 한다.)
즉 암호화는 "원문을 꼭 되찾아야 하는" 드문 예외에만 쓰고, 로그인 비밀번호는 해당하지 않습니다.

그림 1. 평문·인코딩·암호화·빠른 해시는 모두 "되돌리거나 빠르게 추측"할 수 있어 부적합합니다. 올바른 방법은 unique salt를 붙여 느린 적응형 해시(Argon2id·bcrypt 등)로 저장하는 것입니다.
🐇 SHA-256으로 해시하면 왜 안 되나
"그럼 단방향 해시니까 SHA-256이면 되겠네"가 두 번째 오해입니다. SHA-256이나 MD5는 범용 해시라 아주 빠르게 계산되도록 설계돼 있습니다. 파일 무결성 검사 같은 데는 좋지만, 비밀번호에는 바로 그 "빠름"이 약점이 됩니다.
공격자가 유출된 해시 목록을 손에 넣으면, GPU로 초당 수십억 번 후보 비밀번호를 해시해 맞는 걸 찾습니다. 빠른 해시일수록 이 무차별 대입이 빨라집니다. OWASP가 비밀번호에는 SHA 계열 같은 빠른 해시를 쓰지 말라고 하는 이유입니다.
"salt를 붙이면 되지 않나요?"라고 하실 수 있는데, salt만으로는 부족합니다. salt는 뒤에서 보듯 미리 계산해 둔 표(rainbow table)를 무력화할 뿐, 한 번의 추측을 느리게 만들지는 못합니다. 즉 salted SHA-256도 여전히 초당 수십억 번 대입에 노출됩니다.
그래서 필요한 것이 느린 적응형 해시입니다. 쉽게 말하면 계산에 일부러 시간·메모리를 많이 쓰게 만든 해시입니다. 여기엔 work factor(작업 계수) 라는 손잡이가 있어서, 하드웨어가 빨라질수록 값을 올려 공격 비용을 계속 비싸게 유지할 수 있습니다. OWASP의 튜닝 기준은 해시 한 번에 1초 미만이 걸리도록 맞추고, 시간이 지나면 값을 올리는 것입니다.
정리하면 이렇습니다.
| 방식 | 속도 | 비밀번호 저장에 |
|---|---|---|
| MD5 · SHA-1 · SHA-256 (빠른 해시) | 매우 빠름 | ❌ 무차별 대입에 취약 |
| MD5/SHA + salt | 매우 빠름 | ❌ rainbow table만 막음, per-guess는 그대로 |
| bcrypt · scrypt · Argon2id (느린 적응형) | 느림(조절 가능) | ✅ 권장 |
🧂 salt와 pepper는 각각 무엇인가
느린 해시로 넘어가기 전에, 자주 헷갈리는 두 개념을 정리하겠습니다.
salt는 비밀번호마다 붙이는 고유한 무작위 값입니다. 역할은 둘입니다. 첫째, 미리 해시를 계산해 둔 표(rainbow table)를 무력화합니다. 둘째, 같은 비밀번호를 쓴 두 사용자의 해시가 서로 달라져서 비밀번호 재사용이 드러나지 않습니다. salt는 비밀이 아니어도 되고, 해시와 함께 저장합니다(RFC 8018).
여기서 중요한 실무 포인트. bcrypt·Argon2·scrypt 같은 모던 알고리즘은 salt를 알아서 만들어 결과 문자열에 넣어 줍니다. 그러니 salt를 직접 만들어 따로 보관하려 하지 마세요. 예를 들어 PHP password_hash()는 직접 넘긴 salt를 무시(폐기)하고 자동 생성합니다.
pepper는 다릅니다. 앱 전체가 공유하는 하나의 비밀을 추가로 섞는 것으로, DB 바깥(설정, 비밀 볼트, HSM)에 둡니다. DB만 털렸을 때 공격자가 pepper를 모르면 오프라인 대입을 못 하게 하는 심층 방어입니다. 다만 pepper 단독으로는 안전을 보장하지 못하고, 나중에 값을 바꾸려면(회전) 사용자 비밀번호 재설정이 필요한 한계가 있습니다. NIST도 "pepper"라는 단어는 안 쓰지만, 해시와 분리 보관하는 비밀 키를 이용한 방식을 권합니다.
🛡️ 그래서 뭘 쓰나 — Argon2id·bcrypt·PBKDF2
이제 알고리즘 선택입니다. OWASP의 우선순위는 명확합니다.

그림 2. OWASP Password Storage Cheat Sheet. 비밀번호는 평문으로 저장하지 말고 Argon2id·bcrypt·PBKDF2 같은 강하고 느린 해싱으로 보호하라고 명시합니다.
권장 순서와 확인일(2026-07-25) 기준 최소 파라미터는 다음과 같습니다.
| 알고리즘 | OWASP 최소 파라미터 | 언제 |
|---|---|---|
| Argon2id (1순위) | 메모리 19 MiB · 반복 2 · 병렬 1 | 새로 만드는 시스템의 기본값 |
| scrypt | N=2^17(128 MiB) · r=8 · p=1 | Argon2 라이브러리가 없을 때 |
| bcrypt | work factor(cost) 10 이상 | 레거시·bcrypt만 가능할 때 |
| PBKDF2 | HMAC-SHA256 60만 회(SHA512는 22만) | FIPS-140 규제가 필요할 때 |
- Argon2id는 메모리 하드 해시입니다. 쉽게 말하면 계산에 많은 RAM을 쓰게 만들어, 메모리가 부족한 GPU·ASIC의 대량 병렬 대입을 어렵게 합니다(RFC 9106). 새 프로젝트라면 1순위입니다.
- bcrypt를 쓸 때는 유명한 함정 하나. 입력을 72바이트까지만 보고 그 뒤는 조용히 잘라 냅니다. 긴 비밀번호·패스프레이즈를 그대로 넣으면 뒷부분이 무시되니, 길이 제한을 두거나 이 특성을 알고 써야 합니다.
- PBKDF2는 반복 횟수로만 비용을 올리는 방식이라, RFC 8018의 옛 기준(1,000회)이 아니라 현재 권장치(SHA-256 기준 60만 회) 로 맞춰야 합니다.

그림 3. RFC 9106(Argon2, 2021). Argon2를 "비밀번호 해싱을 위한 메모리 하드(memory-hard) 함수"로 정의하는 IRTF 규격입니다. 메모리를 많이 쓰게 만들어 GPU·ASIC의 대량 병렬 대입을 어렵게 하며, 비밀번호 저장에는 Argon2id 변형을 씁니다.
가장 중요한 실무 원칙은 이것입니다. 직접 구현하지 말고 언어 내장 API를 쓰세요. PHP password_hash(), Python hashlib(scrypt/pbkdf2)·argon2-cffi, Node crypto.scrypt 같은 표준 도구는 salt 자동 생성·파라미터 관리·향후 알고리즘 교체까지 고려돼 있습니다. 아래는 방식만 보여 주는 의사코드입니다.
# ❌ 하지 말 것 — 빠른 해시는 salt를 붙여도 비밀번호엔 부적합
stored = sha256(salt + password) # GPU로 초당 수십억 회 대입 가능
# ✅ 해야 할 것 — 느린 적응형 해시 (salt·파라미터는 결과 문자열에 자동 포함)
stored = argon2id_hash(password)
# 예: "$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>"
# 로그인 검증 — 원문 복원이 아니라 재해시 비교 (라이브러리 verify 함수 사용)
ok = argon2id_verify(stored, password) # PHP password_verify / bcrypt.compare 등
🧭 어떤 알고리즘을 고를지 (결정 트리)
말로 풀면 아래 순서로 고르면 됩니다.

그림 4. 새 시스템이면 Argon2id가 기본입니다. 라이브러리 사정·규제·레거시에 따라 scrypt·PBKDF2·bcrypt로 갈라지고, 어느 쪽이든 직접 구현 대신 언어 내장 API를 씁니다.
- 새로 만드는 시스템 → Argon2id(19 MiB·t=2·p=1)를 기본값으로.
- Argon2 라이브러리를 쓰기 어렵다 → scrypt(N=2^17).
- FIPS-140 규제가 필요하다 → PBKDF2(HMAC-SHA256 60만 회).
- 레거시 시스템이라 bcrypt만 가능 → bcrypt(cost 10 이상, 72바이트 한계 유의).
🔁 로그인 검증과 나중에 값 올리기
저장했으면 검증입니다. 앞서 봤듯 원문을 되돌리는 게 아니라, 로그인 때 들어온 비밀번호를 저장된 salt·파라미터로 다시 해시해 비교합니다. salt와 알고리즘·cost는 저장된 해시 문자열 안에 들어 있어서 따로 보관할 필요가 없습니다.
두 가지만 주의하세요.
- 라이브러리의 verify 함수를 쓰세요. 직접 해시해서 문자열을
==로 비교하지 말고password_verify·bcrypt.compare·argon2.verify를 씁니다. 굳이 직접 비교해야 하면 상수 시간 비교(hash_equals·hmac.compare_digest·crypto.timingSafeEqual)를 써서 타이밍 공격을 막습니다. - work factor는 나중에 올릴 수 있습니다. 기준을 높였다면, 로그인이 성공한 순간 저장된 해시의 파라미터가 낮은지 확인하고, 방금 들어온 평문으로 새 파라미터로 다시 해시해 덮어씁니다(lazy 재해시). 단 다시 로그인하지 않는 사용자의 해시는 갱신되지 않으니, 오래된 방식은 별도 폐기 계획이 필요합니다.
📋 비밀번호 규칙은 어떻게 정하나 (NIST 기준)
마지막으로, 저장 방식 못지않게 자주 틀리는 것이 "비밀번호 정책"입니다. NIST SP 800-63B의 방향은 예전 상식과 꽤 다릅니다.
- 긴 패스프레이즈를 허용하세요. 최대 길이를 넉넉히(최소 64자) 두고 공백·유니코드를 받습니다.
- 조합 규칙을 강요하지 마세요. "대문자+숫자+특수문자" 강제는 이제 권장되지 않습니다.
- 주기적 강제 변경을 요구하지 마세요. 유출 정황이 있을 때만 변경을 요구합니다.
- 유출된 비밀번호 목록과 대조해, 이미 털린 흔한 비밀번호는 막습니다.
- 입력 전체를 검증하고, 조용히 잘라 내지 않습니다(bcrypt 72바이트 한계를 특히 주의).
✅ 비밀번호 저장 체크리스트
새 회원가입에 비밀번호 저장을 붙일 때 이 순서로 짚으면 됩니다. 필요하면 복사해서 쓰세요.
[저장 방식]
- [ ] 평문·양방향 암호화·base64 인코딩으로 저장하고 있지 않나?
- [ ] 단방향 해시로 저장하나?
- [ ] SHA-256/MD5 같은 빠른 해시(+salt 포함)를 쓰고 있지 않나?
[알고리즘·파라미터]
- [ ] 새 시스템이면 Argon2id(19MiB·t=2·p=1)를 기본으로 골랐나?
- [ ] (규제) FIPS면 PBKDF2-HMAC-SHA256 60만 회 이상인가?
- [ ] (레거시) bcrypt면 cost 10+ 이고 72바이트 한계를 처리했나?
- [ ] 직접 구현 대신 언어 내장 API(password_hash 등)를 쓰나?
- [ ] 해시 한 번이 1초 미만이 되도록 파라미터를 튜닝했나?
[salt · pepper]
- [ ] salt는 라이브러리가 자동 생성·저장하게 두었나? (직접 보관 금지)
- [ ] (선택) pepper를 쓴다면 DB 밖(볼트/HSM)에 두었나?
[검증 · 정책]
- [ ] 검증은 라이브러리 verify 함수(또는 상수 시간 비교)를 쓰나?
- [ ] work factor를 올릴 때 로그인 시 lazy 재해시를 넣었나?
- [ ] 조합 규칙 강요·주기적 강제 변경을 빼고, 유출 목록 대조를 넣었나?
🫡 마무리
정리하면, 비밀번호 저장에서 먼저 잡을 두 가지는 이것입니다. 첫째, 암호화가 아니라 되돌릴 수 없는 단방향 해시로 저장합니다. 둘째, SHA-256 같은 빠른 해시가 아니라, salt가 들어간 느린 적응형 해시(새 시스템이면 Argon2id)를 씁니다.
이 두 가지와 "직접 구현하지 말고 언어 내장 API를 쓴다"만 지켜도 대부분의 비밀번호 사고를 피할 수 있습니다.
같은 "로그인 만들기" 묶음으로, 로그인 상태를 세션에 둘지 토큰에 둘지는 세션 vs JWT 인증에서, 구글·카카오 같은 외부 계정으로 로그인하는 방법은 소셜 로그인과 OAuth 2.0에서 다뤘습니다. 로그인 요청이 다른 출처로 오갈 때 나는 CORS 에러도 같은 결의 글입니다.
읽어 주셔서 감사합니다 🙇
참고 자료
- OWASP: Password Storage Cheat Sheet
- OWASP: Authentication Cheat Sheet
- NIST SP 800-63B: Digital Identity Guidelines
- RFC 9106: Argon2 Memory-Hard Function
- RFC 7914: The scrypt Password-Based Key Derivation Function
- RFC 8018: PKCS #5 — Password-Based Cryptography (PBKDF2)
- RFC 4648: Base16/32/64 Data Encodings
- PHP Manual: password_hash()
'프론트엔드' 카테고리의 다른 글
| HTTPS 자물쇠가 지켜주는 것과 못 지켜주는 것 — TLS가 실제로 하는 일 (0) | 2026.07.29 |
|---|---|
| 소셜 로그인, OAuth 2.0만으로 붙이면 안 되는 이유 — 인증과 인가부터 구분하기 (0) | 2026.07.25 |
| 세션 vs JWT 인증, 뭘 쓰고 토큰은 어디에 저장하나 (0) | 2026.07.21 |
| CORS 에러, 왜 나고 어떻게 푸나 — 원인부터 해결까지 (0) | 2026.07.19 |
| CodePush가 끝났습니다 — React Native OTA 업데이트를 EAS Update로 옮기기 (0) | 2026.07.19 |
| Expo Go로 안 되는 네이티브 라이브러리, development build로 붙이기 (0) | 2026.07.18 |
| Expo vs React Native CLI, 2026년 어떤 방식으로 크로스플랫폼을 시작해야할까? (0) | 2026.07.17 |
