bcrypt vs Argon2 vs scrypt: 2026 비밀번호 해싱 비교
짧은 결론: 2026년에 새로 시작하는 프로젝트라면 Argon2id 를 m=19456, t=2, p=1 매개변수로 쓰세요. OWASP Password Storage Cheat Sheet 의 기준선이고, 오늘 출시할 수 있는 비밀번호 해싱 알고리즘 중 GPU 공격과 부채널 공격(side-channel) 저항성이 가장 강합니다.
스택이 Argon2를 지원하지 않는다면(드물지만 일부 임베디드 환경이나 오래된 런타임에서는 그렇습니다) scrypt 를 N=2^17, r=8, p=1 로 쓰세요. bcrypt 는 cost=12 로 두되, 이미 bcrypt가 박혀 있어 새 의존성을 도입할 수 없는 레거시에 한해서만 고릅니다. FIPS-140 준수가 필수인 환경에서는 PBKDF2-HMAC-SHA-256, 600,000 iterations 를 고수하세요.
| 알고리즘 | OWASP 2026 매개변수 | 선택 기준 |
|---|---|---|
| Argon2id | m=19456 KiB, t=2, p=1 | 신규 프로젝트 기본값 |
| scrypt | N=2^17, r=8, p=1 | Argon2 미지원 환경 |
| bcrypt | cost=12 (최소 10) | 레거시 시스템 한정 |
| PBKDF2 | HMAC-SHA-256, 600k iterations | FIPS-140 필수 환경 |
이 글의 나머지 부분에서는 왜 이 숫자가 권장되는지, 사용 중인 하드웨어에 맞춰 어떻게 튜닝하는지, 사용자에게 비밀번호 재설정을 강요하지 않고 마이그레이션하는 방법을 설명합니다. 벤치마크용으로 강력한 테스트 비밀번호가 필요하다면 무작위 비밀번호 생성기 를 활용하세요. 더 넓은 보안 맥락은 웹 보안 핵심 가이드 에 있습니다.
비밀번호 해싱이 일반 해싱과 다른 이유
해시 함수는 겉보기엔 다 같습니다. 데이터를 입력하면 고정 길이 다이제스트가 나오고, 뒤집을 수 없습니다. 하지만 “이 4 GB ISO를 해시한다”와 “이 12자 비밀번호를 해시한다”의 설계 목표는 정반대입니다. 한쪽은 실리콘이 허락하는 한 빨라야 하고, 다른 한쪽은 로그인 지연(latency) 예산이 허용하는 한 느려야 합니다.
이 둘을 혼동하는 순간, 데이터 유출은 곧바로 계정 탈취로 이어집니다.
비밀번호에는 왜 MD5와 SHA-256이 부족한가
MD5, SHA-1, SHA-256 같은 범용 해시는 처리량(throughput)을 위해 설계되었습니다. 일반 CPU에서 초당 GB 단위로, GPU에서는 초당 수십 GB까지 처리합니다. 파일 체크섬과 콘텐츠 주소화에는 잘 맞지만, 비밀번호에는 재앙입니다.
2024년 RTX 4090 한 대 기준 Hashcat 벤치마크는 대략 MD5 164 GH/s, SHA-256 22 GH/s 를 기록합니다. 8자 영소문자+숫자 비밀번호(36^8 ≈ 2.8 × 10^12 후보)는 GPU 한 대로 MD5에 대해 1분 안에, SHA-256에 대해 몇 분 안에 무너집니다. sha256(password) 만 저장한 데이터베이스가 유출되면 그건 사실상 평문이나 다름없습니다.
솔트(salt)도 구원이 되지 못합니다. 솔트는 미리 계산된 레인보우 테이블(rainbow table)을 무력화하지만, 계정별 공격 속도는 늦추지 못합니다. 공격자는 그저 유출된 솔트를 후보값에 붙여 다시 해시할 뿐입니다.
보안과 무관한 체크섬 용도라면 MD5와 SHA-256은 여전히 쓸모가 있고, 범용 해시 생성기 같은 도구는 그런 용도로 만들어졌습니다. 어느 알고리즘이 어떤 상황에 맞는지 깊게 비교한 글로 MD5 vs SHA-256 을 읽어보세요. 비밀번호에는 의도적으로 느린 해시가 필요합니다.
현대적 비밀번호 해시의 세 가지 속성
2026년에 출시할 만한 비밀번호 해시는 다음 세 속성을 갖춰야 합니다.
- 튜닝 가능한 작업 인자(work factor)를 가진, 의도적으로 느린 설계. 로그인은 100~500 ms 가 걸려야 합니다. 사용자가 체감하지 못할 만큼은 빠르되, 오프라인 공격자가 백만 번 추측에 며칠을 태울 만큼은 느려야 합니다. 작업 인자는 매개변수로 노출되어야 하며, 그래야 하드웨어가 발전할 때 끌어올릴 수 있습니다.
- 레코드별 솔트. 비밀번호마다 고유한 무작위 솔트를 부여하면 레인보우 테이블이 무력화되고, 공격자는 계정 하나하나를 따로 공략해야 합니다. 현대 알고리즘은 솔트를 직접 생성해서 출력 문자열에 끼워 넣어줍니다.
- 메모리 강성(memory-hard). GPU와 ASIC은 연산은 빠르지만 고대역폭 메모리는 비쌉니다. 해시당 수십 MiB가 필요한 알고리즘이라면 공격자는 병렬도에 비례하는 RAM을 확보해야 하고, 그러면 GPU 팜의 비용 효율이 무너집니다.
bcrypt는 (1)과 (2)를 만족하지만 (3)은 그렇지 못합니다. scrypt는 세 속성을 모두 충족한 첫 알고리즘이었습니다. Argon2는 그 설계를 가다듬어 Password Hashing Competition에서 우승했습니다. 다음 절에서 각각 차례로 살펴봅니다.
세 알고리즘의 구조와 트레이드오프
bcrypt — Blowfish 기반, 시간 강성(time-hard)
bcrypt는 1999년 Niels Provos와 David Mazières가 OpenBSD를 위해 설계했습니다. Blowfish 암호 위에 쌓아 올린 알고리즘으로, 비싼 키 셋업 단계(“EksBlowfish”)를 2^cost 번 반복합니다. 유일한 튜닝 매개변수는 cost factor(일명 “log rounds”)로, 1 증가할 때마다 작업량이 두 배가 됩니다. cost=10 은 1,024번 키 스케줄을, cost=14 는 16,384번을 수행합니다.
bcrypt 해시는 다음과 같이 생겼습니다.
$2b$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
│ │ │ │
│ │ │ └─ 31자 base64 해시
│ │ └─ 22자 base64 솔트
│ └─ cost factor (12)
└─ 알고리즘 식별자 ($2b$ = bcrypt v2)
이 형식은 자기서술적입니다. verify() 가 저장된 문자열에서 cost와 솔트를 직접 읽어가니 별도 컬럼이 필요 없습니다.
단점도 분명합니다. bcrypt의 메모리 사용량은 약 4 KiB 이고, 고급 GPU 한 대에 bcrypt 코어 수천 개를 병렬로 돌릴 수 있을 만큼 작습니다. 그리고 bcrypt는 입력값을 72바이트에서 조용히 자릅니다. 100자짜리 패스프레이즈도 첫 72바이트와 같은 보안 수준입니다. 최대 cost는 31이지만, 일반 하드웨어에서는 ~16 을 넘기는 순간 로그인 지연이 거슬리기 시작합니다.
scrypt — 메모리 강성의 선구자
scrypt는 2009년 Colin Percival이 Tarsnap 백업 서비스용으로 발표했고, 2016년 RFC 7914 로 표준화되었습니다. 메모리 강성(memory-hardness) 이라는 개념을 도입했죠. 알고리즘이 큰 버퍼를 의사 난수 데이터로 채운 뒤 무작위 위치에서 읽어, 어떤 구현이라도 실제로 메모리를 할당하도록 강제하는 방식입니다.
scrypt는 세 매개변수를 받습니다.
- N — CPU/메모리 비용 (2의 거듭제곱이어야 함)
- r — 블록 크기(바이트). 메모리와 믹싱 라운드에 곱셈으로 영향
- p — 병렬도. 독립된 계산 횟수로, 주로 메모리는 늘리지 않으면서 CPU 시간만 늘릴 때 사용
메모리 사용량은 대략 128 × N × r 바이트입니다. OWASP 권장 N=2^17, r=8 이면 128 × 131072 × 8 = 134,217,728 바이트, 정확히 해시당 128 MiB 입니다.
scrypt는 단순한 비밀번호 해시가 아니라 키 유도 함수(KDF)이기도 합니다. 암호화폐 지갑, 디스크 전체 암호화, 초기 라이트코인 작업증명에서 쓰입니다. 비밀번호 저장과 키 유도가 한 라이브러리에서 모두 필요할 때 이 이중 역할이 편리합니다.
Argon2 (id/i/d) — Password Hashing Competition 우승자
2013년부터 2015년까지 진행된 Password Hashing Competition은 후보 알고리즘 24개를 메모리 강성, 부채널 저항성, 구현 단순성 기준으로 평가했습니다. 우승자는 Argon2였고, 2021년 RFC 9106 으로 표준화되었습니다.
Argon2에는 세 변종이 있습니다. 차이는 믹싱 과정에서 메모리 주소를 어떻게 정하느냐로 갈립니다.
- Argon2d 는 데이터 의존적(data-dependent) 메모리 주소를 사용합니다. GPU·ASIC 공격에 대한 저항성은 최대치지만 캐시 타이밍 부채널을 통해 정보가 새어 나갑니다. 인증 용도가 아닌 암호화폐 작업증명에 맞습니다.
- Argon2i 는 데이터 독립적(data-independent) 주소를 씁니다. 부채널에는 안전하지만 GPU 트레이드오프 공격에는 약간 약합니다.
- Argon2id 는 하이브리드입니다. 첫 번째 패스의 전반부는 Argon2i 인덱싱(부채널 안전), 나머지는 Argon2d 인덱싱(GPU 저항)을 씁니다. RFC 9106은 비밀번호 해싱용으로 Argon2id를 명시적으로 권장하며, OWASP 입장도 같습니다.
Argon2는 세 매개변수를 받습니다.
- m — 메모리 (KiB 단위)
- t — 시간 비용 (메모리 버퍼를 몇 번 통과할지)
- p — 병렬도 (동시에 처리되는 레인 수)
Argon2id 해시는 PHC 문자열 형식을 따르며 다음과 같이 생겼습니다.
$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
bcrypt와 마찬가지로 모든 매개변수가 문자열에 포함되어 있어, verify() 가 별도 매개변수 테이블을 참조할 필요가 없습니다.
OWASP 2026 권장 매개변수
OWASP Password Storage Cheat Sheet 가 정통 레퍼런스입니다. 아래 숫자는 그 최신 가이드와 일치합니다. 보수적인 값으로(로그인 지연 예산 100~500 ms 인 일반 웹 서버를 가정), 그래도 실제로 출시하기 전에는 사용 중인 하드웨어에서 직접 벤치마크해 봐야 합니다.
Argon2id 매개변수: 최우선 선택지
OWASP 기준 권장값은 m=19456 (19 MiB), t=2, p=1 입니다.
서버에 RAM 여유가 더 있다면 메모리와 시간 사이에서 작업량을 옮길 수 있습니다. RFC 9106은 동등한 프로파일을 제공하며, OWASP는 다음 중 어느 것도 허용합니다.
| memoryCost (m) | timeCost (t) | parallelism (p) | 해시당 RAM |
|---|---|---|---|
| 47104 | 1 | 1 | 46 MiB |
| 19456 | 2 | 1 | 19 MiB (기준) |
| 12288 | 3 | 1 | 12 MiB |
| 9216 | 4 | 1 | 9 MiB |
| 7168 | 5 | 1 | 7 MiB |
튜닝 요령. m 을 먼저 정합니다. 동시 로그인 최대치에서 사용 가능한 RAM 예산이 기준입니다. 동시 로그인 100건이 예상되고 4 GiB 가 여유라면 해시당 40 MiB 가 됩니다. 그다음 t 를 올려 운영 CPU에서 단일 verify가 100~500 ms 에 들어오게 합니다. p=1 은 다중 코어를 써야 할 구체적 이유가 없는 한 그대로 두세요. 대부분 웹 프레임워크는 이미 요청별로 별도 스레드를 부여합니다.
scrypt 매개변수: Argon2를 못 쓸 때
OWASP 권장값은 N=2^17 (131072), r=8, p=1 이며, 해시당 128 MiB 를 씁니다.
서버 입장에서 동시 로그인당 128 MiB 가 부담된다면 OWASP는 더 가벼운 프로파일도 허용합니다.
| N | r | p | 해시당 RAM |
|---|---|---|---|
| 2^17 | 8 | 1 | 128 MiB (선호) |
| 2^16 | 8 | 1 | 64 MiB |
| 2^15 | 8 | 1 | 32 MiB |
N 은 2의 거듭제곱이어야 합니다. r 을 올리면 메모리와 CPU 작업이 비례해서 늘고, p 를 올리면 인스턴스당 메모리는 그대로인 채 CPU 작업만 늘어납니다. 비밀번호 해싱에서는 r 과 p 를 기본값으로 두고 N 만 조정하세요.
bcrypt: 레거시 한정, cost factor 10 이상
OWASP는 신규 프로젝트에 bcrypt를 더는 권장하지 않지만, 여전히 곳곳에 박혀 있습니다. Devise, Spring Security, ASP.NET Identity, 자체 제작 인증 시스템 무수히 많은 곳이 기본값으로 두고 있습니다.
bcrypt를 어쩔 수 없이 써야 한다면 규칙은 이렇습니다.
- bcrypt 최소 cost factor: 10. 10 미만이면 GPU 한 대로 유출된 데이터베이스를 며칠 안에 끝낼 수 있을 만큼 빠릅니다.
- 권장값: 12~14, 하드웨어에 따라 달라집니다. 최신 x86 서버에서
cost=12는 해시당 약 250 ms,cost=13은 500 ms 가 걸립니다. - 운영 하드웨어에서 verify당 100~300 ms 를 목표로 하세요. 추측하지 말고 벤치마크하세요.
- 72바이트 입력 한계 를 잊지 마세요. 사용자가 패스프레이즈를 고를 수 있다면 SHA-256으로 사전 해시(pre-hash)하세요(FAQ 참조).
bcrypt의 GPU 저항성은 4 KiB 메모리 풋프린트에 묶여 있습니다. 어떤 bcrypt cost factor도 Argon2id의 메모리 강성에는 도달할 수 없습니다. 가능하다면 Argon2id를 고르세요.
실용적 참고치: 2024년형 EPYC 서버에서 bcrypt(cost=12) 는 약 250 ms, 고사양 노트북에서는 약 350 ms 에 끝납니다. 본인 환경의 측정치가 100~500 ms 범위에서 한 자릿수 이상 벗어난다면, 사용 중인 라이브러리가 정말로 네이티브 bcrypt를 쓰고 있는지, 아니면 느린 자바스크립트 폴리필로 떨어져 있는지(일부 번들러는 서버리스 빌드에서 네이티브 의존성을 제거합니다) 다시 확인해 보세요.
PBKDF2: FIPS-140 준수 경로
PBKDF2 (RFC 8018) 는 보안 가이드 관점에서 보면 마지막 수단입니다. bcrypt보다 오래되었고, 메모리 강성이 없으며, 위 세 알고리즘 어느 것보다 GPU 공격에 빠르게 무너집니다. 그래도 FIPS-140 검증을 통과한 유일한 비밀번호 해싱 프리미티브이고, 연방 정부, 의료(HIPAA), 일부 금융 분야에서는 이게 결정적입니다.
PBKDF2가 필요하다면 다음을 따르세요.
- PRF로 HMAC-SHA-256 을 쓰세요 (SHA-1 금지, HMAC 없는 순수 SHA-256 금지)
- 최소 600,000 iterations (OWASP 2026 기준)
- 비밀번호당 최소 16바이트 무작위 솔트
FIPS가 적용되지 않는다면 Argon2id를 고르세요. PBKDF2는 출력 길이와 메모리가 고정되어 있어, 공격자가 GPU에 쓰는 1달러가 그대로 초당 추측 횟수로 환산됩니다.
NIST의 SP 800-63B 는 PBKDF2-HMAC을 비밀번호 해싱용으로 “승인됨(approved)” 으로 분류하면서도, 메모리 강성 대안보다 우선해서 권장하지는 않습니다. 풀어 말하면, NIST가 PBKDF2를 허용하는 이유는 그게 신규 프로젝트에 가장 좋은 선택이어서가 아니라, 이를 폐기하면 모든 레거시 정부 시스템이 무효화되기 때문이라는 뜻입니다.
의사결정 기준: 어떤 알고리즘을 골라야 하나?
비교표
| 항목 | bcrypt | scrypt | Argon2id | PBKDF2 |
|---|---|---|---|---|
| 메모리 강성 | 없음 | 있음 | 있음 | 없음 |
| GPU 저항성 | 중간 | 높음 | 매우 높음 | 낮음 |
| 부채널 저항성 | 중간 | 중간 | 높음 (id) | 중간 |
| 매개변수 복잡도 | 1개 (cost) | 3개 (N, r, p) | 3개 (m, t, p) | 1개 (iterations) |
| 라이브러리 성숙도 | 우수 | 양호 | 양호 | 우수 |
| 입력 길이 제한 | 72바이트 | 없음 | 없음 | 없음 |
| 표준화 | 사실상 표준 | RFC 7914 | RFC 9106 | RFC 8018 |
| OWASP 2026 위치 | 레거시 한정 | 대안 | 최우선 | FIPS 한정 |
기본은 Argon2id
신규 프로젝트, 일반적인 웹앱, 최신 Node/Python/Go/Rust/JVM 스택, FIPS 제약이 없는 환경이라면 — Argon2id m=19456, t=2, p=1 을 쓰세요. 사용 가능한 알고리즘 중 GPU·부채널 저항성이 가장 강하고, 라이브러리 업그레이드에도 살아남는 매개변수 내장 형식을 갖추며, 입력 길이 함정도 없습니다. 라이브러리 생태계도 무르익었습니다. npm의 argon2, PyPI의 argon2-cffi, golang.org/x/crypto/argon2, crates.io의 argon2 crate 모두 활발히 유지보수되며 벤치마크가 공개되어 있습니다.
scrypt나 bcrypt를 골라야 할 때
scrypt를 고르는 경우 는 사용 중인 런타임에서 Argon2가 정말로 안 되는 경우(2026년에 진짜로 드뭅니다. Cloudflare Workers와 Deno에도 이제 들어 있습니다)이거나, 이미 운영 중인 시스템이 scrypt 기반인데 마이그레이션 비용이 보안상 이득보다 큰 경우입니다. scrypt 자체는 여전히 견고한 알고리즘입니다. Argon2id 수준의 부채널 마감이 부족할 뿐입니다.
bcrypt를 고르는 경우 는 레거시 시스템을 유지보수하고 있고, 의존성 최소화가 강한 요구사항이며(네이티브 코드 금지, 추가 패키지 금지), 72바이트 입력 한계가 사용자층에 받아들여지는 경우입니다. bcrypt는 인터넷 규모로 20년 동안 배포되어 왔고, 그 실패 양상(failure mode)이 잘 알려져 있습니다.
PBKDF2를 고르는 경우 는 규제 당국이 그렇게 정했을 때뿐입니다. 이유는 그것 하나입니다. 감사관이 Argon2id를 받아준다면(비-FIPS 워크로드에서는 이를 허용하는 곳이 늘고 있습니다) Argon2id를 쓰세요.
흔히 저지르는 실수
지난 10년간 일어난 비밀번호 저장 관련 침해 대부분은 반복되는 소수의 엔지니어링 실수로 거슬러 올라갑니다. 어느 것도 특이하지 않으며, 아래 목록을 옆에 두고 인증 코드를 검토하면 모두 잡을 수 있습니다.
- 비밀번호를 SHA-256이나 MD5 그대로 해싱하기. 비밀번호 저장에서 가장 큰 단일 실패 사례입니다. 비밀번호에 부적합한 이유는 MD5 vs SHA-256 에서 다룹니다.
- 모든 사용자에게 단일 글로벌 솔트 재사용. 솔트는 레코드별로 고유해야 합니다. Argon2와 bcrypt가 알아서 생성해 주는 것을 임의로 덮어쓰지 마세요.
- 해시 시간을 50 ms 미만으로 설정. 사용자가 체감조차 못 하는 속도 향상을 위해 보안을 팔아넘긴 셈입니다. 100~500 ms 를 목표로 하세요.
- 해시 시간을 1초 이상으로 설정. 본인 로그인 엔드포인트에 대한 서비스 거부(DoS) 벡터를 직접 만든 격입니다. 약 500 ms 에서 상한을 두세요.
- 클라이언트에서 비밀번호를 해싱해 다이제스트를 서버로 전송. 그 해시가 곧 비밀번호가 됩니다. 데이터베이스를 훔친 사람은 역산할 필요도 없이 그대로 인증할 수 있습니다. 해싱은 반드시 서버에서 하세요.
- 알고리즘 매개변수를 별도 컬럼에 저장. PHC 문자열 형식이 매개변수를 해시 안에 박아 줍니다. 그것을 쓰세요.
- 에러 처리 중 비밀번호나 해시를 로깅. 둘 다 사용자 소유이지 로그 수집기 소유가 아닙니다. 로거에 닿기 전, 요청 파싱 단계에서 마스킹하세요.
verify()예외를 인증 실패로 처리. 저장된 해시가 깨져 있어 라이브러리가 예외를 던졌다면 그건 “비밀번호 틀림”이 아니라 에러로 노출해야 합니다. “비밀번호 틀림”(401 반환)과 “저장된 해시가 손상됨”(500 반환 + 온콜 호출)을 구분하세요.
실전 구현
Node.js 의 Argon2id
argon2 패키지(레퍼런스 구현에 대한 네이티브 바인딩)가 Node에서의 정통 선택입니다.
import argon2 from 'argon2';
// 가입 시 또는 비밀번호 변경 시 해싱
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB
timeCost: 2,
parallelism: 1,
});
// → '$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>'
// 로그인 시 검증
const ok = await argon2.verify(hash, candidate);
if (!ok) throw new Error('Invalid credentials');
// 매개변수가 낡았는지 감지하고, 로그인 성공 시 재해싱
if (argon2.needsRehash(hash, { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 })) {
const upgraded = await argon2.hash(candidate, {
type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1,
});
await db.users.update({ id: user.id }, { password_hash: upgraded });
}
needsRehash 단계가 장기 마이그레이션의 핵심입니다. 로그인이 성공할 때마다 저장된 해시를 현재 매개변수로 끌어올릴 기회가 되며, 사용자에게는 아무것도 알리지 않습니다.
Python의 argon2-cffi 로도 같은 패턴이 가능합니다.
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher(memory_cost=19456, time_cost=2, parallelism=1)
# 해싱
stored = ph.hash(password)
# 검증
try:
ph.verify(stored, candidate)
except VerifyMismatchError:
raise ValueError('Invalid credentials')
# 매개변수 업그레이드 시 재해싱
if ph.check_needs_rehash(stored):
stored = ph.hash(candidate)
Go의 golang.org/x/crypto/argon2 에서는 이렇게 됩니다.
import (
"crypto/rand"
"golang.org/x/crypto/argon2"
)
func hashPassword(password string) ([]byte, []byte) {
salt := make([]byte, 16)
rand.Read(salt)
hash := argon2.IDKey([]byte(password), salt, 2, 19456, 1, 32)
return hash, salt
}
Go 표준 라이브러리는 PHC 형식 인코더를 제공하지 않습니다. argon2.IDKey 프리미티브를 직접 쓴다면 매개변수와 솔트를 해시와 함께 인코딩하는 일은 본인 몫입니다. 대부분 Go 프로젝트는 그 작업을 위해 github.com/alexedwards/argon2id 같은 래퍼를 씁니다.
Rust의 argon2 crate도 비슷하게 관용적입니다.
use argon2::{Argon2, PasswordHasher, PasswordVerifier, password_hash::{SaltString, rand_core::OsRng}};
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default(); // 기본값으로 Argon2id, m=19456, t=2, p=1
let hash = argon2.hash_password(password.as_bytes(), &salt)?.to_string();
// 검증 시
let parsed = argon2::password_hash::PasswordHash::new(&hash)?;
argon2.verify_password(candidate.as_bytes(), &parsed)?;
세 런타임 모두에서 만들어진 문자열은 호환됩니다. Node에서 만든 해시가 Python이나 Rust에서 그대로 검증됩니다. 이 런타임 간 호환성 덕분에 다양한 언어가 섞인 아키텍처에서 알고리즘별 래퍼보다 Argon2가 더 안전한 선택이 됩니다.
bcrypt → Argon2id 마이그레이션 패턴
사용자 테이블을 통째로 지우고 새로 시작할 수 있는 경우는 거의 없습니다. 올바른 마이그레이션 패턴은 해시 생성기 FAQ의 MD5 → bcrypt 섹션 에서 다루는 것과 같습니다. 부드럽고, 로그인 기반의 점진 업그레이드입니다.
알고리즘을 추적하는 컬럼을 추가합니다.
ALTER TABLE users ADD COLUMN password_algo VARCHAR(16) NOT NULL DEFAULT 'bcrypt';
로그인 시 알맞은 검증기로 분기시킵니다.
async function verifyAndMaybeRehash(user, candidate) {
let ok;
if (user.password_algo === 'argon2id') {
ok = await argon2.verify(user.password_hash, candidate);
} else if (user.password_algo === 'bcrypt') {
ok = await bcrypt.compare(candidate, user.password_hash);
if (ok) {
// 레거시 검증 성공 → Argon2id로 재해싱
const newHash = await argon2.hash(candidate, {
type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1,
});
await db.users.update({ id: user.id }, {
password_hash: newHash,
password_algo: 'argon2id',
});
}
}
return ok;
}
일몰 기간(sunset window)은 6~12개월 로 잡으세요. 9개월 차에 “비밀번호가 구식 방식으로 저장되어 있으니 로그인해서 업그레이드하라”는 메일을 발송합니다. 12개월 후에도 bcrypt에 남아 있는 계정은 다음 로그인 시 비밀번호 강제 재설정을 요구합니다. 활성 사용자는 자연스럽게 마이그레이션되고, 비활성 계정은 한 번의 마찰을 감수합니다.
scrypt나 PBKDF2에서 옮겨올 때도 같은 패턴이 작동합니다. 필요한 상태는 password_algo 컬럼 하나뿐입니다.
페퍼, 길이 제한, 인코딩 함정
실서비스에서 자주 베이는 날카로운 모서리들을 정리합니다.
페퍼(pepper). 페퍼는 모든 비밀번호에 해싱 전에 더해지는 애플리케이션 레벨 비밀이며, 데이터베이스가 아닌 별도 위치(KMS, 환경 변수, Hashicorp Vault 등)에 보관합니다. 데이터베이스가 새도 앱 비밀이 새지 않는다면, 유출된 해시는 페퍼 없이는 공격 불가능합니다. 단순 연결(concatenation)이 아니라 HMAC으로 적용하세요.
import { createHmac } from 'crypto';
const peppered = createHmac('sha256', process.env.PEPPER).update(password).digest();
const hash = await argon2.hash(peppered, { type: argon2.argon2id, /* ... */ });
페퍼 자체는 자주 회전(rotate)할 일이 없지만(회전하려면 재해싱이 필요), 회전을 지원하려면 버전을 붙이세요. PEPPER_V2 를 두고, 검증 시에는 PEPPER_V1 으로 폴백할 수 있도록.
bcrypt 72바이트 한계. bcrypt를 꼭 써야 하면서 임의 길이 비밀번호를 지원하고 싶다면 SHA-256으로 사전 해시한 뒤 base64로 인코딩하세요(bcrypt는 임베디드 NUL 바이트도 일관되지 않게 처리하므로 이를 회피).
import { createHash } from 'crypto';
const prepped = createHash('sha256').update(password, 'utf8').digest('base64');
const hash = await bcrypt.hash(prepped, 12);
같은 prepped 변환을 검증 시에도 반드시 적용해야 합니다. 인증 코드에 큼지막한 주석으로 적어 두세요. 미래의 본인이 현재의 본인에게 고마워할 겁니다.
UTF-8 정규화. "café" 라는 문자열은 c-a-f-é (4 코드포인트, NFC) 로도, c-a-f-e + 결합 악센트 (5 코드포인트, NFD) 로도 인코딩될 수 있습니다. 보기에 같지만 해시 결과는 다릅니다. 해싱 전에 항상 NFC로 정규화하세요.
const normalized = password.normalize('NFC');
이 문제는 모바일 키보드와 PDF에서의 복사·붙여넣기에서 생각보다 자주 발생합니다.
클라이언트에서 사전 해싱하지 말 것. 클라이언트가 계산해 서버로 전송한 해시는 새로운 비밀번호입니다. 데이터베이스를 본 사람은 누구든 인증할 수 있습니다. 해싱은 서버에서, 두말할 것 없이. JWT를 끼워 넣어도 사정은 달라지지 않습니다. JWT가 무엇을 인증하고 무엇을 인증하지 않는지는 JWT 토큰 디코딩 방법 을 참고하세요.
노트북이 아니라 운영 하드웨어에서 벤치마크할 것. 13세대 인텔 노트북에서 Argon2id m=19456, t=2, p=1 은 약 35 ms 에 끝납니다. 같은 매개변수가 t3.small EC2 인스턴스에서는 180 ms 에 가깝고, 라즈베리 파이 4에서는 600 ms 를 넘깁니다. 실제 운영 환경에서 돌릴 하드웨어를 골라 verify를 1,000회 측정한 뒤 중앙값을 기준으로 튜닝하세요. 서버리스 컨테이너 콜드 스타트로 인한 로그인 지연 분산도 측정해 둘 가치가 있습니다. Lambda 콜드 스타트는 해싱과 무관하게 200~800 ms 를 더할 수 있습니다.
FAQ
비밀번호 해싱과 암호화의 차이는 무엇인가요?
해싱은 단방향입니다. 입력값을 복원할 수 없는 고정 길이 지문을 계산합니다. 암호화는 양방향입니다. 올바른 키만 있으면 원본으로 복호화할 수 있습니다. 비밀번호는 암호화가 아니라 해싱해야 합니다. 서버는 어떤 사용자의 비밀번호도 복원할 수 없어야 하며, 그래야 데이터베이스 유출이 자격증명 유출로 이어지지 않습니다.
비밀번호에 그냥 SHA-256을 쓰면 안 되나요?
SHA-256은 속도를 위한 설계입니다. 최신 GPU는 SHA-256을 초당 220억 회 계산하므로, 유출된 데이터베이스의 8자 영소문자 비밀번호는 몇 분 안에 무너집니다. 비밀번호 해시에는 SHA-256에 없는 세 가지가 필요합니다. 의도적으로 느린 실행, 레코드별 솔트, 메모리 강성. 같은 트레이드오프 원칙은 해시 생성기의 “보안 용도로 MD5를 쓰지 말 것” 가이드 에서 다루며, 약한 해시가 어떻게 평문이 되는지에 대한 자세한 내용은 비밀번호 엔트로피 를 읽어보세요.
bcrypt는 2026년에도 여전히 안전한가요?
bcrypt 자체는 깨지지 않았습니다. Blowfish 기반 키 스케줄은 암호학적으로 여전히 견고합니다. 바뀐 것은 위협 모델입니다. GPU와 ASIC 때문에 bcrypt의 메모리 강성 부재는 Argon2id 대비 의미 있는 약점이 되었습니다. OWASP 2026 입장은, cost ≥ 10 인 레거시 시스템에서는 bcrypt가 받아들일 만하지만 신규 프로젝트는 Argon2id를 골라야 한다는 것입니다.
Argon2i / Argon2d / Argon2id 중 무엇을 써야 하나요?
Argon2id 를 쓰세요. RFC 9106이 비밀번호 해싱용 권장 변종으로 명시한 것입니다. Argon2i는 데이터 독립적(부채널 안전이지만 GPU 트레이드오프 공격에 약함)이고, Argon2d는 데이터 의존적(GPU 강성이지만 캐시 타이밍 부채널에 취약)입니다. Argon2id는 두 속성을 한 번에 가져가는 하이브리드입니다.
우리 앱에 맞는 Argon2id 매개변수는 어떻게 고르나요?
OWASP 기준선 m=19456, t=2, p=1 에서 시작합니다. 그다음 운영 CPU에서 벤치마크해 조정합니다.
- 로그인당 RAM 예산을 정합니다(예: 동시 로그인 피크에서 50 MiB).
m을 그 값 이하로 설정합니다.- 루프에서
argon2.hash()를 돌리며 벽시계 시간을 측정합니다. - 중앙값이 100~500 ms 사이에 들어올 때까지
t를 올립니다.
p=1 은 프로파일링 결과 다중 레인 병렬화가 사용 중인 런타임에서 유효함을 확인한 게 아니라면 그대로 두세요. 트래픽이 많은 인증 서버에서는 t 를 더 높이고 m 을 낮추는 쪽으로 기울이는 편이 RAM 여유 측면에서 더 낫습니다.
bcrypt의 72바이트 한계는 무엇이고, 긴 패스프레이즈는 어떻게 다루나요?
bcrypt는 입력값을 Blowfish 키 스케줄에 넣는데, 거기서 72바이트에서 잘립니다. 150자 패스프레이즈도 첫 72바이트와 같은 보안을 가지며, 나머지는 무시됩니다. 해법은 SHA-256(32바이트) 또는 SHA-512(64바이트)로 사전 해시한 뒤 다이제스트를 base64로 인코딩하고(NUL 바이트 회피), 그 결과를 bcrypt에 넘기는 것입니다. Argon2id와 scrypt에는 이런 제한이 없으며, 임의로 긴 입력을 그대로 받습니다.
bcrypt를 Argon2로 비밀번호 재설정 없이 마이그레이션할 수 있나요?
가능합니다. 패턴은 이렇습니다. 두 알고리즘을 password_algo 컬럼 뒤에 함께 보관하고, 검증을 적절한 라이브러리로 분기시키며, bcrypt 검증이 성공할 때마다 즉시 Argon2id로 재해싱해서 행을 갱신합니다. 활성 사용자는 평소 로그인 주기 안에서 조용히 마이그레이션됩니다. 비활성 계정에는 6~12개월 일몰 기간을 두고, 그 후에도 bcrypt에 남아 있는 레코드에는 비밀번호 강제 재설정을 요구합니다. 같은 패턴이 어떤 알고리즘 간 마이그레이션에도 통합니다.
2026년에도 PBKDF2가 좋은 선택인가요?
FIPS-140 준수가 강제될 때만 그렇습니다. 보통 연방 정부, 규제 의료(HIPAA), 일부 금융 시스템이 해당합니다. PRF로 HMAC-SHA-256, iterations는 최소 600,000을 쓰세요. PBKDF2는 메모리 강성이 없어서, 같은 지연 예산에서 Argon2id보다 GPU 공격에 빠르게 무너집니다. FIPS가 적용되지 않는다면 Argon2id를 고르고 컴플라이언스 곡예는 건너뛰세요.
2026년 비밀번호 해싱의 답은 짧습니다. 기본은 OWASP 기준 매개변수의 Argon2id, Argon2가 안 되면 scrypt로 폴백, 레거시가 요구하는 곳에만 bcrypt를 유지, FIPS 묶인 시스템에는 PBKDF2를 남겨 두세요. 해시는 레코드별 솔트(현대 라이브러리는 자동으로 처리), 데이터베이스 외부에 보관되는 애플리케이션 레벨 페퍼, 그리고 하드웨어가 발전할 때 작업 인자를 끌어올릴 수 있도록 해 주는 로그인 기반 재해싱 루프와 짝지어 두는 것이 좋습니다.
무작위 비밀번호 생성기 로 대표성 있는 비밀번호 집합을 만들고, 운영 CPU에서 verify 경로를 벤치마크하고, 매개변수를 상수 파일에 적어 두어 다음 엔지니어가 2028년에 무엇을 올려야 할지 정확히 알 수 있게 하세요. 전체 보안 맥락 — TLS, 세션 관리, 속도 제한, MFA — 은 웹 보안 핵심 가이드 에 있습니다. 우리 앱에 맞는 해시를 오늘 고르세요.