Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .claude/docs/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
- MSG-238: V7 `idx_grids_region_code`(단순 btree — 167 §D5 예약 발동, region_code 주도 조회 최초 등장의 물리 기반. partial 기각)
- MSG-347: 격자 계산 EPSG:5179 전환 — `GridConstants`(STEP 상수 제거 → `CELL_SIZE_METERS`·`CRS_DEF_EPSG5179` 계약 문자열), `GridEncoder` 내부 교체(Proj4J 1.4.3, `CoordinateTransform` static 공유 — 스레드 안전 판정 근거 주석+병렬 2000회 회귀 테스트) + `viewportRange` 헬퍼 신설(SW·NE 2점 → 꼭짓점 4점 min/max, GridQueryServiceImpl 2곳·HotZoneServiceImpl 1곳 소비 — 5179 격자축 비평행로 인한 가장자리 셀 누락 방지), V28 데이터 전량 이행(스냅숏 구/신 판별·ON CONFLICT·mission target_count 클램프·region_stats 전량 재계산·검증 DO 4항목), `seed/zones.json` 48건 역산 재산출(`scripts/zones-draft/requantize-zones-5179.py` — 확정 시드 역산, 검수 보존), FE 정합성 픽스처 200건(`fixtures/grid-epsg5179-samples.json` crsDefinition 메타 포함 + `GridSampleFixtureTest`), 이행 통합 테스트 `usergrid/GridEpsg5179MigrationTest` 7종(V28 본문 런타임 실행·롤백). 의존성 proj4j implementation·proj4j-epsg testImplementation. FR-13(좌표 범위 검증)은 기구현 확인(`KoreaCoordinates`·3400) + 경계값 테스트만 추가. **2026-08-09 후속 수정**: V28의 좌표 변환이 proj4 문자열 직접 전달이라 호출당 0.846ms가 들어 dev 배포 이행이 수 분간 멈췄다(헬스체크 실패) — SRID 5179 전달(0.008ms, 106배)로 교체하고, 이행 전 두 경로를 221점에 실제로 돌려 좌표를 대조하는 검사를 신설(`proj4text` 컬럼 비교는 무효 — PostGIS가 그 컬럼 대신 PROJ 내장 EPSG DB를 쓰는 것을 실측 확인). dev 실측 `execution_time 245348`ms · `mission_grids` 48,134행
- MSG-349: 지도 응답 3종에 행정동 이름 동봉(MSG-341 "폴백 미제공" 결정 3행을 뒤집음) — 뷰포트 실경로 쿼리 3종에 `LEFT JOIN regions`(+`OccupiedGridProjection.getRegionName`, 접근 B는 무변경·게터 null), 단일 격자 `getCell`은 중심점 재판정(`RegionQueryService.resolveByPoint` 주입, 미점령 격자도 이름 — grids 행 없이도. `KoreaCoordinates.isOutOfService` 가드로 6400 흡수, by-grid MSG-153 §D6 동형), 핫구역은 `GridRepository.findRegionNames`(IN 최대 50, INNER JOIN — regions.region_name NOT NULL+FK라 null 값 불가) 일괄 1회 + `GridRegionNameProjection` 신설. DTO 3종 required+nullable 병기. regionName은 구역 안 격자에도 항상(FR-9 — 디자인 위치줄 시/구 재료). ZoneController·ZoneResponseDto의 "FE-local 계산" 잔재 문구 정정
- MSG-356: 줌아웃 행정 단위 집계 조회 — `GET /api/grids/aggregation`(`unit=DONG|SIGUNGU|SIDO` 대소문자 무관, 페이지 없음, 빈 뷰포트 200+빈 배열), `dto/RegionUnit`(codePrefixLength·nameTokenIndex·maxSpanDeg = 동 10/3/1.0·구 5/2/4.0·시 2/1/10.0)·`dto/RegionAggregateResponseDto`·`service/RegionAggregateView`·`repository/RegionAggregateProjection` 신규, `GridQueryService.getOccupiedAggregatesInViewport`(계약 4번째 메서드 — 기존 3종 불변, B는 friend 위임 소비)·`GridRepository.aggregateOccupiedInRange`(행정동 코드 접두 GROUP BY native — 같은 식 2회 바인딩 시 PG 거절 실측이라 출력 서수(GROUP BY 1) 사용, LEFT JOIN regions·NULL 키 한 그룹이 무귀속 버킷(FR-7), 이름은 split_part 토큰 MIN, 좌표는 center_geom ::geometry 평균)·`GridErrorCode.INVALID_AGGREGATION_UNIT(4405)`. `validateBounds(bounds, maxSpanDeg)` 공통화 — WGS84 범위 검증(±90/±180, NaN·무한대 흡수)이 뒤집힘·상한보다 선행하며 기존 개별·페이지 조회에도 소급(스펙 명시 승인, MSG-73/90 계약 회귀 없음 — 테스트 확인). 벤치 `GridAggregationExplainBenchmark`(GRID_BENCHMARK 게이트) — 1만 행 전국 SIDO EXPLAIN ANALYZE 10.9ms. 전환 축척 표(동 250~500m 등)는 FE 정책 — 서버는 단위 파라미터만. ADR: 위키 "ADR 줌아웃 클러스터링 행정 단위 집계 H3 기각"(cf-34537474)
- **없는 것**: `GridOccupationService`(write는 MSG-66이 흡수), `HotZoneService`(MSG-233 §D5로 `hotzone` 독립 패키지 배치 확정 — grid 아님)

