콘텐츠로 이동

zone 표시명 데이터 파이프라인 해설 — 공공데이터에서 "서면 A-14"까지

요약

격자 이름("서면 A-14")은 ①배포 전 1회: 국가 상권 데이터를 사각형 40개로 다듬어 DB에 넣고②런타임 매번: 화면(FE)이 그 사각형을 보고 뺄셈 두 번으로 이름을 읽는 2단 구조다. 같은 일을 두 번 하는 게 아니라, ①은 "서면이 어디까지인지"를, ②는 "이 칸이 서면의 몇 번째인지"를 만든다. 격자마다 이름을 저장하지 않는 이유: 사각형 1개(숫자 6개)가 그 안 수백 칸의 이름을 전부 결정하기 때문(초안 기준 42배 절약). 준비물은 PR #76(PRD·스펙 D-결정 10건·스크립트 3종)으로 완료, 2026-07-31 현재 후보 29건 = 공공데이터 17 + 수동 작도 12 validate PASS — 남은 건 팀 검수(통칭 목록 확정·눈검수)와 시딩뿐이다 (MSG-259).

이 노트로 답할 수 있는 질문

  • "서면 A-14"라는 이름은 누가, 언제, 어떻게 만드나?
  • 상권 범위 데이터는 어디서 왔고 사람이 뭘 하나?
  • 왜 격자마다 이름을 DB에 저장하지 않나?
  • 왜 서버가 아니라 화면(FE)이 이름을 계산하나?
  • 배포할 때마다 이 작업을 반복해야 하나?
  • 데이터가 잘못 들어가는 걸 어떻게 막나(검증)?

0. 배경 — 무엇이 문제였나

FillMap의 격자는 내부적으로 "39070_112223" 같은 기계용 번호로만 식별된다. 디자인은 격자에 "서면 A-14" 같은 사람용 이름을 요구한다. 그런데 "서면"은 행정동이 아니다(행정동으로는 부전동) — "홍대"·"가로수길" 같은 통칭은 행정 구역 어디에도 없어서, "여기부터 여기까지가 서면"이라는 범위를 누군가 정해줘야만 한다. 그 범위를 zone(구역)이라 부른다.

1. 전체 그림 — 시간축으로 두 단계

핵심은 "처리"가 두 번 나오지만 완전히 다른 일이라는 것. 헷갈리면 이 그림 하나만 기억하면 된다.

━━━━━━ ① 배포 전, 딱 1번 (사람 + 스크립트) ━━━━━━━━━━━━━━━━━

 국가 공공데이터 (전국 상권 1,227개의 경계 좌표)
     │  변환 스크립트 (자동)
     ▼
 초안 233건 — "서면역7번 출구" 같은 잘게 쪼개진 사각형들
     │  팀 검수 1~2시간 (사람): 유명한 것만 고르고, 합치고, 이름 다듬기
     ▼
 확정본 zones.json (~40건) → 검증기 PASS → 앱 기동 시 DB 적재
     │
 ┌───┴──────────────────────────────────────┐
 │ DB에 남는 것 = "이름 + 숫자 6개"짜리 행 ~40개   │
 │   서면:   세로 39056~39072, 가로 112220~112229 │
 │   홍대입구: 세로 41719~41740, 가로 110359~110375 │
 └──────────────────────────────────────────┘
     여기서 백엔드 일은 끝. "A-14"라는 글자는 아직 세상에 없다.

━━━━━━ ② 런타임, 사용자마다 매번 (화면이 계산) ━━━━━━━━━━━━━

 앱 켜짐  → 위 40행을 한 번 내려받아 보관 (8KB, 세션당 1회)
 도감 진입 → 내 격자 목록 수신 (번호만 옴 — 이름은 안 옴)
 카드 그릴 때 → "이 번호… 어느 사각형 안이지? → 서면.
              서면 안에서 몇 번째 칸이지? → 뺄셈 두 번 → A-14"

①은 "서면이 어디까지인지"를 만들고, ②는 "이 칸이 서면의 몇 번째인지"를 읽는다. ① 없이 ②는 못 돌고(서면이 어딘지 모름), ② 없이 ①은 이름을 못 만든다(사용자가 어느 칸을 보는지 모름). 겹치는 작업은 없다.

비유 — 영화관 좌석표

극장 짓기(1번): "3관은 A~Q열, 1~10번 좌석" ← ① 좌석표 제작 입장(매번): 티켓 번호를 좌석표에 대조해 "C열 4번입니다" ← ② 안내

좌석표를 만들 때 "C-4" 안내문을 좌석마다 미리 인쇄해 붙이지 않는다. 관객이 오면 표를 보고 읽으면 되니까. 마찬가지로 격자마다 "서면 C-4"를 저장하지 않는다.

