← 프로젝트 회고 · Backend
RETROSPECTIVE · 회고

결과 대신 입력을 보내게 하기: 격투 랭크전 AI전을 서버에서 다시 돌려 판정하기

Backend

픽셀 격투에 회원끼리 레이팅이 걸린 랭크전을 열었다. 문제는 사람이 적다는 것이었다. 큐에 들어가도 상대가 없으면 한참 기다리다 나가 버린다. 그래서 12초 안에 사람을 못 찾으면 비슷한 레이팅의 AI와 붙이기로 했다. 여기서 바로 다음 질문이 생겼다. AI전은 경기가 브라우저 안에서만 돌아가는데, '이겼다'는 결과를 어떻게 믿지?

결과를 받으면 위조할 수 있다

사람끼리 경기는 두 사람이 각자 결과를 신고하고, 서로 맞는지 볼 수 있다. AI전에는 맞춰 볼 상대가 없다. 브라우저가 { win: true }를 보내면 그걸 그대로 믿는 수밖에 없고, 그러면 개발자 도구 한 줄로 레이팅을 올릴 수 있다.

미니게임 랭킹에서는 1회용 티켓과 최소 소요 시간으로 '말이 안 되는 기록'만 거르는 정도로 타협했었다. 하지만 레이팅은 다른 사람과의 매칭에 바로 쓰이는 값이라, 그 정도로는 부족했다.

이미 가진 것: 결정론적 시뮬레이션

온라인 대전을 만들 때 롤백 넷코드를 쓰려고 게임 로직을 결정론적으로 만들어 뒀다. 같은 시작 상태와 같은 입력이 들어가면 몇 번을 돌려도 같은 프레임에 같은 결과가 나온다. 고정 프레임 단위로만 진행하고, 랜덤은 경기마다 받은 씨앗(seed)으로 만드는 난수만 쓴다.

AI도 마찬가지였다. AI는 입력을 만드는 또 하나의 함수일 뿐이고, 그 판단에 쓰는 난수도 같은 씨앗에서 나온다. 그렇다면 서버가 알아야 하는 건 씨앗과 사람의 입력뿐이다. AI의 입력은 서버가 직접 다시 만들 수 있다.

구조

  1. 매칭이 잡히면 서버가 씨앗과 AI 설정(레이팅에 맞는 단계)을 정해서 내려 준다.
  2. 브라우저는 평소처럼 경기를 하면서 내 입력을 프레임마다 기록한다.
  3. 경기가 끝나면 결과가 아니라 입력 기록을 보낸다. 대부분의 프레임은 같은 키를 누르고 있거나 아무것도 안 누르니, 같은 값이 이어지는 구간을 (값, 길이)로 묶는 RLE로 줄인다.
  4. 서버는 웹과 같은 시뮬레이션 코드와 AI 코드를 그대로 가져다, 같은 씨앗으로 처음부터 끝까지 다시 돌린다. 승패와 라운드 차, 남은 체력은 이 결과로 정한다.
// 서버: 브라우저가 보낸 입력만 받아서 결과를 직접 계산
const sim = createSim({ seed, p1: myChar, p2: botChar, map });
const bot = new FightAI(aiForRating(botRating), seed);
for (const input of expandRle(record)) {
sim.step(input, bot.decide(sim.state));
if (sim.state.over) break;
}
return judge(sim.state); // 승패·압도도는 서버가 정한 값

브라우저가 거짓 입력을 보내면? 그건 '그 입력대로 플레이한 경기'가 될 뿐이다. 이기는 입력열을 만들어 내는 건 실제로 이기는 것과 다르지 않다. 경기 결과를 위조하는 길은 막히고, 남는 건 매크로·봇 플레이 같은 다른 문제다.

같은 코드라는 전제를 지키기

이 방식은 브라우저와 서버가 정확히 같은 코드를 돌린다는 전제 위에 서 있다. 시뮬레이션이나 AI 판단이 한 줄만 달라도 서버의 다시 돌리기는 다른 경기가 된다. 배포 직후 옛 페이지를 열어 둔 사람이 있으면 바로 깨진다.

  1. 시뮬레이션 버전(빌드 ID)과 AI 버전을 합쳐 빌드 지문으로 쓰고, AI 판단 코드를 바꾸면 AI 버전을 올리는 규칙을 정했다.
  2. 큐에 들어올 때 지문을 같이 보내고, 서버와 다르면 AI 매칭은 하지 않고 사람만 찾으면서 새로고침을 안내한다.
  3. 서버는 웹의 격투 로직 폴더를 그대로 import한다. 도커 빌드에서 그 폴더를 복사하는 걸 빠뜨려 첫 배포가 실패했는데, 반대로 말하면 같은 파일을 쓴다는 게 빌드 단계에서 확인된다.

결과가 안 오는 경우

입력 기록을 보내지 않고 창을 닫으면 결과가 영영 안 온다. 지고 있을 때 나가면 기록이 안 남는 구멍이 생긴다. 그래서 경기가 시작된 뒤 나가기·기권, 12분 안에 기록이 안 옴, 기록이 경기 끝까지 이어지지 않음은 전부 패배로 처리했다.

그런데 처음 배포했을 때 캐릭터를 고르고 시작하자마자 '결과 확인 중'이 뜨더니 패배로 끝나는 버그가 있었다. 시작 신호를 보내는 순서가 꼬여서, 서버가 경기를 시작하기도 전에 '기록이 끝까지 안 왔다'로 판단한 것이었다. 실패를 패배로 처리하는 규칙은 공정하지만, 그만큼 서버 쪽 상태 전이가 정확해야 한다는 걸 바로 겪었다.

배운 점

  1. 클라이언트가 계산한 결과는 믿을 수 없지만, 결정론적 시스템에서는 입력만 받아 결과를 서버가 다시 만들 수 있다. 서버 권위(server authoritative)를 실시간으로 하지 않아도, 끝난 뒤 검증으로 같은 효과를 낸다.
  2. 롤백 넷코드 때문에 지켜 둔 결정론이 전혀 다른 문제(부정행위 방지)를 풀어 줬다. 좋은 제약은 나중에 다른 기능의 기반이 된다.
  3. 같은 코드를 두 곳에서 돌리면 버전이 어긋나는 순간을 반드시 감지해야 한다. 조용히 다른 결과를 내는 게 가장 위험하다.
  4. '결과가 안 오면 패배'처럼 실패를 불리하게 처리할수록, 서버가 실패를 잘못 판정하는 버그는 더 치명적이다.
Gunmo Lee