본문으로 건너뛰기
postMessage · v2

부모 창 연동

부모는 팝업과 영구 저장을, 에디터는 도형 편집을 담당합니다. 일반 편집 연동에 Supabase 키나 로그인 토큰을 전달할 필요는 없습니다.

메시지 흐름

수신기를 먼저 등록하고 사용자 클릭에서 팝업을 엽니다. READY를 받은 뒤 INIT을 보내고, SUBMIT 또는 CANCEL을 처리합니다.

메시지 계약
메시지방향필수 데이터·처리
MAP_EDITOR_READY에디터 → 부모데이터 없는 준비 신호. 부모가 INIT으로 응답합니다.
MAP_EDITOR_INIT부모 → 에디터sessionId + scene. 새 INIT은 현재 편집·히스토리를 초기화합니다.
MAP_EDITOR_SUBMIT에디터 → 부모같은 sessionId + 전체 scene. 검증 후 부모 상태에 반영하고 팝업을 닫습니다.
MAP_EDITOR_CANCEL에디터 → 부모같은 sessionId. scene 없이 종료하며 기존 부모 데이터를 유지합니다.
MAP_EDITOR_ERROR에디터 → 부모message + 선택적 issues. sessionId가 없을 수 있습니다.

세 가지를 함께 검증하세요

event.source는 내가 연 팝업, event.origin은 미리 정한 에디터 origin이어야 합니다. SUBMIT/CANCEL은 발급한 sessionId까지 일치해야 합니다. INIT에 targetOrigin="*"를 사용하지 않습니다.

입력과 출력은 같은 v2 형식

scene.json
{  "version": 2,  "id": "delivery-area-edit",  "name": "배송 권역 편집",  "viewport": {    "center": [      127.0276,      37.4979    ],    "zoom": 13  },  "features": [    {      "id": "service-area-1",      "name": "강남 배송권역",      "geometry": {        "type": "Polygon",        "coordinates": [          [            [              127.01,              37.51            ],            [              127.05,              37.51            ],            [              127.05,              37.48            ],            [              127.01,              37.48            ],            [              127.01,              37.51            ]          ]        ]      },      "properties": {        "serviceAreaId": 42      }    },    {      "id": "store-1",      "name": "강남점",      "locked": true,      "geometry": {        "type": "Point",        "coordinates": [          127.0276,          37.4979        ]      }    }  ]}
  • 필수는 version: 2, features, 각 도형의 geometry입니다. 좌표는 WGS84의 [경도, 위도] 순서입니다.

  • Point·MultiPoint·LineString·MultiLineString·Polygon·MultiPolygon을 지원합니다. GeometryCollection은 받지 않습니다.

  • 선택 필드: 도형의 id·name·locked·visible·themeToken·properties, scene의 id·name·viewport. id를 보내면 중복되지 않아야 합니다.

  • 배열 뒤쪽이 지도 위쪽입니다. 반환값에 내부 layers·selection·history는 없고, 숨긴 도형은 포함됩니다.

  • locked는 사용자 UI에서 해제할 수 있는 잠금 상태입니다. 변조 방지나 서버 접근 권한으로 사용하지 마세요.

완료 시 부모가 scene 전체를 교체합니다. 중간 변경 메시지나 자동 저장은 없습니다. 저장 가능 조건도 확인하세요.

복사해서 연결하는 예제

TypeScript + Zod 예제입니다. 아래 네 파일을 같은 폴더에 두고, 기존 부모 화면의 버튼·지도·오류 UI를 연결하세요.

npm install zodbindMapEditor에 실제 editorUrl과 화면 콜백을 전달합니다. 반환된 정리 함수를 부모 화면의 unmount 시 호출하세요.

