콘텐츠로 이동

지도·공간 데이터 설계 (멘토 설명용, 2026-08-06 기준 실측)

§2 격자 규칙은 이후 교체됨 (2026-08-08, MSG-347)

이 문서가 설명하는 위경도 등간격 규칙(floor(lat/0.0009)·floor(lng/0.00115))은 8/8 멘토링 직후 EPSG:5179 미터 좌표 floor(x/100)으로 전환됐다 → ADR 격자 계산 EPSG5179 전환. §2의 왜곡 서술(서울 101.6m·부산 104.7m)과 §6 수집률 분모 오차는 그 전환의 직접 근거이므로 그대로 유효한 기록이다. 나머지 절(테이블 구조·PostGIS 사용처·GIST 두 단계·뷰포트 btree 승리·zone 사각형·알려진 공백)은 현행 그대로다.

요약

FillMap의 지도 도메인 전체를 처음 보는 사람 기준으로 정리한 설명 문서. 수치는 전부 2026-08-06 develop 브랜치 실측이다. 뼈대는 테이블 4개(regions · grids · user_grids · videos)와 나눗셈 2번짜리 격자 규칙(floor(lat/0.0009), floor(lng/0.00115))이 전부다. 관통하는 설계 원칙은 하나 — 조회 경로에서 공간 연산을 하지 않는다. PostGIS가 실제로 도는 곳은 행정동 판정 한 군데뿐이고, 그마저 업로드 시 1회 판정해 grids.region_code에 적어 두는(비정규화) 구조다. 뷰포트 조회는 GIST가 아니라 정수 범위 btree 스캔이 p95 약 2.6배 빠르다 — 규칙적 격자에는 공간 인덱스가 풀 문제가 애초에 없기 때문. 알고 감수한 한계도 같이 적어 뒀다: 칸의 실제 크기가 위도에 따라 100~105m로 변하고, 그 탓에 수집률이 100%를 넘을 수 있다. 가장 크게 비어 있는 건 줌 아웃 시 집계 표현(미구현)이고, 이 문서 §10의 질문 15개가 2026-08-08 멘토 멘토링의 입력이 됐다.

이 노트로 답할 수 있는 질문

  • FillMap 지도 기능은 어떤 테이블 4개로 설명되나? 업로드 1건에 무슨 일이 일어나나?
  • 좌표 → 칸 번호 규칙은 무엇이고, H3·S2 대신 자체 규칙을 쓴 이유는?
  • 왜 정확히 100m × 100m 격자를 만들지 않았나? 감수한 오차는 얼마인가?
  • PostGIS는 실제로 어디서 쓰이나? ST_Covers를 쓴 이유는?
  • 뷰포트 조회에서 공간 인덱스(GIST)가 왜 졌나?
  • 수집률이 100%를 넘을 수 있는 이유는?
  • 상권(zone)을 폴리곤이 아니라 칸 사각형으로 저장한 이유는?
  • 현재 알려진 공백(미구현·감수한 한계)은 무엇인가?

1. 뼈대 테이블 4개

테이블 답하는 질문 키·핵심 컬럼
regions 이 좌표는 어느 행정동인가 PK region_code, boundary_geom, total_grid_count
grids 누군가 발 디딘 칸은 어디인가 PK grid_id(문자열), grid_y/grid_x(정수), region_code
user_grids 이 사용자가 채운 칸은 어디인가 복합 PK(user_id,grid_id), video_count, cover_video_id
videos 언제 어디서 무슨 영상을 올렸나 user_id, grid_id, 원본 좌표 geom, 공개범위, 상태

설계가 갈리는 지점 3개: 1. grids에 지구 전체 칸이 없다. 칸 번호를 계산으로 얻을 수 있으니 미리 만들지 않고 업로드 순간에만 행을 만든다(지연 삽입). 행이 있다 = 누군가 다녀갔다. 2. grid_id가 시퀀스가 아니라 "39064_112225" 문자열이고 그대로 PK다. 좌표만 있으면 서버에 묻지 않고 만들 수 있어 클라이언트가 자기 칸을 스스로 안다. 3. 도감이 별도 테이블이다. video_count가 0이 되면 user_grids 행을 지워 색칠이 풀리지만, grids 행은 남는다.

