비밀번호는 어떻게 저장되는가: 평문에서 Argon2까지

서비스는 비밀번호를 그대로 저장하지 않는다. 평문 → 해시 → 솔트 → 느린 KDF로 이어진 30년의 변화와 2012년 링크드인 유출이 남긴 교훈을 정리한다.

everytooleverywhereallatonce · 작성

로그인할 때 서비스가 확인해야 하는 것은 「이 사람이 아는 비밀번호가 가입 때 정한 것과 같은가」뿐입니다. 그런데 이걸 확인하려고 비밀번호를 그대로 데이터베이스에 적어 두면, 그 데이터베이스가 유출되는 순간 모든 계정이 열립니다. 그래서 30년에 걸쳐 「원본을 저장하지 않고도 맞는지 확인하는」 방법이 발전해 왔습니다. 그 계단을 하나씩 올라가 보겠습니다.

0단계: 평문 — 왜 안 되는가

비밀번호를 그대로 저장하면 두 가지가 무너집니다. 첫째, 내부자나 침입자가 데이터베이스를 보면 즉시 전부 알게 됩니다. 둘째, 사람들이 여러 사이트에 같은 비밀번호를 쓰기 때문에, 한 곳이 뚫리면 그 목록으로 은행·이메일까지 시도하는 크리덴셜 스터핑 공격이 이어집니다. 어떤 서비스도 사용자의 원본 비밀번호를 알 「필요」가 없다는 것이 출발점입니다.

1단계: 해시 — 원본을 저장하지 않기

해시 함수는 입력을 고정 길이 값으로 바꾸고, 그 값에서 원본을 되돌릴 수 없습니다. 그래서 가입 때 해시만 저장하고, 로그인 때 입력값을 같은 방식으로 해시해 비교하면 원본을 보관하지 않고도 확인이 됩니다. 초기 유닉스 시스템부터 쓰인 방식입니다.

문제는 「빠른」 해시를 썼다는 점입니다. MD5나 SHA-1은 파일 검증을 위해 최대한 빠르게 설계됐습니다. 공격자가 유출된 해시 목록을 손에 넣으면, 흔한 비밀번호 후보를 초당 수십억 개씩 해시해 맞춰 볼 수 있습니다. 같은 사이트의 해시 역산기로 짧은 원문이 순식간에 나오는 것을 보면 이 방식의 한계가 실감 납니다. 게다가 같은 비밀번호는 언제나 같은 해시가 되므로, 미리 계산해 둔 대응표(레인보우 테이블)로 한 번에 조회할 수도 있었습니다.

2단계: 솔트 — 미리 계산을 막다

솔트는 비밀번호마다 붙이는 서로 다른 무작위 값입니다. 해시를 계산하기 전에 솔트를 섞으면, 같은 비밀번호를 쓴 두 사용자도 저장된 값이 달라집니다. 그러면 레인보우 테이블이 무력화됩니다. 특정 솔트에 맞춘 표를 새로 만들어야 하는데, 사용자마다 솔트가 다르니 표 하나로 여러 계정을 뚫을 수 없습니다.

솔트는 비밀이 아닙니다. 보통 해시와 함께 저장됩니다. 목적은 「한 번의 계산으로 여러 계정을 동시에 푸는」 것을 막는 데 있지, 솔트를 숨기는 데 있지 않습니다. 이 성질은 해시 생성기에서 같은 텍스트라도 앞에 다른 값을 붙이면 결과가 완전히 달라지는 것으로 확인할 수 있습니다.

2012년 링크드인: 솔트가 없으면 어떻게 되는가

2012년 6월, 링크드인 사용자 약 650만 명의 비밀번호 해시가 온라인에 올라왔습니다. 솔트 없는 SHA-1이었습니다. 솔트가 없으니 공개된 레인보우 테이블과 사전 공격이 그대로 통했고, 수십만 개가 며칠 만에 풀렸습니다. 2016년에는 같은 유출에서 나온 것으로 알려진 1억 1,700만 개 이상의 계정 정보가 다시 판매됐습니다. 링크드인은 이 사건 이후 솔트와 느린 해싱으로 전환했고 집단 소송으로 합의금을 냈습니다. 「해시는 했지만 솔트는 안 한」 저장이 왜 부족한지 보여 준 대표 사례입니다.

3단계: 느린 KDF — 빠른 게 문제였다

솔트를 붙여도 근본 문제가 남습니다. 해시 자체가 너무 빠르다는 것입니다. 그래서 「일부러 느린」 비밀번호 전용 함수(키 유도 함수, KDF)가 등장했습니다.

  • bcrypt (1999): Niels Provos와 David Mazières가 발표했습니다. 비용 인자(cost)를 두어, 값을 1 올릴 때마다 계산이 두 배 느려지도록 했습니다. 하드웨어가 빨라지면 비용만 올려 계속 따라갈 수 있어서 「미래 적응형」이라 불립니다. 실제 동작은 bcrypt 해시 생성기에서 비용을 바꿔 보며 확인할 수 있습니다.
  • scrypt (2009): Colin Percival이 설계했고 RFC 7914로 정리됐습니다. 계산뿐 아니라 많은 메모리를 쓰도록 만들어, 메모리가 적은 전용 하드웨어(ASIC·GPU)로 대량 병렬 공격하는 것을 어렵게 했습니다.
  • Argon2 (2015): 여러 후보를 겨룬 Password Hashing Competition에서 우승했습니다. 메모리·시간·병렬도를 각각 조절할 수 있고, 부채널 공격에 강한 Argon2id 변형이 현재 새 시스템의 권장 기본값입니다.

핵심은 방향이 하나라는 것입니다. 일반 사용자의 로그인은 0.25초쯤이면 충분하지만, 공격자는 그 0.25초를 유출된 해시 수억 개에 곱해야 하므로 사실상 비용을 감당할 수 없게 됩니다.