### `usergrid` (Owner B · 구현 강정민) — 🟡 부분
Expand Down Expand Up @@ -155,6 +156,7 @@
- MSG-285: 친구 여부 read 판정 추가 — `@Transactional(readOnly = true)` 판정(요청 시점 실시간이라 친구 삭제가 다음 요청부터 즉시 반영, 캐시·비정규화 없음). 내부는 MSG-186과 공유하는 무잠금 `existsAcceptedPair` 소비(`findPair`는 `PESSIMISTIC_WRITE` 쓰기용이라 재생 판정마다 행 `FOR UPDATE` 가 걸려 재사용 금지). video 도메인이 FRIENDS 재생 판정에서 소비(B-내부, 계약 인터페이스 아님). **선언 위치는 MSG-312에서 이동** — 당시엔 `FriendService.isFriend` 였고 지금은 `FriendshipQueryService.isFriend` 다
- MSG-187: 친구 도감 레이어 — `FriendController` +2(`GET /{userId}/grids` 뷰포트 커서 · `GET /{userId}/grids/{gridId}/videos` 무페이징 최신순), `FriendService.getFriendGrids`·`getFriendGridVideos`(+Impl — `requireFriend` 가드로 9424 은닉 판정 단일화, getFriendProfile 도 수렴). 뷰포트는 `GridQueryService` 4-인자 **무변경 재사용**(friend 가 신규 크로스 오너 소비자 — userId 에 친구 ID 주입, 검증·에러 4401~4404·`OccupiedGridPageResponseDto` 전부 grid 계약 그대로, grid 패키지 diff 0). 영상 목록 `VideoRepository.findFriendGridVideos`(ACTIVE·READY·visibility IN(PUBLIC,FRIENDS)·`created_at DESC, id DESC`) + `VideoService.getFriendGridVideos`(presign+매핑, 친구 판정은 호출자 책임 명시) + `dto/FriendGridVideoResponseDto`(4필드). D6: `getCollectionGridsForFriend` LATERAL 썸네일 게이트 PUBLIC→IN('PUBLIC','FRIENDS') — MSG-186 예약 TODO 이행(FRIENDS 재생·썸네일 정합). **빈 순환**: MSG-285(video→friend)와 이번(friend→video)으로 상호 의존이 생겨 `FriendServiceImpl`이 `ObjectProvider<VideoService>` 지연 조회로 생성 순환만 회피했었다 — **MSG-312에서 leaf 빈 분리로 구조 해소, 지연 조회 제거**. 마이그레이션·인덱스·전역 PUBLIC 쿼리·신규 에러코드 전부 0
- MSG-312: video↔friend 상호 의존 해소 — `service/FriendshipQueryService`(+Impl) 신설. 친구 판정만 하고 `FriendshipRepository` 하나만 의존하는 leaf 라 어떤 서비스 순환에도 끼지 않는다. `FriendService.isFriend` 는 **제거**(계약에서 삭제 — 소비자였던 `VideoServiceImpl` 이 leaf 로 이동, friend 내부 `requireFriend` 도 leaf 경유해 판정 코드가 한 곳). `FriendServiceImpl` 의 `ObjectProvider<VideoService>` → `VideoService` 생성자 직접 주입 복원. 의존 방향은 `FriendServiceImpl → VideoServiceImpl → FriendshipQueryServiceImpl → FriendshipRepository` 단방향 — 지연 조회 없이 컨텍스트가 뜨는 것이 순환 해소의 증거다(이전 배선은 `BeanCurrentlyInCreationException`). 트랜잭션 경계: leaf 의 `@Transactional(readOnly = true)` 는 전파 REQUIRED 기본이라 호출자(재생 판정·`requireFriend`)가 이미 트랜잭션 안이면 새로 열지 않고 참여한다 — 실행 SQL 은 이전과 같은 `existsAcceptedPair` 단건이고 횟수도 그대로다. 쿼리·응답·에러코드·마이그레이션 전부 0 (동작 무변경 리팩터링)
- MSG-356: 친구 도감 집계 조회 — `FriendService.getFriendGridAggregates(userId, targetUserId, bounds, unit)`(requireFriend 후 `GridQueryService.getOccupiedAggregatesInViewport` 위임 1줄 — 검증 코드 0줄 원칙(MSG-187 D2) 유지), `GET /api/friends/{userId}/grids/aggregation`(`toUnit`은 GridController 동형 사본, 응답은 grid의 `RegionAggregateResponseDto` 재사용). 실패는 기존 9424 단일 응답 재사용(신규 에러코드 0)
- **없는 것**: 도감 공개 범위 설정(Phase 2+)·차단(후속)·코드 재발급(Phase 2+ — MSG-188 종결로 유예 확정)·친구 목록 페이지네이션(수십 명 규모 전제)

