익산시 전용으로 만든 불법주정차 단속 플랫폼을 다른 지자체에도 공급하기로 했다.

그런데 지자체마다 쓰는 지도가 다르다.

어떤 곳은 네이버, 어떤 곳은 카카오, 해외 건은 구글, 폐쇄망 환경은 아예 외부 지도를 못 쓴다.

지도를 바꾸는 게 화면 전체를 다시 만드는 일이 되면 안 됐다.

직접 붙이면 무슨 일이 생기냐면

지도 SDK를 그냥 쓰면, 지도 코드가 화면 전체로 번진다.

마커를 찍는 곳에서 new naver.maps.Marker(...)를 부르고,

줌 레벨을 읽는 곳에서 map.getZoom()을 부르는데 카카오는 map.getLevel()이고 값의 방향도 반대다.

이벤트도 제각각이다. 리스너를 붙이는 방법도, 떼는 방법도 다르다.

이 상태에서 "카카오로 바꿔주세요"가 오면, 바꿔야 할 파일이 수십 개가 된다.

그리고 그 수십 개 중 하나를 빠뜨린다.

그래서 지도를 인터페이스 뒤로 숨겼다.

MapAdapter — 우리가 필요한 것만

핵심은 이 인터페이스 하나다.

export interface MapAdapter {
  // 맵 인스턴스 식별자
  readonly type: "kakao" | "naver" | "google" | "leaflet";

  // === 기본 맵 조작 ===
  getZoom(): number;
  setZoom(zoom: number): void;
  getCenter(): LatLng;
  setCenter(center: LatLng): void;
  getBounds(): Bounds;
  fitBounds(bounds: Bounds, padding?: number): void;

  // === 이벤트 ===
  on<K extends keyof MapEventCallbacks>(
    event: K,
    handler: NonNullable<MapEventCallbacks[K]>
  ): () => void;

  // === 맵 컨트롤 ===
  setDragging(enabled: boolean): void;
  setScrollZoom(enabled: boolean): void;
  setCursor(cursor: string): void;

  // === 정리 ===
  destroy(): void;
}

이 인터페이스를 만들면서 제일 오래 고민한 건 **"어디까지 넣을 것인가"**였다.

처음엔 네 개 지도 SDK의 기능을 다 비교해서 공통분모를 찾으려고 했다.

그런데 그 방향이 틀렸다는 걸 금방 알았다.

공통분모로 그으면 어느 지도에서도 안 되는 게 너무 많아지고, 합집합으로 그으면 어댑터마다 빈 구현이 잔뜩 생긴다.

기준을 바꿨다.

"우리 화면이 실제로 지도에 요구하는 것"만 넣는다.

우리가 필요한 건 줌·중심·바운즈·이벤트·드래그 잠금·커서, 그리고 정리였다.

지도 SDK가 제공하는 나머지 기능은 인터페이스에 없다. 필요해지면 그때 추가한다.

추상화는 SDK를 흉내내는 게 아니라 우리의 요구사항을 선언하는 것이라는 걸 이때 배웠다.

on()이 함수를 돌려주는 이유

작지만 중요한 결정이 하나 있다.

on()해제 함수를 반환한다.

on<K extends keyof MapEventCallbacks>(event, handler): () => void;

지도 SDK마다 리스너 해제 방식이 다르다.

어떤 건 removeListener(listener)처럼 등록 때 받은 핸들을 요구하고, 어떤 건 이벤트명과 함수를 다시 넘겨야 한다.

이 차이를 호출하는 쪽이 알아야 한다면 추상화한 의미가 없다.

그래서 등록할 때 해제 방법을 함께 돌려주게 했다.

useEffect(() => {
  const off = adapter.on("onZoomEnd", handleZoom);
  return off;          // 어떤 지도든 이거면 끝
}, [adapter]);

React의 useEffect 정리 함수와 모양이 그대로 맞아떨어져서, 리스너 누수를 걱정할 일이 없어졌다.