지금 기준: NIST와 OWASP가 말하는 것

NIST의 디지털 신원 지침(SP 800-63B, 2025년 개정)은 저장에 관해 솔트를 붙이고 적절한 비밀번호 해싱 방식을 쓰라고 하며, 솔트는 최소 32비트, 비용 인자는 성능이 허용하는 한 높게 잡으라고 합니다. 나아가 서버만 아는 비밀 키로 한 번 더 처리하는 것(페퍼)을 권합니다. OWASP의 비밀번호 저장 치트시트는 구체적 수치를 제시합니다. Argon2id는 최소 메모리 19MiB·반복 2·병렬 1, 여의치 않으면 scrypt(비용 2^17), 레거시라면 bcrypt 비용 10 이상입니다.

사용자가 고를 비밀번호 쪽 권고도 바뀌었습니다. 문자 종류를 강제하는 복잡도 규칙 대신 길이를 중시하고, 주기적 강제 변경을 요구하지 말라는 것입니다. 왜 길이가 더 중요한지는 비밀번호 생성기에서 엔트로피(비트)가 길이에 따라 어떻게 커지는지 직접 보면 이해가 빠릅니다.

느리게 만드는 것이 왜 그렇게 효과적인가 — 숫자로 보기

비용의 비대칭을 구체적인 숫자로 그려 보겠습니다. 빠른 해시를 쓰면 흔한 그래픽카드 한 장으로도 SHA-1 기준 초당 수십억 개를 시험할 수 있습니다. 사전에 있는 흔한 비밀번호나 짧은 조합은 사실상 즉시 뚫립니다. 반면 로그인 검증이 0.25초 걸리도록 맞춘 느린 KDF라면, 같은 카드가 초당 4개밖에 시험하지 못합니다. 후보 하나를 시험하는 데 드는 시간이 10억 배 가까이 늘어난 셈입니다.

정상 사용자에게 0.25초는 거의 느껴지지 않습니다. 그러나 공격자는 유출된 해시 하나하나에 그 0.25초를 곱해야 합니다. 계정 100만 개에 사용자당 후보 1,000개씩만 시험해도 100만 × 1,000 × 0.25초 = 약 8년이 걸립니다. 같은 작업을 빠른 해시로 하면 몇 초 만에 끝납니다. 느린 KDF의 목적이 「불가능하게 만드는 것」이 아니라 「감당할 수 없을 만큼 비싸게 만드는 것」임을 보여 주는 계산입니다. 그래서 비용 인자를 하드웨어 성능에 맞춰 계속 올리는 일이 중요합니다.

솔트, 페퍼, 그리고 흔한 혼동

사람들이 자주 섞는 세 개념이 있습니다. 솔트는 비밀번호마다 다르고 해시 옆에 함께 저장되며, 미리 계산과 여러 계정 동시 공격을 무력화하는 역할을 합니다. 페퍼는 모든 비밀번호가 공유하는 하나의 비밀 값이지만 데이터베이스 바깥(애플리케이션 설정이나 하드웨어 보안 모듈)에 둡니다. 그래야 가장 흔한 유형인 데이터베이스만 유출된 경우에 공격자가 필요한 것을 다 갖지 못합니다. 둘 다 유출되면 페퍼는 의미가 없습니다. 그래서 데이터베이스 덤프가 닿지 않는 곳에 두어야 합니다.

또 하나는 「해시」와 「암호화」의 혼동입니다. 암호화는 설계상 되돌릴 수 있습니다. 키가 있으면 원문을 복원합니다. 해시는 한 방향이고, 이 되돌릴 수 없음 자체가 비밀번호에서는 핵심입니다. 서비스는 사용자의 예전 비밀번호를 메일로 보내 줄 수 있으면 안 되고, 재설정만 할 수 있어야 합니다. 어떤 사이트가 현재 비밀번호를 그대로 보여 준다면 되돌릴 수 있게 저장했다는 뜻이고, 그것이 0단계에서 말한 위험 신호입니다.

비밀번호 재설정 없이 옛 해시를 올리기

느린 KDF로 바꾸려면 모두에게 비밀번호 재설정을 강제해야 한다고 걱정하기 쉽지만 그렇지 않습니다. 표준적인 이전 방법은 이미 저장된 해시를 새 함수로 다시 해시하는 것입니다. 예를 들어 솔트 없는 옛 SHA-1 값을 새 솔트와 비용으로 bcrypt(sha1_hash) 형태로 감쌉니다. 저장된 모든 기록이 즉시 강해지고, 사용자가 다음에 로그인할 때 실제 비밀번호를 현대 방식으로 조용히 다시 해시하고 감싼 부분을 벗겨 내면 됩니다. bcrypt 비용을 나중에 올리는 것도 같은 방식입니다. 로그인 때 다시 해시해 교체합니다. 비용·솔트·알고리즘 같은 설정이 저장 문자열 안에 함께 들어 있기 때문에, 검증기는 각 기록이 어떻게 만들어졌는지 늘 알고 있어 전환 중에도 올바르게 비교합니다.

정리하면

비밀번호 저장의 역사는 「원본을 갖지 않는다 → 미리 계산을 막는다 → 계산을 비싸게 만든다」로 요약됩니다. 각 단계는 앞 단계가 남긴 구멍을 메웠고, 2012년 링크드인처럼 한 단계를 건너뛴 서비스가 대가를 치렀습니다. 오늘 무언가를 만든다면 답은 명확합니다. Argon2id(또는 상황에 따라 bcrypt·scrypt)에 사용자별 솔트를 붙이고, 사용자에게는 길고 무작위한 비밀번호를 쓰게 하는 것입니다.

출처