비밀번호 저장에 bcrypt 대신 Argon2를 쓰라는 권고는 흔히 듣는다. 그런데 "왜 더 안전한가"를 물으면 나는 오랫동안 "반복 횟수를 더 많이 돌리니까"라고 답해 왔다. 이건 틀린 요약이다. Argon2의 방어력 핵심은 시간(iteration)이 아니라 메모리이고, 그 메모리 강제가 하드웨어 수준에서 GPU·ASIC 크래킹의 경제학을 왜 무너뜨리는지 이 글에서 내부 흐름(블록 행렬 → 압축함수 → 참조 인덱싱)을 따라가며 정리한다.
Argon2는 해시 한 번을 계산하려면 수십~수백 MiB의 메모리를 실제로 채우고 무작위로 되읽게 강제해, 연산 코어는 많아도 빠른 메모리는 적은 GPU·ASIC의 병렬 크래킹 이점을 구조적으로 제거하는 memory-hard 함수다.
Argon2는 2015년 PHC(Password Hashing Competition) 우승작이고 RFC 9106으로 표준화됐다. 입력 파라미터의 핵심은 셋의 역할 분담이다: m(KiB)은 채워야 하는 메모리 양, t(passes)는 그 메모리를 몇 번 재사용하며 갈아엎는지, p(lanes)는 몇 개 lane으로 병렬 계산하는지다. 방어의 무게중심은 m에 있고 t는 시간비용을 선형으로 올리는 보조 손잡이일 뿐이다. 변종 y는 d=0, i=1, id=2로 구분된다.
메모리는 1024바이트 블록 단위로 잡힌다. 총 블록 수 m'는 4p의 배수로 내림 정렬되고, 이를 p개 lane(행) × q = m'/p개 column(열)의 행렬 B[i][j]로 본다. 각 lane은 다시 4개 slice로 세로 분할되며, 이 slice 경계가 lane 간 동기화 배리어가 된다.
col 0 ...... slice0 | slice1 | slice2 | slice3 ...... col q-1
lane 0 [B00][B01] ..........................................[B0,q-1]
lane 1 [B10][B11] ..........................................[B1,q-1]
... (lane끼리는 이미 끝난 slice의 블록만 서로 참조 가능)
lane p-1[..] [Bp-1,q-1]
|
최종 태그 = H'( B[0][q-1] XOR B[1][q-1] XOR ... XOR B[p-1][q-1] )
각 lane의 앞 두 블록만 초기 해시 H0 = BLAKE2b(모든 파라미터 ‖ P ‖ S ‖ ...)에서 직접 나오고, 나머지는 모두 두 블록을 섞어 만든다.
B[i][j] = G( B[i][j-1], B[i'][j'] ) // t>1 pass에선 기존 값과 XOR해 덮어씀
B[i][j-1]은 같은 lane의 직전 블록.B[i'][j']는 참조 블록 — 인덱싱 함수가 정한, 지금까지 계산된 임의 위치의 블록. 이 "무작위 되읽기"가 memory-hardness의 심장이다.압축함수 G(X,Y)는 먼저 R = X XOR Y(1024B를 8×8 워드 행렬로 봄)를 만든 뒤, BLAKE2b 라운드 함수를 행 방향 8번 → 열 방향 8번 적용해 확산시킨 Z를 얻고, 최종 출력은 Z XOR R이다. 결국 1024B 전체가 서로에게 영향을 주도록 뒤섞인다.
참조 위치 (i', j')는 두 값 J1, J2에서 나온다. 이 값을 어디서 얻느냐가 d/i/id를 가른다.
| 변종 | J1,J2 출처 | 성질 |
|---|---|---|
| Argon2d | 직전 블록의 첫 64비트 (데이터 의존) | 접근 패턴이 비밀에 의존 → 캐시 타이밍 사이드채널 취약, 대신 TMTO 저항 최대 |
| Argon2i | G를 카운터 모드로 돌려 생성 (데이터 독립) | 접근 패턴이 입력과 무관 → 사이드채널 안전, 대신 트레이드오프 공격 여지 |
| Argon2id | 첫 pass 전반은 i처럼, 이후는 d처럼 | 둘의 절충. RFC 9106 기본 권장 |
J2는 참조할 lane을, J1은 그 lane 안 참조 area 내 상대 위치를 정한다(최근 블록에 더 몰리는 비균등 매핑). 다른 lane은 직전 slice까지만 참조할 수 있어, 이 제약 덕에 lane들을 slice 단위로 병렬 계산할 수 있다.
공격자가 초당 수십억 해시를 뽑으려면 후보 비밀번호마다 독립적으로 m KiB 메모리를 잡고 무작위 접근해야 한다. GPU는 산술 코어가 수천 개지만 코어당 붙는 빠른 메모리(레지스터·공유메모리)는 작다. m을 64MiB로 잡으면 동시 실행 가능한 해시 수가 메모리 대역폭·용량에 묶여 급감한다. bcrypt(~4KiB 고정)나 PBKDF2(메모리 거의 0)가 GPU에 잘 뚫리는 이유가 바로 이 메모리 비용의 부재다.
"그럼 메모리를 안 쓰고 그때그때 다시 계산하면 되지 않나"(time-memory tradeoff, TMTO)가 자연스러운 반격이다. 하지만 참조 블록이 임의 위치를 가리키고 각 블록이 압축함수로 전부 뒤섞여 있어, 한 블록을 버렸다 다시 만들려면 그것이 참조한 블록, 또 그게 참조한 블록…으로 재계산이 연쇄 폭발한다. RFC 9106은 메모리를 절반으로 줄이면 계산량이 큰 상수배로 뛰도록 설계됐다고 기술한다.
// Spring Security 6 — Argon2id, m=16MiB, t=3, p=1
Argon2PasswordEncoder enc =
new Argon2PasswordEncoder(16, 32, 1, 1 << 14, 3); // saltLen, hashLen, p, m(KiB), t
String h = enc.encode("s3cr3t");
// -> $argon2id$v=19$m=16384,t=3,p=1$<salt>$<hash>
// 파라미터가 문자열에 박혀 있어 matches()가 같은 m/t/p로 재계산해 비교
enc.matches("s3cr3t", h); // true
m=16384(KiB)는 해시 1회에 16MiB를 실제로 채운다는 뜻이다. 즉 검증 서버도 로그인마다 그만큼 메모리를 쓴다 — 로그인 폭주 시 DoS를 고려해야 하는 지점이다. 파라미터가 인코딩 문자열에 함께 박히므로, 나중에 m·t·p를 올려도 기존 해시는 자기 파라미터로 검증되고 신규 해시만 강해진다.
주의할 오해 하나: salt는 속도와 무관하다. salt는 레인보우 테이블 무력화와 "같은 비번 → 같은 해시" 방지용이고, 느리게 만드는 비용은 오직 m·t·p에서 나온다. 둘을 뭉뚱그리면 salt를 늘려 보안을 높이려는 헛수고를 하게 된다.
Argon2의 방어력은 "반복을 더 돌려서"가 아니라 "큰
m으로 메모리를 강제로 채우고 무작위로 되읽게 만들어" 나온다.t는 보조,m이 핵심, 실무 기본 변종은 Argon2id다.
다음에 더 파고들 만한 것:
Argon2PasswordEncoder 문서