연무장(전투 연습) 기능을 만들면서, 랭킹 데이터를 어디에, 어떻게 쌓을지 고민이 길었습니다. 배치로 한 번에 채우는 분포 집계와 달리, 연무장은 유저가 리플레이를 볼 때마다 한 줄씩 늘어납니다. 그래서 세 번째 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 UPDATE에 WHERE 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 = 0과 ROW_NUMBER() ... PARTITION BY가 랭킹 정합성을 맡습니다.
- 저장은 부가 기능입니다. 실패해도 Nexon 응답은 그대로 돌려줍니다.
별도 메시지 큐나 Postgres 없이, 개인 프로젝트 규모에서 조회가 곧 수집이 되게 만든 사례입니다.
'CS > 개인 프로젝트' 카테고리의 다른 글
| 한 프로세스에 서비스 세 개 (0) | 2026.09.06 |
|---|---|
| Rust + SQLite 백엔드, 인프라 대신 경계를 나누다 (0) | 2026.08.23 |
| SQLite는 한 번에 하나만 쓴다 — Writer 직렬화와 집계 DB 분리 (0) | 2026.08.16 |
| 메이플스토리 통계 서비스를 만들며 겪은 시행착오 (0) | 2026.07.19 |
| Cargo dependency 중복 분석기 SearchRustLib 소개 (0) | 2026.05.02 |
댓글