2. ②의 실체 — 뺄셈 두 번

화면이 하는 "계산"은 이게 전부다. 서면 사각형과 내 격자를 겹쳐 그리면:

            가로: 112220 112221 112222 112223 …
                    1      2      3      4        ← 열 번호 (서쪽부터)
세로 39072  A  ┌──────┬──────┬──────┬──────┬───
세로 39071  B  ├──────┼──────┼──────┼──────┼───
세로 39070  C  ├──────┼──────┼──────╔══════╗───
               │      │      │      ║ 내 격자 ║
                  "서면" 사각형 (DB에서 받은 숫자 6개)

 행 = 39072 − 39070 = 2       → A, B, C의 "C"  (사각형 북쪽 끝이 A)
 열 = 112223 − 112220 + 1 = 4
 이름 = "서면 C-4"              ← 이 문자열이 태어나는 유일한 순간

실측: 도감 카드 30장 이름을 전부 계산하는 데 7.1µs(0.0000071초) — 화면이 카드 한 장 그리는 비용의 1/100도 안 된다. "화면에서 계산하면 느리지 않나"는 걱정할 수준이 아니다.

3. 왜 격자마다 이름을 저장하지 않나

"어떤 격자는 서면, 어떤 격자는 홍대"를 DB에 박아두는 방식(격자→zone 매핑 테이블)을 일부러 피했다. 세 가지 이유:

  1. 사각형 하나가 수백 칸을 결정한다 — 홍대 사각형 1건(숫자 6개)이 그 안 격자 374칸의 이름을 전부 만든다. 초안 233건 기준으로 격자별 저장 대비 42배 절약. "안에 있나?"의 판정이 부등호 비교 4번이라 저장해둘 이유가 없다.
  2. 수정이 한 줄로 끝난다 — "홍대입구역9번 출구"를 "홍대"로 바꾸거나 경계를 한 칸 넓혀도 사각형 1행만 고치면 된다. 격자별로 저장했다면 수백 행을 다시 써야(백필) 한다.
  3. 점진 배포가 된다 — zone이 0건이어도 앱은 행정동 이름("부전동")으로 정상 동작하고, 사각형 하나를 넣는 순간 그 안 격자만 "서면 A-14"로 바뀐다. 서버·화면 배포 없이 데이터만으로.

단, 같은 "격자의 이름"이라도 행정동(부전동)은 반대로 격자에 저장한다 (ADR 격자 행정동 라벨 grids.region_code) — 행정동 경계는 사각형이 아니라 울퉁불퉁한 폴리곤이어서 "안에 있나?" 판정이 비싸기 때문. 판정이 공짜면 저장 안 하고, 비싸면 한 번 판정해 저장한다가 일관된 원칙이다.

4. 왜 서버가 아니라 화면이 계산하나

서버가 모든 응답에 이름을 얹는 안을 기각한 이유 (ADR 격자 표시명 zone 이후 MSG-234 §D3 확정):

  • 이름이 필요한 화면 중 지도 뷰포트가 최고 트래픽 경로다(팬할 때마다 폴링, 응답속도 SLO p95<300ms). 서버가 이름을 얹으면 응답이 화면당 +9~23KB씩 매번 커지고 그 SLO 예산을 갉아먹는다.
  • 화면 계산이면 zone 데이터 8KB를 세션당 1번만 내려받고 끝. 이후 비용은 위 뺄셈뿐.
  • 부작용: 계산 규칙이 FE·Android·iOS로 3벌 복제되어 어긋날(드리프트) 위험 → 언어 중립 기준표(계약 픽스처)로 방어하는 것이 MSG-259 스코프에 포함됨.

5. ①의 상세 — 사람이 며칠 그릴 뻔한 걸 검수 1~2시간으로

"서면이 어디까지인가"는 계산할 수 없는 사람의 지역 지식이라 원래 수작업 며칠이 필요했고, 그래서 티켓이 보류됐었다. 해법(2026-07-28 결정, cf-26181633): 국가(소상공인시장진흥공단)가 전국 상권 경계를 이미 그려서 무료 개방하고 있다 → 그걸 자동 변환하고 사람은 검수만 한다.

자동 변환 (스크립트)

상권 경계 좌표(위경도) → 우리 격자 눈금으로 반올림한 사각형. 소스 3종: 주요상권현황 CSV(1차) + 회식상권 SHP(2차, 1차에 없는 이름만) + 해변 점포 밀집 파생(광안리·해운대 — 두 데이터셋 모두에 없어서). 결과 = 초안 233건 (서울 176 · 부산 55 · 해변 2).

사람 검수 (규칙 4개 — 2026-07-30 확정)

