MSG-596 feat: 도감 요약의 방문 행정동 수를 영상이 아니라 행정동별 집계에서 센다 - #285
Conversation
visitedRegionCount 서브쿼리를 videos ⨝ grids COUNT(DISTINCT)에서 region_stats 행 수로 교체. 로컬 실측 격자 38만 사용자 단발 1.4s → 5ms, 헤비 5% 혼합 부하 붕괴 계단 50 rps → 1,000 rps 통과. 등가성은 점령 롤백 규칙(벤치 사용자 204명 mismatch 0).
|
Claude finished @s13121312's task in 2m 27s —— View job 리뷰 완료
확인한 범위: 병렬 경합: 이 PR은 마이그레이션·developCode·인터페이스 시그니처를 건드리지 않는다(순수 쿼리 1개 스펙 대비: 구현이 스펙 문서와 정확히 일치한다. 등가성 근거(점령 롤백 규칙 → 발견 없음 — 인라인 코멘트 없음. 참고로 스펙에 이미 기록된 미해결 항목(RegionSeeder 백필 후 |
테스트 커버리지
|
🎫 관련 티켓
작업 내용
도감 요약
GET /api/collections/summary의 지표 6개 중visitedRegionCount(방문한 서로 다른 행정동 수)만 사용자 영상 수에 비례해 느려졌습니다. 영상 1건당 1행을 읽고 격자를 조인한 뒤COUNT(DISTINCT)를 셌기 때문입니다. 이 PR은 그 서브쿼리1 하나를 이미 운영 중인 행정동별 집계 테이블region_stats를 읽는 것으로 바꿉니다. 응답 계약, 메서드 시그니처, 나머지 지표 5개, 스키마, 인덱스, 마이그레이션은 바뀌지 않습니다. PRD는 성능 개선(요구사항 불변)이라 면제이고, 스펙docs/spec/MSG-596.md에 실측표와 기각안을 남겼습니다.flowchart LR subgraph before["개선 전 (MSG-246)"] V[videos<br/>사용자 영상 전부] --> J[JOIN grids<br/>영상마다 격자 1회] --> D["COUNT(DISTINCT region_code)<br/>정렬 · 병렬 워커"] end subgraph after["개선 후 (MSG-596)"] RS["region_stats<br/>(user_id, region_code) PK 범위 스캔"] --> C["COUNT(*) WHERE collected_count > 0"] end U[업로드 · 삭제 트랜잭션] -. "첫 점령 · 점령 롤백마다<br/>RegionStatsCommandService.refresh" .-> RS커밋 3개입니다.
UserGridRepository.getCollectionSummary서브쿼리 1개 교체 + Javadoc 갱신. 테스트 픽스처occupy()에 운영 쓰기 경로와 같은refreshRegionStats호출 1줄 추가.scripts/bench-msg596*.sql(적재·정리·pg_stat_statements·region_stats 백필과 등가성 검사),bench-msg596-stages.py(Prometheus 계단표),bench-msg596-capture.sh(Grafana PNG), k6load-test/k6/collection-summary-benchmark.js(ramp·헤비 혼합), 대시보드fillmap-collection-summary.json, monitoring compose에 cAdvisor2·Grafana 렌더러 추가.status.mdusergrid 절 1줄, 설계 캡처 3장.변경된 쿼리 전문입니다.
실측은 로컬(PostgreSQL 16 컨테이너, 앱·DB·k6가 노트북 한 대, 격자 61만·영상 135만)이라 절대값보다 단계 간 비율을 봐 주세요.
테스트:
UserGridRepositoryTest31건(MSG-246 의미 9건 포함) 무수정 통과, 관련 테스트 포함 74건 green. 전체 스위트는 로컬 DB에 벤치 시드가 남아 있어 CI에 맡깁니다.🤔 고민한 내용
왜 인덱스가 아니라 집계 테이블인가. 답은 "행정동 몇 개"인데 세는 대상이 영상 전부였습니다. 인덱스로는 세는 대상의 크기가 줄지 않습니다. 단계별로 실측했고 앞의 셋은 기각했습니다.
videos에서user_grids로/dev/shm고갈로 500 1,964건COUNT(DISTINCT)→ GROUP BYregion_stats등가성 근거. 점령 롤백 규칙(격자의 내 영상이 전부 지워지면
user_grids행 삭제 → 그 순간refresh가 재계산)이라 "삭제되지 않은 영상이 있는 격자"와user_grids행이 같은 집합입니다. BLINDED는 롤백 경로를 타지 않아 양쪽 다 포함, 무라벨 격자(region_code IS NULL)는refresh가 행을 만들지 않아 양쪽 다 제외입니다. 롤백으로 0이 된 행은 지워지지 않고 남으므로collected_count > 0필터가 필요합니다. 벤치 사용자 204명 전원에서 before/after 값을 비교해 mismatch 0이었습니다(scripts/bench-msg596-region-stats-backfill.sql마지막 SQL).진실이 읽기 시점에서 쓰기 시점으로 옮겨갔습니다. 쿼리를 바꾸자마자 리포지토리 테스트 8건이 깨졌습니다. 픽스처가
user_grids에 직접 INSERT해서region_stats가 비어 있었기 때문입니다. 픽스처에 운영과 같은refresh호출을 넣어 해결했고, 이제 이 8건이 "쓰기 경로를 우회하는 코드"를 잡는 탐지기 역할을 합니다.Codex 리뷰 1라운드(gpt-5.6-sol · medium): P1 0, P2 3.
PG_CID환경변수로 뺌.👀 리뷰 포인트
RegionSeeder.run이 기동 시 무라벨 격자에 라벨을 백필하지만(backfillGridRegionCodes, MSG-167)region_stats는 다시 세지 않습니다. 종전 쿼리는 라벨을 실시간으로 읽어 백필 직후 반영됐고, 이 쿼리는 그 사용자가 그 행정동에서 다음 업로드·삭제를 하기 전까지 0으로 남습니다. 탐험률은 원래 같은 구멍이 있었고 이번에 방문 행정동 수까지 같은 구멍에 얹힙니다. 닫는 법은 백필이 1건 이상이면user_grids ⨝ grids ⨝ regions전 사용자 재계산 UPSERT 한 문장을 시더에서 한 번 더 도는 것입니다(위 백필 스크립트의 INSERT에서 사용자 조건만 뺀 형태, 기동 1회·멱등). 이 PR에서 같이 닫을지 별도 티켓으로 뺄지 의견 부탁드립니다.refresh가 재계산 방식인데 업로드 advisory lock이 pending S3 키 단위라, 같은 사용자의 같은 행정동·다른 격자 동시 업로드에서collected_count가 1 적게 남을 수 있습니다(다음 refresh에서 복구,> 0판정엔 영향 없음). 별도 안건으로 두었습니다.region_stats는 V1부터 쓰기 경로가 유지돼 왔지만 실측으로 확인하고 배포하는 편이 안전합니다.privileged: true와 호스트/·/sys읽기 마운트가 필요합니다. 로컬 실측용이라 prod compose에는 넣지 않았습니다.Footnotes
스칼라 서브쿼리: SELECT 목록 안에서 값 하나만 돌려주는 서브쿼리. 이 API는 지표 6개를 서브쿼리 6개로 한 번의 왕복에 가져옵니다. ↩
cAdvisor: 컨테이너별 CPU·메모리·디스크 I/O를 Prometheus 형식으로 내보내는 구글의 에이전트. DB 컨테이너와 앱 프로세스 중 어느 쪽이 CPU를 먹는지 가르는 데 썼습니다. ↩