[ 롤모임 운영일지 ] - 18. DB를 다 먹는 테이블 하나 — rawInfo를 밖으로 빼는 설계
설계 기록. 아직 실행하지 않았다. 코드도 인프라도 건드리지 않은 상태이고, 이 글은 13편에서 “별도 글감”이라고 미뤄뒀던 그 설계를 실측과 함께 정리해두는 것이다. 실행하고 나면 계획과 실제가 어디서 달랐는지 이어서 기록할 예정이다.
Supabase 요금 조사에서 나온 두 가지 원인 중 Realtime은 13편에서 정리했다. 남은 하나가 이번 주제다 — RiotMatch.rawInfo, Riot API에서 받아온 전적 원본 JSON을 통째로 담고 있는 컬럼. 이게 DB 용량의 90% 이상을 혼자 먹고 있다.
실측부터 — 한 달 사이 얼마나 자랐나
6월 25일에 작성한 비용 분석 문서(docs/MONETIZATION_PRICING.md)에 당시 수치가 남아 있어서, 오늘 같은 쿼리를 다시 돌려 비교할 수 있었다.
| 지표 | 6/25 (문서) | 7/27 (오늘 실측) |
|---|---|---|
| DB 전체 | 653 MB | 964 MB |
| RiotMatch 테이블 | 611 MB (94%) | 897 MB (93%) |
| RiotMatch 행 수 | 약 16,000 | 23,929 |
한 달 만에 DB가 47% 커졌고, 증가분의 거의 전부가 이 테이블이다. 하루 10MB 꼴로 자라는 셈이다. 유저가 폭증한 게 아니다 — 유저 데이터가 아니라 외부 API 캐시가 DB의 주인 행세를 하고 있는 것이다. Supabase 무료 한도(500MB)를 넘겨 유료 구간에 들어간 주범도 이 테이블이었다.
한 가지 더 재밌는 실측: octet_length 합으로 잰 rawInfo의 압축 전 크기는 1,728MB인데 테이블의 디스크 크기는 897MB다. Postgres가 TOAST로 알아서 압축해주고 있는 덕이다. 건당 평균은 압축 전 74KB — 한 판의 전적 JSON치고 꽤 크다. 10명 전원의 세부 지표에 룬·아이템·challenges까지 다 들어있으니 그럴 만도 하다.
이 데이터, 누가 읽나 — 쓰는 곳 한 곳, 읽는 곳 한 곳
옮기기 전에 코드에서 rawInfo의 실제 쓰임새를 전수 조사했다. 결과가 설계를 아주 쉽게 만들어줬다. 쓰는 곳이 정확히 한 곳:
// packages/api/src/lib/riot-matches.ts:114 — 매치 수집 시 1회 저장
prisma.riotMatch.create({
data: {
id: matchId,
queueId: info.queueId,
gameDuration: info.gameDuration,
gameEndAt,
rawInfo: JSON.stringify(info),
},
}),
읽는 곳도 정확히 한 곳이다. 매치 상세 페이지(GET /riot-match/:matchId) — 10명 전원의 챔피언·지표를 보여줄 때만 파싱한다:
// packages/api/src/routes/misc.ts:193
let info: RawInfo;
try { info = JSON.parse(match.rawInfo) as RawInfo; }
catch { res.status(500).json({ error: "매치 데이터 파싱 실패" }); return; }
핵심은 이거다: 통계·랭킹·승률은 rawInfo를 전혀 안 읽는다. 수집 시점에 우리 유저의 지표를 RiotMatchParticipant로 다 뽑아놓기 때문에, 뜨거운 데이터는 전부 그쪽에 있다. rawInfo는 “매치 상세 화면 하나를 위한 콜드 아카이브”인데 DB에서 제일 비싼 자리를 차지하고 있는 셈이다. 처음 만들 땐 이게 맞는 선택이었다 — JSON.stringify 한 줄이면 끝나고, 인프라 추가도 없고, participant 행들과 같은 트랜잭션에 묶인다. 문제는 그 한 줄의 비용이 매일 10MB씩 복리로 쌓인다는 걸 나중에야 알았다는 것.
설계 — DB엔 통계, 원본은 오브젝트 스토리지로
방향은 단순하다. 원본 JSON은 오브젝트 스토리지(Cloudflare R2 또는 GCS)에 두고, DB에는 지금처럼 추출된 통계만 남긴다.
-
키 설계:
riot-match/{matchId}.json.gz. matchId는 Riot이 보장하는 전역 유니크라 별도 매핑 테이블이 필요 없다 — DB에 참조 컬럼을 새로 추가하지 않고,rawInfo = ''(비어 있음)를 “밖으로 나갔음” 표시로 쓴다. -
읽기 경로: 상세 엔드포인트에서
rawInfo가 비어 있으면 스토리지에서 lazy-fetch. 상세 페이지는 조회 빈도가 낮고 수십 ms 추가 지연이 허용되는 화면이라 이 트레이드오프가 성립한다. - 실패 모드: 스토리지 장애 시 매치 상세 페이지만 죽고, 랭킹·통계·일반 화면은 영향이 없다. 지금은 DB가 죽으면 전부 죽는다 — 격리 관점에선 오히려 낫다.
- 압축: TOAST가 1,728MB를 897MB로 눌러주고 있었으니, gzip으로 올리면 그 이상을 기대할 수 있다(실측 전이라 수치는 안 적는다).
- R2냐 GCS냐: R2는 egress 무료 + S3 호환이 장점이고, GCS는 이미 GCP를 쓰고 있어 자격증명 관리가 단순하다. 이 용량(1~2GB)에선 어느 쪽이든 저장 요금이 월 몇백 원 수준이라 비용으로는 갈리지 않는다 — 아직 결정 안 했다.
멀티테넌시 관점의 보너스도 있다. 비용 문서에 “모임 50개면 전적 캐시가 수~수십 GB로 폭증 가능”이라고 적어놨었는데, 그 선형 증가를 DB 플랜 업그레이드가 아니라 훨씬 싼 스토리지 요금으로 받아낼 수 있게 된다.
마이그레이션 순서 (스케치 — 미실행)
- 버킷 생성 + 자격증명을 Cloud Run env로 주입, 업로드/다운로드 헬퍼 작성
- 신규 매치부터 이중쓰기 — DB rawInfo도 그대로 두고 스토리지에도 올린다
- 읽기 경로를 “스토리지 우선, 실패 시 DB 폴백”으로 전환 — 여기서 충분히 구운다
- 기존 23,929건 백필 — 스크립트로 업로드하고, 건별로 바이트 수 대조 검증
- 검증 끝난 행부터
rawInfo = ''로 비우기 - 공간 회수 —
VACUUM FULL "RiotMatch". 배타 락이 걸리는 작업이라 새벽 시간대에, 그리고 4~5단계가 완전히 끝난 뒤에만
각 단계에서 DB 폴백이 살아 있는 게 롤백 경로다. 6단계 전까지는 언제든 읽기 경로를 DB로 되돌리면 끝난다.
대안 — 그냥 지우는 것
사실 비용 문서(§4.4)에 적어뒀던 원안은 더 단순했다: 오래된 매치의 rawInfo를 retention으로 비우고 VACUUM. 통계는 Participant에서 나오니 아무것도 안 깨지고, 새 인프라도 필요 없다. 잃는 건 오래된 매치의 상세 페이지 하나다.
그런데도 스토리지 이전 쪽으로 기운 이유는, retention은 같은 결정을 계속 반복해야 하기 때문이다 — “몇 달치를 남길 것인가”라는 다이얼을 영원히 관리하게 된다. 옮겨두면 전부 남기고도 DB가 가볍다. 물론 “일단 retention부터 하고 이전은 나중에”라는 절충도 유효하다. 급한 불(용량)은 그게 더 빨리 꺼진다.
왜 아직 안 했나
- 조용히 실패하는 경로를 늘리는 일이라서. 13편에서 배운 게 정확히 이거였다 — 실패를 삼키는 외부 호출 경로는 로그를 파고들기 전까지 존재조차 안 보인다. 업로드 실패·백필 누락 같은 새 실패 모드에 대한 검증 계획 없이 시작하면 같은 종류의 버그를 하나 더 심는 셈이다.
- 백필이 프로덕션을 상대로 하는 작업이라서. 2만 건 업로드 자체는 별거 아니지만, 검증과 롤백 경로를 갖춘 스크립트로 만들 시간이 필요하다.
- 월 단위로 보면 아직 견딜 만해서. 유료 플랜 안에서 당장 터지는 건 아니다. 다만 하루 10MB라는 증가율은 실측으로 확인했으니, “언젠가”가 아니라 순번이 정해진 백로그다.
정리
- DB의 93%가 유저 데이터가 아니라 외부 API 캐시였고, 한 달 새 47% 자랐다. 요금 문제의 얼굴을 하고 있지만 본질은 데이터 배치(placement) 문제다.
- 쓰는 곳 한 곳, 읽는 곳 한 곳 — 옮기기 제일 좋은 형태의 데이터다. 뜨거운 통계는 이미 Participant 테이블로 분리돼 있었던 게 과거의 나가 잘한 일이다.
- 설계의 뼈대: 키는 matchId 그대로, 마커는 빈 문자열, 읽기는 lazy-fetch + DB 폴백, 마이그레이션은 이중쓰기 → 백필 → 검증 → 비우기 → VACUUM 순서.
- 실행하면 이어서 기록한다 — 특히 gzip 압축률과 상세 페이지 지연이 계획과 얼마나 다른지.