일일 퍼즐 점수의 날짜와 리더보드 전송 손실 다루기
하루에 한 문제씩 내는 퍼즐의 결과를 Game Center와 Google Play Games 두 곳에 보내면, 자정을 넘긴 판에서 날짜가 문제가 돼요. 23시 50분에 시작해서 0시 5분에 끝낸 판에서는 퍼즐이 속한 날, SDK가 전송을 받은 날, 리더보드 화면이 보여 주는 범위가 서로 어긋날 수 있어요.
전송 자체도 문제가 돼요. 완료 기록과 통계는 기기에 남았는데 외부 점수는 사라질 수 있어요. 반대로 완료하는 도중에 로그인을 요구하면 게임 진행이 인증에 묶여 버려요.
근거는 Apple과 Google의 공식 문서이고, 2026년 10월 4일에 다시 열어 확인했어요. 이 문서들이 말하는 것은 SDK와 서버의 동작까지예요. 전송 조건, 로그인 화면을 두는 자리, 메모리 보관, outbox는 그 위에 얹는 앱 쪽의 설계이고, 문서가 요구하는 것이 아니에요. 자정을 넘기는 사례는 가상의 예예요. 측정값, 실행 로그, SDK 버전은 이 글에 싣지 않았어요.
날짜처럼 보이는 값이 셋이에요
같은 «날짜»로 보이지만 정하는 쪽이 서로 다른 값이 셋 있어요. puzzle_day는 앱이 정하는 퍼즐의 날짜예요. submitted_at은 앱과 기기에서 정해지는 전송 시각이에요. leaderboard_window는 SDK가 정하는 조회 구간이에요.
퍼즐 날짜는 게임을 만들 때 정하고 세션 안에서 바꾸지 않아요. 전송 시각은 재시도할 때마다 달라질 수 있어요. SDK의 구간이 기기의 자정과 같다는 보장도 없어요. 그래서 완료 시점에 날짜를 새로 계산하지 않고 세션에 들어 있는 값을 써요.
자정을 넘긴 판에서 세 값이 어떻게 달라지는지를 적은 예시예요.
퍼즐 생성: 2026-08-05 23:50 → puzzle_day = 2026-08-05
게임 완료: 2026-08-06 00:05 → submitted_at = 2026-08-06 00:05
제출 메타데이터 → 2026-08-05
부가 값은 구간을 바꾸지 못해요
Android에서는 yyyy-MM-dd 형식의 퍼즐 날짜를 scoreTag에 실을 수 있어요. scoreTag는 LeaderboardsClient의 submitScore가 받는 선택 인자이고, URI에 안전한 문자만 쓸 수 있으며 길이는 64자까지예요. 문서는 이 값을 선택적인 metadata라고만 정의해요. 점수가 어느 퍼즐 날짜에서 왔는지를 나타내는 용도는 앱이 정한 것이에요. 날짜별로 중복을 막거나 화면에서 걸러 주지는 않아요. 태그에 2026-08-05를 넣어도 TIME_SPAN_DAILY의 행이 날짜별로 나뉘지 않아요.
Apple의 context도 같아요. submitScore의 context는 앱이 뜻을 정하는 부가 값이라서, 날짜를 어떻게 표현할지는 앱의 책임이에요. timeScope를 조절하는 수단이 아니에요. context: 0만 보냈다면 점수 데이터에서 퍼즐 날짜를 거꾸로 알아낼 수 없어요. 두 플랫폼에서 이 값의 뜻이 다르다는 점은 제품 요구사항에 적어 둬야 해요.
기본 화면이 보여 주는 하루
Play Games 리더보드는 daily, weekly, all-time을 제공하고, daily의 경계는 일 년 내내 UTC-7의 자정이에요. Apple의 today 범위는 직전 24시간이에요. Apple의 반복형 리더보드에서 회차가 언제 바뀌는지는 App Store Connect의 반복 설정에 달려 있어요. 어느 쪽의 기본 일일 화면도 puzzle_day와 일치한다고 보장하지 않아요.
서울의 자정에 퍼즐이 바뀌는 앱을 예로 들면, 새 퍼즐의 점수와 전날 퍼즐의 점수가 Google의 daily 구간 하나에 함께 들어갈 수 있어요. Apple의 today에도 이틀 치 점수가 같이 있을 수 있어요. 이것은 표시 범위의 계약이 다르기 때문이고, 태그를 고쳐서 해결할 문제가 아니에요.
요구에 따라 고를 수 있는 길은 셋이에요.
- SDK의 일일 화면으로 충분하다면 부가 정보와 기본 UI를 써요.
- Apple에서 회차를 구분해야 한다면 반복형 리더보드를 검토해요.
- 두 플랫폼이 같은 달력 기준과 같은 조회 규칙을 써야 한다면 별도 저장소와 자체 UI가 필요해요.
마지막 길에는 운영, 부정행위 대응, 개인정보 관리 비용이 따라와요. 간단한 일일 경쟁이라면 미리 도입할 필요가 없다고 판단했어요. 이 판단은 문서에 적힌 내용이 아니고 제 해석이에요.
언제 보내고 언제 보내지 않을지
전송 조건은 SDK가 정해 주지 않아요. 아래는 앱 쪽에서 정하는 정책의 예이고, 세 가지를 모두 채울 때에만 보내요.
submit = won && daily && authenticated
무료 모드이거나, 졌거나, 인증되지 않았으면 보내지 않아요. 인증 상태가 불확실할 때에는 화면을 띄우지 않는 조회만 허용해요. 로그인 화면은 사용자가 리더보드 버튼을 눌렀을 때의 경로에 둬요. 서비스가 꺼져 있거나, 로그아웃했거나, 인증을 취소했다고 해서 완료 처리, 통계, 화면 전환이 실패하면 안 돼요.
완료 처리에서 기기가 책임지는 일은 따로 있어요. 종료 전환이 두 번 처리되지 않게 막고, 이어 하기 데이터를 지우고, 통계와 일일 완료 정보를 갱신하는 일이에요. 외부 전송은 이와 독립된 시도이고, 실패해도 완료를 되돌리지 않아요.
여기서 «기기 먼저»라는 말을 조심해야 해요. 이 표현만으로는 디스크에 쓰는 일이 전송보다 먼저 끝난다고 말할 수 없어요. Android에서 비동기 쓰기를 예약한 직후에 전송을 시작하면 어느 쪽이 먼저 끝날지 정해져 있지 않아요. 디스크 기록이 먼저 확정돼야 한다면 저장 완료를 기다리는 설계를 더해야 해요.
점수 한 건을 잠시 보관하기
Android에서는 Activity가 연결되지 않은 짧은 순간에 점수가 도착할 수 있어요. 이런 경우를 위해 메모리에 최대 한 건을 보관하는 설계가 있어요. SDK의 기능이 아니고 앱 쪽의 설계예요. A가 있는데 B가 오면 B로 덮어쓰고, Activity가 연결되면 꺼내서 인증을 확인한 뒤 한 번 전송해요.
Activity 없음
score A 보류
score B 도착 → A를 B로 교체
Activity 연결
B를 꺼내 인증 확인 → 제출 시도
이 보관은 짧은 수명주기 경합을 줄이는 용도이고, 한계가 분명해요. 프로세스가 죽으면 보관한 항목은 사라져요. 꺼낸 뒤에 인증이 false이거나 조회 요청이 실패해도 다시 넣지 않아요. iOS에 같은 보관이 없다면 두 플랫폼에서 점수가 전달될 가능성도 달라져요.
최신 항목을 남긴다는 규칙도 기기 안의 이야기예요. Google은 서버에 있는 값보다 좋은 점수만 반영하고, Apple은 설정에 따라 best score나 most recent score를 골라요. 앱이 최신 항목을 골랐다고 해서 서버가 그 기록을 채택하는 것은 아니에요.
호출 방식에도 차이가 있어요. submitScore는 결과를 알려 주지 않고, submitScoreImmediate는 Task를 돌려줘요. 결과를 받지 않는 방식이면 앱은 전송이 성공했는지 판정할 수 없어요. Task를 받는 쪽도, 문서에는 앱을 다시 시작한 뒤에 다시 보낸다는 내용이 없어요.
전송을 어디까지 보장할지
전달 수준을 세 가지로 나눈 것도 제 분류예요. 문서에 있는 구분이 아니에요.
- 직접 한 번 시도: 세션 안에서 한 번만 보내요. 비용이 가장 적고, 빠져도 되는 기능에 맞아요.
- 메모리 한 칸 보관: Activity가 연결되지 않은 동안 최신 항목을 보관해요. 비용이 낮고, 짧은 수명주기 경합에 맞아요.
- durable outbox: 앱을 다시 시작한 뒤에도 다시 보낼 수 있어요. 비용이 높고, 전송 손실이 보상이나 대회 결과에 영향을 줄 때 맞아요.
durable outbox로 간다면 payload를 먼저 디스크에 적어요. 안정적인 전송 ID, puzzle_day, 점수, 대상 리더보드를 저장해요. 성공 응답을 받은 항목만 지우고, 재시작할 때 남은 항목을 복구해요. backoff와 성공 확인도 들어가요. 서버가 기존의 좋은 기록을 유지하기로 한 것과 앱이 재전송을 그만두는 것은 따로 판단해요. 전송 ID를 저장한다고 해서 SDK가 중복을 없애 주거나 정확히 한 번 처리한다는 뜻은 아니에요.
손실을 받아들이기로 했다면 범용 큐를 키우지 않고, 어떤 조건에서 점수가 빠지는지를 문서와 테스트에 적어요.
테스트로 고정할 기댓값
아래 기댓값은 조건식과 보관 정책을 순수 함수로 두고 테스트로 고정하려는 계획이에요. 실행 결과는 이 글에 싣지 않았어요.
- 무료 모드에서 이기거나 일일 모드에서 지면 제출은 0회예요.
- 일일 모드에서 이겼지만 인증되지 않았으면 제출은 0회이고 로그인 화면도 뜨지 않아요.
- 23시 50분부터 0시 5분까지 플레이하면 세션의 퍼즐 날짜가 유지돼요.
- Activity가 없는 상태에서 A 다음에 B가 오면 B 하나만 보관해요.
- 보관 중에 프로세스가 죽으면 재시작 뒤에 제출하지 않아요.
- 꺼낸 뒤 인증에 실패하면 다시 보관하지 않아요.
- 원격 요청이 실패해도 완료 정보와 통계는 남아요.
- 이전보다 낮은 최신 점수는 기기의 최신 선택과 서버의 최고 기록 선택을 따로 검사해요.
- 종료 이벤트를 다시 받아도 완료 기록은 1회, 전송 시도는 1회예요.
- 기본 daily와 today 조회는 부가 값이 아니라 SDK의 시간 범위를 써요.
단위 테스트가 증명하는 범위는 앱의 분기와 payload까지예요. 서버에 점수가 실제로 반영됐다는 증거는 되지 않아요. 인증 취소, 오프라인, 리더보드 intent와 access point의 표시 오류는 통합 환경에서 따로 확인해야 해요. 기본 화면의 날짜 의미 차이, 메모리 보관의 유실, 전송 결과를 알 수 없는 호출, 디스크 기록의 완료 순서는 이 설계에 그대로 남아 있는 한계예요.