새로고침해도 안 바뀌는 '랜덤'이 필요할 때

Math.sin 해시 기반 seeded pseudo-random

deterministicseeded-randompure-function

난이도: ★★★☆☆ 연관 노트: 서버와 브라우저의 ‘오늘’은 다르다


핵심 요약

  1. Math.random()은 호출할 때마다 계산된다. 인자가 없어서 새로고침하면 이전과 다른 값을 반환한다.
  2. Math.sin은 인자 기준으로 결과를 반환하기에, 인자만 똑같으면 언제 어디서 호출해도 동일한 결과를 반환한다.
  3. 그래서 “날짜”를 인자(시드)로 넣으면 오늘의 고정된 랜덤이 된다 — 새로고침해도, 다른 기기에서 봐도 같은 값.

왜 헷갈렸나

Math.random()Math.sin 해시를 “택 1 해도 되는 동급 선택지”라고 생각했다. 그리고 sin을 쓰는 이유를 “중복이 안 생겨서”라고 짚었다.

둘 다 틀렸다. 중복 방지는 핵심이 아니고, Math.random()은 이 요구사항(새로고침해도 같은 숫자)을 아예 만족할 수 없다. 시드를 지정할 방법이 없기 때문이다.


메커니즘

요구사항: 가입자 수 카운터가 매일 자연스럽게 다른 폭으로 증가하되, 같은 날짜면 모든 사용자·새로고침·기기에서 같은 숫자여야 한다.

Math.random()                    Math.sin(dayIndex)
─────────────                    ──────────────────
호출 1: 0.7231...                sin(1) = 0.8414... (영원히)
호출 2: 0.1892...  ← 매번 다름    sin(2) = 0.9092... (영원히)
호출 3: 0.5547...                sin(3) = 0.1411... (영원히)

새로고침 → 숫자 널뛰기 💥          새로고침 → 같은 숫자 ✓
유저 A ≠ 유저 B 💥                유저 A = 유저 B ✓

Math.sin이 해시 함수 노릇을 할 수 있는 성질 3가지:

  1. 순수 함수 — 같은 입력이면 언제나 같은 출력
  2. 예측 불가능해 보이는 출력 — 입력이 1, 2, 3으로 규칙적이어도 출력은 0.84 → 0.91 → 0.14로 널뛴다
  3. 긴 소수부× 10000으로 소수점을 끌어내면 사실상 무작위처럼 보이는 수가 나온다

해결 패턴

/** 날짜 인덱스 기반 결정적(deterministic) 일별 증가량 (3~30) */
const getDailyIncrement = (dayIndex: number) => {
  // sin 결과(-1~1)에 10000을 곱해 소수부를 정수부로 끌어냄
  const hash = Math.abs(Math.sin(dayIndex + 1) * 10000);
  // % 28 → 0~27, +3 → 3~30 범위
  return 3 + Math.floor(hash % 28);
};

const getCount = (now: Date) => {
  const elapsedDays = Math.max(
    0,
    Math.floor((now.getTime() - new Date(BASE_DATE).getTime()) / 86_400_000),
  );
  let count = BASE_COUNT;
  for (let i = 0; i < elapsedDays; i++) {
    count += getDailyIncrement(i); // 같은 날짜 → 항상 같은 누적값
  }
  return count;
};

셰이더/게임 개발 쪽에서 오래 쓰인 트릭이다. 암호학적으로는 전혀 안전하지 않지만, “매일 그럴듯하게 다른 값” 용도로는 충분하다.


다음에 이 상황을 만나면

“랜덤인데 재현 가능해야 한다”는 요구가 보이면Math.random()은 후보에서 제외하고 시드 기반으로 간다.

  • 이 값이 새로고침/다른 기기에서 같아야 하는가? → Yes면 seeded
  • 시드로 쓸 안정적인 입력이 있는가? (날짜, ID, 인덱스)
  • 보안 용도인가? → 그러면 sin 해시 금지, crypto API로

커넥팅 닷

← 선행 개념 (이걸 알아야 이해된다)

  • 순수 함수(Pure Function) — “같은 입력 → 같은 출력”이 이 패턴의 전부다. sin은 순수, random은 비순수
  • 해시 함수 — 입력을 예측 불가능해 보이는 고정 출력으로 매핑한다는 개념. sin을 싸구려 해시로 쓴 것

→ 확장 개념 (여기서 더 나아가면)

  • PRNG(의사 난수 생성기)와 시드 — Mulberry32, xorshift 등 진짜 seeded PRNG. sin 해시의 정석 버전
  • crypto.getRandomValues() — 보안이 필요한 난수는 이쪽. Math 계열은 절대 금지

↔ 같은 원리가 적용되는 곳

  • 서버와 브라우저의 ‘오늘’은 다르다 — 같은 기능(카운터)에서 만난 문제. 결정적 계산이어도 “입력(날짜)“이 서버/클라에서 다르면 결과가 갈린다
  • React의 렌더 순수성 — 렌더 함수도 “같은 props/state → 같은 JSX”를 요구한다. 렌더 중 Math.random() 호출이 안티패턴인 이유와 동일

참고