Skip to content

MSG-596 feat: 도감 요약의 방문 행정동 수를 영상이 아니라 행정동별 집계에서 센다 - #285

Merged
s13121312 merged 3 commits into
developfrom
feature/MSG-596-collection-summary-region-stats
Sep 23, 2026
Merged

s13121312 merged 3 commits into
developfrom
feature/MSG-596-collection-summary-region-stats

Conversation

@s13121312

@s13121312 s13121312 commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

🎫 관련 티켓

  • Closes MSG-596
  • 지표 의미 정본: MSG-246 (by-grid 귀속·DELETED 제외·BLINDED 포함), 집계 테이블 출처: MSG-167 (탐험률)

작업 내용

도감 요약 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
Loading

커밋 3개입니다.

  1. feat UserGridRepository.getCollectionSummary 서브쿼리 1개 교체 + Javadoc 갱신. 테스트 픽스처 occupy()에 운영 쓰기 경로와 같은 refreshRegionStats 호출 1줄 추가.
  2. chore 실측 자산. scripts/bench-msg596*.sql(적재·정리·pg_stat_statements·region_stats 백필과 등가성 검사), bench-msg596-stages.py(Prometheus 계단표), bench-msg596-capture.sh(Grafana PNG), k6 load-test/k6/collection-summary-benchmark.js(ramp·헤비 혼합), 대시보드 fillmap-collection-summary.json, monitoring compose에 cAdvisor2·Grafana 렌더러 추가.
  3. docs 스펙 신규, status.md usergrid 절 1줄, 설계 캡처 3장.

변경된 쿼리 전문입니다.

-- before
(SELECT COUNT(DISTINCT g.region_code)::int
	FROM videos v
	JOIN grids g ON g.grid_id = v.grid_id
	WHERE v.user_id = :userId AND v.status <> 'DELETED') AS "visitedRegionCount",
-- after
(SELECT COUNT(*)::int
	FROM region_stats WHERE user_id = :userId AND collected_count > 0) AS "visitedRegionCount",

실측은 로컬(PostgreSQL 16 컨테이너, 앱·DB·k6가 노트북 한 대, 격자 61만·영상 135만)이라 절대값보다 단계 간 비율을 봐 주세요.

사용자 개선 전 (단발) 개선 후 (단발)
보통 (격자 384·영상 2,000) 27ms · 7,739블록 (병렬 워커 기동 20ms 포함) 1ms 미만
헤비 (격자 38만·영상 80만) 0.7~1.4s · 정렬 디스크 spill 11MB 4~6ms · 526블록
부하 (50→1,000 rps 계단, 헤비 요청 5% 혼합) 붕괴 계단 비고
개선 전 50 rps 응답 중앙값 34s, 커넥션 풀 대기 190, DB CPU 9.6코어
개선 후 1,000 rps 통과 dropped 0 · 500 0 · 풀 대기 0, p50 1.6ms, DB CPU 1.1코어

테스트: UserGridRepositoryTest 31건(MSG-246 의미 9건 포함) 무수정 통과, 관련 테스트 포함 74건 green. 전체 스위트는 로컬 DB에 벤치 시드가 남아 있어 CI에 맡깁니다.

🤔 고민한 내용

왜 인덱스가 아니라 집계 테이블인가. 답은 "행정동 몇 개"인데 세는 대상이 영상 전부였습니다. 인덱스로는 세는 대상의 크기가 줄지 않습니다. 단계별로 실측했고 앞의 셋은 기각했습니다.

단계 변경 헤비 5% 혼합 붕괴 계단 기각 이유
1 세는 축을 videos에서 user_grids로 300 rps 헤비 사용자는 격자 38만 행 병렬 해시 조인이 남음. 요청마다 병렬 워커를 띄워 도커 /dev/shm 고갈로 500 1,964건
2 커버링 인덱스 2개 150 rps 보통 사용자 블록 −25%뿐, 헤비 경로엔 플래너가 안 씀. 최종 쿼리에 불필요해 마이그레이션에 넣지 않음
3 COUNT(DISTINCT) → GROUP BY 300 rps 정렬 spill은 0이 됐지만 조인 행 수는 그대로
4 (채택) region_stats 1,000 rps 통과 탐험률용으로 이미 운영 중인 쓰기 시점 집계 재활용. 새 저장소·새 갱신 코드 없음

