it-box

bcrypt 비용 계수 고르는 법과 해시 구조

비밀번호 저장에 일반 해시를 쓰면 안 되는 이유, salt의 역할, bcrypt cost 선택 기준, 72바이트 제한, $2a$·$2b$ 해시 문자열 구조를 실제 값으로 설명합니다.

비밀번호를 SHA-256으로 저장하면 안 되는 이유

회원가입 기능을 처음 만들면 비밀번호를 해시해서 저장하는 것까지는 대부분 합니다. 문제는 해시 함수를 고르는 단계입니다. SHA-256이나 MD5를 그대로 쓰는 코드를 자주 봅니다. 해시는 맞지만 비밀번호 저장에는 맞지 않습니다.

이유는 속도입니다. SHA-256은 입력을 빠르게 처리하도록 설계된 함수입니다. 그래서 공격자는 GPU 여러 대를 써서 초당 엄청난 수의 후보 비밀번호를 해시해 볼 수 있습니다. 데이터베이스가 유출되면 해시 값과 후보 값을 비교하는 작업이 빠르게 진행됩니다. 짧거나 흔한 비밀번호는 몇 분 안에 풀릴 수도 있습니다.

비밀번호 저장용 함수(bcrypt, scrypt, Argon2 등)는 반대 목표를 가집니다. 계산을 일부러 느리게 만들어서, 한 번 검증하는 정상 사용자에게는 체감되지 않을 정도의 지연을 주고, 대량 대입 공격에게는 큰 비용을 치르게 합니다. bcrypt는 이 목적으로 1999년에 발표된 방식입니다.

정리하면 다음과 같습니다.

  • SHA-256 한 번: 빠르고, 대량 대입에 취약합니다.
  • bcrypt(cost 10): 정상 로그인 한 번에 수십에서 수백 밀리초가 걸리고, 공격자의 대량 대입 비용을 크게 높입니다.

salt는 무엇을 막는가

salt는 해시 계산에 넣는 무작위 값입니다. 목적은 두 가지입니다.

  1. 같은 비밀번호를 쓰는 사용자들의 해시가 같아지지 않게 합니다. salt가 없으면 password123의 해시를 한 번 계산해 두고 전체 데이터베이스와 비교할 수 있습니다.
  2. 미리 계산해 둔 표(레인보우 테이블)를 쓸 수 없게 합니다. salt마다 값이 다르므로 표를 사용자마다 새로 만들어야 합니다.

bcrypt는 salt를 직접 만들어 해시 문자열 안에 함께 넣습니다. 그래서 별도 컬럼에 salt를 저장할 필요가 없습니다. 검증할 때는 저장된 해시에서 salt와 비용 계수를 읽고 같은 계산을 다시 수행합니다. 개발자가 salt를 따로 관리하다 실수할 가능성이 줄어드는 것이 장점입니다.

해시 문자열 구조 읽기

bcrypt 해시는 항상 60자입니다. 실제 예제를 보겠습니다. bcrypt 도구에 password123을 넣고 비용 10으로 계산했을 때 나온 값입니다.

$2a$10$Yvrxp/fsy1TinZZSZ/cvkO6mBtb6mHbLFMaSpwKQKn7LHXsrldACa

이 문자열을 나누면 다음과 같습니다.

부분 값 길이 의미
접두사 $2a$ 4 bcrypt 버전 표시
비용 계수 10 2 2^10번 반복
salt Yvrxp/fsy1TinZZSZ/cvkO 22 16바이트 salt를 bcrypt 전용 Base64로 표현
해시 본문 6mBtb6mHbLFMaSpwKQKn7LHXsrldACa 31 실제 해시 결과

접두사 $2a$, $2b$, $2y$는 같은 형식을 가리키는 버전 표시입니다. 이 도구는 bcryptjs 2.4.3을 사용하며, 생성되는 접두사는 $2a$입니다. 검증할 때는 접두사를 보고 적절한 알고리즘 변형을 골라야 하므로, 라이브러리를 바꾸면 접두사도 바뀔 수 있다는 점을 알고 있어야 합니다.