업로드 1건의 트랜잭션: videos INSERT → grids UPSERT(이때 중심점으로 행정동 1회 판정해 region_code 기입) → user_grids UPSERT(첫 방문 = 점령) → 첫 점령이면 region_stats 재계산. 이후 조회는 전부 grids.region_code 등호 읽기다.

2. 격자 규칙과 감수한 왜곡

gridY = floor(위도 / 0.0009)      gridX = floor(경도 / 0.00115)
서면역 (35.1578, 129.0594) → 39064_112225

상수는 "한국 위도에서 한 눈금 ≈ 100m"를 자릿수 짧게 반올림한 값이다(남북 100/111,320 ≈ 0.000898 → 0.0009 / 동서 서울 37.5° 기준 0.001132 → 0.00115). 서버·웹·안드로이드·iOS 네 곳에 하드코딩으로 복제되는 계약이라 짧은 값이 전달·대조에 유리하고, 어차피 동서 폭이 위도마다 변해 정밀도를 좇을 실익이 없다.

자체 규칙에서 얻은 것: 전역 고정 원점(0,0) + 반열림 구간이라 겹침·틈이 구조적으로 없음 · 발급 주체 없음(지연 삽입 가능) · 이웃 칸이 정수 덧셈 · 지도 SDK 무관(카카오→네이버 전환 때 백엔드 무수정).

대가: 동서 폭이 서울 약 101.6m, 부산 약 104.7m, 적도 약 128m. 국내 오차 1~5%.

정확한 100×100m로 못 가는 이유 3단: ① 경도 1도 길이가 cos(위도)로 변해 상수 하나로 전국을 못 맞춤 ② 위도별 가변 스텝은 벽돌쌓기가 되어 정수 좌표계가 붕괴(칸 계약·zone 사각형·"서면 I-6" 산술·뷰포트 정수 조회가 전부 축 정렬에 의존) ③ 평면 투영(UTM-K)은 네 플랫폼이 투영 라이브러리와 부동소수 결과까지 일치시켜야 하고, 한국이 51/52 존 경계에 걸치며, 가우스 정리상 투영 좌표의 100m도 지상에서 완전히 정확하진 않다. 어떤 방식이든 "지상에서 정확한 100m 타일"은 없고, 오차를 어디에 둘지 고르는 문제.

3. PostGIS가 실제로 도는 곳은 한 군데

PostgreSQL 16 + PostGIS 3.4, 지오메트리는 전부 GEOGRAPHY SRID 4326(전 구간 WGS84, 평면 투영 없음).

테이블 컬럼 인덱스 조회에 쓰나
regions boundary_geom GIST — 진짜 공간 연산이 도는 유일한 곳
grids center_geom 없음 위 판정의 입력값으로만
grids bbox_geom GIST 안 씀 — 벤치마크 대조군으로 보존
videos geom 없음 안 씀 (원본 좌표 보존용)
zones 없음 없음 지오메트리 컬럼 자체가 없음

4. 행정동 판정 — 인덱스와 비정규화

GIST는 두 단계로 일한다: ① 폴리곤 외접 사각형 비교로 3,558개 → 후보 2~3개 ② 추려진 것만 ST_Covers 정밀 판정. 인덱스를 끄면 Parallel Seq Scan으로 3,558건 전수 판정 — 2.9ms(콜드)·0.05ms(워밍) vs 208.9ms, 버퍼 32 vs 2,671페이지.

ST_Contains가 아니라 ST_Covers인 이유가 결정적이다. boundary_geom이 geography라 ST_Contains::geometry 캐스트가 필요하고 그 순간 GIST를 못 탄다(위 208.9ms 경로로 조용히 떨어진다). 의미상으로도 경계선 위 점을 포함으로 보는 쪽이 맞다.