등가성 근거. 점령 롤백 규칙(격자의 내 영상이 전부 지워지면 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.

  • k6 합격선이 실패 1%·dropped 1,000까지 통과 → 둘 다 0으로 조임.
  • 계단표 스크립트의 컨테이너 id 하드코딩 → PG_CID 환경변수로 뺌.
  • 아래 리뷰 포인트 첫 항목(미해결).

👀 리뷰 포인트

  • [Codex P2, 결정 필요] 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 판정엔 영향 없음). 별도 안건으로 두었습니다.
  • 배포 전 dev에서 등가성 검사 SQL을 한 번 돌려 mismatch 0을 확인하는 절차를 스펙 작업 로그에 적었습니다. 운영 region_stats는 V1부터 쓰기 경로가 유지돼 왔지만 실측으로 확인하고 배포하는 편이 안전합니다.
  • monitoring compose의 cAdvisor는 privileged: true와 호스트 /·/sys 읽기 마운트가 필요합니다. 로컬 실측용이라 prod compose에는 넣지 않았습니다.

Footnotes

  1. 스칼라 서브쿼리: SELECT 목록 안에서 값 하나만 돌려주는 서브쿼리. 이 API는 지표 6개를 서브쿼리 6개로 한 번의 왕복에 가져옵니다. ↩

  2. cAdvisor: 컨테이너별 CPU·메모리·디스크 I/O를 Prometheus 형식으로 내보내는 구글의 에이전트. DB 컨테이너와 앱 프로세스 중 어느 쪽이 CPU를 먹는지 가르는 데 썼습니다. ↩

visitedRegionCount 서브쿼리를 videos ⨝ grids COUNT(DISTINCT)에서 region_stats 행 수로 교체. 로컬 실측 격자 38만 사용자 단발 1.4s → 5ms, 헤비 5% 혼합 부하 붕괴 계단 50 rps → 1,000 rps 통과. 등가성은 점령 롤백 규칙(벤치 사용자 204명 mismatch 0).
@claude

claude Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Claude finished @s13121312's task in 2m 27s —— View job


리뷰 완료

  • 컨텍스트 수집 (diff, 스펙 문서, 관련 파일)
  • 병렬 브랜치 경합 확인 (마이그레이션 번호, developCode 대역, Owner A/B 계약)
  • 스펙(docs/spec/MSG-596.md) 대비 구현 확인
  • 실행 시 드러나는 결함 확인 (트랜잭션, 동시성, 시간대, N+1)
  • PR 본문 주장 검증 (등가성, 무변경 주장 등)

확인한 범위: UserGridRepository.getCollectionSummary 쿼리 교체분, region_stats 쓰기 경로
(RegionStatsCommandServiceImpl·RegionRepository.refreshRegionStats·VideoServiceImpl의 두 호출
지점), 테스트 픽스처 변경, docs/spec/MSG-596.md, origin/develop 대비 마이그레이션/developCode
대역/Owner 계약 파일 변경 여부.

병렬 경합: 이 PR은 마이그레이션·developCode·인터페이스 시그니처를 건드리지 않는다(순수 쿼리 1개
교체 + 테스트/문서/실측 자산). 경합 지점 없음.

스펙 대비: 구현이 스펙 문서와 정확히 일치한다. 등가성 근거(점령 롤백 규칙 → user_grids 행 집합
= "영상이 있는 격자" 집합, BLINDED 양쪽 포함, 무라벨 양쪽 제외, collected_count > 0 필터 필요)를
RegionRepository.refreshRegionStats·VideoServiceImpl(라인 262, 421 두 호출 지점) 코드로 직접
대조해 확인했고, 주장대로다. UserGridRepositoryTest의 DELETED/BLINDED/무라벨 케이스도 실제로
그 조건을 검증하는 구조로 짜여 있다(픽스처가 조건별로 occupy() 호출 여부를 가른다).

발견 없음 — 인라인 코멘트 없음.

참고로 스펙에 이미 기록된 미해결 항목(RegionSeeder 백필 후 region_stats 미재계산 — 탐험률과 같은
구멍이 방문 행정동 수에도 얹힘)은 재확인만 했고 새 발견은 아니다. 담당자 판단 대기 상태로 남겨도
이 PR의 머지를 막을 사안은 아니라고 본다(운영 데이터는 라벨링이 이미 끝난 상태라 즉시 영향은 낮고,
닫는 SQL도 스펙에 준비돼 있음) — 다만 최종 판단은 팀 몫이다.

@github-actions

Copy link
Copy Markdown

테스트 커버리지

Overall Project 95.85% 🍏

There is no coverage information present for the Files changed

@s13121312
s13121312 merged commit 732f747 into develop Sep 23, 2026
2 checks passed
@s13121312
s13121312 deleted the feature/MSG-596-collection-summary-region-stats branch September 23, 2026 05:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant