자막 캐시를 1000만 명 규모로 — 전역 공유 캐시의 두 빈틈(경합 insert + 무한 재긁기)
1000만 명까지 가는 설계에서 '실시간 스크래핑'은 비용·차단·법적으로 불가능하다. 답은 캐싱. videos 테이블은 이미 전역 공유 캐시였지만, 동시 insert 경합과 UNAVAILABLE 무한 재긁기 두 빈틈이 스케일에서 터진다.
질문: 1000만 명이면 자막을 어떻게 가져오나
답부터: 1000만 규모에서 "YouTube를 유저당 실시간으로 긁는" 방법은 전부 죽는다. 온디바이스 긁기는 대량 패턴으로 앱이 통째 차단되고, 서버/프록시는 비용·레이트리밋이 폭발하고, 서드파티 API는 수억 호출이면 천문학적이다. 실시간 스크래핑 위에 1000만 서비스를 세우는 건 기술 문제가 아니라 사업·법무 문제가 된다(구글이 협상 상대거나 적).
현실적 답은 둘이다: (1) 한 번 긁고 캐싱, 그리고 더 큰 규모에선 (2) 큐레이션/제휴. 이 글은 (1) 캐싱을 스케일에 맞게 단단히 한 기록이다.
핵심 통찰: 1000만 유저 ≠ 1000만 번 긁기
유저는 1000만이어도 고유 영상 수는 훨씬 적다. 인기 영상 하나를 수천 명이 본다. 그러니 캐시가 제대로면 실제 긁기는 고유 영상 1회뿐이다.
Mimi의 videos 테이블은 이미 이렇게 설계돼 있었다:
videos: youtube_id UNIQUE, user_id 컬럼 없음
→ 전역 공유 캐시. 자막(가장 비싼 자원)은 영상당 1행에 저장되어 모든 유저가 공유.유저 데이터(클립·리뷰·덱)는 user_id로 격리하면서, 공유 자원(영상+자막)은 전역 캐시 — 교과서적 분리다. importByUrl은 findByYoutubeId로 캐시 히트면 재사용, 미스일 때만 긁는다.
그런데 두 빈틈이 스케일에서 터진다
빈틈 1 — 동시 첫 import 경합
두 유저가 같은 새 영상을 동시에 import하면 둘 다 findByYoutubeId가 비어 캐시 미스 → 둘 다 save() → 진 쪽이 uk_videos_youtube_id 유니크 위반으로 DataIntegrityViolationException → 500. 인기 영상이면 1000만 규모에서 이건 예외가 아니라 일상이다.
find-then-insert는 요청 간 원자적이지 않다. → 고친 법: cache-fill을 fetchAndPersistRaceSafe로 감싸 유니크 위반을 잡고, 승자가 쓴 행을 다시 읽어 치유한다.
빈틈 2 — UNAVAILABLE 무한 재긁기
recoverIfNeeded가 READY 아닌 영상을 재import할 때마다 yt-dlp를 다시 돌렸다 — 이미 UNAVAILABLE로 마킹된 것까지. 인기 무자막 영상이면 import 시도마다 재긁기 → 헛된 호출 + 스스로 부른 YouTube 레이트리밋. 캐시가 부하 증폭기가 된다.
→ 고친 법: 서버 긁기는 status가 PENDING(한 번도 해결 안 됨)일 때만. UNAVAILABLE(시도했고 자막 없거나 차단됨)은 서버 재긁기 안 함. 단 클라가 보낸 자막(모바일, 가정 IP)은 항상 치유 허용 — 폰은 AWS 서버가 못 가져오는 자막을 가져올 수 있으니, UNAVAILABLE 행도 모바일이 살릴 수 있다.
커밋
df4e4d2 (수정), dfaeb69 (로그).
패턴 (재사용 교훈)
- UNIQUE 키 공유 캐시를 트랜잭션 밖에서 채우면 "진 경합" 경로가 필요하다 — 제약 위반을 잡고 재읽기. find-then-insert는 원자적이지 않다.
- "재import 시 self-heal"은 한 번도 안 해봄(PENDING, 재시도) 과 해봤는데 실패(UNAVAILABLE, 그만) 를 구분해야 스케일에서 부하 증폭기가 안 된다.
면접 한 줄
"1000만이면 실시간 스크래핑은 비용·차단·법적으로 불가능해서, youtube_id 전역 공유 캐시로 고유 영상만 1회 긁습니다. 동시 첫 import는 유니크 위반을 잡아 승자 행을 재사용하고, 무자막 영상은 재긁기하지 않아 캐시가 부하 증폭기가 되지 않게 합니다. 그 위 규모는 큐레이션/제휴로 전환합니다."