왜 원본을 다시 조회하지 않고 스냅샷으로 쌓는가

인스타그램 Graph API는 과거 시점의 지표를 돌려주지 않습니다. 오늘 조회하면 오늘의 팔로워 수를 줄 뿐이고, 지난달 15일의 값은 어디에도 없습니다. 크리에이터에게 성장 추이를 보여주려면 매 주기 값을 우리 쪽에 저장해 두는 수밖에 없습니다.

문제는 산수입니다. 계정이 4,800여 개, 계정마다 미디어가 수십에서 수백 개, 미디어마다 주기적으로 메트릭을 수집합니다. 이 곱셈이 쌓여서 현재 미디어 메트릭 스냅샷 테이블은 약 4,900만 행, 계정 정보 스냅샷은 약 510만 행이 됐습니다. 그리고 이 테이블 위에서 성장, 활성, 리텐션 통계와 주간 리포트가 매일 돌아야 합니다.

append-only 테이블과 두 개의 인덱스

스냅샷 테이블은 수정하지 않습니다. 수집 시점마다 새 행을 추가하기만 하니 쓰기 경합이 없고, 과거가 바뀌지 않으니 집계 결과도 언제 돌려도 같습니다. 대신 조회를 어떻게 받칠지가 문제였는데, 실제 조회 패턴을 뜯어보니 두 가지뿐이었습니다.

-- 패턴 1: 특정 미디어의 기간 추이 (미디어 상세, 리포트)
SELECT ... WHERE mediaId = ? AND periodEnd BETWEEN ? AND ?

-- 패턴 2: 기간 전체를 훑는 집계 (대시보드 통계, 정리 배치)
SELECT ... WHERE periodEnd >= ? AND periodEnd < ?

그래서 인덱스도 두 개만 걸었습니다. 복합 인덱스 (mediaId, periodEnd)가 패턴 1을 받고, 단독 인덱스 (periodEnd)가 패턴 2를 받습니다. 5,000만 행 테이블에서 혹시 몰라서 추가한 인덱스는 매 수집 주기마다 쓰기 비용과 스토리지로 청구서를 보냅니다. 조회 패턴에 없는 인덱스는 걸지 않는다는 규칙을 지키고 있습니다.

JSON 컬럼을 버리고 백필한 이유

초기 스키마는 메트릭 응답을 JSON 컬럼에 그대로 넣었습니다. API 응답이 바뀌어도 스키마를 안 고쳐도 되니 초기에는 합리적인 선택이었습니다. 그런데 통계 쿼리가 늘면서 한계가 왔습니다. JSON 추출 함수에는 인덱스를 못 걸고, 집계할 때마다 수천만 행의 JSON 파싱 비용을 내야 했습니다.

결국 자주 조회하는 지표를 정규 컬럼으로 승격하고, 기존 행은 SQL 백필 스크립트로 JSON에서 컬럼으로 옮겼습니다. 스키마 유연성이 필요한 시기와 조회 성능이 필요한 시기는 다릅니다. 그 전환 비용을 치를 타이밍을 놓치지 않는 게 중요하다는 걸 이때 배웠습니다.

수집 파이프라인, 크론 35개와 중복 실행 락

수집은 35개의 크론 잡이 나눠 맡습니다. 여기서 함정을 하나 밟았는데, 크론은 이전 실행이 끝났는지에 관심이 없습니다. API가 느려져서 수집이 주기를 넘기면 같은 잡이 겹쳐 돌고 스냅샷이 중복으로 쌓입니다. 그래서 모든 잡을 락으로 감쌌습니다.

async function withLock(jobName: string, fn: () => Promise<void>) {
  if (runningJobs.has(jobName)) {
    logger.warn(`Skipping ${jobName}: previous run still in progress`);
    return; // 겹치면 건너뛴다. 다음 주기가 곧 온다
  }
  runningJobs.add(jobName);
  try { await fn(); }
  finally { runningJobs.delete(jobName); }
}

실패는 Slack으로 알리되, 같은 잡의 반복 실패는 30분 쿨다운으로 묶었습니다. 새벽에 API가 흔들릴 때 알림 수백 개가 쏟아지는 대신 이 잡이 실패 중이라는 신호 하나가 옵니다.

남은 고민

다음 병목은 예측이 됩니다. 행이 계속 자라면 periodEnd 범위 스캔 비용도 같이 자랍니다. 기간 파티셔닝과 오래된 스냅샷의 집계 후 아카이빙을 검토하고 있고, 이번 백필처럼 필요해지기 조금 전에 움직이는 게 목표입니다.