commits

랭킹 시즌 아카이브 시스템 — 삭제 대신 보존, 그리고 실시간 동기화까지

R
이더
2026. 04. 08. PM 08:44 · 4 min read · 0

🤖 4577 in / 2000 out / 6577 total tokens

랭킹 리셋 요구사항이 들어왔을 때 가장 먼저 든 생각은 "그냥 DELETE 날리면 되겠네"였다. 근데 막상 UI를 그리다 보니 의문이 생겼다. 사용자가 3개월간 쌓은 기록을 확인 한 번에 날려버리는 게 맞나. 그래서 방향을 틀었다. 삭제가 아니라 아카이브다. 시즌이라는 개념을 도입해서 과거 기록은 쌓아두고, 현재 시즌만 새로 시작한다.

DB 설계는 의외로 간단했다. season_archives 테이블 하나 만들고, servers.current_season 컬럼만 추가하면 끝. 핵심은 트랜잭션이다. 현재 랭킹을 아카이브로 복사하고, 원본은 비우고, 시즌 카운터를 올리는 세 단계가 하나라도 실패하면 전부 롤백돼야 한다. 게임 서버에서 흔히 쓰는 패턴이라 어렵지 않았다.

javascript await client.query('BEGIN'); const seasonResult = await client.query( 'SELECT current_season FROM servers WHERE id = $1 FOR UPDATE', [serverId] ); const currentSeason = seasonResult.rows[0].current_season;

await client.query( INSERT INTO season_archives (server_id, season, ranking_data, archived_at) SELECT $1, $2, _agg(row_to_(r.*)), NOW() FROM rankings r WHERE r.server_id = $1, [serverId, currentSeason] );

await client.query('DELETE FROM rankings WHERE server_id = $1', [serverId]); await client.query( 'UPDATE servers SET current_season = current_season + 1 WHERE id = $1', [serverId] ); await client.query('COMMIT');

FOR UPDATE로 락을 잡은 건 동시에 두 호스트가 새 시즌 버튼을 누르는 상황을 방지하기 위해서다. 사설 서버라 봐봤자 호스트가 한 명이겠지만, 방어적으로 짜는 습관이 들어있다.

프론트엔드에서는 시즌 셀렉터 드롭다운이 핵심이었다. RankingModule_viewingSeason 변수를 뒀다. null이면 현재 시즌, 숫자면 과거 시즌. 캐시도 시즌별로 관리해야 해서 캐시 키에 시즌 번호를 붙였다. 여기서 좀 해맸다. 초기화할 때 _isHost 플래그를 안 넘겨줘서 새 시즌 버튼이 안 보이는 이슈가 있었다. 각 게임 HTML 파일마다 RankingModule.setHost() 호출을 추가해서 해결.

소켓 이벤트 newSeason은 모든 클라이언트에게 브로드캐스트한다. 누군가 새 시즌을 시작하면 다른 사람들도 즉시 UI가 갱신돼야 한다. 캐시도 invalidateCache()로 날려버린다. E2E 테스트는 Playwright로 짰는데, 테스트 서버를 직접 띄워서 API와 소켓을 모두 검증했다. 33개 테스트 케이스가 전부 통과했을 때는 뿌듯했다.

한 가지 아쉬운 점은 game_sessions 테이블을 이번 v1에서는 무시했다는 거다. 주문통계랑 탈것통계는 시즌과 무관하게 유지되는데, 이게 맞는 설계인지는 사용자 피드백 받아봐야 알 것 같다.

삭제는 순식간이지만, 보존은 구조가 필요하다. 시즌이라는 이름의 구조.

← 이전 글
AI 업데이트: 로컬 LLM 하드웨어 전쟁과 보안 AI의 진화
다음 글 →
AI 업데이트: 사이버보안 위협과 언론 노조의 AI 저항