### `hotzone` (Owner A · 구현 성민) — ✅ 완성 (MVP 범위)
Expand Down
139 changes: 139 additions & 0 deletions docs/prd/MSG-356-prd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# PRD: 지도 축소 시 격자 집계 조회 (줌아웃 클러스터링)

> 티켓: MSG-356 · 작성일: 2026-08-10 · 작성: prd-writer
> 상태: 검토됨 (2026-08-10 정민 승인 — 집계 단위는 행정 3단(동/구/시), 레이어는 점령 격자 조회 경로만, bbox는 단위별 차등으로 확정. 500m 묶음은 실측 후 확장 지점) <!-- 수명주기: 초안 → 검토됨(사용자 승인 시 게이트가 갱신) → 확정 -->

## 1. 문제 상황

지도 홈을 축소해도 서버에는 개별 100m 격자 조회밖에 없다. 넓은 시야에서 점령 격자가
수백에서 수천 개면 전량을 내려보내며 페이지를 넘기는데, 화면은 그 밀도를 읽지 못하고
전송량만 커진다. 지도 홈 디자인에는 줌아웃 클러스터링[^1] 화면이 있는데 그걸 받쳐 줄
조회가 서버에 없는 상태다.

2026-08-08 김민수 멘토링(정리 cf-33685556, 13·14·15·18절)이 방향을 확정했다. 축소 상태에서
100m 격자 전량 전송은 비효율이므로 축척에 따라 집계 표현으로 전환하고, 서버가 적당히 압축한
집계를 내려주면 프론트가 마커[^2] 병합으로 마무리한다. 전환 지점은 줌 레벨 숫자가 아니라
화면당 약 100객체 기준으로 실측해 정한다.

기존에 FE 연동 가이드(8/7)가 적어 둔 "클러스터는 프론트 로컬 산술" 서술은 멘토링 이전
결정이라 이 티켓이 대체한다 (2026-08-10 사용자 지시).

## 2. 목적 · 목표

