FillMap · MSG-385 · 2026-08-15

팝업 판정 반경 40m,
9×9를 좁힌 과정

팝업스토어는 가게 하나, 좌표로는 점 하나다. 그런데 방문 인정 범위는 축제와 같은 9×9 격자, 곧 사방 450m였다. 옆 블록 카페에서 영상을 올려도 "그 팝업에 갔다"는 스탬프가 나왔다. 이 문서는 그 범위를 반경 40m로 좁히며 했던 고민과 실제 수정 내용을 남긴다.

판정 격자를 81칸에서 1~4칸으로 좁혔다. 걸침 판정은 원이 아니라 축 독립 40m (한 변 80m 정사각형)로 했고, 이미 적재된 225건은 시더 재실행의 차등 교체로 같은 규칙이 됐다. dev 실측으로 판정 격자 행이 약 1만 8천에서 722로 줄었고 스탬프는 한 건도 건드리지 않았다.

티켓
MSG-385 팝업 방문 인정 범위를 반경 40m로 좁히기
PR
#183 (구현) · #184 (재산출 실측 기록)
정본
docs/spec/MSG-385.md · PRD docs/prd/mission-map-explore.md §8
변경 파일
구현 2 + 테스트 3 (마이그레이션·API·에러코드 0)

1. 왜 40m인가

칸 수를 고정하지 않고 반경으로 정한 이유부터. 팝업 좌표는 격자 안 어디에나 떨어진다. 가운데면 자기 칸 하나로 충분하지만, 경계에 붙어 있으면 옆 칸에 서 있는 사람도 사실상 가게 앞이다. 반경으로 정의하면 위치에 따라 1칸, 2칸, 4칸이 자연스럽게 나온다.

반경 값은 실제 팝업 600곳의 좌표로 분포를 계산해 골랐다.

반경1칸2칸4칸
25m22%57%21%
30m15%51%35%
40m3%31%66%
50m0%0%100%

기준은 도심 GPS 오차가 10~30m라는 사실이다. 반경을 30m 아래로 내리면 1칸짜리 팝업이 15%를 넘는데, 그 팝업들은 가게 안에 서 있어도 위치가 한 번 튀면 인정이 안 된다. 40m는 그 오차를 견디는 최소값이다. 축제는 행사장이 실제로 넓으므로 9×9를 그대로 둔다.

2. 첫 번째 고민, 원인가 정사각형인가

"반경 40m 안에 걸치는 격자"를 구현하는 방법이 둘 있었다. 유클리드 원판과 격자의 정확한 교차를 계산하거나, 축마다 따로 40m를 재는 것(체비쇼프 거리, 기하로는 한 변 80m 정사각형)이다. 축 독립 쪽을 골랐고 근거는 셋이다.

가운데 → 1칸 경계 근처 → 2칸 (1×2) 모서리 근처 → 4칸 (2×2)
점을 중심으로 한 색칠된 작은 사각형이 사방 40m(한 변 80m)다. 셀 한 변이 100m라 이 사각형은 이웃 한 칸을 넘지 못하고, 걸치는 칸은 항상 1, 2, 4칸 중 하나다.

근거 1. 승인된 실측이 이 규칙의 산출이다

위 분포표(3%, 31%, 66%)는 축 독립 규칙으로 계산한 값이다. 셀 안 위치를 균등 분포로 보면 이 규칙의 이론 분포가 1칸 4%, 2칸 32%, 4칸 64%로 실측과 맞는다. 원판 교차로 구현하면 모서리 셀만 빠지는 L자 3칸이 약 14% 생기고 4칸이 약 50%로 떨어져, 40m를 고르는 데 쓴 근거 수치와 구현이 서로 어긋난다.

근거 2. L자 3칸은 40m를 고른 이유를 스스로 무너뜨린다

원판 교차에서 빠지는 모서리 셀의 최근접점은 팝업에서 40~57m다. 위치 오차가 10~30m라면 가게 안 사용자의 좌표가 대각선 방향으로 그 셀에 찍힐 수 있고, 그 업로드는 인정을 못 받는다. 반경을 30m 아래로 안 내린 이유(위치 튐 방어)가 모서리 방향에서만 무효가 되는 셈이다.

근거 3. 산출이 항상 직사각형이라 화면 계약과 정확히 맞는다

팝업의 렌더 형상 BoxShape는 격자 집합을 감싸는 경계 사각형이다. 1, 2, 4칸은 전부 직사각형이라 경계 사각형이 곧 판정 집합과 일치한다. L자 3칸이 생기면 미션 선택 시 그려지는 판정 범위가 실제로는 판정하지 않는 셀 하나를 포함해 화면이 거짓말을 하게 된다.

수식은 두 줄이다. EPSG:5179 미터 좌표 v(x 또는 y), 셀 한 변 L = 100, 반경 r = 40일 때:

minIdx = floor((v - r) / L)
maxIdx = ceil((v + r) / L) - 1     // 정확히 닿기만 하면 제외 (반열림 정합)