지오코더는 따로 뺐다

주소를 좌표로, 좌표를 주소로 바꾸는 기능은 지도 조작과 성격이 다르다.

지도 인스턴스 없이도 필요할 때가 있고, 지도 제공자와 지오코딩 제공자가 달라도 된다.

그래서 인터페이스를 분리했다.

export interface GeocoderAdapter {
  reverseGeocode(latlng: LatLng): Promise<string | null>;
  geocode?(address: string): Promise<LatLng | null>;
}

geocode가 선택(?)인 건, 역지오코딩만 지원하는 환경이 실제로 있어서다.

필수와 선택을 타입으로 구분해두면, 없는 기능을 부르려 할 때 컴파일 단계에서 막힌다.

레이어는 공통으로, 에디터는 개별로

여기가 이 구조에서 제일 재밌는 부분이다.

마커·클러스터·히트맵 레이어는 공통 컴포넌트 하나로 만들었다.

adapters/
├── CommonMarkerLayer.tsx
├── CommonClusterLayer.tsx
└── CommonHeatmapLayer.tsx

좌표를 받아서 화면 위치를 계산하고 마커를 그리는 일은, 지도가 무엇이든 결국 같은 일이기 때문이다.

반면 영역 편집기(도형을 그리고 수정하는 기능)는 지도별로 따로 만들었다.

adapters/
├── naver/NaverMapEditorAdapter.ts
├── kakao/KakaoMapEditorAdapter.ts
├── google/GoogleMapEditorAdapter.ts
└── leaflet/LeafletEditorAdapter.ts

도형 편집은 각 SDK가 제공하는 드로잉 도구의 차이가 너무 컸다.

핸들의 동작, 스냅 방식, 편집 중 이벤트 타이밍이 전부 다르다.

이걸 억지로 하나로 묶으면 네 개의 지도에서 다 어색한 편집기가 나온다.

그래서 규칙을 이렇게 정했다.

차이가 작으면 공통화하고, 차이가 본질적이면 나눈다. 억지로 통일한 추상화는 통일하지 않은 것보다 나쁘다.

Leaflet을 넣은 이유

네이버·카카오·구글은 예상 가능한 선택인데, Leaflet은 왜 넣었냐면 — 폐쇄망 때문이다.

지자체 환경 중에는 외부 인터넷이 막혀 있어서 상용 지도 API를 아예 호출할 수 없는 곳이 있다.

Leaflet은 타일 서버만 내부에 두면 오프라인에서도 동작한다.

이걸 처음부터 고려했던 건 아니고, 어댑터 구조를 만들고 나서 "그럼 Leaflet도 되겠네"가 된 경우다.

추상화의 진짜 이득은 예상하지 못했던 선택지가 생긴다는 것이었다.

처음에 목표했던 건 "지도를 갈아끼울 수 있게"였는데, 결과적으로 얻은 건 "폐쇄망 납품이 가능해졌다"였다.

정리하면

  • 추상화의 범위는 SDK 기능이 아니라 우리 요구사항으로 그었다
  • 이벤트 등록은 해제 방법을 함께 반환하게 해서 SDK별 차이를 감췄다
  • 성격이 다른 기능(지오코딩)은 별도 인터페이스로 분리했다
  • 차이가 작은 것(레이어)은 공통화하고, 차이가 본질적인 것(에디터)은 나눴다

지금은 지자체가 추가돼도 지도 때문에 화면을 다시 만들 일이 없다.

설정에서 어느 어댑터를 쓸지 고르면 된다.

그리고 이 작업을 하면서 제일 크게 배운 건, 추상화를 미리 완벽하게 설계하려 하지 않아도 된다는 것이었다.

필요한 것부터 좁게 시작하고, 요구가 생길 때 넓히는 편이 결과적으로 더 정확했다.

처음부터 모든 걸 예측하려 했다면 아마 Leaflet은 못 넣었을 것 같다.