목차
- 왜 8년 된 bcrypt 코드를 굳이 바꿨나
- 두 알고리즘이 막으려는 공격이 다르다
- 첫 주: Python에서 argon2-cffi를 붙여봤다
- 둘째 달: Node.js 컨테이너가 OOM으로 죽기 시작했다
- 파라미터 재조정 — m=19456, t=2, p=1
- 레거시 bcrypt 유저는 점진 업그레이드
- 3개월 운영하면서 알게 된 작은 것들
- 다음 프로젝트라면 이렇게 한다
bcrypt argon2 비교를 다시 들춰본 건 신규 SaaS 인증 모듈을 처음부터 짜야 하는 상황이 왔기 때문이다. 결과만 말하면 Argon2id로 갔고, 3개월 운영 후 OOM 한 번을 빼면 큰 문제 없이 돌고 있다.
따라서, 이번 글은 알고리즘 자체를 다루기보다 3개월 프로젝트가 어떻게 흘러갔는지를 시간순으로 풀어보려 한다. 결정의 근거, 중간에 깨진 것, 그리고 같은 결정을 다시 한다면 어디를 다르게 할지에 집중한다.
왜 8년 된 bcrypt 코드를 굳이 바꿨나
물론, 기존 모놀리식 서비스에는 bcrypt(cost=10)가 8년째 박혀 있다. 별 사고 없이 잘 돌았다. "잘 되면 안 건드린다"는 입장에서 보면 굳이 바꿀 이유가 없다.
이처럼, 다만 신규 모듈은 처음부터 짜는 프로젝트였고, OWASP Password Storage Cheat Sheet의 2023년 개정판이 Argon2id를 1순위로 명시한 게 마음에 걸렸다(출처: OWASP Cheat Sheet Series, 2023년 4월 개정본). 두 번째 권고가 scrypt, 세 번째가 bcrypt, 마지막이 PBKDF2 순이다.
결국, bcrypt는 1999년 USENIX 발표 알고리즘이다. 25년 넘게 살아남았다는 건 그 자체로 신뢰의 근거다. 하지만 설계 시점에 GPU·ASIC 대량 병렬 공격이 지금처럼 흔하리라곤 가정하지 않았다. 그래서 "보안 강도"라는 단어가 의미하는 게 시대에 따라 달라졌다는 게 출발점이었다.
두 알고리즘이 막으려는 공격이 다르다
bcrypt는 CPU 시간만 늘린다. cost 파라미터가 1 오르면 연산 시간이 2배가 된다. 공격자 입장에선 GPU 수천 개로 병렬화하면 시간이 짧아진다. bcrypt는 메모리를 거의 안 쓰기 때문에 GPU 1장에 인스턴스를 수백 개씩 올릴 수 있다.
그런데, Argon2는 메모리도 같이 늘린다. m=19456 KiB로 잡으면 1회 해싱에 약 19 MiB가 필요하다. GPU 1장의 메모리(예: 24GB)로는 동시 인스턴스 수가 제한된다. 이게 "memory-hard" 알고리즘의 핵심이다(출처: Argon2 논문, Biryukov et al., 2015).
| 항목 | bcrypt | Argon2id |
|---|---|---|
| 발표 연도 | 1999 | 2015 (PHC 우승) |
| 메모리 사용 | 4 KB 수준 | 조정 가능 (보통 19 MiB~64 MiB) |
| 주요 파라미터 | cost(=work factor) | m, t, p |
| OWASP 2023 순위 | 3순위 | 1순위 |
| GPU 공격 내성 | 약함 | 강함 |
| 표준화 | de facto | RFC 9106 (2021) |
즉, 요약하면, 같은 "느리게 만든다"는 목표라도 막는 비용 모델이 다르다. CPU만 비싸게 할 것이냐, 메모리도 같이 비싸게 할 것이냐의 차이다.
첫 주: Python에서 argon2-cffi를 붙여봤다
API 서버는 FastAPI(Python 3.11), 인증 워커는 별도 컨테이너로 분리해두고 있다. argon2-cffi 23.1.0 (작성 시점 기준)을 깔고 기본 파라미터로 돌렸다.
# 첫 주에 그냥 기본값으로 돌려본 코드
from argon2 import PasswordHasher
ph = PasswordHasher() # 라이브러리 기본값 m=65536, t=3, p=4
# 단일 해싱 체감 시간: M2 Pro 노트북 기준 ~80ms
hashed = ph.hash("test_password")
이처럼, 라이브러리 기본값(m=65536 KiB, t=3, p=4)은 의외로 무겁다. 단일 해시 80ms는 받아들일 만한데, 문제는 동시성이다. 인증 워커 컨테이너의 메모리 limit를 2 GiB로 잡아둔 상태였다. 동시 인증 100건이 큐에 쌓이면 이론상 6.4 GiB가 필요하다는 계산이 첫 주말에 뒤늦게 나왔다.
예를 들어, 이 시점까지는 "기본값 그대로 가도 되겠지"라고 생각했다. 실제 부하 테스트 전이라 체감을 못 한 거다.
둘째 달: Node.js 컨테이너가 OOM으로 죽기 시작했다
같은 인증 워커를 일부 마이크로서비스에서는 Node.js 20.11로도 쓰고 있다. argon2 npm 패키지(0.31.x 버전대) 기준이다. 스테이징에 트래픽을 흘리기 시작한 둘째 달, 컨테이너 두 개가 OOMKilled로 죽었다.
그래서, 원인은 단순했다. 라이브러리 기본 파라미터를 안 건드린 채 동시 인증 요청이 몰린 거다. Kubernetes 이벤트 로그에 다음과 같이 찍혔다.
State: Terminated
Reason: OOMKilled
Exit Code: 137
Last State: Running
Started: ...
Finished: ...
Memory Working Set: 1.98 GiB / 2 GiB
또한, 처음엔 메모리 누수를 의심했다. 힙 덤프를 떠봤더니 누수가 아니라 그냥 동시 Argon2 해싱이 메모리 한계를 정직하게 채우고 있었다. 부끄러운 얘기지만 m 파라미터의 단위가 KiB라는 것을 그제서야 다시 확인했다(공식 문서에는 명시되어 있다). 65536 KiB × 100 동시 요청 = 6.4 GiB. 산수만 했어도 미리 알 수 있었다.
파라미터 재조정 — m=19456, t=2, p=1
OWASP가 2023년 개정에서 권장하는 첫 번째 Argon2id 프로파일은 m=19456 KiB(19 MiB), t=2, p=1이다(출처: OWASP Password Storage Cheat Sheet, "Argon2id" 섹션). 메모리를 70% 가까이 줄였는데도 보안 강도는 "현재 인증 시스템에서 합리적 수준"이라고 명시되어 있다.
from argon2 import PasswordHasher
ph = PasswordHasher(
time_cost=2,
memory_cost=19456, # 19 MiB. KiB 단위라는 점 주의.
parallelism=1,
hash_len=32,
salt_len=16,
)
이 조합으로 다시 부하를 걸어봤다. 단일 해시 체감 시간은 M2 Pro 기준 35~45ms로 줄었다. 동시 100건일 때 워커 메모리는 약 2 GiB → 700 MiB 수준으로 떨어졌다. 다행히 OOM은 그 뒤로 한 번도 발생하지 않았다.
파라미터 선택 기준을 어디서 가져왔나
처음엔 "메모리를 더 줄이면 보안이 약해지지 않나" 싶었다. RFC 9106 권고는 "메모리 부족 환경에서는 m=2^16 KiB(=64 MiB)를 쓰되 t를 높여 보상하라"는 식이지만, OWASP 가이드는 운영 현실을 더 반영해 m을 낮추는 쪽을 우선한다. 운영자 입장에선 OWASP 권고가 더 실용적이라는 평이 많다.
파라미터를 어디에 저장해야 할까
Argon2의 해시 문자열에는 파라미터가 그대로 인코딩된다($argon2id$v=19$m=19456,t=2,p=1$...$...). 그래서 나중에 파라미터를 올려도 기존 해시는 그대로 검증 가능하다. bcrypt도 마찬가지다($2b$10$...의 10이 cost). 이 점은 두 알고리즘 모두 좋게 설계되어 있다.
레거시 bcrypt 유저는 점진 업그레이드
또한, 기존 모놀리식 서비스에는 bcrypt 해시가 수십만 건 박혀 있다. 한 번에 마이그레이션하려면 유저 비밀번호 평문이 필요한데, 당연히 그건 불가능하다. 그래서 흔히 쓰는 "로그인 시 재해싱" 방식을 적용했다.
def verify_and_upgrade(user, plain_password):
if user.hash.startswith("$2"):
# bcrypt 해시. 검증만 하고, 통과하면 Argon2id로 재해싱.
if bcrypt.checkpw(plain_password.encode(), user.hash.encode()):
user.hash = ph.hash(plain_password)
user.save()
return True
return False
# Argon2id 경로
try:
ph.verify(user.hash, plain_password)
if ph.check_needs_rehash(user.hash):
user.hash = ph.hash(plain_password)
user.save()
return True
except VerifyMismatchError:
return False
3개월간 약 38% 유저가 로그인하면서 자연 마이그레이션됐다. 나머지는 휴면 유저라 당분간 bcrypt로 남는다. 이건 보안상 큰 문제가 아니라고 본다 — bcrypt도 여전히 OWASP 3순위 권고 안에 들어가는 알고리즘이다.
그런데, 다만 코드베이스에 두 알고리즘이 공존하는 기간이 길어지면 라이브러리 업그레이드 시 양쪽 호환성을 신경 써야 한다. 이건 운영 부담이긴 하다.
3개월 운영하면서 알게 된 작은 것들
결국, 성능 모니터링 대시보드에 인증 latency p95를 따로 떼서 봤다. Argon2id 도입 직전 bcrypt(cost=10) 기준 p95가 평균 110ms 수준이었는데, Argon2id(m=19456, t=2, p=1)로 바꾼 뒤 85~95ms로 오히려 떨어졌다. 알고리즘이 빨라졌다기보다 컨테이너 사양(c6i.large)에서 bcrypt cost=10의 CPU 시간이 Argon2id의 메모리 접근 시간보다 더 걸리는 환경이었던 거다. 환경에 따라 결과가 뒤집힐 수 있다는 점을 직접 확인한 셈이다.
해시 길이는 평균 97자(Argon2id) vs 60자(bcrypt). 데이터베이스 컬럼은 VARCHAR(255)로 잡아뒀기 때문에 문제는 없었다.
물론, 그리고 Argon2의 가장 큰 함정은 이미 말한 메모리다. 인증 워커를 따로 분리하지 않은 채 API 서버에서 직접 해싱한다면 m을 더 낮추거나(예: m=9216 KiB) bcrypt를 유지하는 게 합리적일 수 있다. 인증 부하가 API 워크로드와 메모리를 공유한다는 점을 잊으면 안 된다.
다음 프로젝트라면 이렇게 한다
그러나, 같은 규모의 프로젝트를 다시 한다면, 이 순서로 갈 것 같다.
- 부하 테스트를 코드 작성 전에 한다. 라이브러리 기본값으로 동시 100/500/1000건을 먼저 돌려본다. 메모리 곡선을 보고 m, t, p를 결정한다.
- 컨테이너 메모리 limit를 파라미터에 맞춰 명시적으로 계산한다.
m × max_concurrent + base_overhead정도의 보수적 공식이면 충분하다. - 레거시가 있다면 "검증 후 재해싱" 패턴을 하루 안에 붙인다. 이건 거의 표준 패턴이라 짧다.
- Argon2 파라미터를 환경변수로 빼둔다. 6~12개월 뒤 권고치가 바뀌어도 코드 수정 없이 올릴 수 있다.
bcrypt를 떠나야 한다는 얘기는 아니다. bcrypt는 여전히 합리적 선택이고, 특히 기존 코드를 굳이 건드릴 이유가 없다면 그대로 두는 게 낫다. 새로 짜는 시스템이고 인증 워커를 분리할 수 있다면, 개인적으로는 Argon2id에 OWASP 권고 프로파일을 그대로 쓰는 게 가장 부담 없는 선택이라고 본다.
관련 글
- SQL Injection 방지 실전 가이드: Parameterized Query와 ORM 패턴 – 코드 리뷰에서 f-string으로 조립된 SQL 쿼리를 본 적이 있다. SQL Injection 방지 실전을 parameterized qu…
- CORS 오류 해결 완벽 가이드: Nginx·FastAPI·Express 환경별 안전 설정 – 프론트에서만 보던 CORS 오류를 백엔드에서 직접 다뤄보니 완전히 다른 풍경이 보였다. 세 가지 환경에서 안전하게 푸는 기준을 정리한다.
- Refresh Token Rotation 구현: 3개월간 부순 것과 고친 것 – reuse_detected 알람이 87건 한꺼번에 떴다. 사용자는 강제 로그아웃됐고 CS는 불탔다. Refresh Token Rotatio…