초안은 그대로 못 쓴다. "서면역7번 출구"처럼 잘게 쪼개져 있고, 우체국·호텔 같은 비상권이 51건 섞여 있고, 사각형끼리 138곳 겹친다. 검수 규칙:

  1. 유명 통칭 목록을 먼저 정한다 (30~50개, 팀 임의 선정) — 비상권과 안 유명한 이웃 상권 (역삼·합정 등)은 안 뽑는 것으로 겹침을 원천 차단
  2. 같은 통칭은 같은 시/도 안에서만 합친다 — 조건이 없으면 서울시청+부산시청이 한 사각형으로 묶여 265km짜리가 된다 (실측으로 확인된 함정)
  3. 목록에 있는데 공공데이터에 없는 곳은 손으로 그린다 — 가로수길·연남·대학로 등 13곳. 공공데이터가 "상가 밀집" 기준이라 관광·거리형 상권이 빠져 있다
  4. 남는 겹침은 숫자 하나로 가른다 — 서면과 전포가 한 열 겹침 → 서면의 가로 끝값을 112230→112229로 한 칸 물리면 분리 끝. 정수 사각형 방식의 최대 장점

드라이런 결과 (2026-07-30)

규칙 1·2·4를 스크립트로 미리 적용해봤다(build-candidate.py): - 후보 17건 생성, 검증기 PASS — 경계 조정은 정수 2개(서면·홍대입구 각 1)가 전부 - 팀 검수가 "233건에서 고르기"에서 "17건 확인 + 13곳 그리기"로 축소 - 부수 발견: 겹침 예상 3쌍 중 1쌍(강남↔압구정)은 분석 스크립트의 과매칭이 만든 허상이었음 — 머리로 세운 계획이 데이터에 닿으면 틀리고, 드라이런이 그걸 검수 전에 잡는다

6. 검증 — "어떻게 확인하냐"의 답

눈으로는 233×232 조합의 겹침을 못 본다. 3층으로 잡는다:

도구 잡는 것
시딩 전 validate-zones.py 크기 한계(세로 26칸=2.6km)·겹침·식별자 중복 — 위반 시 실패 코드, 통과 전 적재 금지
적재 시 DB 제약(CHECK) 크기·범위 오류를 DB가 최종 거부
geojson.io 지도 오버레이 위치가 실제 상권과 맞는지, 이름이 자연스러운지 (기계가 못 보는 것)

미검수 초안을 넣으면 위반 138건으로 실패하고(정상 — 검수 전이니까), 검수 완료의 기계적 정의가 "위반 0건"이 된다.

7. 배포마다 반복하나? — 아니다

  • 평소 배포: zone 관련 비용 0. 데이터는 DB에 있고, 적재기(시더)는 플래그 꺼짐이 기본
  • 데이터 갱신(상권 추가·이름 수정, 드묾): zones.json 몇 줄 수정 → 검증기 → 적재 1회. 같은 파일을 몇 번 넣어도 결과가 같아서(멱등) 사고 위험이 없다
  • 공공데이터 재수집: 지역 확장(전국) 때나. 연 1회면 충분
  • 유일한 함정 — 삭제: 적재는 "넣기/고치기"만 하고 지우지 못한다. zones.json에서 지워도 DB에 남는다 → 삭제는 수동 처리(규모 40행이라 충분), 반복되면 동기화 로직 추가 검토

8. 현재 상태와 남은 일 (MSG-259)

  • 기능 코드(테이블·API·적재기·테스트 23건): MSG-234로 구현 완료, develop 반영
  • 시드 데이터: 0건 — 그래서 지금 모든 격자는 행정동 이름으로 표시됨 (의도된 폴백)
  • 2026-07-30 PR #76 (6 files +714, 서버 코드 변경 0): PRD(docs/prd/MSG-259-prd.md, FR 19건 — 파이프라인 개편의 첫 PRD 적용 사례) + 스펙(docs/spec/MSG-259.md, D-결정 10건 — 픽스처 JSON 언어중립화·시더 상시 on· zone 삭제 수동 처리 등) + 스크립트 3종. 검수 규칙 4개·priority 전원 0(보험)·검증 3층을 실측으로 확정
  • 2026-07-31 갱신: 수동 작도 12곳(zones-manual.json) 병합 완료 — 후보 29건 = 공공데이터 17 + 수동 12, validate-zones.py PASS. 공공데이터 출처·선택 근거 상세는 zone 상권 공공데이터 근거
  • 남은 일: 팀 스크럼에서 유명 통칭 목록 확정(후보 23곳은 제안) → geojson.io 눈검수 → 이름 다듬기 → 확정본 zones.json 커밋 → 시딩. 더불어 명명 규칙 기준표(FE·앱 드리프트 방어)와 용어사전 등재
  • 요구사항 정본: BE 레포 docs/prd/MSG-259-prd.md