- **목적**: 축소한 시야에서도 도감 분포를 한눈에 보여주되, 전송량과 화면 객체 수를 읽을 수
있는 수준으로 유지한다.
- **목표**:
- FE가 집계 단위(동, 구, 시)를 파라미터로 골라, 뷰포트[^3] 안 점령 격자의 단위별 집계
목록(이름, 대표 좌표, 격자 수)을 한 번에 받는다
- 친구 도감 레이어도 같은 조회로 집계를 받는다
- 어느 단위로 묶어 세어도 개별 격자 조회와 총합이 일치한다
- 구현 후 FE와 함께 전환 기준(화면당 객체 수)을 실측해 기록으로 남긴다 (티켓 완료 조건)
- **비목표(스코프 제외)**:
- 서버가 전환 줌을 정하는 것: 전환은 FE 몫이다. 합의된 축척[^4] 기준은 100m대 개별 격자,
250m~500m 동, 1km~8km 구, 16km~전체 시 (2026-08-10 FE 합의). 서버는 단위 파라미터만 받고
전환점을 갖지 않는다
- 격자 시프트(500m 묶음) 클러스터: 이번에 만들지 않는다. 멘토링 사다리(13절)의 "중간 집계"
단계를 의도적으로 생략하는 결정이라, 서버가 내려주는 가장 촘촘한 집계는 동 단위가 된다.
동 마커가 너무 성기다는 실측이 나오면 집계 단위 하나를 추가하는 확장으로 대응한다
(2026-08-10 확정)
- 핫구역 집계: 상위 50개 제한이라 화면당 객체 수 기준을 이미 만족한다. 묶을 것이 없다
- 전체 공개 지도(타인 데이터 합산): 프라이버시 미확정 그대로 Phase 2
- 개별 격자 조회의 계약 변경: MSG-73/90 계약 불변

## 3. 기능 요구사항

| ID | 요구사항 | 우선순위 |
|----|----------|----------|
| FR-1 | 사용자는 뷰포트와 집계 단위(동, 구, 시)를 지정해 그 범위 안 자기 점령 격자의 단위별 집계 목록을 페이지 없이 한 번에 받는다 | Must |
| FR-2 | 집계 항목마다 단위 이름(예: 부전2동, 부산진구, 부산광역시), 마커 표시용 대표 좌표, 점령 격자 수가 담긴다 | Must |
| FR-3 | 집계의 격자 수 합은 같은 뷰포트 개별 격자 조회의 총 개수와 일치한다. FE가 항목을 더 묶어 합산해도 값이 어긋나지 않는다 | Must |
| FR-4 | 친구 도감 레이어도 같은 집계를 받는다. 친구 관계 검증은 기존 친구 뷰포트 조회와 동일하게 요청 시점 실시간이다 | Must |
| FR-5 | 점령 격자가 없는 단위는 응답에 포함되지 않는다. 뷰포트 안에 점령 격자가 하나도 없으면 빈 목록으로 200 응답한다 | Must |
| FR-6 | 집계 조회의 뷰포트 허용 폭은 단위별로 다르게 두며, 시 단위는 전국 시야까지 허용한다. 개별 격자 조회의 0.5도 상한은 그대로다 (구체 수치는 스펙에서) | Must |
| FR-7 | 행정동이 판정되지 않은 격자(해상 등)도 집계 총합에서 누락되지 않는다. 어떤 항목으로 묶을지는 스펙에서 정한다 | Must |

## 4. 비기능 요구사항

| 분류 | 요구사항 |
|------|----------|
| 성능 | 시 단위 전국 시야가 가장 많은 행을 스캔한다. 이 경우에도 기존 뷰포트 조회 응답 목표(p95 300ms)를 준용한다. 신규 성능 목표는 세우지 않는다 |
| 보안/인가 | 본인 집계는 로그인 토큰으로, 친구 집계는 ACCEPTED 관계를 요청 시점에 검증한다 (기존 친구 레이어와 동일 정책) |
| 데이터 정합 | 집계와 개별 조회의 총합 일치(FR-3), 미판정 격자 포함(FR-7)이 정합 기준이다 |
| 운영 | 스키마 변경 없이 기존 데이터(격자의 행정동 라벨과 인덱스)로 성립할 것으로 본다. 마이그레이션이 필요해지면 스펙에서 판단한다 |

## 5. 시퀀스 다이어그램

