개발 이야기3분 읽기

무작위가 필요한 일곱 자리, 요구가 다 다르다

A/B 배정과 샤딩 키는 오히려 결정적이어야 합니다. 같은 단어로 묶이는데 요구가 반대인 자리들.

"무작위 값이 필요하다"는 말로 묶이는 자리가 여럿인데, 요구하는 성질이 서로 다릅니다. 어떤 자리는 오히려 무작위이면 안 됩니다.

일곱 자리

자리            요구              수단
세션 ID         예측 불가          암호학적 난수 128비트 이상
API 토큰        예측 불가          같음
비밀번호 재설정 링크  예측 불가 + 만료   같음, 유효 기간 필수
AES 논스        재사용 금지        카운터 또는 96비트 난수
비밀번호 솔트    유일하기만 하면 됨  16바이트 난수, 비밀 아님
A/B 배정        균등 분포 + 재현    해시(사용자ID) % N
샤딩 키         균등 분포 + 재현    해시(키)

앞의 넷과 뒤의 셋이 다릅니다.

예측 불가가 필요한 자리

세션 ID, API 토큰, 비밀번호 재설정 링크입니다. 공격자가 다음 값을 알아맞히면 안 됩니다.

Math.random()                    // 안 됨
crypto.randomBytes(16)           // 됨
crypto.randomUUID()              // 됨 (v4)

Math.random()은 빠르지만 예측 가능한 알고리즘입니다. 출력 몇 개를 보면 내부 상태를 복원할 수 있고, 그러면 다음 값이 나옵니다. 시뮬레이션이나 애니메이션에는 충분하지만 이 자리에는 못 씁니다.

128비트가 기준선인 이유는 생일 문제 때문입니다. 값이 아무리 많아도 충돌 확률이 무시할 만하려면 그 정도가 필요합니다.

재사용 금지가 요구인 자리

AES-GCM의 논스가 여기 해당합니다. 요구가 "예측 불가"가 아니라 "같은 키로 두 번 쓰지 않을 것"입니다.

논스는 공개돼도 됩니다. 암호문과 함께 평문으로 전송합니다. 그런데 같은 키로 같은 논스를 두 번 쓰면 두 메시지의 평문이 XOR로 드러나고, GCM에서는 인증 키까지 복원됩니다.

그래서 카운터가 무작위보다 안전한 자리입니다. 카운터는 재사용이 구조적으로 불가능합니다. 무작위 96비트는 확률적으로만 안전합니다 — 같은 키로 2^32개 메시지를 넘기면 충돌 확률이 무시할 수 없어집니다.

분산 환경에서 카운터를 관리하기 어려우면 무작위를 쓰되, 키를 자주 바꿔야 합니다.

유일하기만 하면 되는 자리

비밀번호 솔트입니다. 솔트는 비밀이 아닙니다. 해시와 함께 저장되고 함께 읽힙니다.

$2y$10$4ynIhrlKtywu3FMagWFMgO9K0Haiof4JPJtLnqvxCXT5bKtq/inwK
      ^^^^^^^^^^^^^^^^^^^^^^ 솔트가 그대로 들어 있다

솔트의 목적은 하나입니다 — 같은 비밀번호가 같은 해시를 내지 않게 하는 것. 그래야 미리 계산된 표를 못 쓰고, 유출된 파일에서 같은 비밀번호를 쓰는 계정이 한눈에 보이지 않습니다.

예측 가능해도 목적을 달성합니다. 다만 사용자명 같은 값을 솔트로 쓰면 여러 서비스에서 같은 솔트가 되므로, 난수를 쓰는 편이 낫습니다.

결정적이어야 하는 자리

여기가 반전입니다.

A/B 테스트 배정. 사용자가 페이지를 새로 고칠 때마다 다른 그룹에 배정되면 실험이 성립하지 않습니다. 같은 사용자는 항상 같은 그룹이어야 합니다.

const group = hash(userId + experimentId) % 2;

해시를 쓰면 저장 없이 재현됩니다. 실험 ID를 섞는 이유는 여러 실험에서 같은 사용자가 항상 같은 쪽에 몰리는 것을 막기 위해서입니다.

샤딩 키. 데이터를 어느 샤드에 넣을지 정할 때 무작위를 쓰면 나중에 찾을 수 없습니다. 키에서 결정적으로 계산해야 조회할 때 같은 샤드로 갑니다.

두 자리 모두 요구는 "균등 분포"입니다. 무작위가 아니라 해시가 균등 분포를 만듭니다.

비밀번호 생성기는 또 다르다

사람이 입력하거나 옮겨 적을 값이라 제약이 붙습니다.

문자 집합           길이   엔트로피
숫자만              4자    13.3비트
소문자+숫자         8자    41.4비트
대소문자+숫자+기호   12자    ~79비트
영단어 4개(2048단어) 4단어   44비트

엔트로피는 log2(문자 종류) × 길이입니다. 문자 집합을 넓히는 것보다 길이를 늘리는 편이 효율적입니다 — 길이는 곱해지고 문자 집합은 로그가 붙기 때문입니다.

단어 조합이 같은 엔트로피에서 외우기 쉬운 이유도 여기 있습니다. 다만 옮겨 적기는 길어집니다.

확인 순서

  1. Math.random()이 보안 목적으로 쓰이는 자리를 찾습니다. 토큰·세션·솔트가 대상입니다
  2. AES 논스가 같은 키로 재사용될 수 있는 경로가 있는지 봅니다
  3. A/B 배정과 샤딩이 난수를 쓰고 있다면 해시로 바꿉니다. 재현되지 않으면 데이터가 어긋납니다
  4. 솔트를 비밀로 취급하고 있다면 그럴 필요가 없습니다. 유일하기만 하면 됩니다
  5. 비밀번호 정책에서 문자 종류를 강제하는 것보다 길이를 늘리는 편이 엔트로피에 유리합니다

tools.onuel.dev비밀번호 생성기는 문자 집합과 길이를 바꿔 가며 엔트로피가 어떻게 변하는지 볼 수 있고, 난수 생성기는 범위를 정해 값을 뽑습니다. 식별자가 필요하면 무작위인 UUID v4와 시간이 섞인 UUID v7이 갈리고, 결정적 배정에 쓸 해시는 SHA-256, 논스를 다루는 쪽은 AES입니다.

  • #난수
  • #보안
  • #설계