격자 셀은 [n·100, (n+1)·100) 반열림 구간이고 이 산출의 정사각형은 열린 집합이다. 40m가 셀 경계에 정확히 닿기만 하면 그 셀은 걸친 것이 아니라 제외되며, ceil - 1이 그 제외를 수식으로 만든다. 이 계산은 GridEncoder.radiusRange로 순수 추가했다. 좌표를 셀로 옮기는 5179 변환과 양자화가 그 클래스의 비공개 내부라, 시더가 자체 변환을 드는 안은 변환 객체 스레드 안전 근거까지 복제하게 돼 기각했다.

3. 두 번째 고민, 이미 적재된 227건을 어떻게 바꾸나

신규 적재만 바꾸면 기존 팝업은 영원히 81칸으로 남는다. 처음엔 "시더 재실행 vs Flyway 마이그레이션"의 선택인 줄 알았는데, 코드를 열어 보니 전제가 틀려 있었다. 시더의 dedupe 경로는 이미 아는 팝업을 만나면 메타데이터만 갱신하고 격자를 아예 건드리지 않는다. 재실행만으로는 아무 일도 일어나지 않는다. 실제 선택지는 "갱신 경로에 격자 재산출을 넣는다 vs 마이그레이션"이었다.

기각 · Flyway 마이그레이션

재산출에 필요한 원좌표가 DB에 없다. missions에 좌표 컬럼이 없고, 격자 블록에서 복원할 수 있는 건 근사 중심 셀뿐이다. 1, 2, 4칸을 가르는 정보(좌표가 셀 안 어디에 떨어졌나)는 양자화에서 이미 소실됐다. 셀 중심을 대입하면 사방 50m > 40m라 전량이 1칸이 되는 오답이 나온다.

채택 · 시더 갱신 경로의 차등 교체

좌표는 소스 스냅샷(popups.jsonl)에만 있고, 그걸 읽는 코드가 시더다. 산출 규칙도 Java 한 곳에 남는다. dedupe 경로에 regrid를 넣어, 기존 격자와 새 산출의 차집합만큼만 지우고 넣는다.

통삭제가 아니라 차집합인 이유

"다 지우고 다시 넣기"가 더 단순해 보이지만 JPA에서는 함정이 있다. 새 집합은 구 81칸 블록과 거의 언제나 겹치므로, 같은 PK (mission_id, grid_id)를 지웠다가 다시 넣게 된다. Hibernate의 쓰기 지연은 한 flush 안에서 INSERT를 DELETE보다 먼저 내보내서 이 코드는 PK 충돌로 터진다. 차집합 교체는 삭제 집합과 삽입 집합의 PK가 구성상 겹치지 않아 이 함정이 원천적으로 없고, 두 집합이 다 비면 그 자체로 무변경이라 멱등이 따로 필요 없다.

// 유지 = 기존 ∩ 산출,  삭제 = 기존 − 산출,  삽입 = 산출 − 기존
private boolean regrid(Mission mission, List<String> desiredGridIds, List<MissionGrid> currentGrids) {
	Set<String> desired = Set.copyOf(desiredGridIds);
	Set<String> current = currentGrids.stream().map(MissionGrid::getGridId).collect(Collectors.toSet());
	List<MissionGrid> removals = currentGrids.stream()
		.filter(grid -> !desired.contains(grid.getGridId())).toList();
	List<MissionGrid> additions = desired.stream().filter(gridId -> !current.contains(gridId))
		.map(gridId -> new MissionGrid(mission.getId(), gridId)).toList();
	if (removals.isEmpty() && additions.isEmpty()) {
		return false;
	}
	missionGridRepository.deleteAll(removals);   // bulk JPQL 금지 — 더티 체킹 유실 방지
	missionGridRepository.saveAll(additions);
	return true;
}

덤으로 잠복 결함 하나가 닫혔다. 지금까지는 팝가에서 좌표가 수정된 팝업도 격자가 옛 자리 그대로였는데, 차등 교체는 그 경우도 같은 경로로 옮겨 심는다. 그리고 이 시그니처(산출 목록을 인자로)는 스펙 초안의 PopupRecord 인자와 다른데, 다음 절의 축소가 같은 경로를 재사용하려면 이 형태여야 해서 스펙 쪽을 정정했다.

스탬프는 어떤 경로로도 건드리지 않는다. user_missionsmissions.id만 참조하고 격자를 참조하지 않아, 격자를 전부 갈아치워도 이미 받은 스탬프에 닿는 경로 자체가 없다. 범위 축소는 앞으로의 판정에만 적용된다는 비회수 원칙 그대로다.

4. 세 번째 고민, 스냅샷에서 사라진 팝업

차등 교체는 스냅샷에 있는 팝업만 지나간다. 팝가에서 내려가 스냅샷에서 사라졌지만 DB에는 미종료로 남은 팝업은 40m 참값을 계산할 수 없다. 그대로 두면 그 팝업들은 시작 시각이 오면 900m 판정으로 활성화된다. "다음 주기에 좁혀진다"는 보장도 없다. 내려간 팝업은 영영 안 돌아올 수 있다.