요청마다 판정 vs 미리 저장 — 이전 멘토링 피드백("감으로 하지 말고 둘 다 구성해 성능으로 비교하라")에 따라 실측했다(regions 3,558건 실데이터, 28점 × 8,400회):

방식 p50 p95 p99
(a) 요청마다 ST_Covers 0.053 ms 0.132 ms 0.218 ms
(b) 저장된 행정동 코드 읽기 0.006 ms 0.006 ms 0.010 ms

(a)도 1ms를 안 넘는다. 핵심은 속도 차가 아니라 (b)의 값이 누군가 (a)를 한 번 실행해 채워 둔 것이라는 점 — 비용이 사라진 게 아니라 잦은 읽기에서 드문 쓰기로 옮겨갔다. 이 측정은 설계 변경 근거가 아니라 기존 설계가 맞았는지 확인하는 자료가 됐다. 요청마다 판정하는 경로는 "라벨 없는 임의 좌표"(사용자가 지도를 찍었을 때) 하나만 남았다.

귀속 기준은 칸 중심점 하나로 통일(ADR 격자 행정동 라벨 grids.region_code). 저장하는 게 N:M 포함관계가 아니라 함수값(칸 1점 → 동 1개) 이라 다대다가 생길 여지가 없고, 이름표·탐험률·동 단위 목록·수집률이 전부 같은 기준을 본다. 대가는 경계 칸이 면적 비례로 안 나뉘고 통째로 한쪽에 가는 것.

5. 뷰포트 조회 — 공간 인덱스가 진 이유

접근 방식 인덱스 결과
A. 정수 범위 스캔 화면 사각형 → 칸 번호 범위 BETWEEN btree 채택
B. 공간 인덱스 화면 사각형 ↔ bbox_geom 교차 GIST 기각 (A가 p95 약 2.6배 빠름)

규칙적 격자라 화면 사각형을 칸 번호 범위로 정확히 바꿀 수 있어 R-tree 하강도, 정밀 재검도 필요 없다. PostGIS가 느린 게 아니라 공간 인덱스가 풀 문제가 없었다는 이야기.

방어 둘: 화면 사각형 한 변 0.5도(약 55km) 상한(초과 시 거절), 커서 페이지네이션(마지막 (grid_y, grid_x) 기준, 기본 1,000건·상한 5,000건). 줌 레벨 파라미터는 없다(화면 사각형이 줌을 담고 있음). 아무도 안 채운 칸은 서버가 안 내려주고 점선 격자망은 클라이언트가 산술로 그린다.

6. 수집률 분모 — 100%를 넘을 수 있는 이유

total_grid_count = ROUND(ST_Area(boundary_geom::geography) / 10000) — 칸을 열거하지 않고 면적을 명목 칸 면적(10,000m²)으로 나눈다. 그런데 실제 칸 면적은 서울 10,150m²·부산 10,470m²라 분모가 1.5~4.7% 과대계상되고, 경계 부분 칸 잡음이 O(1/√C)로 더해진다. 그래서 수집률이 100%를 넘을 수 있다 — 저장은 원값, 화면에서 자른다. 칸 면적 상수는 조절 가능하게 남겨 뒀다(10,150으로 바꾸면 서울 편향 소멸).

7. 핫구역 — DB를 안 쓰는 시간 축 집계

업로드 시 칸 번호를 Redis Sorted Set의 6시간 버킷+1, 조회는 최근 8버킷(48시간) 합산 → 전국 상위 50 중 점수 3 이상 → 화면 범위 필터(정수 비교). 버킷 TTL 54시간. 영상을 지워도 점수를 깎지 않고, Redis가 날아가도 서비스는 안 멈추며, 도배 방어도 없다 — "지금 뜨는 곳"은 근사값이어도 되는 정보라는 판단.