1. 부모 화면에서 연결하기
parent-page.example.ts
import type { EditorSceneInput } from "./editor-contract.example";import { inputScene } from "./input-scene.example";import { createMapEditorHost } from "./map-editor-host.example";
type ParentPageBindings = {  // 예: https://maps-editor.pages.dev/editor/ 또는 로컬 /editor/  editorUrl: string;  openButton: HTMLButtonElement;  renderOnParentMap: (featureCollection: {    type: "FeatureCollection";    features: Array<{      type: "Feature";      id?: string;      geometry: EditorSceneInput["features"][number]["geometry"];      properties: Record<string, unknown>;    }>;  }) => void;  showEditorError: (message: string) => void;};
export function bindMapEditor({  editorUrl,  openButton,  renderOnParentMap,  showEditorError,}: ParentPageBindings) {  let currentScene: EditorSceneInput = inputScene;
  const mapEditor = createMapEditorHost({    editorUrl,    getScene: () => currentScene,    onSubmit(editedScene) {      // 반환값 전체를 다음 편집의 기준 데이터로 교체합니다.      currentScene = editedScene;
      // 부모 화면의 지도·폼·상태 관리에는 features를 사용합니다.      renderOnParentMap({        type: "FeatureCollection",        features: editedScene.features.map((feature) => ({          type: "Feature",          id: feature.id,          geometry: feature.geometry,          properties: {            ...feature.properties,            name: feature.name,            locked: feature.locked,            visible: feature.visible,          },        })),      });    },    onCancel() {      // CANCEL에는 scene이 없으므로 기존 currentScene을 그대로 유지합니다.    },    onError: showEditorError,  });
  const openEditor = () => {    try {      mapEditor.open();    } catch (error) {      showEditorError(        error instanceof Error ? error.message : "편집기를 열지 못했습니다.",      );    }  };  openButton.addEventListener("click", openEditor);
  return () => {    openButton.removeEventListener("click", openEditor);    mapEditor.dispose();  };}
2. 팝업·메시지 처리
map-editor-host.example.ts
import {  completionMessageSchema,  type EditorSceneInput,} from "./editor-contract.example";
const EDITOR_WINDOW_NAME = "map-editor-child";const EDITOR_WINDOW_FEATURES = "width=1280,height=860";
type MapEditorHostOptions = {  editorUrl: string;  getScene: () => EditorSceneInput;  onSubmit: (scene: EditorSceneInput) => void;  onCancel?: () => void;  onError?: (message: string) => void;};
export function createMapEditorHost(options: MapEditorHostOptions) {  const editorUrl = new URL(options.editorUrl, window.location.href);  const editorOrigin = editorUrl.origin;  let editorWindow: Window | null = null;  let sessionId: string | null = null;  let initialScene: EditorSceneInput | null = null;
  const closeEditor = () => {    editorWindow?.close();    editorWindow = null;    sessionId = null;    initialScene = null;  };
  const handleMessage = (event: MessageEvent<unknown>) => {    // 반드시 내가 연 창과 배포된 편집기의 정확한 origin을 함께 확인합니다.    const targetWindow = editorWindow;    if (      !targetWindow ||      event.source !== targetWindow ||      event.origin !== editorOrigin    ) {      return;    }
    const data = event.data;    if (      typeof data !== "object" ||      data === null ||      !("type" in data) ||      typeof data.type !== "string"    ) {      return;    }
    if (data.type === "MAP_EDITOR_READY") {      // 같은 팝업의 재전송에도 세션과 최초 입력을 일관되게 유지합니다.      if (!sessionId || !initialScene) return;      targetWindow.postMessage(        {          type: "MAP_EDITOR_INIT",          sessionId,          scene: initialScene,        },        editorOrigin,      );      return;    }
    const completion = completionMessageSchema.safeParse(data);    if (completion.success) {      if (completion.data.sessionId !== sessionId) {        return;      }
      closeEditor();      if (completion.data.type === "MAP_EDITOR_SUBMIT") {        options.onSubmit(completion.data.scene);      } else {        options.onCancel?.();      }      return;    }
    if (data.type === "MAP_EDITOR_ERROR") {      const message =        "message" in data && typeof data.message === "string"          ? data.message          : "지도 편집기에서 오류가 발생했습니다.";      options.onError?.(message);    }  };
  window.addEventListener("message", handleMessage);
  return {    open() {      // 중복 클릭으로 이미 편집 중인 창을 닫거나 데이터를 덮지 않습니다.      if (editorWindow && !editorWindow.closed) {        editorWindow.focus();        return;      }      closeEditor();      initialScene = structuredClone(options.getScene());      sessionId = crypto.randomUUID();      editorWindow = window.open(        editorUrl.href,        EDITOR_WINDOW_NAME,        EDITOR_WINDOW_FEATURES,      );
      if (!editorWindow) {        closeEditor();        throw new Error("팝업이 차단되었습니다.");      }    },    dispose() {      window.removeEventListener("message", handleMessage);      closeEditor();    },  };}
3. 결과 검증 스키마
editor-contract.example.ts
import { z } from "zod";
// 외부 서비스로 복사할 수 있는 최소 검증 예제입니다. 업무별 저장 권한·면적 검증은 별도입니다.const coordinateSchema = z.tuple([  z.number().min(-180).max(180),  z.number().min(-90).max(90),]);const lineStringCoordinatesSchema = z.array(coordinateSchema).min(2);const ringSchema = z  .array(coordinateSchema)  .min(3)  .superRefine((ring, context) => {    const first = ring[0];    const last = ring[ring.length - 1];    const closed = first && last && first[0] === last[0] && first[1] === last[1];    const vertices = closed ? ring.slice(0, -1) : ring;    if (      (closed && ring.length < 4) ||      new Set(vertices.map((point) => point.join(","))).size < 3    ) {      context.addIssue({        code: "custom",        message: "Polygon에는 서로 다른 정점이 3개 이상 필요합니다.",      });    }  });const polygonCoordinatesSchema = z.array(ringSchema).min(1);
const geometrySchema = z.discriminatedUnion("type", [  z.object({    type: z.literal("Point"),    coordinates: coordinateSchema,  }),  z.object({    type: z.literal("MultiPoint"),    coordinates: z.array(coordinateSchema).min(1),  }),  z.object({    type: z.literal("LineString"),    coordinates: lineStringCoordinatesSchema,  }),  z.object({    type: z.literal("MultiLineString"),    coordinates: z.array(lineStringCoordinatesSchema).min(1),  }),  z.object({    type: z.literal("Polygon"),    coordinates: polygonCoordinatesSchema,  }),  z.object({    type: z.literal("MultiPolygon"),    coordinates: z.array(polygonCoordinatesSchema).min(1),  }),]);
const featureInputSchema = z.object({  geometry: geometrySchema,  id: z.string().optional(),  name: z.string().optional(),  locked: z.boolean().optional(),  visible: z.boolean().optional(),  themeToken: z.string().optional(),  properties: z.record(z.string(), z.unknown()).optional(),});
export const editorSceneInputSchema = z.object({  version: z.literal(2),  features: z.array(featureInputSchema),  id: z.string().optional(),  name: z.string().optional(),  viewport: z    .object({      center: coordinateSchema.optional(),      zoom: z.number().optional(),    })    .optional(),});
export const completionMessageSchema = z.discriminatedUnion("type", [  z.object({    type: z.literal("MAP_EDITOR_SUBMIT"),    sessionId: z.string().min(1),    scene: editorSceneInputSchema,  }),  z.object({    type: z.literal("MAP_EDITOR_CANCEL"),    sessionId: z.string().min(1),  }),]);
export type EditorSceneInput = z.infer<typeof editorSceneInputSchema>;

좌표 범위와 링 구조를 검사하는 연동 예제이며 모든 업무 규칙을 대신하지 않습니다. 부모 서버에서도 면적·위치·식별자와 저장 권한을 검증하세요.

4. 입력 데이터
input-scene.example.ts
import type { EditorSceneInput } from "./editor-contract.example";
export const inputScene = {  version: 2,  id: "delivery-area-edit",  name: "배송 권역 편집",  viewport: {    center: [127.0276, 37.4979],    zoom: 13,  },  features: [    {      id: "service-area-1",      name: "강남 배송권역",      geometry: {        type: "Polygon",        coordinates: [          [            [127.01, 37.51],            [127.05, 37.51],            [127.05, 37.48],            [127.01, 37.48],            [127.01, 37.51],          ],        ],      },      properties: {        serviceAreaId: 42,      },    },    {      id: "store-1",      name: "강남점",      locked: true,      geometry: {        type: "Point",        coordinates: [127.0276, 37.4979],      },    },  ],} satisfies EditorSceneInput;

팝업 연결을 유지하세요

noopener·noreferrer로 연 창이나 opener를 끊는 호스트 보안 정책은 현재 연결 방식과 맞지 않습니다. iframe 연동이 아닌 별도 창 방식입니다. Google 로그인은 에디터가 추가 팝업에서 처리하며 부모는 토큰을 받지 않습니다.

오류 처리

부모 연동 문제 해결
증상확인할 것
창이 열리지 않음사용자 클릭에서 window.open을 호출하고 팝업 차단 여부를 확인합니다.
READY를 받지 못함수신기를 먼저 등록했는지, opener가 유지되는지, 에디터 URL·허용 부모 origin이 맞는지 확인합니다.
MAP_EDITOR_ERROR빈 sessionId, 중복 id, 좌표 순서·범위와 v2 형식을 확인합니다. 오류 메시지는 화면에 안전하게 표시합니다.
편집 내용이 초기화됨같은 창에 새 INIT을 계속 보내고 있지 않은지 확인합니다. INIT은 업데이트 이벤트가 아닙니다.
결과를 받았는데 저장되지 않음SUBMIT 검증 뒤 부모의 저장 API를 호출해야 합니다. 예제는 부모 지도 반영까지만 수행합니다.