비용 계수 10은 반복 횟수가 아니라 지수입니다. 실제 반복은 2^10 = 1024번입니다. 비용이 1 늘어나면 시간은 두 배가 됩니다. 비용이 2 늘어나면 네 배가 됩니다.

같은 비밀번호를 두 번 해시하면 결과가 달라집니다. salt가 매번 새로 만들어지기 때문입니다. 검증은 compare 함수로 합니다. 도구에서 password123과 Password123을 같은 해시와 비교하면 첫 번째는 "예", 두 번째는 "아니요"로 나옵니다. 대소문자 차이도 전혀 다른 비밀번호로 취급된다는 뜻입니다.

비용 계수를 고르는 법

비용 계수는 "이 서버에서 한 번 검증하는 데 얼마의 시간을 쓸 것인가"로 정합니다. 기준을 정하는 순서는 다음과 같습니다.

  1. 실제 운영 서버와 같은 환경에서 비용별 시간을 잽니다.
  2. 로그인 한 번이 체감되지 않는 수준(보통 수백 밀리초 이하)에 들어오는 가장 큰 비용을 고릅니다.
  3. 서버 성능이 좋아지면 비용을 올릴 수 있도록, 값을 설정 파일이나 환경 변수로 빼 둡니다.

이 글의 측정값은 한 대의 개발 머신에서 bcryptjs 2.4.3으로 잰 결과입니다. 환경에 따라 값은 크게 달라집니다.

비용 계수 해시 한 번 걸린 시간(측정값)
10 약 59ms
12 약 241ms
14 약 962ms

비용이 2씩 늘 때 시간이 대략 네 배씩 늘어나는 것을 볼 수 있습니다. 측정 한 번으로 결론을 내리면 안 되지만, 증가 규칙은 확인할 수 있습니다.

비용 선택에서 흔한 실수는 다음과 같습니다.

  • 개발 환경에서 본 빠른 속도를 기준으로 비용을 정하는 경우입니다. 개발 머신은 보통 운영 서버보다 빠르거나, 반대로 컨테이너가 CPU를 적게 씁니다. 반드시 운영 환경에서 재야 합니다.
  • 비용을 너무 높게 잡아서 로그인 요청이 몰릴 때 서버 CPU가 포화되는 경우입니다. 인증 요청 동시 처리 수를 계산해 두어야 합니다.
  • 테스트 코드에서도 비용 12로 돌려서 테스트가 느려지는 경우입니다. 테스트에서는 낮은 비용을 쓰고, 운영 설정과 분리하는 것이 일반적입니다.

bcrypt 도구의 "솔트 횟수" 입력은 0부터 100까지 받습니다. 비용 계수는 지수이므로, 큰 값을 넣으면 계산이 사실상 끝나지 않습니다. 도구를 점검할 때는 10 근처의 값만 쓰는 것이 안전합니다. 운영 코드에서도 하드코딩하기보다는 허용 범위를 정해 두는 것이 좋습니다.

OWASP Password Storage Cheat Sheet는 bcrypt의 최소 작업 계수로 10을 권장합니다. 이 값은 시작점일 뿐이며, 여러분의 서버 성능에 맞춰 다시 확인해야 합니다.

72바이트 제한

bcrypt는 입력 비밀번호의 앞 72바이트만 사용합니다. 나머지 바이트는 해시 계산에 들어가지 않습니다. 이 동작을 직접 확인해 보겠습니다.

const bcrypt = require('bcryptjs');
const longPassword = 'a'.repeat(72);
const hash = bcrypt.hashSync(longPassword + 'X', 4);

bcrypt.compareSync(longPassword + 'Y', hash); // true
bcrypt.compareSync('a'.repeat(71) + 'b', hash); // false

73번째 바이트인 X와 Y의 차이는 무시됩니다. 72번째 바이트 b는 a와 다르므로 실패합니다. 이 제한이 실무에서 문제가 되는 경우는 두 가지입니다.