```mermaid
sequenceDiagram
participant FE as FE (지도 홈)
participant API as GridController / FriendController
participant S as GridQueryService
participant DB as PostgreSQL
Note over FE: 축소 감지, 축척 표로 단위 선택<br/>(250m~500m 동, 1km~8km 구, 16km~ 시)
FE->>API: GET 집계 조회 (뷰포트, 단위)
API->>S: 집계 요청 (소유자 userId, 뷰포트, 단위)
Note over API,S: 친구 레이어는 관계 검증 후<br/>친구 userId로 같은 조회 위임
S->>DB: 뷰포트 안 점령 격자를 단위별로 집계
DB-->>S: 단위별 이름, 대표 좌표, 격자 수
S-->>FE: 집계 목록 (빈 뷰포트면 빈 목록)
Note over FE: 마커 렌더링, 필요 시 마커 병합(개수 합산)
```

## 6. 클래스 다이어그램

신규와 변경 타입만. 이름은 스펙에서 확정한다.

```mermaid
classDiagram
class GridQueryService {
<<interface, A 제공 B 소비 계약>>
+기존 뷰포트 조회 2종 (불변)
+뷰포트 집계 조회 (신규)
}
class GridController {
+집계 엔드포인트 (신규)
}
class FriendController {
+친구 집계 엔드포인트 (신규, 위임)
}
class RegionAggregateResponseDto {
<<신규>>
단위 이름, 대표 좌표, 격자 수
}
GridController --> GridQueryService
FriendController --> GridQueryService : 관계 검증 후 위임
GridQueryService --> RegionAggregateResponseDto
```

## 7. 변경 파일 목록

| 파일 | 변경 | Owner |
|------|------|-------|
| `src/main/java/com/msg/fillmap/grid/controller/GridController.java` | 수정 (집계 엔드포인트 추가) | A |
| `src/main/java/com/msg/fillmap/grid/service/GridQueryService.java` (+Impl) | 수정 (계약 메서드 추가, 기존 2종 불변) | A |
| `src/main/java/com/msg/fillmap/grid/repository/` | 수정 (집계 쿼리 추가) | A |
| `src/main/java/com/msg/fillmap/grid/dto/` | 신규 (집계 응답 DTO) | A |
| `src/main/java/com/msg/fillmap/friend/controller/FriendController.java` | 수정 (친구 집계 엔드포인트, 위임) | B |
| `src/main/java/com/msg/fillmap/friend/service/FriendServiceImpl.java` | 수정 (위임 추가) | B |
| `src/test/java/com/msg/fillmap/grid/`, `friend/` | 신규 테스트 | A / B |

마이그레이션 없음 예상. 집계 재료는 이미 있다. 격자마다 행정동 코드가 저장되어 있고
(MSG-167), 행정동 이름이 "부산광역시 부산진구 부전2동" 전체 경로 형식이라 구와 시 이름도
저장된 데이터에서 나온다 (코드 앞자리가 구와 시를 가리키는 행정동 코드[^5] 체계).

## 8. 미해결 질문

- [ ] **디자인 미확인**: 피그마 월 조회 한도 소진(2026-08-07)으로 줌아웃 클러스터 화면을 실측하지
못했다. 마커 표기 형식(이름과 개수 병기 여부 등)은 FE 몫이지만, 응답 필드가 그 조립에
충분한지 스펙 전에 확인한다
- [ ] 행정동 미판정 격자(FR-7)를 어떤 항목으로 묶을지 (별도 항목으로 낼지, 제외 불가만
보장할지)
- [ ] 구와 시 마커의 대표 좌표 산출 기준. 스펙 몫이되 FE 마커 위치와 얽히면 FE와 합의

[^1]: 클러스터링: 지도를 축소했을 때 낱개 마커를 개수 딸린 묶음 마커로 합쳐 보여주는 기법.
[^2]: 마커: 지도 위에 찍는 표시점. 여기서는 "부전2동 31"처럼 이름과 개수를 단 집계 표시.
[^3]: 뷰포트: 지금 화면에 보이는 지도 영역. 남서와 북동 두 모서리 좌표로 표현한다.
[^4]: 축척: 지도 축소 정도를 나타내는 눈금(스케일 바). 250m, 1km처럼 화면 눈금 길이가 실제
몇 미터인지로 읽는다.
[^5]: 행정동 코드: 행정안전부의 10자리 행정동 식별 코드. 앞 2자리가 시도, 앞 5자리가 시군구를
가리켜 코드만으로 상위 단위를 알 수 있다.
Loading
Loading