같은 날, 같은 퍼즐: generatorVersion이 있는 PuzzleIdentity 설계
데일리 퍼즐은 보통 날짜를 seed로 바꾸는 데서 시작한다. 같은 날짜를 같은 정수로 만들고, 같은 난수 생성기와 같은 알고리즘에 넣으면 같은 보드를 얻는다. 여기까지 구현하면 “오늘의 퍼즐”이 완성된 것처럼 보인다.
문제는 생성 알고리즘이 바뀌는 날 시작된다. 같은 seed라도 셀 제거 순서, 재시도 횟수, 난이도 기준이 바뀌면 결과 보드는 달라질 수 있다. 그런데 저장·분석·리더보드가 날짜만 퍼즐 ID로 쓰고 있다면 서로 다른 두 보드를 같은 퍼즐로 기록하게 된다.
DailySudoku의 개발 소스는 이 경계를 잘 보여준다. 이 글의 구현 근거는 2026년 8월 6일에 고정해 확인한 develop 커밋 9c999305다. 이 커밋의 fresh generation은 결정적이지만, 생성기 버전을 포함한 영구 식별 계약까지 구현된 상태는 아니다.
seed 하나로는 퍼즐 정체성이 되지 않는다
지원되는 정상 daily UI에서 시작한 fresh game은 세 플랫폼에서 대략 같은 흐름을 따른다.
현재 시각
-> 기기 로컬 시간대의 epochDay
-> dailySeed(epochDay)
-> generate(MEDIUM, seed)
-> puzzleId = "DAILY-<epochDay>"
dailySeed는 64비트 wrapping 연산과 logical shift를 사용하는 SplitMix64 finalizer다. 생성기는 프로젝트가 소유한 RNG로 완성 보드를 만든 뒤, 해답이 하나로 유지되는 동안 clue를 제거한다. iOS와 Android는 UniFFI, Web은 WebAssembly를 통해 같은 Rust core를 호출한다.
따라서 현재 코드가 보장하는 범위는 구체적이다.
동일한 local epoch day, 정상 daily 진입점의 MEDIUM 난이도, 동일한 Rust core 빌드라면 fresh game의 보드와 해답이 같다.
여기서 seed는 생성 입력이지 영구 ID가 아니다. seed의 의미는 RNG와 generator 구현에 의존한다. 실제 개발 이력에서도 Expert 퍼즐의 최대 재시도 횟수를 8회에서 4,096회로 바꾸자 seed 159가 종전 25-clue fallback 대신 24-clue 보드를 만들게 됐다. 현재 daily는 MEDIUM이므로 이 사례가 당일 보드를 바꿨다는 뜻은 아니다. 다만 같은 seed와 난이도만으로 알고리즘 변경 전후의 보드까지 동일하다고 말할 수 없다는 실제 반례다.
같은 seed의 결정성과 golden fixture를 어떻게 검사하는지는 이전의 Rust 공통 모듈 테스트와 CI 글에서 이미 다뤘다. 여기서 필요한 다음 질문은 “결과가 바뀌는 변경을 어떻게 식별할 것인가”다.
epochDay를 계산하기 전에 시간대 계약을 고정한다
DailySudoku의 epochDay는 UTC 날짜가 아니라 기기 로컬 날짜다. 여기서 offsetAtInstant는 해당 instant의 local - UTC를 밀리초로 환산한 값이다.
localEpochDay = floor((utcMillis + offsetAtInstant) / 86_400_000)
API의 부호와 단위는 서로 다르다. Android의 TimeZone.getOffset은 그대로 밀리초를 쓰고, iOS의 secondsFromGMT(for:)는 초에 1,000을 곱한다. Web의 Date.getTimezoneOffset()은 반대 방향인 UTC - local 분을 반환하므로 -getTimezoneOffset() * 60_000으로 바꾼다. 세 구현은 이렇게 정규화한 뒤 같은 식을 적용한다. 따라서 서머타임이 있는 지역에서도 offset을 고정값처럼 하드코딩하지 않는다.
이 정책에는 중요한 의미가 있다.
- 서울과 로스앤젤레스의 사용자는 같은 UTC 순간에도 서로 다른 local epoch day를 가질 수 있다.
- 사용자가 여행 중 시간대를 바꾸면 다음 실행에서 다른 daily ID가 계산될 수 있다.
- 이미 시작한 게임은 그 판을 시작할 때의 epoch day를 유지하고, 다음 daily 진입에서 새 날짜를 판단한다.
그러므로 “전 세계가 같은 순간에 같은 보드를 받는다”는 현재 구현의 보장이 아니다. 정확한 표현은 “같은 local epoch day를 정상 daily UI의 MEDIUM으로 선택한 사용자는 같은 core 빌드에서 같은 보드를 받는다”다.
시간대 ID 자체를 퍼즐 ID에 넣을 필요는 없다. 같은 core 빌드의 정상 MEDIUM daily에서 서울과 도쿄가 같은 epoch day라면 같은 보드를 공유하는 것이 현재 정책이기 때문이다. 대신 날짜를 고르는 규칙은 버전된 값으로 명시해야 한다.
type DailyPuzzleIdentity = {
dayPolicy: "device-local-v1";
epochDay: number;
difficulty: "MEDIUM";
generatorVersion: number;
};
dayPolicy는 어떤 시간대를 관측 메타데이터로 남길지와 별개다. 나중에 UTC 자정이나 서버 기준 날짜로 정책을 바꾸더라도 기존 ID를 새 규칙으로 재해석하지 않게 만드는 최소 계약이다.
generatorVersion이 같은 seed의 의미를 고정한다
현재 daily ID는 DAILY-<epochDay>다. 정상 UI에서는 난이도가 MEDIUM으로 고정되지만 그 값은 ID에 없고, generator 버전도 없다. Rust 저장 JSON의 version: 1은 직렬화 schema version이며 다른 버전은 migration 없이 거부된다. 생성 알고리즘 버전과는 관계가 없다.
최소한의 버전된 ID는 다음처럼 만들 수 있다.
DAILY-device-local-v1-20671-MEDIUM-g1
필드별 책임은 분리된다.
| 필드 | 답하는 질문 |
|---|---|
dayPolicy |
어떤 날짜 경계로 daily를 선택했는가 |
epochDay |
그 정책에서 어느 날인가 |
difficulty |
어떤 생성 규칙과 목표를 요청했는가 |
generatorVersion |
seed 파생·RNG·보드 생성 규칙의 어느 버전인가 |
seed |
그 버전의 생성기를 재현할 입력은 무엇인가 |
seed는 epochDay에서 다시 계산할 수 있어 ID에 중복 저장하지 않아도 된다. 다만 장애 분석과 fixture 생성에 유용하므로 persistence나 telemetry에 파생값으로 남길 수 있다. 핵심은 seed를 저장했느냐가 아니라 그 seed를 해석하는 generator 버전을 함께 고정했느냐다.
generatorVersion은 Cargo package version과도 분리하는 편이 낫다. UI 수정이나 광고 SDK 업데이트마다 퍼즐 정체성이 바뀔 이유는 없다. 반대로 RNG, shuffle, 난이도 목표, clue 제거, 재시도 정책처럼 보드 결과를 바꿀 수 있는 변경은 generator 버전을 올려야 한다.
한 번 발급한 version의 의미는 불변이어야 한다. Rust generator가 version을 입력받아 identity와 board를 함께 반환하게 만들면 caller가 g2 board에 g1 ID를 붙이는 실수를 줄일 수 있다. 그래도 version 번호를 올리지 않은 코드 변경까지 자동으로 막아 주는 것은 아니다. immutable fixture는 선택된 입력의 drift를 잡고, 모든 board의 절대 일치를 확인해야 하는 시스템은 canonical board digest나 archive를 추가로 사용해야 한다.
퍼즐 ID 유일성과 해답 유일성은 다른 검증이다
“unique puzzle”이라는 표현에는 서로 다른 두 문제가 섞이기 쉽다.
첫째는 스도쿠 규칙의 유일성이다. 현재 generator는 clue를 하나 제거할 때마다 해답 수를 최대 2개까지 세고, 정확히 하나일 때만 제거를 유지한다. 이 검사는 fresh puzzle에 해답이 하나뿐인지 확인한다.
둘째는 데이터 식별자의 유일성이다. DAILY-20671이라는 문자열이 오직 한 보드만 가리키는지 확인하는 문제다. 해답이 하나인 보드 두 개가 같은 ID를 쓸 수도 있고, 같은 보드가 서로 다른 ID로 기록될 수도 있다. solver는 이 충돌을 잡지 못한다.
따라서 검증도 나눠야 한다.
solver invariant
-> 이 board에는 해답이 정확히 하나인가
identity invariant
-> 이 PuzzleIdentity는 배포·복원·분석 전체에서 같은 board를 뜻하는가
board hash를 ID에 추가하면 결과 drift를 탐지하는 데는 도움이 된다. 하지만 버전 계약을 대신하지는 않는다. hash만 있으면 왜 결과가 바뀌었는지, 어떤 generator를 다시 실행해야 하는지 알 수 없다. 먼저 버전된 identity를 만들고, 실제 불일치 탐지가 필요할 때 checksum을 붙이는 순서가 단순하다.
과거 PuzzleIdentity는 새 generator로 재해석하지 않는다
생성기 교체에서 가장 위험한 시점은 배포 채널과 앱 binary가 독립적으로 움직여 서로 다른 core 버전이 공존하는 기간이다.
버전이 없는 ID에서 g1을 g2로 교체하면 같은 날짜에 다음 상태가 공존할 수 있다.
| 사용자 상태 | 생성되는 보드 | 저장되는 ID |
|---|---|---|
| 이전 앱의 신규 사용자 | g1(day) |
DAILY-day |
| 새 앱의 신규 사용자 | g2(day) |
DAILY-day |
| 이전 저장을 복원한 사용자 | 저장된 g1 board |
DAILY-day |
현재 저장 schema와 호환되는 generator 변경이라면 저장된 board와 solution을 그대로 복원할 수 있어 진행 중인 구 보드를 새 generator로 다시 만들 필요는 없다. 하지만 외부 ID는 여전히 같아서 analytics는 두 보드를 구분하지 못한다. 표준 Game Center·Play Games 리더보드도 동적 puzzle ID로 점수를 분할하지 않으므로, 제출 전에 어느 버전의 점수를 받을지 결정하지 않으면 같은 기간의 결과가 섞인다. context나 score tag의 한계는 데일리 리더보드 날짜 경계 글에서 별도로 정리했다.
새 identity를 넣을 때는 저장 schema도 함께 올려야 한다. 현재 legacy 저장본에는 generator version이 없으므로 “기존 identity를 그대로 보존한다”는 말만으로는 부족하다. 최소 마이그레이션 규칙은 다음과 같다.
- schema v2는 board snapshot과 versioned identity를 함께 쓴다.
- v1 저장본이 모두
g1에서 만들어졌음을 릴리스 이력으로 확인할 수 있을 때만g1으로 승격한다. - 그 출처를 확인할 수 없다면 저장 시점마다 바뀌는 entry·history·경과 시간은 제외하고,
given + solution + difficulty처럼 불변인 퍼즐 내용의 canonical digest를 사용한legacy-<digest>namespace로 격리해 version별 비교에 넣지 않는다. - board나 version이 없는 과거 analytics 기록은 사후에 정확히 복구할 수 없으므로
legacy-unknown으로 남긴다. - fresh game만 활성
generatorVersion으로 생성하고, 과거 날짜를 재생성할 때는 기록된 version으로 dispatch한다. - 지원하지 않는 과거 version은 최신 generator로 조용히 재해석하지 않고 명시적으로 거부한다.
과거 daily를 다시 열지 않는 제품이라면 모든 generator 구현을 영구 보관할 필요는 없다. 진행 중 저장본에 board와 solution이 있고, 이미 발급한 identity를 저장·분석 경로가 유지하면 된다. 과거 퍼즐 재플레이나 서버 검증을 제공할 때만 구 generator 또는 immutable board archive가 필요하다.
versioning은 구·신 보드를 같은 ID로 합치지 않게 할 뿐, rollout 중 “하루에 보드 하나”를 보장하지 않는다. 그것이 제품 요구라면 immutable한 epochDay -> generatorVersion 발급 정책과 구 client 처리 전략을 서버 같은 단일 권위에서 운영해야 한다.
롤백해도 이미 발급한 퍼즐은 바꾸지 않는다
배포 직후 g2에 문제가 생겨 g1으로 롤백하는 상황도 같은 계약으로 처리해야 한다. 여기서 안전한 롤백은 pre-version binary로 되돌리는 것이 아니라, version-aware binary를 유지한 채 활성 발급만 g2에서 g1으로 바꾸는 것이다. 이미 g2 ID로 발급한 보드를 g1 결과로 바꾸면 안 된다.
안전한 롤백 기준은 코드 버전이 아니라 identity다.
PuzzleIdentity.generatorVersion == 1 -> g1 또는 저장된 g1 board
PuzzleIdentity.generatorVersion == 2 -> g2 또는 저장된 g2 board
이 롤백을 가능하게 하려면 먼저 versioned ID와 schema v1/v2 읽기를 지원하지만 g1만 발급하는 호환 build를 배포해야 한다. 그다음 build나 원격 정책에서 g2를 활성화한다. 롤백 기간에는 g1 생성과 g2 snapshot decoding을 모두 보존하고, pre-version binary로 내려가지 않는다.
현재 DailySudoku의 generator는 클라이언트 binary에 포함돼 있고 중앙 버전 선택기가 없다. 서버나 Web 배포를 롤백해도 이미 설치된 g2 모바일 앱은 계속 g2를 실행한다. versioned identity는 이 공존을 숨기지 않게 만들 뿐, 배포를 원자적으로 되돌려 주지는 않는다. 모든 사용자에게 한 version만 강제해야 한다면 서버가 identity나 board를 발급하거나 최소 지원 앱 version을 올리는 별도 운영 장치가 필요하다.
서버 또는 Remote Config가 버전을 선택하고 두 generator가 클라이언트에 함께 들어 있는 구조라면 새 발급만 g1으로 되돌릴 수 있다. 그래도 이미 저장된 g2 게임은 g2 identity와 board로 끝낼 수 있어야 한다. 표준 Game Center·Play Games는 임의의 puzzle ID로 점수를 제외하거나 분할하는 기능이 아니므로, g2 점수를 받지 않기로 했다면 제출 전 정책에서 차단해야 한다. version별 조회가 필요하면 커스텀 백엔드나 미리 분리한 leaderboard 구성이 필요하다.
버전 전환 경계만 계약 테스트로 고정한다
현재 Rust parity fixture는 4개 epoch day와 5개 난이도의 seed·givens·solution을 고정해 선택된 입력에서 generator 출력의 비의도적 변경을 탐지한다. 하지만 fixture를 새 알고리즘 결과로 재생성하면 구 version의 의미는 사라진다.
새로운 대규모 테스트 체계보다 버전 경계 몇 개를 불변 fixture로 남기는 편이 낫다.
g1 fixture: 변경 금지
(device-local-v1, day-before-cutover, MEDIUM, g1) -> board A
g2 fixture: 새 파일
(device-local-v1, cutover-day, MEDIUM, g2) -> board B
resolver test
저장된 g1 identity -> g1 또는 저장 snapshot
새 g2 identity -> g2
여기에 다음 세 검사면 버전 전환의 핵심을 잠글 수 있다.
- 어느 지원 build에서든
g1identity는 immutableg1fixture와 같은 board와 solution을 만든다. - 서로 다른 generator version은 같은 외부 puzzle ID를 만들지 않는다.
- 롤백 후에도 저장된 g2 identity를 g1으로 읽지 않는다.
- Rust generator가 identity와 board를 함께 반환해 caller가 서로 다른 version을 조합할 수 없다.
Swift, Kotlin, TypeScript가 Rust 알고리즘을 그대로 복제해 테스트할 필요는 없다. 버전 전환과 관련된 플랫폼 테스트는 versioned ID를 손실 없이 저장·복원하고 analytics와 리더보드 제출 정책까지 전달하는지 확인한다.
현재 보장과 다음 계약을 구분한다
DailySudoku의 현재 구현을 과장 없이 정리하면 다음과 같다.
| 항목 | 현재 보장 |
|---|---|
| 동일 local epoch day의 seed | 고정된 64비트 연산과 golden value로 검사 |
| 선택된 입력의 fresh board | Rust generator와 parity fixture로 출력 drift 검사 |
| fresh puzzle의 유일해 | clue 제거 단계와 선택된 seed 테스트로 검사 |
| 날짜 경계 | 기기 로컬 시간대의 해당 instant offset 사용 |
| daily ID | DAILY-<epochDay> |
| generator version 식별 | 없음 |
| 구·신 generator 공존 | 구분할 계약 없음 |
| version별 migration·rollback | 없음 |
다음 구현의 최소 순서는 Rust가 identity와 board를 함께 반환하게 만들기, schema v2에서 identity 보존, 분석 키를 versioned ID로 전환, 리더보드 제출 허용 정책에서 version 확인, version별 immutable fixture 추가다. 별도 factory나 범용 migration framework부터 만들 필요는 없다. 실제 두 번째 generator가 생기기 전까지 dispatch는 작은 switch 하나면 충분하다.
정리
결정론적 생성은 “같은 입력과 같은 코드가 같은 출력을 낸다”는 보장이다. 퍼즐 정체성은 배포 버전과 시간이 지나도 한 ID가 같은 보드를 뜻하게 하는 데이터 계약이다. 둘은 연결돼 있지만 같은 문제가 아니다.
DailySudoku의 지원되는 정상 daily UI는 local epoch day, MEDIUM 난이도, 공통 Rust core와 golden fixture로 고정 커밋 안의 일관성을 만든다. 반면 DAILY-<epochDay>에는 날짜 정책, 난이도, generator version이 없다. 알고리즘 변경이나 롤백이 시작되면 같은 ID 아래에 다른 보드가 들어갈 수 있다.
seed를 더 정교하게 섞는 것이 해법은 아니다. dayPolicy, epochDay, difficulty, generatorVersion을 identity로 만들고, 저장·분석과 리더보드 제출 정책이 그 값을 끝까지 보존해야 한다. 여기에 version 불변성과 identity·board의 원자적 발급을 지켜야 서로 다른 보드를 같은 ID로 합치는 일을 막을 수 있다. versioning만으로 mixed rollout의 모든 사용자에게 같은 보드를 강제할 수는 없다.
출처
- DailySudoku 비공개 저장소(권한 필요) — 기준 commit
9c99930582be23d2c72d37fb46d77a488b9fe04c:core/sudoku-rs/crates/sudoku-core/src/seed.rs:1-27,core/sudoku-rs/crates/sudoku-core/src/generator.rs:1-130,core/sudoku-rs/crates/sudoku-core/src/serialization.rs:14-160,core/sudoku-rs/crates/sudoku-core/tests/parity.rs:1-113,apps/web/lib/data/session.ts:27-50 - DailySudoku 비공개 저장소(권한 필요) — Expert generator 재시도 정책 변경 commit
08857af1581fac3346314dbba8622155e4460324:core/sudoku-rs/crates/sudoku-core/src/generator.rs:20-26,147-161 - Apple Developer Documentation — TimeZone.secondsFromGMT(for:)
- Android Developers — TimeZone.getOffset
- ECMAScript Language Specification — Date.prototype.getTimezoneOffset
- Rust Standard Library — u64::wrapping_add
- Rust Standard Library — u64::wrapping_mul
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.