순위를 읽는 API가 없는 리더보드에 기록탭을 붙인 이야기 — 게임 20종
토스 게임센터 리더보드에 점수를 올리려다 읽기 API가 없다는 걸 알았습니다. 순위를 받아올 수 없으니 화면에 순위표를 그릴 수가 없습니다. 그래서 시간축은 우리가, 남들과의 비교는 토스가 맡는 2층 구조로 게임 20종에 일간·주간·월간·명예의 전당 기록탭을 붙였습니다. 하는 김에 일시정지와 하트 UI까지 손본 하루
미니게임에 랭킹을 붙이고 싶었습니다. 기록이 남으면 한 번 더 하게 되니까요.
서버를 두고 싶지는 않았습니다. 1인 개발이라 서버가 생기는 순간 장애 대응, 개인정보 처리방침, 스토어 데이터 안전 항목까지 줄줄이 따라옵니다. 게임 하나가 아니라 스무 개가 넘는데 말이죠.
그래서 먼저 확인한 게 토스 게임센터 리더보드였습니다. SDK 안에 이미 들어 있었습니다.
있는 것과 없는 것
@apps-in-toss/web-framework 의 Game 네임스페이스가 주는 건 셋뿐이었습니다.
| 있다 | 없다 |
|---|---|
Game.setLeaderboardScore({score}) — 점수 제출 | 순위를 읽어오는 API |
Game.openLeaderboard() — 토스가 만든 랭킹 웹뷰 열기 | 기간별 조회·필터 |
Game.getUserProfile() — 게임센터 닉네임·프로필 | 사용자 식별자(보안상 응답에서 제외) |
읽기 API가 없다는 게 설계를 갈랐습니다. 랭킹 데이터를 받아올 수 없으니 우리 화면에 순위표를 그릴 방법이 없습니다. 문서에도 “리더보드 UI와 데이터는 토스가 관리하며 개발자가 수정·삭제할 수 없다”고 적혀 있었습니다.
처음엔 이걸 제약으로만 봤는데, 뒤집어 보니 역할 분담이 명확해졌습니다.
2층으로 나눴다
1
2
3
4
5
6
┌─ 기록 ───────────────────────────────┐
│ [오늘] [이번 주] [이번 달] [명예의 전당] │ ← 우리 화면: 내 기록
│ 1 1,240점 오늘 14:22 │
│ 2 1,180점 오늘 09:15 │
│ 🏆 토스 전체 랭킹 │ ← 토스: 전체·친구 랭킹
└──────────────────────────────────────┘
우리 탭은 “내 기록의 시간축”, 토스는 “지금 남들과의 비교” 를 맡습니다. 실기기로 확인해 보니 토스 랭킹 웹뷰는 전체 랭킹과 친구 랭킹 두 탭이었고 기간 구분은 없었습니다. 역할이 겹치지 않아서 오히려 서로를 채웁니다.
이 구조의 값어치는 빠진 것들에 있습니다. 서버 0, 개인정보 수집 0, 어뷰징 대응 0. 순위 조작을 막는 건 토스 몫이고, 우리는 기기 안의 내 기록만 다루니 스토어 데이터 안전 항목을 건드릴 일도 없습니다.
기존 기록 배열로는 부족했다
게임 14개가 이미 records[] 에 달성 시각(at)을 갖고 있었습니다. 그대로 쓰면 되겠다 싶었는데 안 됩니다.
기존 배열은 점수순 TOP 10입니다. 오늘 낸 기록이 역대 15등이면 이미 버려지고 없습니다. “오늘의 기록”을 만들려면 기간별로 따로 쌓아야 합니다.
그래서 기간 버킷을 뒀습니다.
1
2
3
4
5
periods: {
day: { key: '2026-09-24', top: [...] },
week: { key: 'W2026-09-21', top: [...] },
month: { key: '2026-09', top: [...] },
}
제출할 때 키가 바뀌었으면 그 버킷을 비우고 다시 쌓습니다. 저장 공간은 O(1)이고, 아무리 많이 플레이해도 각 기간의 정확한 TOP 10이 남습니다. 명예의 전당만 기존 records[] 를 그대로 재사용합니다 — 같은 기록을 두 곳에 저장하면 언젠가 어긋나니까요.
기간 경계를 기기 시계로 재면 안 된다
처음엔 new Date() 로 오늘 날짜를 만들었습니다. 그런데 기기 시계를 하루 뒤로 돌리면 “오늘 1위”가 공짜가 됩니다.
SDK에 Environment.getServerTime() 이 있어서 앱 시작할 때 오프셋을 한 번 재 두고 그걸로 계산합니다. 미지원 버전이면 기기 시계로 떨어지되 기록탭 자체는 그대로 동작합니다.
주 단위 키는 ISO 주차 대신 그 주 월요일 날짜로 잡았습니다. 연말에 “2026년 53주”인지 “2027년 1주”인지 따지는 예외가 사라집니다.
조용히 틀리는 것들
기능을 붙이고 나서 실제로 제보를 받은 것들이 더 배울 게 많았습니다.
“한 판 끝냈는데 랭킹에 안 올라가요”
스도쿠에서 한 판을 끝냈는데 토스 랭킹에 안 보인다는 제보였습니다. 확인해 보니 쉬움 난이도로 푸신 거였고, 의도한 동작이었습니다.
토스 리더보드는 앱당 하나입니다. 초급 20초와 전문가 10분이 같은 표에 섞이면 순위가 의미를 잃습니다. 그래서 대표 난이도(보통)만 제출하게 해 뒀는데 — 사용자 입장에서는 그걸 알 길이 없었습니다.
버튼에 기준을 적었습니다.
🏆 토스 전체 랭킹 · 보통 기준
같은 제보에서 진짜 버그도 하나 나왔습니다. 다른 게임에는 있는데 스도쿠를 포함한 5개 게임에 seed() 가 빠져 있었습니다. 업데이트 전부터 쌓인 최고 기록을 시작할 때 한 번 올려 주는 장치인데, 이게 없으면 예전부터 하시던 분은 자기 최고 기록을 깨기 전까지 랭킹에 아예 존재하지 않습니다.
우리 탭에선 1등인데 토스에선 꼴찌
정렬 방향은 콘솔의 gameInfo.sortOrder 가 정합니다. 그런데 출시할 때 전부 기본값 점 / DESC 로 들어가 있었습니다. 반응속도(ms)조차 그랬습니다.
시간이 짧을수록 좋은 게임을 DESC로 두면 가장 느린 사람이 1등이 됩니다. 화면에는 아무 오류도 안 나고, 우리 기록탭은 멀쩡히 1등을 보여 줍니다. 눈으로는 절대 안 잡히는 종류입니다.
그래서 감사기를 만들었습니다.
1
2
3
4
5
6
게임 코드 콘솔 단위
breakout desc DESC 점
memory-card asc ASC 초
reaction asc ASC ms
...
✓ 이상 없음
코드의 dir 과 콘솔 값을 대조하고, 기간 탭 요소가 있는지, 래퍼 브릿지에 Game API가 실렸는지까지 한 번에 봅니다. 처음 돌렸을 때 문제 66건이 나왔습니다. 정렬 불일치 9건에 브릿지 미적재 57건이었습니다.
브릿지 미적재는 특히 무섭습니다. 래퍼에 Game API를 안 실으면 제출이 통째로 무음 실패하는데 앱은 멀쩡히 돌아갑니다. 전에도 브릿지 스크립트가 주입되지 않은 번들을 세 번 올려 반려당한 적이 있어서, 이런 건 문서에 적어 두는 대신 검사기로 막기로 했습니다.
하는 김에 고친 것들
랭킹을 붙이려고 홈 화면을 들여다보다 보니 눈에 걸리는 게 계속 나왔습니다.
한 행은 60px이다
.screen 이 flex column에 gap 1.25rem이라 요소 하나를 흐름에 두면 그게 곧 한 행(약 60px) 입니다. 하트 배지를 그냥 뒀더니 ‘이런 앱도 있어요’가 스크롤해야 보였고, 결과 화면은 버튼 넷이 각자 한 행을 먹어 ‘처음으로’가 화면 밖에 있었습니다.
두 군데를 묶었습니다.
.start-row— 시작 버튼과 하트를 한 행에.result-actions— 결과 화면 보조 버튼(자랑하기·기록·처음으로)을 한 행에
중간에 한 번 틀렸습니다. 하트를 기존 링크 행에 끼웠더니 375px에서 ‘게임 방법’이 잘렸습니다. 버튼 두 개가 이미 폭을 다 쓰고 있었던 겁니다. 결국 시작 버튼 옆으로 갔는데, 하트를 소모하는 바로 그 버튼 옆이라 의미도 더 맞았습니다.
검증은 눈으로 하면 안 됩니다. 375×812에 가짜 배너 60px을 세우고 scrollHeight === clientHeight 를 재야 잡힙니다.
손을 떼면 그냥 죽는 게임
전화가 오면 판이 날아갑니다. 백그라운드 전환은 이미 막아 뒀지만 앱을 켜 둔 채 잠깐 멈추는 방법이 없었습니다.
실시간 7종에 일시정지를 넣었습니다. 공용 모듈로 만들되 모듈이 게임 루프를 직접 만지지 않게 했습니다. 게임마다 상태 기계가 달라서요 — 하늘 점프는 이미 paused 페이즈를 갖고 있었는데, 모듈이 루프를 멋대로 세우면 그 규칙이 깨집니다. 버튼과 덮개만 모듈이 만들고 멈춤·재개는 게임이 콜백으로 처리합니다.
제한 시간이 있는 두더지 잡기가 가장 중요한 검증이었습니다. 시계가 같이 멈추지 않으면 일시정지가 무의미하니까요. 재 보니 46.5초에서 멈춰 2.5초 뒤에도 46.5초였고, 재개하니 시간 점프 없이 이어졌습니다. 남은 시간이 루프 안의 tick(dt) 로만 줄어드는 구조라 루프를 세우면 시계도 함께 섭니다.
덮개는 뒤로가기 스택에도 등록했습니다. 토스에서 뒤로가기를 누르면 앱이 꺼지는 게 아니라 게임이 재개됩니다.
아이콘 하나가 톤을 깬다
일시정지 버튼을 ⏸ 이모지로 넣었더니 기기가 노란색으로 그려서 옆의 뒤로가기(←)와 따로 놀았습니다. currentColor 로 막대 두 개를 직접 그려 버튼 글자색을 따라가게 했습니다.
위치도 어긋났습니다. HUD가 justify-content: space-between 인 게임에서는 버튼을 그냥 끼우면 가운데로 밀립니다. 두더지 잡기는 flex-start 라 멀쩡했고요 — 그래서 게임마다 달라 보였던 겁니다.
개수가 늘면 폭도 늘어난다
하트를 타로 앱처럼 글리프로 바꿨는데(❤️❤️🤍), 광고를 여러 번 보면 하트가 계속 늘어나 HUD를 밀어냅니다. 기본 개수까지만 글리프로 보여주고 넘으면 숫자로 줄였습니다.
| 상태 | 표시 |
|---|---|
| 3개 중 2개 | ❤️❤️🤍 + |
| 기본 3 초과(12개) | ❤️ 12 + |
배지를 눌러 미리 충전할 수 있게 해 놓고 문구는 “하트를 다 썼어요”만 띄운 것도 고쳤습니다. 남은 개수에 따라 “하트 3개 남았어요 / 광고를 보면 3개를 더 받아요”로 갈립니다.
좌표로 맞춘 자리는 기기가 바꾼다
주스 스피너에서 하트가 선택 패널과 겹친다는 제보가 왔습니다. 브라우저 375×812에서는 안 겹쳤는데 말이죠.
실기기는 토스 헤더와 배너 때문에 화면이 줄어들어 패널이 위로 올라옵니다. 하트는 top: 58px 에 못 박혀 있었고요. 좌표를 다시 찍어도 다른 기기에서 또 어긋납니다. 절대배치를 걷어내고 상단 필 줄 안으로 넣어 구조적으로 겹칠 수 없게 했습니다.
버튼은 display:flex여도 내용만큼만 넓어진다
‘이런 앱도 있어요’ 목록에서 화살표(›)가 행마다 다른 위치에 있었습니다. 설명 길이에 따라 행 너비가 달라졌던 겁니다.
<button> 은 폼 컨트롤이라 display: flex 를 줘도 너비가 내용에 맞춰집니다. 홈에서는 부모가 align-items: stretch 로 늘려 줘서 멀쩡했는데, 바텀시트는 그냥 블록 컨테이너라 늘려 주는 주체가 없었습니다. width: 100% 를 박아 컨테이너와 무관하게 꽉 차게 했습니다.
힌트마다 광고는 가혹하다
스도쿠에서 “힌트 볼 때마다 광고 보는 건 너무하지 않냐”는 이야기가 나왔습니다. 맞는 말입니다. 광고를 아끼려다 퍼즐 자체를 포기시키면 손해입니다.
판당 무료 2개를 먼저 주고, 그다음에 초대 리워드 티켓, 그다음이 광고입니다. 버튼 아이콘이 💡2 → 💡1 → 📺 로 바뀌어서 누르기 전에 지금이 무료인지 광고인지 보입니다. 누르고 나서 광고가 뜨면 배신감이 드니까요.
남은 것
게임 20종에 기록탭이 들어갔고, 콘솔 정렬값 15종을 실제 지표에 맞게 고쳐 검토에 넣었습니다.
아직 답을 못 찾은 건 B급 지표입니다. 단어 맞추기는 “몇 번째 시도에 맞혔나”(1~6)를 올리는데, 값의 폭이 좁아서 순위가 거의 동점입니다. 불끄기 퍼즐의 “도달 레벨”도 비슷합니다. 랭킹이 의미를 가지려면 지표가 충분히 흩어져야 하는데 그렇지 못한 게임이 있습니다. 실제 분포를 보고 다시 정해야 할 것 같습니다.
그리고 이번에 만든 감사기가 처음 돌자마자 66건을 잡은 걸 보면, 다음에도 비슷한 게 있을 겁니다. 문서로 적어 두면 잊고, 검사기로 만들면 안 잊습니다.
