주성진·article·2026.08.17·11 min read·조회수623
프로젝트마다 다시 짜던 지도 코드, OpenLayers를 React 컴포넌트로 감싼 이야기
관제 화면을 만들 때마다 비슷한 지도 코드를 반복해서 짰습니다. 그걸 React GIS 컴포넌트 라이브러리로 묶었던 경험을 바탕으로, 명령형 OpenLayers를 선언형 React에 안전하게 붙이는 방법을 정리했습니다.
스마트시티나 재난안전 관제 화면을 만들다 보면 지도가 안 들어가는 화면이 거의 없습니다. 이노뎁에 있을 때 여러 프로젝트를 하면서 지도를 만들고, 배경지도를 바꾸고, 마커 레이어를 올리고, 클릭하면 팝업을 띄우는 코드를 몇 번이나 다시 짰는지 모르겠습니다. 프로젝트가 바뀔 때마다 조금씩 다르게요.
결국 이걸 공통 지도 모듈로, 다시 React GIS 컴포넌트 라이브러리로 묶어서 후속 프로젝트에서 재사용했습니다. 이 글에서는 그때 정리했던 설계 원칙을, 지금 다시 짠다면 어떻게 짤지를 기준으로 풀어 보겠습니다.
1. 명령형 라이브러리를 선언형으로 쓰고 싶었습니다
OpenLayers는 명령형입니다. 지도 객체를 만들고, addLayer로 레이어를 넣고, 필요 없어지면 removeLayer로 뺍니다. React는 선언형이죠. "지금 이 레이어가 있어야 한다"를 JSX로 적으면 나머지는 React가 맞춰 줍니다.
목표는 이렇게 쓰는 거였습니다.
<OlMap center={[127.0276, 37.4979]} zoom={13}>
<BaseLayer type="vworld" />
<AssetLayer assets={cctvList} onSelect={setSelected} />
{selected && <AssetPopup asset={selected} />}
</OlMap>2. 지도는 한 번만 만들고 Context로 나눠 줍니다
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
import OlMapClass from "ol/Map";
import View from "ol/View";
import { fromLonLat } from "ol/proj";
import "ol/ol.css";
const MapContext = createContext<OlMapClass | null>(null);
export function useOlMap() {
const map = useContext(MapContext);
if (!map) throw new Error("useOlMap은 <OlMap> 안에서만 쓸 수 있습니다.");
return map;
}
type Props = { center: [number, number]; zoom: number; children?: ReactNode };
export function OlMap({ center, zoom, children }: Props) {
const target = useRef<HTMLDivElement>(null);
const [map, setMap] = useState<OlMapClass | null>(null);
useEffect(() => {
const instance = new OlMapClass({
target: target.current!,
view: new View({ center: fromLonLat(center), zoom }),
controls: [],
});
setMap(instance);
return () => {
instance.setTarget(undefined); // DOM에서 떼고
instance.dispose(); // 내부 리스너와 리소스를 정리한다
};
// 지도는 마운트될 때 한 번만 만든다. center/zoom 변경은 아래 effect에서 반영한다.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
map?.getView().animate({ center: fromLonLat(center), zoom, duration: 300 });
}, [map, center[0], center[1], zoom]);
return (
<div ref={target} className="relative size-full">
{map && <MapContext.Provider value={map}>{children}</MapContext.Provider>}
</div>
);
}여기서 신경 쓴 건 두 가지입니다.
먼저 만드는 것과 바꾸는 걸 나눴습니다. 지도는 한 번만 만들고, props가 바뀌면 기존 인스턴스에 반영합니다. 예전에 center가 바뀔 때마다 지도를 새로 만드는 코드를 본 적이 있는데, 화면이 깜빡이고 느려지는 원인이었습니다.
그리고 정리 코드를 꼭 넣었습니다. React 18부터 개발 모드의 StrictMode는 effect를 마운트, 정리, 다시 마운트 순서로 두 번 돌립니다. 정리 코드가 없으면 지도가 두 장 겹쳐서 생깁니다. 처음 이걸 봤을 때는 버그인 줄 알았는데, 지금은 정리 코드가 제대로 짜였는지 알려 주는 좋은 신호로 생각합니다.
3. 레이어는 컴포넌트가 붙으면 추가, 떨어지면 제거
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import Feature from "ol/Feature";
import Point from "ol/geom/Point";
import { fromLonLat } from "ol/proj";
import { Style, Icon } from "ol/style";
import { unByKey } from "ol/Observable";
type Asset = { id: string; lon: number; lat: number; status: "ok" | "warn" | "error" };
const ICON: Record<Asset["status"], Style> = {
ok: new Style({ image: new Icon({ src: "/markers/ok.svg", anchor: [0.5, 1] }) }),
warn: new Style({ image: new Icon({ src: "/markers/warn.svg", anchor: [0.5, 1] }) }),
error: new Style({ image: new Icon({ src: "/markers/error.svg", anchor: [0.5, 1] }) }),
};
export function AssetLayer({ assets, onSelect }: { assets: Asset[]; onSelect: (a: Asset) => void }) {
const map = useOlMap();
const [source] = useState(() => new VectorSource());
const [layer] = useState(() => new VectorLayer({ source, style: (f) => ICON[f.get("status") as Asset["status"]] }));
// 레이어 수명 = 컴포넌트 수명
useEffect(() => {
map.addLayer(layer);
return () => { map.removeLayer(layer); };
}, [map, layer]);
// 데이터가 바뀌면 피처만 바꾼다(레이어는 그대로)
useEffect(() => {
source.clear(true);
source.addFeatures(
assets.map((a) => {
const f = new Feature({ geometry: new Point(fromLonLat([a.lon, a.lat])) });
f.setId(a.id);
f.setProperties({ status: a.status, asset: a });
return f;
}),
);
}, [source, assets]);
// 클릭하면 선택. 항상 최신 onSelect를 쓰도록 ref에 담아 둔다.
const onSelectRef = useRef(onSelect);
onSelectRef.current = onSelect;
useEffect(() => {
const key = map.on("singleclick", (e) => {
map.forEachFeatureAtPixel(e.pixel, (f) => {
onSelectRef.current(f.get("asset"));
return true; // 첫 번째 피처에서 멈춘다
}, { layerFilter: (l) => l === layer });
});
return () => unByKey(key);
}, [map, layer]);
return null; // 그리는 건 OpenLayers가 한다
}코드에서 눈여겨볼 부분은 스타일 객체를 미리 만들어 두고 돌려쓴다는 점입니다. 자산이 수천 개 올라가는 관제 화면에서 피처마다 new Style()을 만들면 렌더링이 눈에 띄게 느려집니다. 저도 한 번 이걸로 고생했습니다.
4. 자산이 많아지면 묶고, 더 많아지면 WebGL로
CCTV, 스마트폴, 센서처럼 자산이 수천 개, 수만 개가 되면 일반 벡터 레이어로는 버겁습니다. 그래서 줌 레벨에 따라 방법을 나눴습니다. 넓게 볼 때는 Cluster 소스로 묶어서 개수만 보여 주고, 가까이 들어가면 개별 아이콘과 상태 색을 보여 줬습니다. 수만 개를 넘어가면 WebGL 포인트 레이어로 그렸습니다.
import Cluster from "ol/source/Cluster";
const clustered = new Cluster({ source, distance: 40, minDistance: 20 });5. 라이브러리로 묶을 때 정한 선
라이브러리를 만들면서 몇 가지 선을 정했습니다.
지도 엔진은 숨기지 않았습니다. useOlMap()으로 원본 인스턴스를 꺼낼 수 있게 열어 뒀습니다. 라이브러리가 모든 기능을 감쌀 수는 없고, 프로젝트마다 특수한 요구가 꼭 하나씩은 있었거든요.
좌표는 경계에서 한 번만 바꿨습니다. 컴포넌트 props는 항상 경위도(EPSG:4326)로 받고, 내부에서만 지도 좌표계(EPSG:3857)로 바꿉니다. 좌표계 이야기는 따로 쓴 글에 더 자세히 정리했습니다.
배경지도 키는 밖에서 받았습니다. VWorld처럼 API 키가 필요한 배경지도는 Provider로 주입받게 해서 라이브러리 코드에 키가 남지 않게 했습니다.
마치며
명령형 라이브러리를 React로 감쌀 때의 원칙은 OpenLayers가 아니어도 비슷합니다. 차트든 에디터든 3D 뷰어든요. 인스턴스는 한 번 만들어서 Context로 나눠 주고, 하위 객체의 수명은 컴포넌트 수명에 맞추고, 정리는 반드시 하는 것.
혹시 비슷하게 지도 코드를 프로젝트마다 다시 짜고 계신다면, 지도 생성과 레이어 하나만이라도 먼저 컴포넌트로 빼 보시길 권합니다. 그 두 개만 있어도 다음 프로젝트가 꽤 편해집니다.
Comments (0)