본문 바로가기
CS/개인 프로젝트

조회 트래픽으로 랭킹을 채운다

by lms0806 2026. 8. 30.
728x90
반응형

연무장(전투 연습) 기능을 만들면서, 랭킹 데이터를 어디에, 어떻게 쌓을지 고민이 길었습니다. 배치로 한 번에 채우는 분포 집계와 달리, 연무장은 유저가 리플레이를 볼 때마다 한 줄씩 늘어납니다. 그래서 세 번째 SQLite 파일을 두고, 조회 API 두 번으로 “스텁 → 채우기”를 나눴습니다.

왜 세 번째 DB인가

이전에 분포 집계 테이블을 주 DB에서 분리했을 때의 교훈은 단순했습니다. 쓰기 성격이 다른 데이터는 파일을 나눕니다. 그때는 “파일을 나누면 writer가 둘”이었습니다. 지금은 writer가 셋입니다.

파일 역할 채우는 방식
project.db 검색·세션·프로필 요청 시 upsert, 배치 재조회
project_distribution.db 유저 분포 집계 cron 배치
project_battle_practice.db 연무장 측정·직업별 랭킹 유저가 리플레이를 조회할 때

분포 DB와 연무장 DB의 차이는 누가 언제 쓰는지입니다. 분포는 cron이 돌리고, 연무장은 프론트가 리플레이 목록·결과를 볼 때 백엔드가 따라 저장합니다. 별도 수집 파이프라인을 두지 않아도, 이미 나가는 Nexon 프록시 트래픽에 태워 랭킹을 만듭니다.

 

각 DB마다 앱 레벨 쓰기 락을 둬서, SQLite 단일 writer 제약을 프로세스 안에서 직렬화합니다.

전체 흐름

연무장 랭킹은 두 단계로 쌓입니다.

1) 리플레이 목록 조회
   Nexon API → replay_id 목록 응답
   → 측정 테이블에 스텁 INSERT (DPS = 0)

2) 결과 조회 (유저가 특정 리플레이 클릭)
   Nexon API → DPS·데미지·플레이 시간
   → 같은 replay_id 행 UPDATE (직업·전투력 등)

스텁만 있고 아직 결과를 안 본 리플레이는 DPS가 0으로 남습니다. 랭킹 쿼리는 측정이 채워진 행만 골라서, 실제로 결과까지 연 기록만 목록에 올라갑니다.

저장이 실패해도 조회는 성공한다

연무장 저장은 부가 기능입니다. Nexon 응답이 본편이고, DB는 그 옆에 조용히 쌓습니다.

 

서비스 계층에서 저장 실패 시 warn만 남기고 API는 그대로 성공 응답을 돌려줍니다. 핸들러는 저장 결과를 기다리지 않습니다.

if let Err(error) = save_stub_rows(state, &rows).await {
    warn!(%error, "리플레이 스텁 저장 실패");
}
// 조회 응답은 위와 무관하게 반환

캐릭터 정보 API가 실패해도 DPS·데미지·시간은 저장하고, 직업·전투력만 비워 둘 수 있습니다. 랭킹에 안 올라가더라도 유저가 보고 있던 화면은 깨지지 않는다는 것이 전제입니다.

데이터 모델 (요약)

한 테이블에 스텁과 완성 행이 같이 있습니다. replay_id가 기준이고, 시즌(period_no)·캐릭터 식별자·닉네임·DPS·직업 정도만 랭킹에 씁니다. 나머지 컬럼은 상세 표시용입니다.

구분 저장 시점 랭킹 반영
스텁 리플레이 목록 조회 아직 (DPS = 0)
완성 행 결과 상세 조회 O (직업·DPS 채워짐)

기존 주 DB에 있던 연무장 테이블은 기동 시 세 번째 파일로 한 번 이전한 뒤 주 DB에서는 제거했습니다. 운영 중 데이터를 잃지 않게 마이그레이션만 넣었습니다.

SQL에서 핵심만

전체 쿼리는 공개하지 않고, 설계를 이해하는 데 필요한 패턴만 적습니다.

1. 스텁 UPSERT — 이미 채워진 기록은 덮지 않음

목록 API를 여러 번 호출해도, 이미 DPS가 채워진 행은 스텁 UPDATE 대상이 아닙니다.

ON CONFLICT(replay_id) DO UPDATE SET ...
WHERE ... total_dps = 0

DO UPDATEWHERE total_dps = 0을 붙이는 것이 핵심입니다. 결과 조회로 메트릭이 들어간 뒤에는 닉네임·시즌만 바뀌어도 스텁 단계가 덮어쓰지 않습니다.

2. 메트릭 채우기 — 빈 값은 기존을 유지

결과 조회 시 일부 필드만 API에서 오거나 실패할 수 있습니다. CASE빈 문자열이면 기존 값을 유지하도록 했습니다.

3. 랭킹 — 시즌·캐릭터당 최고 기록만

같은 캐릭터가 한 시즌에 여러 리플레이를 남길 수 있습니다. 윈도 함수로 묶어 캐릭터·시즌당 DPS 최고 한 줄만 남깁니다.

ROW_NUMBER() OVER (
    PARTITION BY <캐릭터 식별자>, period_no
    ORDER BY total_dps DESC, replay_id
) AS rn
-- ...
WHERE rn = 1

동점이면 replay_id로 순서를 고정해 결과가 흔들리지 않게 했습니다. API 응답에는 DPS 대비 전투력 비율 같은 파생 값도 붙일 수 있습니다.

계층 배치

저장 로직은 HTTP 핸들러에 두지 않았습니다.

  • API: 외부 API 프록시 후 서비스 호출만
  • 서비스: 스텁·메트릭 조립, 저장 실패 시 warn
  • 저장소: SQL만 담당
  • 랭킹 API: 저장소 읽기 전용

조회 트래픽으로 쓰기가 발생하지만, 쓰기 실패가 조회를 망가뜨리지 않게 서비스 경계에서 끊었습니다.

정리

  • 세 번째 SQLite는 “연무장만의 쓰기 패턴”을 격리하기 위한 선택이었습니다.
  • 분포 DB처럼 배치가 아니라, 이미 있는 조회 API에 저장을 얹었습니다.
  • 스텁(total_dps = 0)과 채우기(UPDATE)를 나눠, 목록 조회와 상세 조회의 책임을 분리했습니다.
  • ON CONFLICT ... WHERE total_dps = 0ROW_NUMBER() ... PARTITION BY가 랭킹 정합성을 맡습니다.
  • 저장은 부가 기능입니다. 실패해도 Nexon 응답은 그대로 돌려줍니다.

별도 메시지 큐나 Postgres 없이, 개인 프로젝트 규모에서 조회가 곧 수집이 되게 만든 사례입니다.

728x90
반응형

댓글