zone 표시명 체계 설계¶
⚠️ 결정 변경 (2026-08-07): §D3(표시명 FE-local 계산)은 MSG-341로 대체됐다 — 서버가 계산해 격자 조회 응답에
zoneName·zoneCell을 싣는다. 명명 규칙과 픽스처 정본은 불변. 상세는 §D3 배너와docs/spec/MSG-341.md.
Owner: A (grid/region)
— (이력 — 2026-08-07 결정 변경 전 기준. MSG-341이 표시명을 서버 계산으로 바꾸면서 소유는 공동이 됐고 Owner B DTO 확장이 발생한다. 상단 배너 참조.) 티켓 산출물(zones 테이블·V8·명명 산술 규칙·GET /api/zones)이 전부 Owner A(지도 인프라: grid/region) 안에 있다. 크로스오너 계약 4종 시그니처 불변, Owner B 코드 변경 0(§계약 변경). 이는 §D3에서 표시명을 "서버가 각 응답에 얹는" 대신 "zones 데이터 + 좌표 산술을 클라이언트가 계산"하도록 결정한 결과다 — 서버 계산을 택했다면 usergrid/video의 기존 DTO(CollectionGridResponseDto·RegionVideoResponseDto)에 displayName을 얹어야 해 공동이 됐다. FE-local 계산이라 B의 기존 응답을 바이트 하나 안 건드린다(§D3 근거).
정본 소스: ADR 격자 표시명 zone(04-decisions, status:active, 2026-07-21) — 명명 규칙 확정본. ADR 장소 검색 카카오 로컬 프록시(MSG-251, 2026-07-28 — 장소 검색 전담·234는 순수 표시명으로 축소). MSG-167(
grids.region_code·regionName— 행정동 폴백의 원천, merge 완료).⚠️ 위키 충돌(이력): 위키 Region API 예정의
GET /api/regions/search초안은 zone ADR의 2단 폴백이 대체했었으나, 둘 다 ADR 장소 검색 카카오 로컬 프록시(MSG-251, 최신 decision)로 최종 대체됐다 — 서버 이름 검색 자체가 이관·제거(§D6 결정 변경 배너 참조).의존 전부 develop merge 완료 — 실코드 존재:
GridEncoder/GridConstants(grid, 순수 유틸 — 명명 산술이 옆에 자리) ·grids.region_code+regions.region_name(MSG-167 V5 — 행정동 폴백 원천,regionName은 이미CollectionGridResponseDto에 있음) ·CollectionGridResponseDto/RegionVideoResponseDto(둘 다 이미gridId보유 — 표시명 계산 입력을 FE가 이미 쥠) ·RegionRepository/RegionController(/api/regions) ·RegionSeeder(MSG-154 — 플래그 게이트 시딩 선례, zone 시더가 그대로 모방) · V1~V6 소진(다음 = V8).형제 경계: MSG-167(
grids.region_code·regionName— 본 티켓은 그 값을 폴백 표시명으로 소비, 무변경) / MSG-153·156(GET /api/regions/stats·by-grid— 검색 화면의 "구별 격자 수/수집률"이 여기서 이미 나옴, §D6 Open Q) / MSG-93(resolveByPoint— 좌표 축, 본 티켓 명명은 격자 인덱스 정수 산술이라 무관) / MSG-73·90(GET /api/grids뷰포트 색칠 — 검색→fitBounds 후 FE가 호출, 무변경).
개요¶
격자에 사람이 읽는 표시명("서면 A-14")을 붙인다. {grid_y}_{grid_x} 기계 식별자만으로는 "홍대"·"가로수길" 같은 통칭을 표현할 수 없다(행정동도 역명도 아님). 해법은 수동 지정 구역(zone) + 격자 좌표 정수 산술이다.
[데이터] zones(name·region_code·min/max_grid_y/x·priority) — 정수 사각형, PostGIS 불필요(V8)
예) 서면 = grid_y ∈ [Y0..Y0+n], grid_x ∈ [X0..X0+m], region_code=부산진구 서면
[명명] 표시명 = zone.name + " " + 행(A=북쪽) + "-" + 열(서→동, 1부터) ← 순수 정수 산술
행 = max_grid_y − grid_y (0→A, 1→B … 최대 25→Z, 26행=2.6km 한계)
열 = grid_x − min_grid_x + 1
매칭 zone 없음 → 행정동 폴백(regionName, 번호 없음 — §D4)
[소비] 산술은 순수 함수라 grids 저장 없이도 즉시 계산 → FE가 zones 데이터 + gridId 로 로컬 계산(§D3)
빈 격자·미점령 격자도 즉시 이름이 나온다(사전 생성 불필요)
[검색] (이관) 장소 검색은 카카오 로컬 프록시 MSG-251 — zone 이름 매치는 FE가 /api/zones 캐시로 로컬 필터
zones가 MVP에 0행이어도 전 시스템이 행정동 이름으로 우아하게 폴백한다 — 구역을 그려 넣는 즉시(시딩) 그 사각형 안 격자만 "서면 A-14"로 바뀐다. 명명 기계장치와 구역 데이터가 분리돼 있어, 본 티켓은 기계장치를, 구역 데이터(30~50개 작도)는 후속으로 낼 수 있다(§D7).
배경 · 목표¶
- 사용자/제품 관점: 디자인 ver 5가 "서면 A-14"를 전 화면 채택한다 — 도감 리스트("서면 A-14 · 2시간 전 · 영상 4개"), 지역별 갤러리 항목("서면 A-14 #4"), 격자 상세 헤더("서면 A-14 / 부산 부산진구 서면"), 탐색 격자 카드. 지금 FE가 쥔 건
gridId(기계 식별자)와 MSG-167이 준regionName(행정동)뿐이라 "서면 A-14"를 만들 수 없다. 또 검색 화면(장소 검색 + 최근 방문 구 칩 + 구별 격자 수)에서 "서면"으로 검색해 지도를 그리로 옮기는 흐름이 없다(장소 검색 자체는 MSG-251 카카오 프록시 소관, zone 이름 매치는 FE 캐시 로컬 필터 — §D6 결정 변경). - 목표:
zones테이블(정수 사각형)을 신설하고(V8), 좌표 산술 명명 규칙을 단일 진실 원천으로 못박아 FE/BE/모바일이 공유하게 한다.- 표시명을 격자 저장 없이 즉시 계산되게 한다(순수 산술) — FE-local 계산으로 기존 조회 응답을 건드리지 않는다(§D3).
- 매칭 zone이 없으면 행정동 이름으로 폴백한다(MSG-167
grids.region_code소비, 번호 없음 — §D4). - ~~
GET /api/search2단 폴백~~ — 제거(2026-07-28): 장소 검색은 카카오 로컬 프록시(MSG-251)로 이관(ADR 장소 검색 카카오 로컬 프록시 "234는 순수 표시명으로 축소"). zone 이름 매치는 FE가/api/zones캐시(30~50행)로 로컬 필터.
선행 상태 (실코드 확인 결과)¶
| 사실 | 근거 | MSG-234에서 |
|---|---|---|
GridEncoder.decode(gridId)→(gridY,gridX)·center·bbox (순수 유틸) + GridConstants(GRID_LAT_STEP=0.0009, GRID_LNG_STEP=0.00115) |
grid/GridEncoder·GridConstants |
명명 산술의 이웃. ADR가 "이름 계산 유틸은 GridEncoder 옆"으로 지목(§D2) |
grids.region_code(중심점 행정동, 무귀속 NULL) + regions.region_name |
V5__grids_region_code.sql·V1__init.sql L54–55 (MSG-167) |
행정동 폴백의 원천. zone 미매칭 격자의 표시명 = 이 regionName(§D4) |
CollectionGridResponseDto에 gridId·regionName 이미 있음 |
usergrid/dto (153·167) |
FE가 표시명 계산 입력(gridId + zones + regionName 폴백)을 이미 쥐고 있다 → B DTO 무변경(§D3) |
RegionVideoResponseDto에 gridId 이미 있음(regionName은 없음) |
usergrid/dto (167 Open Q2) |
지역별 갤러리 항목 "서면 A-14 #4"를 gridId로 FE 계산. 그 화면은 이미 행정동 문맥 안이라 폴백용 regionName은 문맥에서 앎(§D3) |
RegionRepository(native + 파생 쿼리 혼용)·RegionController(/api/regions, anyRequest().authenticated()) |
region/* (154·93·156·153) |
(이력) 검색 폴백 소비처였으나 검색은 §D6 결정 변경으로 미출하 — 234의 region 소비는 폴백 이름(§D4)뿐 |
RegionSeeder(플래그 fillmap.region.seed.enabled 기본 off, 데이터 파일 로드 → RegionRepository.upsert) |
region/seed/* (MSG-154) |
zone 시더가 그대로 모방 — fillmap.zone.seed.enabled 게이트 + 데이터 파일(§D7) |
마이그레이션 V1~V6 소진(V5 = grids.region_code, MSG-167) |
db/migration/ |
다음 번호 = V8. V1~V6 무수정(MSG-130 Flyway CI 가드) |
검색 관련 코드 전무(search/Search 매치 0) |
grep src/main/java |
~~전부 신설~~ → §D6 결정 변경으로 미출하 — 검색은 MSG-251 이관 |
위키 Region API 예정 = GET /api/regions/search + bounds(ST_Envelope), 열린질문에 "랜드마크 검색(격자 표시명과 함께)" |
03-specs/Region API 예정.md |
(이력) 2단 폴백이 대체했었으나 최종적으로 ADR 장소 검색 카카오 로컬 프록시가 재대체(§D6 결정 변경) |
RegionErrorCode 6400/6404 · AuthErrorCode 2xxx |
region·auth/exception |
zones 조회는 신규 에러 코드 없음(빈 결과 = 200 []) |
| glossary에 "구역(zone)"·"표시명(display name)" 정의 없음 | .claude/rules/glossary.md |
ADR 후속 "glossary에 용어 추가" — 본 스펙이 정의를 못박고(§도메인), glossary 등재는 Wrap-up |
성공 기준 (관찰 가능)¶
zones테이블 존재(V8):zone_key(UNIQUE 안정 식별자)·name(비유일)·region_code(FK, nullable)·min/max_grid_y·min/max_grid_x·priority,min ≤ maxCHECK, 행 폭 ≤ 25(max_grid_y − min_grid_y ≤ 25, 알파벳 26행 한계) CHECK. PostGIS 컬럼·GIST 인덱스 없음(정수 사각형).- 명명 산술이 규칙대로다:
gridId가 어느 zone 사각형 안이면 표시명 ="{name} {행}-{열}", 행 =max_grid_y − grid_y를 A(0)…Z(25)로, 열 =grid_x − min_grid_x + 1. 예) 북쪽 첫 행·서쪽에서 14번째 ="서면 A-14". - 매칭 zone 없으면 행정동 폴백: 표시명 = 그 격자
grids.region_code의region_name(번호 없음 — §D4).region_code도 NULL(해안)이면 표시명 없음(FE가gridId등으로 처리). - 겹침 결정성: 한 격자가 두 zone 사각형에 겹치면
ORDER BY priority DESC, zone_key ASC LIMIT 1로 항상 같은 zone 하나를 고른다(§D5). - 표시명은 클라이언트가 계산: 서버는
GET /api/zones로 zone 목록(사각형+이름+region_code)을 주고, 기존 조회 응답(CollectionGridResponseDto·RegionVideoResponseDto·by-grid)에displayName을 얹지 않는다(§D3). 조회 핫패스에 zone 조인·산술이 안 붙는다. - ~~
GET /api/search?q=2단 폴백~~ — 제거(2026-07-28, §D6 이관 결정 참조). - zones 0행이어도 무해: 시딩 전(빈 zones)에는 모든 격자가 행정동 폴백으로 표시된다. 구역을 시딩하면 그 사각형 안 격자만 "서면 A-14"로 바뀐다(§D7·D8).
- 크로스오너 계약 4종 시그니처 불변. Owner B 코드 변경 0. 공유 로컬 DB 미오염, 전체 테스트 green.
스코프¶
하는 것 (전부 Owner A)
- V8 V8__zones.sql: zones 테이블(정수 사각형 + CHECK, 인덱스는 §D1 판단). 데이터 시딩은 분리(§D7).
- 명명 산술 규칙: 이 스펙 §도메인 + glossary에 단일 진실 원천으로 명문화. Java 유틸은 §D2(서버 미호출이라 MVP 미구현, 위치·시그니처만 확정 + 계약 테스트 픽스처).
- Zone 엔티티 + ZoneRepository(전 컬럼 매핑 — geospatial 0이라 native 불필요, JPA 파생 쿼리로 족함) + GET /api/zones(전체 목록, 30~50행).
- ZoneSeeder(플래그 fillmap.zone.seed.enabled 기본 off + 데이터 파일 로더 — MSG-154 모방). 실제 구역 데이터(30~50개)는 작도 후 주입(§D7).
- 테스트: 명명 산술(행/열/A-Z 경계/폴백/겹침) / GET /api/zones / V8 스키마 제약.
범위 제외
| 항목 | 사유/소관 |
|---|---|
기존 조회 응답에 displayName 필드 추가(서버 계산) |
불채택(§D3) — FE-local 계산이 조회 핫패스·B DTO를 안 건드림. 서버 계산이 정말 필요해지면 후속에서 displayName + zone 조인 |
서버측 명명 Java 유틸(ZoneNamer) 구현 |
MVP 미구현(§D2) — 서버 호출 경로 없음(라벨=FE). 위치·시그니처만 확정, 후속 포팅 |
| 30~50개 구역 사각형 작도(누가/무슨 도구로) | ADR 쟁점 ③ — 미해결(§D7·§미해결 Q1). 본 티켓은 시딩 기계장치만, 데이터는 후속 |
| 검색 화면 "구별 격자 수" 목록·"최근 방문 구 칩" | §미해결 Q2 — 156 GET /api/regions/stats(수집률)로 이미 상당수 servable, "격자 수" 의미(total_grid_count vs 등록 COUNT) 확정 전까지 신설 안 함 |
| "격자 검색"("서면 A-14" 역파싱 → 격자 이동)·"영상 검색" | §미해결 Q3 — 장소 검색은 MSG-251(카카오 프록시) 소관. 역파싱·영상 콘텐츠 검색은 미확정 |
| 행정동 폴백에 번호 부여("서면 1"류) | 불채택(§D4, ADR 쟁점 ① 권장안) — 이름만 |
| zones 겹침 방지 운영 규칙 강제(툴 레벨) | 부분 미해결(§D5·§D7) — 스키마는 priority로 결정성 확보, "안 겹치게 그리는" 운영 규칙은 작도 도구 확정과 함께 |
결정 (Decisions)¶
D1 — zones 스키마·V8 = 정수 사각형 + CHECK, PostGIS 불필요. 인덱스 미추가¶
- 결정:
V8__zones.sql에 아래 테이블을 만든다.id BIGSERIAL PK,zone_key VARCHAR(30) NOT NULL UNIQUE(안정 식별자 slug — 시딩 자연키·클라이언트 참조·타이브레이크),name VARCHAR(50) NOT NULL(비유일 표시명),region_code VARCHAR(10) REFERENCES regions(region_code)(nullable — 소속 행정동 문맥용),min_grid_y·max_grid_y·min_grid_x·max_grid_x INTEGER NOT NULL,priority INTEGER NOT NULL DEFAULT 0. CHECK 3종:min_grid_y ≤ max_grid_y,min_grid_x ≤ max_grid_x,max_grid_y − min_grid_y ≤ 25(알파벳 26행 한계). geospatial 컬럼·GIST 없음. 인덱스 미추가. - 근거:
- 정수 사각형이라 PostGIS 0: zone은
grid_y/grid_x정수 범위다(경위도 폴리곤 아님). 매칭·명명·bbox가 전부 정수 비교/산술 → geospatial 금지 ADR을 구조적으로 자동 충족(성공 기준 7).boundary_geom같은 컬럼이 애초에 없다. - 행 폭 CHECK가 명명 규칙을 강제: 행 =
max_grid_y − grid_y를 A~Z(0~25)로 매핑하므로max − min > 25면 Z를 넘는 격자가 라벨 불가. CHECK로 작도 단계에서 막는다(2.6km 한계를 스키마가 지킴). 열은 숫자라 상한 없음. - 인덱스 불요:
GET /api/zones는 전체(30~50행)findAll— 인덱스 무의미. FE-local 명명은 서버 조회 자체가 없음(§D3).region_codeFK 정합은 참조되는regions.region_code(PK) 인덱스로 충분. - 기각:
- (b) PostGIS 폴리곤 zone: 정수 사각형이면 될 걸 geometry로 저장하면 geospatial 매칭이 필요해져 ADR 위반 + 작도가 복잡. ADR이 명시적으로 "정수 사각형(PostGIS 불필요)"로 결정.
- (c) 인덱스
(min_grid_y,max_grid_y,min_grid_x,max_grid_x)선제 추가: 어떤 쿼리도 이 범위를 주도로 안 탄다(전체 로드뿐). 30~50행에 인덱스는 오버. ponytail: zones 가 수백 행으로 커지고 "격자→zone" 서버 조회가 생기면 그때 범위 인덱스. MVP 30~50 행엔 seq scan 이 빠름.
D2 — 명명 산술 = 문서화된 순수 함수(단일 진실 원천). 서버 Java 유틸은 MVP 미구현(위치·시그니처만 확정)¶
- 결정: 명명 규칙(행 =
max_grid_y − grid_y→ A…Z, 열 =grid_x − min_grid_x + 1, 표시명 ="{name} {행}-{열}")을 이 스펙 §도메인 + glossary에 단일 진실 원천으로 못박는다. 표시명 계산은 클라이언트가 한다(§D3) → 서버엔 이 산술을 호출하는 경로가 없다 → Java 유틸을 MVP에 만들지 않는다. 후속에 서버가 라벨을 산출/파싱할 경로(예: "격자 검색" 역파싱 — §미해결 Q3)가 생기면com.msg.fillmap.grid.ZoneNamer(GridEncoder 옆, ADR 지목 위치)로 포팅한다. 시그니처 예약:static String label(String gridId, ZoneRect zone)·static boolean contains(ZoneRect zone, long gridY, long gridX). - 근거:
- YAGNI(ponytail rung 1): 서버가 이 함수를 부르는 데가 없다 — 라벨은 FE-local 산술뿐. 안 쓰는 Java를 지금 만들면 죽은 코드다. 규칙(산술)은 계약이라 문서화가 진짜 산출물이고, Java 포팅은 호출자가 생길 때.
- FE 포팅 검증 수단은 남긴다: 규칙이 FE/모바일로 흩어져 드리프트할 위험을 계약 테스트 픽스처(입력
gridId+zone → 기대 표시명 표, §테스트 모듈 1)로 고정한다 — GridConstants를 FE·BE·앱이 공유하는 것과 같은 방식. - 위치는 ADR이 지목: "이름 계산 유틸(GridEncoder 옆)". 순수 격자 인덱스 산술이라 grid 패키지(Owner A)가 자연 위치 — 포팅 시 그대로.
- 기각:
- (b) 지금 Java 유틸 + 서버가 모든 응답에 라벨 주입: §D3의 서버 계산안과 한 몸 — 조회 핫패스·B DTO 오염(아래 §D3에서 기각).
- (c) 규칙을 코드에만 두고 문서화 생략: 3개 클라이언트(웹/안드/iOS)가 각자 재발명 → 드리프트. 규칙은 문서가 정본이어야 한다(GridConstants 선례).
D3 — 표시명 소비 = zones 데이터 API + FE-local 계산. 기존 조회 응답 무변경¶
⚠️ 결정 변경 (2026-08-07, MSG-341): 표시명 계산 주체가 서버로 바뀌었다 — 격자를 담는 조회 응답 9종에
zoneName·zoneCell필드가 실린다(FE는 조립과 폴백 표시만). 이 절의 FE-local 한정과 "기존 조회 응답에 얹지 않는다" 결정은 폐기됐다. 명명 규칙 자체(행 A=북단, 열 1=서단, 폴백 번호 없음)와 픽스처 정본(zone-naming.json)은 불변. 아래 원문은 결정 이력 보존용. 정본:docs/spec/MSG-341.md·docs/prd/MSG-341-prd.md
- 결정: 서버는
GET /api/zones로 zone 목록(사각형+name+region_code)을 한 번 내려주고, FE가 그걸 캐시해 손에 쥔gridId로 표시명을 로컬 산술한다. 폴백(zone 미매칭)의 행정동 이름 취득 경로는 화면 동선별로 이미 존재한다: 갤러리 목록 =CollectionGridResponseDto.regionName(167) / 격자 클릭·격자 상세 =by-grid응답regionName(153 — 상세 화면이 탐험률 표시를 위해 이미 호출하는 API) / 지역별 갤러리 항목 = 헤더 문맥(같은 동).GridCellResponseDto(GET /api/grids/{gridId})는 regionName을 담지 않으며 확장하지 않는다 — 그 응답을 쓰는 화면은 by-grid를 병행 호출하는 동선이라 추가 필드가 불필요(정정 2026-07-23, Codex 지적 — "FE가 이미 쥔"의 정확한 범위 명시). 기존 조회 응답(CollectionGridResponseDto·RegionVideoResponseDto·by-grid·탐색 카드)에displayName을 얹지 않는다. - 근거:
- ADR 정합: "순수 산술이라 grids에 저장 안 해도 즉시 계산·빈 격자도 계산·격자 사전 생성 불필요"가 ADR의 핵심 가치 명제 — 그건 클라이언트가 계산할 수 있다는 뜻이다. zones(30~50행)를 한 번 받으면 FE가 O(1)로 어떤 격자든 라벨링. 서버 왕복·저장이 없다.
- 조회 핫패스·B DTO 불가침(surgical): 서버 계산이면 도감 목록·동 단위 영상·by-grid·탐색 카드 모든 읽기 경로에 zone 조인/산술 +
displayName필드가 붙는다 — 방금 merge된 MSG-167 DTO 2개를 다시 열고(오너 B), 매 항목 zone 매칭 서브쿼리를 돈다. FE-local이면 서버 코드 델타 = zones 조회 1개, B 변경 0 → 본 티켓이 Owner A로 닫힌다(§Owner). - FE 입력은 동선별로 완비: zone 산술 입력(gridId)은
CollectionGridResponseDto·RegionVideoResponseDto에, 폴백 입력(regionName)은CollectionGridResponseDto·by-grid응답에 이미 있다(실코드 확인 — 위 폴백 경로 표 참조). 추가 필드 불요. - 기각 — (서버 계산,
displayName주입): - 조회 핫패스에 zone 산술/조인 상시 유입 + B 도메인 DTO 4곳 오염 + 오너 경계 확장(공동화). 명명이 순수 함수라 서버가 유일 계산자일 이유가 없다 — 오히려 저장 없는 즉시 계산이라는 ADR 가치가 클라이언트 계산을 가리킨다.
- 단, 표시명을 검색 결과·알림 등 서버가 문자열을 만들어야 하는 경로에서 필요로 하면 그때 §D2
ZoneNamer를 포팅해 그 경로에서만 계산(전 조회 응답 주입은 여전히 불필요). 현재 그런 경로 없음.
D4 — 무매칭 폴백 = 행정동 이름만(번호 없음)¶
- 결정: 어느 zone에도 안 드는 격자의 표시명 = 그 격자
grids.region_code의region_name(MSG-167). 번호를 붙이지 않는다("서면 A-14"의 zone 축과 섞지 않음).region_code가 NULL(해안/무귀속)이면 표시명 없음 — FE가gridId등으로 대체(경미, FE 재량). - 근거:
- ADR 쟁점 ① 권장안: "행정동 폴백은 번호 없이 '서교동'만". zone 번호(A-14)는 zone 좌표계 산물이라 zone 밖에선 정의되지 않는다 — 행정동에 인위적 번호를 부여하면 의미 없는 순번이 된다.
- 원천이 이미 있다(무비용): MSG-167이
grids.region_code·regionName을 이미 저장·전달한다. 폴백은 그 값을 그대로 쓰는 것이라 신규 조회·저장 0. - 기각: 행정동 + 격자 순번("서교동 3") — 순번의 기준(무슨 순서?)이 zone 없이는 임의라 안정적이지 않고 사용자에게 무의미. ADR도 "이름만" 권장.
- 잔여:
region_codeNULL일 때 최종 표시(gridId? 좌표? 빈값?)는 FE 표시 정책이라 BE 스코프 밖 — §미해결로 넘기지 않고 FE 재량으로 명시(서버는 폴백 원천 regionName까지만 책임).
D5 — 겹침 = priority DESC, zone_key ASC 결정성 + 안 겹치게 그리는 운영 규칙(부분 미해결)¶
- 결정: 한 격자가 둘 이상 zone 사각형에 들면 매칭은
ORDER BY priority DESC, zone_key ASC LIMIT 1로 결정적으로 하나를 고른다(priority높은 게 우선, 동률은zone_key오름차순). FE-local 매칭이 이 규칙을 따른다(서버 검색 정렬 용례는 §D6 결정 변경으로 소멸). 작도 단계에서 zone을 겹치지 않게 그리는 운영 규칙을 권장하되(§D7 도구와 함께), 실수로 겹쳐도 표시가 흔들리지 않게priority로 방어한다. - 근거:
- ADR 쟁점 ②를 결정성으로 봉쇄: ADR은 "priority vs 안 겹치게 운영" 둘을 제시 — 스펙은 둘 다 취한다.
priority는 코드가 지금 강제할 수 있는 결정성(비결정 매칭이 최악)이고, "안 겹치게"는 데이터 품질 규칙이라 작도 도구(§D7)에 얹는다. priority는 의도 표현도 됨: 큰 구역 안에 작은 핫스팟 구역을 겹쳐 얹고 작은 쪽에 높은 priority를 주면 "안쪽은 세부 구역명, 바깥은 큰 구역명" 같은 의도적 중첩도 결정적으로 표현된다(MVP엔 안 겹치게가 기본).- 기각: priority 없이 "절대 안 겹치게"만 규칙으로: 데이터 실수 한 번에 매칭이 비결정(같은 격자가 새로고침마다 다른 이름) — 방어선이 없다. 결정성은 코드가 보증해야 한다.
- 부분 미해결: "누가 안 겹치게 보장하나"는 작도 주체/도구(§D7·미해결 Q1)에 종속 — 스키마·매칭 결정성은 여기서 확정, 운영 강제는 후속.
D6 — 검색 = ~~2단 폴백~~ → 제거·MSG-251 이관 (2026-07-28 결정 변경)¶
⚠️ 결정 변경(2026-07-28, 사용자 확정): 아래 2단 폴백 검색은 구현됐다가 제거됐다. 같은 날 확정된 ADR 장소 검색 카카오 로컬 프록시(MSG-251)가 "234의 지역 검색은 251로 이관, 234는 순수 표시명으로 축소"를 명시 — 자유 텍스트 장소 검색은 카카오 로컬 키워드 프록시(
GET /api/search/places, 좌표→gridId 즉석 산출)가 전담하고, zone 이름 매치는 FE가/api/zones캐시로 로컬 필터한다. 재개 스펙이 이 최신 ADR을 대조하지 못해 한 차례 구현됐고, PR 직후 사용자 지적으로 제거(SearchController·SearchService.search·RegionRepository.searchByName·SearchResultResponseDto·테스트 17건). 아래 원문은 이력 보존.
(제거된 원결정) GET /api/search?q= zones→regions 2단 폴백¶
- 결정:
GET /api/search?q=(인증). ①zones.name ILIKE '%q%'(파생 쿼리,ORDER BY priority DESC, zone_key ASC) → 결과 있으면 zone 결과 반환(bbox = 정수 산술). ② zone 매치 0일 때만regions.region_name ILIKE '%q%'(native,ORDER BY region_code LIMIT :limit,bounds = ST_Envelope(boundary_geom)) → region 결과. 매치 0 = 200 +[]. 응답 항목은type(ZONE/REGION)으로 구분되는SearchResultResponseDto리스트. - 근거:
- ADR 결정 직결: "검색도 zones→regions 2단 폴백" — 네이버 SDK 키워드 검색 부재를 우리 DB가 메운다(ADR 지도 SDK 네이버 전환 강화). "서면"은 zone에서 잡히고, zone에 없는 행정동명("역삼1동")은 regions로 폴백.
- geospatial 최소(성공 기준 7): zone 결과 bbox는 사각형 정수 → GridConstants 산술(SW=
min_grid·step, NE=(max_grid+1)·step,ST_0). regions 폴백 branch에서만ST_Envelope가 도는데, 폴백은 zone 미매치일 때만 +LIMIT로 결과 수 유한 + 사용자 타이핑 단발이라 MSG-93이 허용한 "저빈도·사용자 트리거 단건류" 예외에 해당(위키 초안도 이 방식). - 얇은 3-layer:
SearchController(파싱+호출+SuccessResponse) →SearchService(zones 파생 → 폴백 regions native) → 각 repo. zones는 geospatial 0이라Zone엔티티 + 파생 쿼리로 족하고, regions 폴백만RegionRepository에 native 검색 1개 추가(Owner A 내부, 크로스오너 아님). - ⚠️ 위키 충돌·해소: 위키 Region API 예정는
GET /api/regions/search(regions 단독)를 제안. 본 티켓은 ADR(상위·최신)의GET /api/search2단 폴백을 따른다 — regions만으론 "서면"이 안 잡히는 게 zone 도입 사유. bounds를ST_Envelope로 내는 초안 방식은 regions 폴백 branch에 그대로 채택(초안과 정합), 경로·zone 우선만 ADR로 갱신. - 에러:
q누락 = 400(globalMissingServletRequestParameter— MSG-167에서 전역 매핑 추가됨).q공백/무매치 = 200 +[]— 공백은 서비스 선두 trim 가드로 조기 반환한다(ILIKE '%%'전건 매치 방지). 미인증 = 401(global). 신규 에러 코드 없음. - "구별 격자 수"·"최근 방문 구 칩": 검색 화면의 부가 요소 — 본 검색 API 스코프 밖(§미해결 Q2). "구별 격자 수" 의미(총 격자 vs 등록 격자)가 미확정이고, 상당수는 156
GET /api/regions/stats로 이미 servable.
D7 — 초기 zones 시딩 = 플래그 게이트 시더 + 데이터 파일(기계장치만), 작도는 후속¶
- 결정:
ZoneSeeder(MSG-154RegionSeeder모방 —fillmap.zone.seed.enabled기본 off 게이트, 앱 기동 시 데이터 파일 로드 →ON CONFLICT (zone_key) DO UPDATE멱등 UPSERT — 안정 식별자zone_key(slug)가 자연키(V8 UNIQUE), 사람용name은 비유일 표시명(동명 상권 허용 — 정정 2026-07-23). 재실행 시 같은 값 수렴·이름/사각형 수정도 반영). 데이터 파일 형식 = JSON 배열(resources/seed/zones.json), 항목{zoneKey, name, regionCode, minGridY, maxGridY, minGridX, maxGridX, priority}— 필드명은 엔티티/API와 동일한zoneKey로 통일(매핑 누락 방지). 실제 구역 데이터(30~50개 사각형)의 작도는 본 티켓 밖 — zones 0행으로 배포해도 전 시스템이 행정동 폴백으로 정상 동작(성공 기준 8)하므로, 기계장치를 먼저 내고 데이터는 작도 후 파일로 주입한다. - 근거:
- ADR 쟁점 ③이 미해결: "서울 상권 30~50개를 누가·무슨 도구로(드래그 내부 도구 제안)"는 결정 안 됨 — 스펙이 지어낼 수 없다. 작도 도구/주체 = §미해결 Q1. 기계장치(테이블·명명·시더)는 데이터와 독립이라 먼저 완성 가능.
- 우아한 빈-상태(성공 기준 8): 빈 zones면 매칭 0 → 전부 행정동 폴백. 깨지지 않는다. 한 사각형만 시딩해도 그 안 격자만 즉시 "서면 A-14"로 바뀜 — 점진 롤아웃.
- 154 선례 재사용: 플래그 게이트 + 파일 로더 + 멱등 저장은
RegionSeeder가 확립. 새 패턴 발명 없음(ponytail rung 2). - 기각:
- (b) V8에
INSERT하드코딩: 구역은 운영 중 추가·수정되는 데이터(마스터 아님). 마이그레이션에 박으면 수정마다 새 V 파일 — 시더+파일이 유연. - (c) 구역 작도 CRUD API를 본 티켓에 포함: 관리자 작도 도구는 별개 대형 작업(ADR 쟁점 ③) — 스코프 폭증. 시더로 파일 주입이 MVP엔 충분.
ponytail: 작도 도구(드래그 UI)가 정해지면 그 산출물을 zones.json 형식으로 뱉게 하면 시더 그대로 재사용. 관리 CRUD API 는 운영 규모가 시더로 버거워질 때.
D8 — glossary 용어 등재 = "구역(zone)"·"표시명(display name)" 신규 정의¶
- 결정: glossary에 구역(zone)(수동 지정 격자 사각형,
zones테이블)과 표시명(display name)("서면 A-14" 또는 행정동 폴백)을 신규 용어로 추가한다. 본 스펙 §도메인 로직이 정의의 초안 원천, PR로 glossary에 반영(Wrap-up). "점령/방문/도감"과 혼용 금지 — 표시명은 격자의 이름이지 상태(점령)가 아니다. - 근거: glossary는 단일 진실 원천이고 "새 용어는 정의 먼저 추가"가 규칙. ADR 후속 목록에도 "glossary에 용어 추가" 명시. 명명 규칙이 코드·문서·디자인·검색에 걸치므로 정의가 흩어지면 드리프트.
- 주의(용어 대조): "구역"은 UI 노출 용어("서면 A-14")라 게임화 톤("점령/정복") 금지 규칙과 무관하나, 코드 심볼은
zone/displayName으로 통일(한글 "구역/표시명"은 문서·UI, 코드는 영문). 이 스펙 전체가 이미 이 규칙을 따름.
API 명세¶
출하 엔드포인트는 GET /api/zones 하나다(②는 §D6 결정 변경으로 미출하). 인증 필수(SecurityConfig anyRequest().authenticated() → 미인증 401), SecurityConfig 무변경(지도 화면 뒤 조회라 로그인 필요 — RegionController 선례와 동일). 성공은 SuccessResponse.of(...)(HTTP 200 · developCode 200).
① GET /api/zones — 구역 목록 (표시명 계산용, 신설)¶
- 요청 파라미터 없음(전체 목록, 30~50행). FE가 캐시해 로컬 명명·구역 오버레이에 쓴다.
- 성공 200
body=List<ZoneResponseDto>(빈 배열 허용 — 시딩 전).
ZoneResponseDto (record):
| 필드 | 타입 | 소스 | 비고 |
|---|---|---|---|
zoneKey |
String | zones.zone_key |
안정 식별자(환경·재시딩 무관 — 클라이언트 참조·타이브레이크 기준) |
name |
String | zones.name |
구역명("서면") |
regionCode |
String (nullable) | zones.region_code |
소속 행정동 코드(문맥) |
minGridY |
Integer | zones.min_grid_y |
사각형 남단 행 |
maxGridY |
Integer | zones.max_grid_y |
사각형 북단 행(A행) |
minGridX |
Integer | zones.min_grid_x |
사각형 서단 열(1열) |
maxGridX |
Integer | zones.max_grid_x |
사각형 동단 열 |
priority |
Integer | zones.priority |
겹침 결정성(§D5) |
응답 예시
{ "developCode": 200, "httpStatus": "OK", "message": "성공", "body": [
{ "zoneKey": "seomyeon", "name": "서면", "regionCode": "2623051000",
"minGridY": 39710, "maxGridY": 39725, "minGridX": 109830, "maxGridX": 109850, "priority": 0 },
{ "zoneKey": "hongdae", "name": "홍대입구", "regionCode": "1144012100",
"minGridY": 41650, "maxGridY": 41668, "minGridX": 110430, "maxGridX": 110455, "priority": 10 } ] }
"body": []. 도메인 에러 없음(미인증 401만).
FE-local 명명(참고, 서버 계산 아님): FE는 이 목록을 캐시하고
gridId→(gridY,gridX)(GridEncoder.decode)로,contains(zone)격자를priority DESC, zone_key ASC로 하나 골라label = name + " " + ('A'+(maxGridY-gridY)) + "-" + (gridX-minGridX+1). 매칭 없으면regionName(167) 폴백.
② (제거됨 — §D6 결정 변경) GET /api/search — 장소 검색¶
미출하. 아래 계약은 구현됐다가 MSG-251(카카오 프록시) 이관으로 제거됐다 — 구현·검증에 사용 금지, 이력 보존용(§D6 배너 참조).
| 파라미터 | 타입 | 필수 | 의미 |
|---|---|---|---|
q |
String | ✅ | 검색어("서면", "역삼", "강남") |
limit |
int | ✕(기본 10) | 최대 결과 수 — zone·regions 두 분기 모두 적용, 1~50 clamp(범위 밖은 경계값 보정 — 정정 2026-07-23) |
- 성공 200
body=List<SearchResultResponseDto>. zone 매치가 있으면 zone 결과만, 없으면 regions 폴백 결과(§D6). 매치 0 = 빈 배열.
SearchResultResponseDto (record):
| 필드 | 타입 | 비고 |
|---|---|---|
type |
String | "ZONE" | "REGION" |
name |
String | 히트 이름(zone.name 또는 region_name) |
regionCode |
String (nullable) | zone.region_code 또는 region.region_code |
parentCode |
String (nullable) | 시군구 코드 — 동명이인 행정동 구분(REGION), zone은 null 가능 |
minLat minLon maxLat maxLon |
Double | fitBounds용 bbox. ZONE=정수 산술, REGION=ST_Envelope |
요청/응답 예시 — zone 매치
GET /api/search?q=서면
{ "developCode": 200, "httpStatus": "OK", "message": "성공", "body": [
{ "type": "ZONE", "name": "서면", "regionCode": "2623051000", "parentCode": "26230",
"minLat": 35.1495, "minLon": 129.0507, "maxLat": 35.1603, "maxLon": 129.06795 } ] }
GET /api/search?q=역삼
{ "developCode": 200, "httpStatus": "OK", "message": "성공", "body": [
{ "type": "REGION", "name": "서울특별시 강남구 역삼1동", "regionCode": "1168051500", "parentCode": "11680",
"minLat": 37.4901, "minLon": 127.0301, "maxLat": 37.5052, "maxLon": 127.0448 } ] }
| 에러 | 응답 |
|---|---|
q 누락 |
400 BAD_REQUEST (global, 필수 파라미터 누락) |
q 공백/무매치 |
에러 아님 — 빈 배열 200 |
| 미인증 | 401 (global) |
도메인 로직¶
명명 산술 (단일 진실 원천 — §D2, 클라이언트가 계산)¶
gridId와 매칭 zone으로 표시명을 만드는 순수 정수 함수. 서버는 MVP에 호출하지 않으나(§D3), 규칙은 여기가 정본이다:
(gridY, gridX) = GridEncoder.decode(gridId) // 순수 유틸
// 매칭 zone: gridY ∈ [min_grid_y, max_grid_y] AND gridX ∈ [min_grid_x, max_grid_x]
// 겹치면 priority DESC, zone_key ASC 로 하나
if 매칭 zone 있음:
rowIndex = zone.max_grid_y - gridY // 0 = 북단 = 'A'
letter = (char)('A' + rowIndex) // rowIndex ∈ [0, 25] (V8 CHECK 로 보장)
col = gridX - zone.min_grid_x + 1 // 1 = 서단
label = zone.name + " " + letter + "-" + col // "서면 A-14"
else:
label = regionName(grids.region_code) // 행정동 폴백, 번호 없음 (§D4)
// region_code NULL 이면 label 없음 → FE 재량 (gridId 등)
- 행이 A~Z 안에 있음:
max_grid_y − gridY는 격자가 사각형 안이면[0, max−min] ⊆ [0, 25](V8 CHECK). 밖이면 애초에 매칭 아님. - 저장 없이 계산: 입력은
gridId(FE 보유) + zones 목록(GET /api/zones1회 캐시)뿐 — grids 조회·저장 0.
① GET /api/zones (Owner A — ZoneRepository.findAll, geospatial 0)¶
// ZoneQueryService — JPA 파생, native 불필요
List<Zone> zones = zoneRepository.findAll(); // 30~50행, 전 컬럼 매핑
return zones.stream().map(ZoneResponseDto::from).toList();
Zone은 geometry가 없어 전 컬럼을 JPA로 매핑(Grid/Region이 geometry 때문에 부분 매핑한 것과 대조 — zone은 매핑 회피할 컬럼이 없음).
② (제거됨 — §D6 결정 변경) GET /api/search 구현 스케치¶
미출하. 아래 스케치의
SearchService·폴백 분기는 제거됐다(§D6) — 이력 보존용.
// SearchService.search(q, limit):
String query = q == null ? "" : q.trim();
if (query.isEmpty()) {
return List.of(); // 공백 가드 — ILIKE '%%' 전건 매치 차단
}
int capped = Math.min(Math.max(limit, 1), 50); // 1~50 clamp — 음수(Stream.limit 예외)·거대값(무한 조회) 차단
List<Zone> zoneHits = zoneRepository
.findByNameContainingIgnoreCaseOrderByPriorityDescZoneKeyAsc(query); // 파생 쿼리, geospatial 0
if (!zoneHits.isEmpty()) {
return zoneHits.stream().limit(capped) // limit 은 zone 분기에도 적용
.map(z -> SearchResultResponseDto.ofZone(z)).toList();
// bbox = 정수 산술: SW(min_grid_y·LAT, min_grid_x·LNG), NE((max_grid_y+1)·LAT, (max_grid_x+1)·LNG)
// GridConstants 재사용 — ST_ 없음
}
return regionRepository.searchByName(query, capped).stream() // 폴백도 trimmed query·capped limit 사용
.map(SearchResultResponseDto::ofRegion).toList();
regions 폴백 검색 — RegionRepository(native, ST_Envelope는 폴백·LIMIT로 유한):
SELECT region_code AS "regionCode", region_name AS "regionName", parent_code AS "parentCode",
ST_YMin(env) AS "minLat", ST_XMin(env) AS "minLon",
ST_YMax(env) AS "maxLat", ST_XMax(env) AS "maxLon"
FROM (
SELECT region_code, region_name, parent_code,
ST_Envelope(boundary_geom::geometry) AS env
FROM regions
WHERE region_name ILIKE '%' || :q || '%'
ORDER BY region_code
LIMIT :limit
) t
ST_Envelope — zone 미매치일 때만, LIMIT :limit로 결과 유한, 사용자 타이핑 단발(MSG-93이 허용한 저빈도 단발류 예외). 위키 Region API 예정 초안의 ST_Envelope bounds와 정합.
- 동명이인(같은 region_name이 여러 구에): parentCode로 FE가 구분(위키 열린질문 반영).
조회 경로 ADR 정합¶
- 명명(FE-local)·
GET /api/zones(정수 전 로드) = geospatial 0. zones 매칭·명명은 정수 사각형 포함 판정이라 애초에 PostGIS를 안 탄다. (검색 branch의 ST_Envelope 논의는 §D6 결정 변경으로 소멸 — 234 출하 경로에 geospatial 0)
데이터 모델¶
V8 마이그레이션 (신규 — Owner A)¶
V8__zones.sql:
-- MSG-234: 격자 표시명 구역(zone). grid_y/grid_x 정수 사각형 — PostGIS 불필요(geospatial 0).
-- 표시명 = name + 행(A=북쪽, max_grid_y-grid_y) + 열(서→동, grid_x-min_grid_x+1). 매칭 없으면 행정동 폴백.
CREATE TABLE zones (
id BIGSERIAL PRIMARY KEY,
zone_key VARCHAR(30) NOT NULL UNIQUE, -- 안정 식별자·시딩 멱등 UPSERT 자연키(ON CONFLICT (zone_key)) — 예: "seomyeon"
name VARCHAR(50) NOT NULL, -- 사람용 표시명 — 비유일(동명 상권 허용, 예: 전국 "중앙시장"류)
region_code VARCHAR(10) REFERENCES regions(region_code), -- 소속 행정동(문맥), nullable
min_grid_y INTEGER NOT NULL,
max_grid_y INTEGER NOT NULL,
min_grid_x INTEGER NOT NULL,
max_grid_x INTEGER NOT NULL,
priority INTEGER NOT NULL DEFAULT 0, -- 겹침 결정성(§D5)
CONSTRAINT chk_zones_y_range CHECK (min_grid_y <= max_grid_y),
CONSTRAINT chk_zones_x_range CHECK (min_grid_x <= max_grid_x),
CONSTRAINT chk_zones_row_cap CHECK (max_grid_y - min_grid_y <= 25) -- 알파벳 26행(A..Z)=남북 2.6km 한계
);
COMMENT ON TABLE zones IS '격자 표시명 구역(zone) — 정수 사각형, 좌표 산술 명명. MSG-234';
엔티티·DTO·시딩¶
- 신규 엔티티
zone/entity/Zone: 전 컬럼 JPA 매핑(geometry 없어 부분 매핑 불요 — Grid/Region 대조).@Entity @Table(name="zones"),@NoArgsConstructor(PROTECTED). - 신규 리포지토리
zone/repository/ZoneRepository extends JpaRepository<Zone, Long>:findAll(목록) + 시딩 UPSERT. 파생 이름 검색·RegionRepository.searchByName은 §D6 결정 변경으로 제거됨. - 신규 DTO:
ZoneResponseDto(zone/dto).SearchResultResponseDto는 §D6 결정 변경으로 제거됨. - 신규 시더
zone/seed/ZoneSeeder+resources/seed/zones.json(초기 빈 배열 또는 미포함 — §D7). 플래그fillmap.zone.seed.enabled기본 off.
스키마 vs JPA 엔티티¶
zones→zone/entity/Zone전 컬럼 매핑(신규 행). status.md "스키마 vs JPA 엔티티" 표에 추가(Wrap-up).
계약 변경¶
크로스오너 계약 4종(GridQueryService·HotZoneService·UserGridQueryService·UserOidcCommandService) 시그니처 전부 불변. Owner B 코드 변경 0. 본 티켓은 zones 테이블·명명 규칙이 전부 Owner A(grid/region) 안에서 닫힌다.
- A↔B 경계면 없음: §D3의 FE-local 계산 결정 덕에 usergrid/video의 기존 DTO(
CollectionGridResponseDto·RegionVideoResponseDto)를 건드리지 않는다. FE가 B의 기존 응답(gridId·regionName) + Owner A의GET /api/zones를 클라이언트에서 합성할 뿐 — 서버 계약면 아님. - 만약 서버 계산(§D3 기각안)을 택했다면 B DTO에
displayName주입 → 공동 + 신규 계약면이 됐다. 그 경계를 만들지 않은 게 이 티켓의 설계 선택. - Owner A 내부 확장(크로스오너 아님):
ZoneRepository(신설). region↔zone은 같은 오너라 인터페이스 계약 불요. (검색 관련RegionRepository.searchByName·SearchService는 §D6 결정 변경으로 미출하) - 패키지 배치 판단:
zone신규 패키지(Owner A) 권장 —Zone엔티티/리포/컨트롤러/시더. 명명 산술 Java(후속·§D2)만 ADR 지목대로grid패키지(ZoneNamer). 대안: zone 자산을region패키지에 흡수(새 패키지 회피) — 둘 다 Owner A라 무해, 구현자 재량. 크로스오너 판정에는 영향 없음. - 신규 에러 코드 없음: zones 조회는 빈 결과 200·미인증 401 global.
RegionErrorCode·신규 도메인 대역에 추가 안 함.
테스트 시나리오 (JUnit5 + AssertJ · 한국어 백틱 메서드명 · 모듈 단위)¶
테스트 격리(MEMORY 'shared local DB' · 154/167 §격리 원칙): 합성 fixture만 쓴다 — 자기 유저 신규 생성 + 합성
zones(합성region_code99950~99959대역,m234접두 이름, 실존 3,558 무충돌) + 그 사각형 안/밖 격자 인덱스. (regions 폴백 검색 fixture는 §D6 결정 변경으로 불요 — 해당 테스트 삭제됨)@Transactional롤백 우선.regions·grids·zones(공유 후)·videos를 truncate하지 않는다(공유 DB FK — NonUniqueResult). 실데이터regions(3,558) 불가침.zones는 본 티켓 신규 테이블이라 합성 zone만 넣고 스코프 삭제.
모듈 1 — 명명 산술 계약 (순수 함수 픽스처 · §D2 정본 검증)¶
서버 Java 유틸 미구현(§D2)이라 계약 픽스처 테스트로 규칙을 고정한다 — FE 포팅이 이 표에 맞는지 검증하는 기준(후속
ZoneNamer포팅 시 그대로 재사용). 순수 산술이라 DB 불요. -북단_서단_격자는_A_1로_명명된다(rowIndex 0='A', col 1) -북단에서_14번째_열은_A_14다(col = gridX - min_grid_x + 1) -한_행_남쪽은_B로_내려간다(rowIndex 1='B', max_grid_y-grid_y) -남단_행은_사각형_높이에_따라_알파벳이_증가한다(max-min=25 → 'Z' 경계) -사각형_밖_격자는_zone_매칭이_아니다(contains false → 폴백) -두_zone에_겹치는_격자는_priority가_높은_zone을_고른다(priority DESC, zone_key ASC 결정성 — §D5) -매칭_zone이_없으면_행정동_이름으로_폴백한다(regionName, 번호 없음 — §D4)
모듈 2 — V8 스키마 제약 (DB 통합 · Owner A)¶
min이_max보다_크면_삽입이_거부된다(chk_zones_y_range · chk_zones_x_range)행_폭이_25를_넘으면_삽입이_거부된다(chk_zones_row_cap — 26행 한계)region_code가_null인_zone도_삽입된다(nullable FK)zones에_geometry_컬럼과_GIST_인덱스가_없다(정수 사각형 — 스키마 메타 확인)
모듈 3 — GET /api/zones (ZoneRepository·컨트롤러 MockMvc · Owner A)¶
구역_목록을_전체_반환한다(findAll 전 컬럼)zones가_비어있으면_빈_배열_200이다(시딩 전 — 성공 기준 8)구역_목록_조회에_geospatial_연산이_없다(전 컬럼 정수 로드)미인증_요청은_401이다
모듈 4 — (제거됨 — §D6 결정 변경) GET /api/search 검색 테스트¶
미출하. 아래 시나리오는 구현·통과 후 검색 제거와 함께 삭제됐다 — 릴리스 검증 기준에서 제외(§D6 배너 참조). -
zone_이름이_매치되면_zone_결과를_반환한다(type=ZONE, bbox 정수 산술) -zone_결과의_bbox는_사각형_정수_산술과_일치한다(SW/NE = min/max grid·step) -zone_매치가_있으면_regions_폴백을_타지_않는다(2단 폴백 — zone 우선) -zone_매치가_없으면_regions_이름으로_폴백한다(type=REGION, ST_Envelope bbox) -동명_행정동은_parentCode로_구분된다(동명이인 — 위키 반영) -매치가_없으면_빈_배열_200이다-q가_공백이면_zone과_regions를_조회하지_않고_빈_배열_200이다(trim 가드 —ILIKE '%%'전건 매치 방지, 정정 2026-07-23) -limit은_zone_분기에도_적용된다(zone 매치 수 > limit → 절단, 정정 2026-07-23) -q가_없으면_400이다·미인증_요청은_401이다-검색_zone_branch에_ST__geospatial_연산이_없다(regions 폴백에서만 ST_Envelope)
모듈 5 — ZoneSeeder (플래그 게이트 · Owner A)¶
seed_enabled가_off면_시더가_동작하지_않는다(기본 off — 154 선례)seed_enabled가_on이면_데이터_파일의_zone을_적재한다(JSON 로드)시더를_두번_돌려도_zone이_중복되지_않는다(멱등)같은_zone_key로_이름이나_사각형을_바꿔_재시딩하면_기존_행이_갱신된다(ON CONFLICT (zone_key) DO UPDATE — 중복 방지가 아니라 갱신 수렴 검증, 정정 2026-07-23)
DoD 수동 검증 (공유 로컬 DB, zones 시딩)¶
- 합성 zone("m234서면", 소형 사각형) 시딩 →
GET /api/zones에 등장. 그 사각형 북단·서단 격자 gridId로 FE 산술 재현("m234서면 A-1"), 한 행 남쪽은 "B-1". - zones 비운 상태에서
GET /api/zones=[], 명명은 전부 행정동 폴백(167 regionName) — 아무것도 안 깨짐.
미해결 질문 (Open Questions)¶
-
구역 30~50개 작도 주체·도구 (ADR 쟁점 ③) — 스펙은 시딩 기계장치(테이블·시더·파일 형식)만 확정(§D7). "누가 무슨 도구로 서울 상권 30~50개 사각형을 그리나"(ADR: 드래그 내부 도구 제안)는 미결. 이게 정해져야 (a) 실제 zones 데이터가 채워지고 (b) §D5의 "안 겹치게 그리는 운영 규칙"이 강제된다. PO/디자이너 확인 필요 — 도구가 정해지면 산출물을
zones.json형식으로 뱉게 해 시더 재사용. 그 전까지 zones 0행으로 배포(전 시스템 행정동 폴백, 무해). -
검색 화면 "구별 격자 수" 의미 — 디자인 검색 화면의 "구별 격자 수 목록"이 (a) 구별
total_grid_count합산(그 구의 전체 격자 수)인지 (b) 등록된 격자 COUNT(실제 grids row = 누군가 올린 격자)인지 미확정. 상당수는 156GET /api/regions/stats(수집률·collected/total)로 이미 servable — 의미 확정 후 신설 여부 판단. PO 1줄 확인. -
"격자 검색"·"영상 검색" 스코프 — 디자인 검색 바가 "장소·격자·영상"을 나열하나, ADR 스코프·본 티켓은 장소(zone/region) 검색뿐. "격자 검색"이 "서면 A-14" 역파싱(문자열 → gridId → 지도 이동)을 의미하면 서버 역파싱(§D2
ZoneNamer포팅) + 파싱 실패 처리가 필요하고, "영상 검색"은 videos 콘텐츠 검색(Owner B, 인프라 전무)이라 별도 티켓. FE/PO에 "검색 바의 격자·영상 탭이 MVP 범위인가" 확인 — 범위면 각각 후속 티켓 분리. -
GET /api/zones공개 여부 — 현재anyRequest().authenticated()로 로그인 필요(RegionController 선례). MVP는 로그인 전제라 인증 유지(변경 없음), 공개 요구 시 후속 SecurityConfig 티켓. (검색 공개 여부는 MSG-251 소관으로 이관)
결정으로 종결(비-Open): zones 스키마·V8(§D1)·명명 산술 정본·유틸 위치(§D2)·FE-local 소비(§D3)·행정동 폴백 이름만(§D4)·겹침 priority 결정성(§D5)·검색 제거·MSG-251 이관(§D6 결정 변경)·시딩 기계장치(§D7)·glossary 등재(§D8)는 근거·기각안과 함께 확정 — 재논의 대상 아님. 위키 Region API 예정
GET /api/regions/search와의 경로 충돌은 §D6에서 ADR 우선으로 해소.
Wrap-up 체크리스트 (구현 후)¶
- [x]
status.md:zone신규 패키지 섹션 —Zone/ZoneRepository/GET /api/zones/ZoneSeeder(티켓당 한 줄 append — MSG-169 규칙). "스키마 vs JPA 엔티티" 표에zones → zone/entity/Zone(전 컬럼 매핑) 추가. (검색 항목은 §D6 결정 변경으로 제외) - [ ] glossary.md에 "구역(zone)"·"표시명(display name)" 정의 추가(§D8 — 별도 PR, 정의 먼저). 명명 규칙·행정동 폴백·26행 한계 명시.
- [ ] 크로스오너 계약 4종 시그니처 불변 확인. Owner B 코드 변경 0 확인(FE-local 결정의 귀결).
- [ ] V8 = 다음 번호(V1~V6 소진) 확인, V1~V6 무수정. CHECK 3종·인덱스 없음·geospatial 컬럼 없음 검증.
- [x]
GET /api/zones정수 전 로드·geospatial 0 확인. (검색 branch 검증 항목은 §D6 결정 변경으로 제외) - [x] 커밋 분리(작업 단위):
MSG-234 feat: zones 테이블 + 표시명 좌표 산술 규칙 (V8)/MSG-234 feat: 구역 목록 조회 API (GET /api/zones)/MSG-234 chore: ZoneSeeder 플래그 게이트 시딩 기계장치/MSG-234 docs: 스펙 및 status.md 갱신. (검색 feat 커밋은 §D6 결정 변경으로 revert됨 — 9267105) - [ ] 스펙 완성 시 지라 MSG-234에 D-결정·Open Q 요약 코멘트(MEMORY 'spec-to-jira-sync').
작업 로그¶
2026-07-28 — 보류 해제·재개¶
- 보류 사유 해소: 상권 작도 문제를 "공공데이터 초안 + 사람 검수"로 확정(컨플루언스 26181633) — 소진공 회식상권 SHP → 초안 184건(서울 147·부산 37) 자동 생성, 검수 1~2시간만 잔여. zones 실데이터는 검수 확정 후
zones.json주입. - (같은 날 추가) 주요상권현황 CSV 통합: 소진공 주요상권현황(2024-01, 전국 1,227상권, 경계 꼭짓점 WGS84 원본)을 1차 소스로 승격 → 초안 231건(서울 176·부산 55, 회식상권은 서울·부산 전 상권명이 포함돼 보충 0). 서면 bbox 교차 검증으로 회식상권 SHP = EPSG:5181 확정(좌표계 육안 판정 액션 폐기). 잔여 한계: 광안리·해운대 등 해변 관광 상권은 양쪽 데이터셋 모두 부재 — 검수 때 소수 수동 추가. 상세는 컨플루언스 26181633 v3.
- develop 리베이스: 충돌 0 —
RegionRepository에 236 equirefreshRegionStats와 234searchByName공존 확인. - V6→V8 재번호: V6은 MSG-166(미션 스키마)이, V7은 MSG-238(grids.region_code 인덱스, 병렬 세션·로컬 DB 기적용)이 선점 → zones = V8. 스펙 전체 표기 스윕. 머지 순서 의존: 238(V7)이 234(V8)보다 먼저 머지돼야 dev/prod 플라이웨이가 순차 적용된다 — 역전 시 머지 시점 재번호 필요(스크럼 조율 항목).
- 공유 로컬 DB 멀티브랜치 대응:
src/test/resources/application-local.yml에 flywayignore-migration-patterns: "*:missing"추가(테스트 한정) — 타 브랜치 세션이 적용한 미머지 마이그레이션(V7=238 사례)이 이 브랜치에 파일로 없어도 validate가 죽지 않게. CI는 매 실행 새 DB라 무영향. - reviewer 재검증 통과: 525 테스트 green(cleanTest 강제 재실행), 계약 4종 불변·Owner B 변경 0·zone branch geospatial 0·V8 CHECK 3종 확인. 경미 3건 반영(V8 sql 주석 V1~V6 정정, SearchServiceTest 동일 패키지 import 제거, ZoneSeeder jackson import 위치 선례 정합).
SearchServiceTest클래스명(대상 클래스 없음 — 검색이 ZoneQueryService에 흡수)은 재명명 권장으로 보류. - Codex 스톱 게이트 3건 판정: V6 충돌(워킹트리 V8 스윕으로 기해소) / parentCode 소스 부재(기각 —
ofZone이 regionCode 앞 5자리 파생, null 가드 존재) / "서면 A-14 미출시"(기각 — §D3 FE-local·§D7 데이터 분리·성공 기준 8의 결정된 설계).
2026-07-28 — 검색 API 제거 (MSG-251 이관, PR #65 반영)¶
- 결정 충돌 발견(사용자 지적): 같은 날 오전 확정된 ADR 장소 검색 카카오 로컬 프록시(MSG-251)가 "234의 지역 검색은 251로 이관, 234는 순수 표시명으로 축소"를 명시 — 재개 스펙·구현이 이 최신 ADR을 대조하지 못하고 2단 폴백 검색을 포함해 출하했음.
- 제거 반영:
SearchController·SearchResultResponseDto·ZoneQueryService.search(+impl 분기)·ZoneRepository이름 검색 파생 쿼리·RegionRepository.searchByName(+RegionSearchProjection)·검색 테스트 17건 삭제. zone 이름 매치는 FE/api/zones캐시 로컬 필터, 자유 텍스트·행정동은 카카오 프록시(251) 전담. §D6에 결정 변경 배너 + 원문 이력 보존.