첫째는 사용자가 긴 문장을 비밀번호로 쓰는 경우입니다. 앞 72바이트가 같은 두 비밀번호가 같은 것으로 취급됩니다. 둘째는 한글을 쓰는 경우입니다. 한글은 UTF-8에서 한 글자가 보통 3바이트입니다. 한글 24글자가 정확히 72바이트이고, 25글자는 75바이트입니다. 따라서 한글 24글자 이후는 실제로 비교되지 않습니다. 예를 들어 가를 24개 넣고 뒤에 나를 붙이는 식의 입력은 앞 24글자만 같으면 같은 해시로 검증됩니다.

대응 방법은 다음 중 하나입니다.

  • 비밀번호 길이 상한을 72바이트 이하로 두고, 입력 검증에서 바이트 기준으로 확인합니다. 문자 수로만 제한하면 한글에서 빠집니다.
  • 비밀번호를 먼저 SHA-256 같은 해시로 줄인 뒤 bcrypt에 넣는 방식을 씁니다. 이 방식은 구현 실수가 생기기 쉬우므로, 라이브러리가 지원하는지 먼저 확인해야 합니다.
  • scrypt나 Argon2처럼 길이 제한이 없는 알고리즘을 검토합니다.

비밀번호 강도 도구와 함께 보기

비밀번호 강도 분석기는 입력 문자열의 문자 종류와 길이로 엔트로피를 계산합니다. 소스를 보면 소문자 26개, 대문자 26개, 숫자 10개, 특수 문자 32개를 기준으로 문자 집합 크기를 정하고, log2(집합 크기) × 길이로 엔트로피를 구합니다. 그리고 초당 10억 번 시도한다고 가정해서 걸리는 시간을 계산합니다.

password123을 넣으면 다음과 같이 계산됩니다.

문자 집합: 소문자 26 + 숫자 10 = 36
길이: 11
엔트로피: log2(36) × 11 ≈ 56.87비트

이 계산은 모든 문자가 무작위로 골라졌다고 가정합니다. 실제 공격자는 사전 단어와 흔한 패턴부터 시도합니다. 그래서 password123처럼 사전에 있는 단어는 이 계산보다 훨씬 빨리 풀립니다. 강도 도구는 "문자 종류와 길이"를 보여 주는 참고 도구로만 쓰는 것이 맞습니다. 보안 정책을 정할 때는 사전 검사와 최소 길이를 함께 씁니다.

강도 도구의 결과와 bcrypt 비용은 서로 다른 질문에 답합니다. 강도 도구는 "이 비밀번호가 얼마나 추측하기 어려운가"를 묻고, bcrypt 비용은 "유출된 해시를 얼마나 느리게 만들 것인가"를 묻습니다. 둘 다 필요하지만, 하나가 다른 하나를 대신하지는 못합니다.

실무 점검 목록

  • 비밀번호 저장에 SHA-256, MD5, SHA-1을 쓰고 있지 않은가?
  • bcrypt 비용을 운영 서버에서 직접 측정했는가? 측정 결과를 설정으로 뺐는가?
  • 인증 요청이 몰릴 때 CPU 사용량을 감당할 수 있는가?
  • 비밀번호 길이 제한이 바이트 기준인가? 한글 입력이 72바이트를 넘지 않는가?
  • 해시 문자열을 로그나 화면에 출력하고 있지 않은가?
  • 검증 함수가 compare를 쓰고, 해시 문자열을 직접 비교하지 않는가?
  • 테스트 환경의 비용과 운영 환경의 비용이 분리되어 있는가?
  • 유출 대응 계획이 있는가? 비용을 올린 뒤 기존 해시를 로그인 시점에 다시 해시하는 방식이 필요할 수 있습니다.

마지막 항목은 자주 놓치는 부분입니다. 비용을 올리면 새로 저장되는 해시만 강해집니다. 기존 해시는 그대로이므로, 사용자가 로그인할 때 이전 비용으로 검증에 성공하면 새 비용으로 다시 해시해서 저장하는 방식을 써야 합니다.

관련 도구