비밀번호를 안전하게 저장하고 검증하기

회원 가입 API를 만들다 보면 데이터베이스의 비밀번호 열을 어떻게 채워야 할지 고민하게 됩니다. 평문을 그대로 넣으면 안 된다는 사실은 알겠는데, AES로 암호화해야 할까요? 아니면 SHA-256으로 해시하면 충분할까요?
둘 다 안전한 답은 아닙니다. 비밀번호 저장은 일반 데이터 암호화나 파일 무결성 검사와 다른 위협을 상대하기 때문인데요. 공격자가 데이터베이스를 통째로 가져간 뒤에도 사용자의 원래 비밀번호를 알아내기 어렵게 만들어야 합니다.
이번 글에서는 비밀번호를 저장하고 검증하는 기본 흐름부터 우리에게 익숙한 범용 해시 알고리즘이 적합하지 않은 이유, 소금(salt)과 비용 매개변수의 역할까지 차근차근 살펴보겠습니다. Argon2id, scrypt, bcrypt, PBKDF2 가운데 어떤 알고리즘을 골라야 하는지도 함께 정리해 볼게요.
비밀번호는 복호화가 불가능해야 합니다
비밀번호를 저장할 때 가장 먼저 버려야 할 생각은 “필요할 때 원문으로 되돌려 보자”입니다. AES 같은 대칭키 암호화를 사용하면 올바른 키를 가진 서버는 암호문(ciphertext)을 다시 평문(plaintext)으로 복호화할 수 있는데요. 데이터베이스와 복호화 키를 함께 탈취당하거나 내부적으로도 복호화 권한이 악용되면 보안 사고로 이어질 수 있습니다.
로그인(log in)의 본래 기능을 생각해보면 사용자의 비밀번호를 알아내야 할 이유가 전혀 없습니다. 사용자가 입력한 비밀번호를 저장할 때와 같은 조건으로 계산해, 저장된 결과와 일치하는지만 확인하면 되는데요. 그래서 비밀번호는 복호화할 수 있는 암호문이 아니라 원문으로 되돌리는 절차가 없는 해시값으로 저장해야합니다.
이 원칙 때문에 정상적인 서비스의 관리자나 고객 지원 담당자도 사용자의 기존 비밀번호를 모릅니다. 비밀번호를 잊었다고 문의해도 보통 이메일 등 다른 인증 수단으로 본인임을 확인한 뒤 새 비밀번호를 설정하게 안내하죠. 어떤 서비스가 사용자의 기존 비밀번호를 그대로 알려준다면 비밀번호를 복호화할 수 있는 형태로 저장하고 있다는 위험 신호입니다. (심지어 평문으로 저장할지도? 🙊)
비밀번호 저장과 검증은 해시값을 이용합니다
회원 가입과 로그인을 구현할 때는 반드시 비밀번호 전용으로 설계된 해시 알고리즘을 지원하는 라이브러리를 사용해야합니다.
이런 라이브러리는 무작위로 만든 소금(salt)과 비용 매개변수를 적용하고, 그 조건과 해시값을 하나의 인코딩된 문자열로 돌려줍니다. 회원 가입 시에는 애플리케이션은 원래 비밀번호 대신 이 문자열만 데이터베이스에 저장합니다.
로그인 시에는 사용자가 입력한 비밀번호와 저장된 문자열을 라이브러리의 검증 함수에 함께 전달합니다. 검증 함수가 문자열에서 알고리즘과 소금, 비용 매개변수를 읽어 같은 조건으로 계산한 뒤 일치 여부를 알려주죠.
flowchart TB
accTitle: 비밀번호 저장과 로그인 검증 흐름
accDescr: 회원 가입할 때는 새 비밀번호를 무작위 소금과 비용 매개변수를 적용한 전용 해시 함수로 처리해 인코딩된 문자열만 데이터베이스에 저장한다. 로그인할 때는 입력한 비밀번호와 저장된 문자열을 검증 함수에 전달하고 일치 여부에 따라 로그인을 허용하거나 거부한다.
subgraph signUp["회원 가입: 비밀번호 저장"]
direction LR
newPassword[/"새 비밀번호"/] --> passwordHash["비밀번호 전용<br/>해시 함수"]
hashOptions["무작위 소금<br/>+ 비용 매개변수"] --> passwordHash
passwordHash --> encodedHash["소금, 비용, 해시값이 담긴<br/>인코딩 문자열"]
end
encodedHash --> database[("데이터베이스")]
subgraph signIn["로그인: 비밀번호 검증"]
direction LR
inputPassword[/"입력한 비밀번호"/] --> passwordVerify["비밀번호<br/>검증 함수"]
passwordVerify --> matched{"저장된 해시와<br/>계산 결과가 같은가?"}
matched -- "예" --> allow(["로그인 허용"])
matched -- "아니요" --> deny(["로그인 거부"])
end
database -- "저장된 문자열 조회" --> passwordVerify
다이어그램에서 중요한 부분은 비밀번호가 평문으로 데이터베이스에 쓰거나 읽히는 화살표가 없다는 점입니다. 데이터베이스에는 검증에 필요한 문자열만 남고, 로그인 결과도 원문 복원이 아니라 일치 여부로 끝납니다.
SHA-256은 너무 빨라서 오히려 위험합니다
암호학적 해시 함수에 관한 흔한 오해가 단방향이라서 복호화가 안 되고 그래서 비밀번호 저장을 위해 쓸 수 있다는 것입니다. 하지만 우리에게 친숙한 MD5, SHA-1, SHA-256, SHA-512와 같은 알고리즘은 파일과 메시지를 빠르게 처리하도록 만든 범용 해시 함수입니다.
아이러니하게도 이러한 고성능 알고리즘은 해시 속도는 데이터베이스를 훔친 공격자에게는 큰 기회가 됩니다. 공격자는 서버에 로그인 요청을 보낼 필요 없이 자신의 GPU나 전용 하드웨어에서 후보 비밀번호를 대량으로 계산할 수 있습니다. 이런 오프라인 공격(offline attack)에서는 로그인 횟수 제한이나 계정 잠금도 방어막이 되지 못합니다.
| 알고리즘 | 출력 길이 | 비밀번호 저장에 부적합한 이유 |
|---|---|---|
| MD5 | 128비트(16진수 32자) | 계산이 매우 빠르고 충돌 저항성까지 깨졌습니다. |
| SHA-256 | 256비트(16진수 64자) | 공격자가 많은 후보를 짧은 시간에 시험할 수 있습니다. |
| SHA-512 | 512비트(16진수 128자) | 출력은 더 길지만 후보 하나를 시험하는 비용은 여전히 낮습니다. |
해시값이 길어져도 사용자가 고른 비밀번호 자체가 더 예측하기 어려워지지는 않습니다.
password123을 SHA-512로 해시해도 공격자는 같은 후보를 SHA-512로 계산해 바로 대조할 수 있죠.
비밀번호 저장에서 직접적인 문제는 출력 길이보다 공격자가 추측을 얼마나 빠르게 반복할 수 있느냐입니다.
비밀번호 전용 해시 함수는 일부로 느립니다
비밀번호 전용 해시 함수는 후보 하나를 시험하는 데 많은 시간과 메모리가 들도록 설계되어 있습니다. 서버에서 한 번 로그인할 때는 감당할 만한 비용이지만, 공격자가 수십억 개의 후보를 확인하려면 전체 비용이 크게 불어나는 구조인데요. 하드웨어가 빨라지면 비용 매개변수(work factor)를 올릴 수 있다는 점도 범용 해시와 다른 부분입니다.
새 시스템이라면 메모리를 많이 사용하도록 설계된 Argon2id를 먼저 고려합니다. Argon2id를 지원하지 않는 환경에서는 scrypt가 다음 선택지입니다. bcrypt는 Argon2id와 scrypt를 도입하기 어려운 기존 시스템에 주로 남아 있고, FIPS 140 준수가 필요하다면 검증된 구현이 있는 PBKDF2가 알맞습니다.
| 알고리즘 | 주로 선택하는 상황 | 확인할 점 |
|---|---|---|
| Argon2id | 새로 만드는 시스템 | 메모리, 반복 횟수, 병렬성 설정 |
| scrypt | Argon2id를 쓸 수 없는 환경 | 메모리 비용과 병렬성 설정 |
| bcrypt | 기존 시스템과의 호환 | 비용과 구현체의 입력 길이 제한 |
| PBKDF2 | FIPS 140 준수가 필요한 환경 | 내부 해시 함수와 충분한 반복 횟수 |
사용자마다 다른 소금을 뿌려야 합니다
소금(salt)은 사용자마다 무작위로 만드는 값입니다. 소금이 없으면 공격자는 자주 쓰는 비밀번호와 해시값을 미리 계산해 둘 수 있습니다. 데이터베이스를 훔친 뒤에는 새로 계산하지 않고 유출된 값이 준비해 둔 목록에 있는지만 찾으면 되죠. 이런 사전 계산 결과를 해시 연쇄로 압축한 조회표가 무지개 테이블(rainbow table)입니다. 해시를 거꾸로 복호화하는 도구라기보다, 가능한 입력을 미리 계산해 두고 해시값으로 원본 후보를 찾는 시간과 공간의 절충 기법에 가깝습니다.
소금은 비밀번호와 함께 해시 함수에 들어가 해시값과 함께 데이터베이스에 저장하는데요. 여기서 흔한 실수가 모든 사용자의 비밀번호 같은 소금을 뿌리는 것입니다. 소금은 숨겨야 하는 값이 아니지만 사용자마다 달라야 합니다. 그래야 두 사용자가 같은 비밀번호를 골라도 데이터베이스에는 서로 다른 결과가 남습니다.
사용자마다 고유한 소금을 넣으면 공격자는 소금마다 별도의 표를 다시 만들어야 합니다. 이미 만들어 둔 공용 표를 여러 사용자에게 재사용할 수 없으니 사전 계산의 이점이 사라지죠. 다만 소금은 비밀번호 후보 하나를 시험하는 계산 자체를 느리게 만들지는 않습니다. 따라서 소금을 뿌린다고 위에서 설명드린 SHA-256과 같은 범용 해시 함수를 쓸 수 있는 것은 아닙니다.
소금만 믿지 말고 Argon2id 같은 느리고 메모리를 많이 쓰는 해시 알고리즘을 함께 사용해야합니다.
소금 생성은 누구의 몫일까요?
Argon2id, scrypt, bcrypt, PBKDF2는 소금을 정식 입력값으로 받도록 설계되어 있습니다. 그래서 흔히 “소금이 내장된 알고리즘”이라고 표현하는데요. 엄밀히 말하면 알고리즘 자체가 언제나 소금을 생성한다는 뜻은 아닙니다. 소금을 안전한 난수로 만드는 일은 보통 라이브러리의 고수준 API가 맡고, 알고리즘은 전달받은 소금을 비밀번호와 함께 처리합니다.
Argon2id와 bcrypt 라이브러리는 다음 정보를 하나의 문자열로 인코딩해 돌려주는 경우가 많습니다.
알고리즘 + 버전 + 비용 매개변수 + 소금 + 해시값
Bun에 내장된 Bun.password로 확인해 보면 이 구성이 그대로 드러납니다.
const stored = await Bun.password.hash("correct horse battery staple");
console.log(stored);
// $argon2id$v=19$m=65536,t=2,p=1$0MT+EzCQZSBAvrCLsR52+vENEXFPQxCfJUXZKgijJj4$wdHhymqw1HAUyfOKNDT4vLuL5XQQ5VDXRVqJqhfVLik
$로 나뉜 구간이 차례대로 알고리즘(argon2id), 버전(v=19), 비용 매개변수(m=65536,t=2,p=1), 소금, 해시값에 대응하는데요.
소금을 별도 열에 따로 관리하지 않아도 검증에 필요한 정보가 모두 담겨 있는 셈입니다.
애플리케이션은 이 문자열 전체를 저장하면 됩니다. 로그인할 때 라이브러리가 문자열에서 소금과 비용 매개변수를 읽어 같은 조건으로 검증하죠. 소금은 비밀이 아니므로 해시값과 같은 문자열이나 같은 데이터베이스 행에 들어 있어도 괜찮습니다.
scrypt와 PBKDF2도 소금을 사용하지만 결과를 저장하는 형식은 라이브러리마다 다를 수 있습니다. 어떤 API는 소금과 비용 매개변수를 포함한 문자열을 만들어 주고, 저수준 API는 호출자가 소금을 만들어 별도 열에 저장하도록 요구합니다. 선택한 라이브러리의 비밀번호 저장용 API가 소금 생성과 저장까지 맡는지 문서에서 확인해야 합니다.
페퍼는 선택적인 추가 방어선입니다
페퍼(pepper)는 소금과 달리 데이터베이스에 저장하지 않는 비밀 값입니다. 키 관리 시스템이나 별도의 비밀 저장소에 두고 비밀번호 해싱 과정에 추가합니다. 공격자가 데이터베이스만 가져간 경우에는 페퍼 없이 후보를 검증할 수 없으므로 한 겹의 방어선이 더 생기죠.
대신 운영 부담도 따라옵니다. 페퍼가 유출되면 기존 해시만으로 새 페퍼를 적용할 수 없어 대개 사용자가 비밀번호를 다시 입력하거나 재설정해야 합니다. 여러 애플리케이션 인스턴스가 같은 페퍼에 안전하게 접근하는 방법과 회전 절차도 필요합니다. 따라서 소금과 충분한 비용을 갖춘 전용 함수가 기본이고, 페퍼는 위협 모델과 키 관리 체계가 준비된 환경에서 추가하는 편이 좋습니다.
해시 비용은 로그인할 때 갱신할 수 있습니다
오늘 충분히 느린 설정도 몇 년 뒤에는 공격자에게 저렴해질 수 있습니다. 그렇다고 모든 사용자의 비밀번호를 한꺼번에 다시 해시할 수는 없습니다. 서버에는 원래 비밀번호가 없기 때문이죠.
보통은 로그인 성공 시점에 저장된 문자열의 비용 매개변수를 현재 기준과 비교합니다. 예전 기준이라면 방금 사용자가 입력한 비밀번호로 새 소금과 높은 비용을 적용해 다시 저장합니다.
const CURRENT_PREFIX = "$argon2id$v=19$m=65536,t=2,p=1$";
if (!(await Bun.password.verify(input, stored))) {
throw new Error("비밀번호가 일치하지 않습니다");
}
// 예전 기준으로 저장된 해시라면 방금 받은 비밀번호로 다시 만들어 둡니다
if (!stored.startsWith(CURRENT_PREFIX)) {
await saveHash(userId, await Bun.password.hash(input));
}
예전 비용으로 저장된 해시도 검증 자체는 문제없이 통과합니다. 문자열 안에 그때의 매개변수가 남아 있어 같은 조건으로 다시 계산할 수 있기 때문이죠. 활성 사용자는 자연스럽게 최신 설정으로 이동하고, 오랫동안 로그인하지 않은 계정만 예전 설정에 남게 됩니다.
비용을 높일 때는 실제 서버에서 회원 가입과 로그인 지연 시간을 측정해야 합니다. 너무 낮으면 공격 비용이 줄고, 너무 높으면 로그인 폭주가 서비스 거부(denial-of-service, DoS) 공격의 발판이 될 수 있습니다. 라이브러리의 기본값을 무작정 고정하기보다 현재 권고를 출발점으로 삼아 운영 환경에서 조정하는 것이 안전합니다.
검증된 라이브러리의 고수준 API를 사용하세요
개발자가 비밀번호 해시 함수를 직접 구현해보는 것은 학습 차원에서는 큰 도움이 됩니다. 하지만 실제 상용 서비스에서는 매우 무모하고 위험한 선택이 될 수 있습니다. 안전한 소금 생성, 매개변수 인코딩, 상수 시간 비교(constant-time comparison), 입력 길이 처리, 비용 상향 여부 등 챙겨야 할 것들이 너무 많습니다. 그래서 이미 생태계에서 오랜 기간 많은 사용자를 통해 검증된 인증 라이브러리를 사용하는 것이 강력히 권장됩니다.
Bun을 예로 들면 회원 가입과 로그인에 필요한 코드는 이 정도가 전부입니다.
// 회원 가입: 소금 생성과 매개변수 인코딩까지 라이브러리가 처리합니다
const stored = await Bun.password.hash(password);
// 로그인: 저장된 문자열에서 알고리즘과 비용을 읽어 같은 조건으로 계산합니다
const matched = await Bun.password.verify(input, stored);
옵션을 하나도 넘기지 않았지만 Argon2id에 m=65536,t=2,p=1이 적용됩니다.
소금도 호출할 때마다 새로 생성되죠.
algorithm이나 memoryCost를 직접 지정할 수도 있지만, 기본값이 이미 권고 수준을 넘는다면 굳이 손댈 이유가 없습니다.
프레임워크나 인증 라이브러리가 비밀번호 저장 기능을 제공한다면 그 고수준 API를 우선 사용하세요. 알고리즘을 직접 호출해야 한다면 라이브러리가 소금을 자동으로 생성하는지, 반환 문자열에 검증 조건이 모두 담기는지, 오래된 비용을 감지하는 기능이 있는지 확인해야 합니다. 알고리즘 이름만 맞추는 것보다 안전한 기본 동작을 제공하는 API를 고르는 일이 더 중요합니다.
마치며
비밀번호 저장의 목표는 원문을 숨겨 두었다가 나중에 꺼내는 것이 아닙니다. 데이터베이스가 유출돼도 공격자가 비밀번호 후보 하나를 시험할 때마다 큰 비용을 치르게 하는 게 핵심인데요. 원문 대신 비밀번호 전용 해시를 저장하고, 사용자마다 다른 소금과 조정 가능한 비용을 적용해야 합니다.
새 시스템에서는 Argon2id를 먼저 살펴보고, 환경에 따라 scrypt나 PBKDF2를 고려하세요. bcrypt는 기존 시스템과의 호환이 필요할 때 신중하게 유지하는 편이 좋습니다. 어느 알고리즘을 고르든 검증된 라이브러리의 고수준 API를 쓰고, 시간이 지나면 비용을 올릴 수 있는 운영 절차까지 준비해야 합니다.
구체적인 최신 비용 기준은 OWASP 비밀번호 저장 지침을 확인하세요. Argon2id의 입력과 매개변수, 권고 설정은 RFC 9106에 정리되어 있습니다.
This work is licensed under CC BY 4.0