8. 외부 공간 데이터

  • 행정동 경계 3,558건ST_GeomFromGeoJSON + 면적 산출을 PostGIS가 한 문장에서 처리, 행 단위 UPSERT라 멱등. 수십 MB라 마이그레이션이 아니라 플래그 시더로 분리.
  • 상권 zone — 소진공 주요상권현황(1,227상권)에서 자동 초안 231건을 뽑았지만 최종 48건 중 31건이 수동 작도다. 상권 데이터는 상가 밀집도 기준이라 통칭과 어긋나고(가로수길·연남동·광복동·대학로·샤로수길), 광안리·해운대 같은 해변 관광 상권은 두 데이터셋 모두에 아예 없다. 좌표계 표기가 없던 회식상권 SHP는 이미 범위를 아는 지역(서면)과 겹쳐 보는 방식으로 EPSG:5181임을 역판정했다. → zone 상권 공공데이터 근거
  • zone을 폴리곤이 아니라 칸 사각형(정수 4개)으로 저장한 진짜 이유는 이름 붙이기다. 서면 사각형(39056~39072, 112220~112229)에서 행 = maxGridY - gridY, 열 = gridX - minGridX + 1"서면 I-6". 뺄셈 2번이라 클라이언트가 계산하고 서버는 문자열을 만들지 않는다(zone 표시명 FE 계약). 행 이름표가 Z에서 끝나 남북 26칸 상한이고 이를 DB CHECK 제약으로 강제했다. zone 데이터가 0건이어도 행정동 이름으로 폴백돼 시스템이 정상 동작한다(실제로 기계장치 먼저 배포, 데이터 나중 투입).
  • 걷기 코스 148개(약 2,400km) — 경로에 공간 연산이 하나도 안 붙어 geometry 대신 JSONB로 GeoJSON 원문 저장(읽을 때 변환 0). 포토스팟 선정은 500m 재샘플링 + 관광 API 반경 400m 조회 파이프라인인데, API 키·쿼터(약 5천 콜)·레이트리밋 때문에 저장소 밖 파이썬으로 빼고 서버는 산출물만 적재한다.
  • 좌표 순서 함정(GeoJSON/WKT는 [경도, 위도], DTO는 (위도, 경도))은 유틸 클래스 한 곳에 가뒀다.

9. 알려진 공백

항목 상태 내용
줌 아웃 집계 표현 미구현 가장 크게 비어 있음. 방어가 범위 상한과 페이징뿐
뷰포트 응답 캐시 미구현 설계(칸 단위 스냅 캐시 키)는 끝, 구현 정지 (MSG-89)
반경 탐색 없음 거리 기반 조회 0건. 칸 링 확장 vs ST_DWithin 미정
실서버 부하 한계점 부분 노트북 닫힌 모델 수치(약 286 RPS, p95 96ms)만 존재
행정동 경계 개편 대응 감수 재적재 + 전체 칸 재라벨링 운영 절차 없음 (비정규화의 대가)
수집률 분모 오차 감수 §6 — 100% 초과 가능, 화면에서 절삭
zone 겹침 방지 부분 결정성은 스키마가 보장, 작도 규칙은 도구 미강제

멘토에게 물은 것 (요지)

줌 아웃 대응의 정석(특히 사용자별 레이어라 타일 캐시가 안 되는 문제) · 격자 규칙 유지 여부와 H3/S2 전환 기준 · 100m 단일 해상도 · 비정규화 라벨 무효화 관리 · 중심점 대표 귀속의 통계 타당성 · 공간 인덱스를 뺀 선택의 유효 범위 · WGS84 vs 평면 투영 · 면적 나눗셈 근사 · 통칭을 못 담는 공공 상권 데이터 대안 · 좌표계 미표기 파일의 정석 판정 절차 · 공간 데이터 갱신 운영 · 격자 상수 정밀도 · 등면적이 필수가 되는 요구는 무엇인지. → 답변은 2026-08-08 멘토 멘토링