점수를 브라우저가 계산하는 랭킹, 어디까지 막을 수 있나: 1회용 티켓과 최소 소요 시간
사이트에는 지뢰찾기, 반응속도, 멜론게임, 리듬게임, AI 대국 같은 게임마다 랭킹이 있다. 전부 브라우저에서 돌아가는 게임이라 점수도 브라우저가 계산해서 보낸다. 백엔드를 붙이기 전까지 등록은 Firestore 보안 규칙에서 형식과 범위만 검사하고 있었다. 즉, 요청 한 번이면 지뢰찾기를 1초에 깬 기록을 넣을 수 있었다.
먼저 한계부터 인정하기
점수를 클라이언트가 계산하는 한 완벽한 방어는 없다. 클라이언트 코드를 고쳐서 진짜처럼 보이는 값을 진짜처럼 보이는 시간에 보내면 서버는 구분할 수 없다. 완벽하게 하려면 게임마다 입력 기록을 받아 서버에서 다시 돌려 보는 리플레이 검증이 필요하다.
그래서 목표를 '불가능하게'가 아니라 '말이 안 되는 기록은 못 넣고, 조작하려면 실제 플레이만큼의 시간과 수고가 들게'로 잡았다. 그리고 한 사람이 랭킹을 도배하지 못하게 했다.
1. 로그인과 1회용 티켓
- 등록은 로그인한 사용자만. 회원이 아니어도 익명 로그인 uid가 있다.
- 게임을 시작할 때
POST /api/rankings/tickets로 1회용 티켓을 받고, 등록할 때 같이 보낸다. 티켓은 한 번 쓰면 끝이고, 7일 뒤 TTL 인덱스로 자동 삭제된다. - 티켓 발급도 uid당 분당 60개로 제한했다.
티켓만으로 막히는 건 별로 없다. 하지만 티켓에 발급 시각이 찍혀 있다는 게 다음 단계의 재료가 된다.
2. 최소 소요 시간
기록마다 '이 기록을 내려면 적어도 이만큼 걸린다'는 시간이 있다. 티켓을 받은 뒤 지난 시간이 그보다 짧으면 거절한다. 네트워크 지연을 고려해 3초 여유를 줬다.
| 게임 | 최소 소요 시간 |
| 지뢰찾기 | 기록한 시간 그대로 |
| 멜론게임 | 한 판의 길이 (약 118초) |
| 반응속도 | 5회 × (최소 대기 1.5초 + 평균 반응 시간) |
| AI 대국·격투 | 경기에 걸린 초 |
| 리듬게임 | 채보의 마지막 노트 시각 |
이제 1초짜리 지뢰찾기 기록을 넣으려면 적어도 1초를 기다려야 한다. 별것 아닌 것 같지만, 반대로 118초 걸리는 멜론게임에서 '말도 안 되는 점수'를 넣으려면 매번 118초를 기다려야 한다.
3. 값의 정합성: 서버가 아는 걸로 다시 계산
- AI 대국: 점수 공식(상대 단계, 기물 우세, 둔 수, 걸린 시간)을 웹과 서버가 같은 파일로 쓴다. 서버가 다시 계산해서 다르면 거절.
- 리듬게임: 서버가 채보 데이터를 갖고 있다. 최대 콤보가 노트 수를 넘거나, 점수와 정확도가 맞지 않거나, 풀콤보 표시와 판정 수가 어긋나면 거절.
- 반응속도: 사람이 낼 수 없는 100ms 미만은 거절.
여기서 '서버가 웹 파일을 그대로 import한다'는 선택을 했다. 공식을 서버에 따로 복사하면 언젠가 둘이 어긋난다. 리듬게임 곡을 추가할 때 서버의 채보 목록에도 넣어야 하는데, 빠뜨리면 테스트가 실패하도록 했다.
4. 한 사람당 기록 하나
예전에는 같은 사람이 랭킹 10줄을 다 차지할 수 있었다. 이제 uid당 랭킹마다(리듬게임은 채보마다) 가장 좋은 기록 하나만 남는다. 더 좋은 기록이면 같은 줄을 교체하고, 아니면 저장하지 않고 '기존 기록 유지'라고 응답한다. 조회 응답에서는 uid를 뺐다. uid 없이 쌓인 예전 기록은 그대로 뒀다.
5. 거절 이유는 숨기기
거절할 때 '시간이 너무 짧음', '점수 공식 불일치'처럼 알려 주면 조작하려는 사람에게 힌트가 된다. 응답은 RANK_REJECTED 하나로 통일하고, 실제 사유는 서버 로그에만 남겼다.
6. 새로고침해도 이어지게
AI 대국은 새로고침해도 판을 이어서 둘 수 있다. 그런데 티켓이 메모리에만 있으면 새로고침 뒤에 이긴 판은 등록할 수 없다. 그래서 티켓을 받으면 localStorage에도 저장하고, 각 게임의 '시작' 지점에 beginRun(board) 한 줄만 넣었다. 지뢰찾기는 첫 칸을 열 때, 반응속도는 첫 회, 멜론과 리듬은 시작 버튼, AI 대국은 랭킹 모드로 시작할 때.
배운 점
- 보안은 '완벽'이 아니라 '공격 비용을 얼마나 올리느냐'로 설계할 수 있다. 무엇을 못 막는지 문서에 적어 두는 것까지가 설계다.
- 시간은 위조하기 어려운 증거다. 서버가 찍은 시각 하나(티켓 발급)로 많은 규칙을 만들 수 있다.
- 검증 공식은 클라이언트와 서버가 같은 코드를 쓰게 한다. 복사본은 반드시 어긋난다.
- 거절 사유는 사용자에게는 뭉뚱그리고 로그에는 자세히.
- 진짜로 막아야 하는 곳(레이팅처럼 다른 사람에게 영향을 주는 값)은 결국 입력을 받아 서버가 다시 돌려 보는 방식으로 갔다. 이 랭킹 방어는 그 전 단계로서 충분히 제 몫을 했다.