처분은 센트로이드 셀 1칸으로 축소다. 격자 인덱스의 축별 평균을 반올림한 셀이 집합 안에 있으면 그 셀, 없으면 체비쇼프 거리 최근접 셀(동률이면 y, x 오름차순)이다. 사방 450m 과대 판정이 최대 한 칸 어긋날 수 있는 1칸 근사로 줄어드는 것이라 수용했고, 미션을 통째로 종료 처리하는 안은 사용자가 진행 중일 수 있어 기각했다.

"81칸이면 축소 대상"이 아닌 이유, 실데이터가 반증했다

처음엔 "정확히 81칸인 행"을 레거시로 판정하고 블록의 min + 4로 중심을 복원하려 했다. 로컬 DB를 실제로 세어 보니 팝업 227건 중 온전한 9×9 정사각형은 168건뿐이었다. V28(EPSG:5179 전환)이 구 셀들을 개별 재사상하며 중복을 병합해서, 78~80칸짜리 13건과 81칸이지만 블록 폭이 8칸이 아닌 46건이 남아 있었다. 81칸 가드는 그 46건을 오통과시키고 min + 4는 깨진 블록에서 엉뚱한 셀을 집는다. 반면 칸 수 > 4 하나는 레거시 블록(78~81칸)과 새 규칙의 유효 집합(1~4칸)을 실측상 정확히 가른다.

완전성 가드, 잘린 스냅샷이 전량을 오축소하지 않게

축소의 트리거가 "스냅샷에 없음"이라는 부재 신호라는 게 위험했다. 크롤이나 복사가 중간에 끊긴 부분 스냅샷이 문법상 유효한 1행이라도 남기면, 빠진 미종료 팝업 전부가 의도적 제외로 오판돼 일괄 축소된다. 그래서 스냅샷 유효 레코드 수가 DB 미종료 적재분의 절반 미만이면(records × 2 < notEnded) 축소 단계를 통째로 건너뛰고 경고만 남긴다. 가드가 잘못 발동해도 축소가 다음 주기로 미뤄질 뿐이지만, 없을 때의 피해는 미종료 전량의 오축소다.

UTC 함정, 9시간 이르게 죽는 팝업

missions의 시각은 UTC naive로 저장되는데, 이 시더의 기본 시계는 종료 필터의 KST 달력 판정 때문에 Clock.system(KST)다. 그 시계의 벽시계로 end_at을 비교하면 9시간 이르게 종료로 오판해, 앞으로 9시간 안에 끝나는 팝업이 축소 대상에서 잘못 빠진다. 그래서 미종료 판정만 LocalDateTime.ofInstant(clock.instant(), ZoneOffset.UTC)로 비교한다. 테스트는 end_at = now + 5시간인 행을 프로덕션 기본 시계 그대로 심어서, 벽시계 구현이었다면 실패하는 판별력 있는 구조로 짰다.

테스트 격리, 가드가 로컬에서만 오발동하는 비대칭

완전성 가드는 DB 전체의 미종료 팝업 수를 센다. 공유 로컬 DB에는 실데이터 195건이 있어서, 작은 테스트 스냅샷을 쓰면 가드가 발동해 축소 경로가 로컬에서만 스킵된다(CI의 빈 DB에서는 통과). 가드의 분모와 축소 후보가 나오는 우주를 notEndedPopups() 한 메서드로 모으고, 테스트 서브클래스가 super를 먼저 통과시킨 뒤 자기 픽스처로 스코프만 좁히게 했다. super를 먼저 부르는 순서가 핵심이다. 통째로 대체했다면 방금 만든 UTC 판정이 테스트에서 우회됐을 것이다.

5. 결과, dev 실측

머지 후 dev에서 시더를 1회 기동했다.

항목
격자 재산출 (regridded)225건 (활성 전량)
센트로이드 축소 (shrunk)0건
완전성 가드미발동 (스냅샷 2,804행 ≫ 미종료 225건)
판정 격자 행 수약 18,000 → 722
칸 수 분포1칸 1.8% · 2칸 36.9% · 4칸 61.3%
레거시 블록 잔존 (칸 수 > 4)0건
0.5도 강남역 POPUP 목록169건, 변화 없음

분포가 PRD 예측(3%, 31%, 66%)과 부합한다. 축소가 0건인 것도 예상대로다. 이번 스냅샷이 미종료 적재분을 전부 담고 있어 모든 팝업이 차등 교체를 지나갔다. 강남역 0.5도 뷰포트 건수가 그대로인 것은 뷰포트가 약 55km라 미션 사각형이 450m에서 100~200m로 줄어도 가장자리 효과에 그치기 때문이다. 화면에서 체감되는 변화는 목록이 아니라 미션을 선택했을 때 그려지는 판정 범위가 눈에 띄게 작아지는 것이다.

검증은 세 겹이었다. 팀 리뷰 3회(위반 0, FestivalMissionSeeder는 blob 해시 대조로 무수정 확인), Codex 교차 리뷰 1라운드 수렴, 전체 회귀 1,781 테스트 통과. 스펙 단계에서 Codex 4라운드가 미리 잡아 둔 결함(잘린 스냅샷 일괄 축소, 미래 시작 팝업 누락, KST 9시간 오판)이 전부 구현과 테스트에 반영됐다.