해시와 비밀번호 저장 — 왜 단방향이고, 왜 솔트가 필요한가
crypto.subtle.digest로 본 해시는 되돌릴 수 없는 단방향 함수다. 비밀번호를 평문으로 저장하면 안 되는 이유, 단순 해시의 한계와 레인보우 테이블, 솔트와 bcrypt·Argon2, 그리고 클라이언트 해싱이 답이 아닌 이유를 정리했다.
해시와 비밀번호 저장
브라우저에서 비밀값을 디코딩한다는 것에서 crypto.subtle.digest로 SHA-256 해시를 만들어 봤다. 그때 "해시는 단방향이라 되돌릴 수 없다"고만 했는데, 이 단방향성이 비밀번호 저장의 핵심이다.
const bytes = new TextEncoder().encode('hello');
const buf = await crypto.subtle.digest('SHA-256', bytes);
// 같은 입력 → 항상 같은 해시, 하지만 해시 → 입력은 불가능해시는 단방향이다
해시 함수는 임의 길이의 입력을 고정 길이의 값으로 바꾼다. 두 가지 성질이 중요하다.
- 단방향(one-way): 입력 → 해시는 쉽지만, 해시 → 입력은 사실상 불가능하다. 암호화(복호화 가능)와 다른 점이다.
- 결정적(deterministic): 같은 입력은 항상 같은 해시를 낸다.
이 덕분에 비밀번호를 원문 대신 해시로 저장할 수 있다. 로그인 때 입력값을 같은 방식으로 해시해서, 저장된 해시와 비교하면 된다. 원문을 모른 채 일치 여부만 확인하는 것이다.
왜 평문 저장은 안 되나
비밀번호를 평문으로 저장하면, DB가 한 번 유출되는 순간 모든 사용자의 비밀번호가 그대로 노출된다. 더 나쁜 건, 사람들은 같은 비밀번호를 여러 사이트에 재사용하므로 피해가 그 사이트에 그치지 않는다.
그래서 "서버조차 사용자의 비밀번호 원문을 몰라야 한다." 해시만 저장하면 DB가 털려도 원문은 바로 드러나지 않는다.
단순 해시의 한계 — 레인보우 테이블
그런데 SHA-256(password)만으로는 부족하다. 두 가지 문제가 있다.
- 같은 비밀번호 → 같은 해시. 두 사용자가 같은 비밀번호를 쓰면 해시가 같아서, 유출 시 한눈에 보인다.
- 레인보우 테이블. 공격자가 흔한 비밀번호들의 해시를 미리 계산해 표로 만들어 두면, 해시를 역으로 조회해 원문을 찾는다. SHA는 빠르기 때문에 이런 대량 계산이 오히려 쉽다.
솔트 — 같은 비밀번호도 다르게
해결책은 솔트(salt)다. 사용자마다 다른 무작위 값을 비밀번호에 섞어서 해시한다.
저장: hash(password + salt) 와 salt 를 함께 저장- 같은 비밀번호라도 솔트가 다르면 해시가 완전히 달라진다 → 레인보우 테이블 무력화.
- 솔트는 비밀이 아니어도 된다. 함께 저장해도 안전하다. 목적은 "미리 계산된 표"를 못 쓰게 만드는 것이다.
bcrypt, Argon2 — 일부러 느리게
솔트만으로도 부족하다. SHA는 너무 빨라서, 공격자가 솔트를 알아도 초당 수십억 번 대입(brute force)할 수 있다. 그래서 비밀번호 해싱에는 일부러 느리게 설계된 전용 알고리즘을 쓴다.
- bcrypt, scrypt, Argon2: 계산 비용(반복 횟수·메모리)을 조절할 수 있어, 한 번 검증은 충분히 빠르되 대량 대입은 비싸게 만든다.
- 이들은 솔트를 내부에서 처리하고, 하드웨어가 빨라지면 비용 파라미터를 올려 대응한다.
정리하면, 비밀번호 저장은 "빠른 해시"가 아니라 "느린, 솔트가 적용된 전용 해시"가 정답이다. SHA-256은 무결성 검증엔 좋지만 비밀번호 저장엔 그대로 쓰면 안 된다.
클라이언트에서 해싱하면 되지 않나
자연스러운 의문이다. 브라우저에서 crypto.subtle로 해시해서 보내면 원문이 안 흐르니 더 안전하지 않을까. 답은 "그것만으로는 안 된다"이다.
- 클라이언트가 보낸 해시가 곧 "비밀번호 역할"을 한다. 공격자가 그 해시만 가로채면 그대로 보내서 로그인할 수 있다(pass-the-hash). 즉 해시가 새 비밀번호가 될 뿐이다.
- 서버는 받은 값을 결국 다시 솔트+느린 해시로 저장해야 한다. 신뢰 경계는 서버다.
- 전송 구간 보호는 해싱이 아니라 HTTPS/TLS의 몫이다.
클라이언트 해싱이 의미를 갖는 경우도 있지만(원문을 서버에 절대 노출하지 않으려는 설계 등), 그건 서버 측 해싱을 대체하는 게 아니라 추가하는 것이다.
정리
- 해시는 단방향·결정적 함수다. 비밀번호를 원문 대신 해시로 저장해, 서버도 원문을 모르게 한다.
- 단순 빠른 해시(SHA)는 같은 비밀번호가 같은 해시가 되고 레인보우 테이블에 취약하다.
- 솔트로 같은 비밀번호도 다르게 만들고, bcrypt/Argon2 같은 느린 전용 알고리즘으로 대량 대입을 비싸게 만든다.
- 클라이언트 해싱은 서버 측 해싱을 대체하지 못한다. 신뢰 경계는 서버이고, 전송 보호는 TLS가 한다.
crypto.subtle.digest는 파일 무결성·서명 검증엔 훌륭하지만, "비밀번호 저장"이라는 문제는 빠른 해시가 아니라 느리고 솔트가 적용된 해시를 요구한다.
참고 문서
Related Posts
같이 읽으면 좋은 글
JavaScript는 어디서 실행되는가 — 엔진(V8), 런타임(브라우저·Node), 그리고 window
같은 JS 코드가 브라우저에서도 Node에서도 도는데 window는 한쪽에만 있다. 언어·엔진·런타임·라이브러리의 네 층을 구분하고, V8과 Web API의 경계, window가 만들어지는 과정, Node가 언어가 아니라 런타임인 이유, Buffer와 바이트까지 정리했다.
유니코드, UTF-8, 그리고 JavaScript 문자열 — 이모지 length가 2인 이유
atob이 한글을 깨뜨린 근본 원인을 따라간다. 문자에 번호를 매기는 유니코드, 번호를 바이트로 바꾸는 UTF-8, 내부적으로 UTF-16인 JavaScript 문자열의 관계와 거기서 생기는 length·slice 함정을 정리했다.
JavaScript 동기와 비동기 — 이벤트 루프부터 async/await까지
JavaScript의 동기/비동기 모델을 콜 스택, 이벤트 루프, 태스크 큐 관점에서 정리하고 콜백, Promise, async/await의 차이와 실무에서 자주 만나는 함정을 함께 다룬다.