경계 데이터 어댑터
자신이 모은 행정동·법정동·커스텀 경계를 JSON이나 별도 서버에서 읽도록 연결합니다. 경계 조회와 인증 정책을 각각 교체하고, 편집기의 공통 데이터 계약을 유지합니다.
데이터 공급자와 접근 정책을 나눕니다
서비스가 INIT.scene.features로 보낸 폴리곤은 바로 편집할 도형입니다. 이 페이지의 어댑터는 에디터 안에서 조회하고 골라 쓰는 참고 경계를 공급합니다. 입력 scene에 경계를 넣는 것만으로 경계 선택 메뉴의 데이터가 바뀌지는 않습니다.
| 역할 | 현재 구현 | 내재화할 때 |
|---|---|---|
| 데이터 읽기·검증 | regionsApi.ts → Supabase regions 함수 | JSON·자체 HTTP API를 같은 조회 함수에 연결 |
| 경계 사용 허용·캐시 범위 | useBoundaryAccess → Google 사용자 | 공개 데이터 또는 자체 인증의 allowed·subject |
| 도구 진입 시 접근 요청 | useBoundaryLogin → Google 팝업 | 즉시 허용 또는 자신의 로그인 과정 |
| 표시·채택·편집 | TanStack Query → OpenLayers → scene | 원래 흐름 유지. 선택한 원본 geometry만 scene에 반영 |
현재 구현과 연결 예제
아래 코드는 내재화한 소스에 적용하는 어댑터 예제입니다. 기본 에디터에 자동 등록되거나 환경 변수만으로 활성화되지 않습니다. 데이터 요청부와 인증 게이트를 함께 바꾸어야 합니다.
조회 함수가 돌려줘야 하는 데이터
| 기존 함수 | 입력 | 출력·책임 |
|---|---|---|
| fetchRegionKinds | country, signal | kind·label·level·min_zoom·sort_order·selectable 목록 |
| fetchRegionsByView | bbox·zoom·kind·country, signal | FeatureCollection + country·kind·level·truncated |
| fetchRegionById | boundaryId, signal | 표시한 Feature.id에 대응하는 원본 Feature 또는 null |
| fetchRegionByCode | kind·code·country, signal | 코드로 조회한 원본 또는 null. 편집 채택은 byId 사용 |
| fetchRegionTileManifest | signal | 타일 메타데이터 또는 null. null이면 기존 byView 흐름 사용 |
| fetchRegionsByTile | tile·manifest·zoom·kind, signal | 타일을 지원할 때만 메타데이터와 일치하는 경계 응답 |
도형은 GeoJSON Polygon 또는 MultiPolygon이며 좌표는
[경도, 위도]입니다. 링을 닫고, 서로 다른 정점 3개 이상을 넣습니다.Feature.id는 전체 경계 데이터에서 유일하고 버전별 원본을 식별해야 합니다. 화면 bbox마다 새 번호를 붙이거나 같은 ID의 원본을 도중에 교체하지 마세요.지도 라벨은
properties.name입니다. 예제는properties.kind·code로 필터링합니다. 도형의 kind와 종류 카탈로그를 일치시키세요.같은 조회를 나눠 호출해도 country·kind·level은 일치해야 합니다. 화면 표시용 좌표를 단순화할 수 있지만, 채택할 때의 byId는 원본을 돌려줘야 합니다.
작은 데이터셋을 JSON으로 제공하기
아래는 실제 행정경계가 아닌 형식 설명용 사각형입니다. 자신의 경계로 바꿔 public/boundaries/regions.json에 저장하면 같은 origin의 /boundaries/regions.json으로 읽을 수 있습니다. 파일을 두는 것에 더해 다음 어댑터 연결이 필요합니다.
{ "country": "KR", "kinds": [ { "kind": "adminDong", "label": "행정동", "level": 2, "min_zoom": 12, "sort_order": 0, "selectable": true } ], "features": [ { "type": "Feature", "id": "demo-2026-09:adminDong:001", "geometry": { "type": "Polygon", "coordinates": [ [ [127.01, 37.48], [127.05, 37.48], [127.05, 37.51], [127.01, 37.51], [127.01, 37.48] ] ] }, "properties": { "kind": "adminDong", "code": "demo-001", "name": "예시 행정동 (실제 경계 아님)" } } ]}이 예제는 줌 12부터 행정동을 표시합니다. 법정동이나 자체 권역도 kinds에 추가하고 해당 kind의 features를 넣으세요. 다른 기본 메뉴가 보이지 않게 하려면 카탈로그 실패 시 사용하는 regionKindModel.ts의 fallback도 자신의 종류에 맞춥니다.
JSON 어댑터 전체 코드
import bbox from "@turf/bbox";import { z } from "zod";import type { RegionFeature, RegionFeatureCollection, RegionKind, RegionTile, RegionTileManifest, RegionViewQuery,} from "@/pages/editor/features/regions/api/regionsApi";
// 문서용 구현 예제입니다. 기본 에디터에는 자동 등록되지 않습니다.// 내재화한 소스에서 기존 regionsApi.ts의 조회 함수를 이 구현에 위임하세요.export type BoundaryDataAdapter = { fetchRegionKinds(country?: string, signal?: AbortSignal): Promise<RegionKind[]>; fetchRegionsByView( query: RegionViewQuery, signal?: AbortSignal, ): Promise<RegionFeatureCollection>; fetchRegionById( boundaryId: string | number, signal?: AbortSignal, ): Promise<RegionFeature>; fetchRegionByCode( kind: string, code: string, country?: string, signal?: AbortSignal, ): Promise<RegionFeature>; fetchRegionTileManifest(signal?: AbortSignal): Promise<RegionTileManifest | null>; fetchRegionsByTile( tile: RegionTile, manifest: RegionTileManifest, zoom: number, kind: string, signal?: AbortSignal, ): Promise<RegionFeatureCollection>;};
const coordinate = z.tuple([ z.number().min(-180).max(180), z.number().min(-90).max(90),]);const ring = z .array(coordinate) .min(4) .refine((points) => { const first = points[0]; const last = points[points.length - 1]; if (!first || !last) return false; return ( first[0] === last[0] && first[1] === last[1] && new Set(points.map((point) => point.join(","))).size >= 3 ); }, "링을 닫고 서로 다른 정점을 3개 이상 넣어주세요.");const polygon = z.array(ring).min(1);const geometry = z.discriminatedUnion("type", [ z.object({ type: z.literal("Polygon"), coordinates: polygon }), z.object({ type: z.literal("MultiPolygon"), coordinates: z.array(polygon).min(1) }),]);const bundleSchema = z .object({ country: z.string().length(2), kinds: z.array( z.object({ kind: z.string().min(1), label: z.string().min(1), level: z.number().int(), min_zoom: z.number().finite(), sort_order: z.number().int(), selectable: z.boolean(), }), ), features: z.array( z.object({ type: z.literal("Feature"), id: z.string().min(1), geometry, properties: z .object({ kind: z.string().min(1), code: z.string(), name: z.string() }) .passthrough(), }), ), }) .superRefine((bundle, context) => { const kinds = new Set(bundle.kinds.map((kind) => kind.kind)); const ids = new Set(bundle.features.map((feature) => feature.id)); if (kinds.size !== bundle.kinds.length || ids.size !== bundle.features.length) { context.addIssue({ code: "custom", message: "kind와 Feature.id는 중복될 수 없습니다.", }); } if (bundle.features.some((feature) => !kinds.has(feature.properties.kind))) { context.addIssue({ code: "custom", message: "모든 도형의 kind를 카탈로그에 등록하세요.", }); } });
// 작은 공개 데이터셋용입니다. 전국 데이터는 bbox를 처리하는 자체 API로 분리하세요.export function createJsonBoundaryAdapter(url: string): BoundaryDataAdapter { async function read(signal?: AbortSignal) { signal?.throwIfAborted(); const response = await fetch(url, { signal }); if (!response.ok) throw new Error(`경계 JSON 요청 실패: ${response.status}`); const payload: unknown = await response.json(); signal?.throwIfAborted(); return bundleSchema.parse(payload); }
return { async fetchRegionKinds(country = "KR", signal) { const bundle = await read(signal); return country === bundle.country ? [...bundle.kinds].sort((a, b) => a.sort_order - b.sort_order) : []; }, async fetchRegionsByView(query, signal) { const bundle = await read(signal); const country = query.country ?? "KR"; const kind = bundle.kinds.find((item) => item.kind === query.kind); const features = country === bundle.country && kind && query.zoom >= kind.min_zoom ? bundle.features.filter((feature) => { if (feature.properties.kind !== query.kind) return false; const [west, south, east, north] = bbox(feature); return ( west <= query.maxLng && east >= query.minLng && south <= query.maxLat && north >= query.minLat ); }) : []; // 화면 bbox와 겹치는 도형을 전체 좌표로 반환합니다. geometry를 화면에 맞춰 자르지 않습니다. return { type: "FeatureCollection", country, kind: query.kind, level: kind?.level ?? null, truncated: false, features, }; }, async fetchRegionById(boundaryId, signal) { const bundle = await read(signal); return ( bundle.features.find((feature) => feature.id === String(boundaryId)) ?? null ); }, async fetchRegionByCode(kind, code, country = "KR", signal) { const bundle = await read(signal); return country === bundle.country ? (bundle.features.find( (feature) => feature.properties.kind === kind && feature.properties.code === code, ) ?? null) : null; }, async fetchRegionTileManifest(signal) { signal?.throwIfAborted(); // 기존 hook은 null을 받으면 낮은 줌에서도 byView 경로를 사용합니다. return null; }, async fetchRegionsByTile() { throw new Error("이 JSON 어댑터는 타일 조회 대신 byView를 사용합니다."); }, };}전체 파일을 읽고 bbox와 겹치는 도형을 고르는 작은 데이터셋용 예제입니다. 도형을 화면 경계로 자르지 않으며 표시와 채택에 같은 원본을 사용합니다. 전국 단위 데이터는 서버에서 bbox·종류·줌을 처리하고 원본 ID 조회를 분리하세요.
기존 호출부에 어댑터 연결하기
전체 예제를
features/regions/api/jsonBoundaryAdapter.ts로 복사합니다. 예제의 타입은 현재 API 계약과 맞춰 검사됩니다.regionsApi.ts의 기존 여섯 조회 함수 구현을 아래 위임으로 교체합니다. 같은 이름의 함수를 중복 선언하지 말고, 기존 타입과RegionApiErrorexport는 유지합니다.Google·Supabase 요청 helper와 호출 코드가 더 이상 쓰이지 않으면 이 어댑터 경로에서 제거합니다. 두 공급자를 유지하려면 각각 별도 구현에 두고, 이 API 진입점에서 선택합니다.
// 내재화한 regionsApi.ts에서 기존 같은 이름의 함수 구현을 교체합니다.// 기존 타입과 RegionApiError export는 유지합니다.import { createJsonBoundaryAdapter } from "./jsonBoundaryAdapter";
const adapter = createJsonBoundaryAdapter("/boundaries/regions.json");export const fetchRegionKinds = adapter.fetchRegionKinds;export const fetchRegionsByView = adapter.fetchRegionsByView;export const fetchRegionById = adapter.fetchRegionById;export const fetchRegionByCode = adapter.fetchRegionByCode;export const fetchRegionTileManifest = adapter.fetchRegionTileManifest;export const fetchRegionsByTile = adapter.fetchRegionsByTile;별도 서버도 같은 함수 계약에 맞춰 응답을 변환하면 됩니다. 자체 인증의 쿠키나 토큰은 이 서버 어댑터에서 처리하며, Google 토큰을 요구할 필요는 없습니다.
// 별도 서버 어댑터의 요청부 예시입니다.// endpoint·GET/POST·응답 변환은 서버에 맞추고 아래 조회 함수 계약을 유지합니다.async function request(operation, payload, signal) { const response = await fetch("/api/boundaries", { method: "POST", headers: { "Content-Type": "application/json" }, // 사내 세션 쿠키를 쓰는 같은 origin 서버의 예시입니다. credentials: "same-origin", signal, body: JSON.stringify({ operation, ...payload }), }); if (!response.ok) throw new RegionApiError(operation, response.status); return response.json(); // 각 함수에서 실제 응답을 Zod로 검증·변환합니다.}로그인 없이 사용하거나 자체 인증 연결하기
공개 JSON 배포는 아래처럼 허용 상태와 데이터 범위를 반환할 수 있습니다. subject는 인증 토큰이 아니라 Query 캐시를 구분하는 값이며, 비어 있으면 조회·원본 채택이 실행되지 않습니다.
// src/features/auth/hooks/useBoundaryAccess.ts// 자체 공개 JSON 데이터만 사용하는 배포의 예시입니다.export function useBoundaryAccess() { return { allowed: true, subject: "public-json:2026-09" };}
// src/pages/editor/features/regions/hooks/useBoundaryLogin.ts// useAuth/Google 팝업 호출 없이 동일한 hook 반환 계약을 유지합니다.export function useBoundaryLogin() { return { requestBoundaryAccess: async () => true, isSigningIn: false, error: null, cancel: () => {}, };}사내 인증을 쓴다면
allowed는 실제 로그인·권한에서,subject는 사용자·테넌트·데이터 버전에서 정합니다. 변경 시 이전 범위의 캐시가 섞이지 않게 하세요.Google UI를 완전히 제거하려면
AuthSessionButton과/auth/callback을 제거·교체하고, 모든useAuth소비처를 바꾼 뒤AppProviders의 기존AuthProvider를 정리합니다. Provider만 먼저 삭제하면 소비 hook이 오류를 냅니다.로그인 없는 구성에서도 서비스 메시지의 origin·세션 검증과 geometry 검증은 유지합니다. 기본 운영 서버의 인증을 끄는 것이 아니라 자신의 데이터 공급자와 접근 정책을 연결하는 작업입니다.
VITE_E2E_AUTH_BYPASS는 테스트 전용입니다. 운영 인증 선택 스위치로 사용하지 않습니다. 기본 구성을 유지할 때만 Google·Supabase 설정을 적용하세요.
데이터를 바꾼 뒤 확인할 흐름
선택 가능한 종류와 라벨이 자신의 카탈로그에서 표시되는지 확인합니다.
bbox·줌·종류 변경, 데이터 없음, 중단한 요청, 잘못된 JSON 응답을 확인합니다.
낮은 줌에서 manifest null → byView로 이어지고, 예제는 줌 12 이상에서 경계가 나타나는지 확인합니다.
참고 경계의 추가·합치기·빼기가 같은 ID의 원본을 사용하고 undo가 동작하는지 확인합니다.
저장 시 선택해 반영한 도형만 전체 scene에 포함되고, 참고 경계 목록이나 인증 정보는 반환되지 않는지 확인합니다.