const-tommy.dev
기록을 불러오는 중입니다
muroom 프로젝트에서 가장 고전했던 지점은 명령형(Imperative) SDK인 네이버 지도를 리액트의 선언적(Declarative) 환경에 이식하는 것이었다. 단순히 기능을 구현하는 것을 넘어, 브라우저의 렌더링 메커니즘을 통제하여 '지도 엔진'을 설계한 과정을 기록한다.
네이버 지도는 브라우저 전역 객체(window)에 의존하므로, Next.js의 SSR 환경에서 안정적으로 구동시키기 위한 처리가 필수적이었다.
next/dynamic을 사용하여 지도를 포함한 컴포넌트의 서버 사이드 렌더링을 차단(ssr: false)했다.const CommonMap = dynamic(() => import('@/components/common/map'), {
ssr: false,
loading: () => <Loading />,
});global.d.ts를 직접 작성하여 전역 naver 객체에 타입을 부여함으로써 개발 시 타입 안정성을 확보했다.useNaverMap)지도는 무거운 인스턴스이므로 리액트의 리렌더링 사이클에서 완전히 격리되어야 한다. 특히 Next.js 환경에서 데이터 로딩(isLoading) 상태에 따라 조건부 렌더링을 할 경우, 일반적인 useEffect와 useRef 조합으로는 지도가 나타나는 정확한 시점에 엔진을 가동하기 어렵다.
기존의 useEffect 방식은 컴포넌트 마운트 시점에 단 한 번 실행되는데, 이때 지도가 로딩 중이라면 Ref는 null인 상태로 엔진 등록에 실패하게 된다. 나는 이를 해결하기 위해 돔 요소가 생성되는 순간 호출되는 Callback Ref 방식을 도입하여 엔진의 '시동' 타이밍을 완벽하게 확보했다.

브라우저 새로고침 시 지도가 나타나자마자 '연결' 로그가 뜨고, 사이드바 조절 시 '변화 감지' 로그가 쏟아지며, 페이지 이동 시 '해제' 로그가 찍히는 모습
isLoading이 끝나고 지도가 화면에 렌더링되는 찰나를 놓치지 않고 ResizeObserver를 부착한다.resize 이벤트를 강제로 전파, 레이아웃 붕괴를 막는다.이 엔진 설계 덕분에 사이드바(리스트/상세 뷰)가 열리고 닫히며 지도 영역의 너비가 동적으로 변해도, 지도는 항상 자신의 중심을 유지하며 부드럽게 반응한다.
const observerRef = useRef<ResizeObserver | null>(null);
const mapContainerRef = useCallback((node: HTMLDivElement | null) => {
if (node !== null) {
// 돔이 생성되는 순간 실행
console.log("✅ [Map Engine] 컨테이너 포착! 옵저버를 연결");
네이버 지도의 마커는 기본적으로 문자열 기반의 HTML만 렌더링할 수 있다. 마커 내부에서 리액트의 상태 관리와 이벤트를 온전히 사용하기 위해, 각 마커를 독립적인 React Root로 설계했다.
createMarkerWithReactRoot 유틸리티명령형인 네이버 마커 인스턴스와 선언적인 리액트 컴포넌트를 물리적으로 연결하기 위해 전용 유틸리티를 구축했다.
// createMarkerWithReactRoot.ts: React Root와 Naver Marker의 결합
export function createMarkerWithReactRoot(
map: naver.maps.Map,
data: StudiosMapSearchItem,
CustomMarkerComponent: React.ComponentType<CustomMarkerProps>,
// ...props
): { marker: naver.maps
muroom의 지도 검색은 가격, 제원, 악기 옵션 등 20여 개가 넘는 방대한 필터 상태를 실시간으로 동기화해야 한다. 나는 휘발성인 전역 상태의 한계를 극복하고, 공유와 재현이 가능한 시스템을 구축하기 위해 URL을 Single Source of Truth 로 설정하는 설계를 택했다.
<URL(nuqs)을 기점으로 데이터 페칭과 지도 렌더링이 유기적으로 흐르는 단방향 아키텍처>
이 아키텍처는 수십 개의 필터가 바뀌어도 데이터 페칭과 지도 렌더링 사이의 싱크가 어긋나는 현상을 방지하며, 다음과 같은 강력한 UX를 제공한다.
nuqs의 Shallow Routing과 배치 업데이트를 통해 불필요한 서버 부하를 줄이고 매끄러운 상태 전환을 구현했다.🔗 기술 상세 구현 확인: https://www.const-tommy.dev/blog/0bf25c8d-6be8-41c2-b16c-4ba2c03a4cfa
지도를 움직일 때 발생하는 수많은 연산은 메인 스레드에 엄청난 부하를 준다. 나는 브라우저의 이벤트 루프와 렌더링 원리를 활용해 이 문제를 정면으로 돌파했다.
requestAnimationFrame)지도의 좌표 정보를 읽어오는 작업은 브라우저의 레이아웃 계산을 강제로 유도(Forced Reflow)하여 병목을 만든다. 나는 이 로직을 requestAnimationFrame 으로 감싸 브라우저의 리페인트 직전, 가장 최적의 타이밍에 실행되도록 동기화했다.
동일한 로컬 개발 환경에서 최적화 전후의 성능 지표를 정밀하게 측정하여 로직의 유효성을 검증했다.
| Forced Reflow 경고 | INP(Interaction to Next Paint) 수치 |
|---|---|
![]() | ![]() |
| Fire idle callback 내부에 가득한 빨간 삼각형(Forced Reflow) | 사용자 입력 반응 속도가 2,328ms까지 치솟은 최악의 UX |
rAF 적용 전 로컬 환경에서는 지도가 멈출 때마다 강제 레이아웃 재계산이 발생하여 메인 스레드가 완전히 차단되었다. 특히 INP 2,328ms라는 숫자는 드래그 후 화면이 반응하기까지 2초 이상 걸린다는 심각한 지표였으며, 이는 사실상 인터랙션이 불가능한 수준임을 시사한다.

✅ rAF 적용 후 로컬 환경: 빨간 삼각형 소멸 및 INP 22ms(Good) 달성
최적화 후, 지도가 멈춘 직후에 발생하던 Forced Reflow(빨간 삼각형)가 완전히 제거되었다. 무엇보다 2.3초에 달하던 사용자 입력 지연 시간이 22ms라는 극도로 안정적인 수치로 개선되었다. 렌더링 시점을 브라우저 주기와 동기화하는 것만으로도 저사양 환경이나 무거운 연산 중에도 60FPS에 준하는 부드러움을 보장할 수 있게 되었다.
지도를 이동하다 멈추는 순간(idle), 좌표와 필터 등 다수의 상태가 동시에 변하면 불필요한 네트워크 오버헤드와 히스토리 파편화가 발생한다.

단 한 번의 API 요청에 6개 이상의 파라미터를 담아 전송하는 배치 업데이트 확인
나는 nuqs의 useQueryStates를 활용해 6개 이상의 파라미터 변화를 하나로 묶어 단 한 번의 URL 업데이트와 네트워크 요청으로 처리했다. 이를 통해 브라우저 히스토리 스택의 오염을 방지하고 API 호출 효율을 극대화했다.
지도는 일반적인 UI 컴포넌트보다 훨씬 많은 메모리 자원을 점유한다. 특히 Next.js의 클라이언트 사이드 내비게이션 환경에서는 페이지 전환 후에도 파괴되지 않은 지도 인스턴스가 메모리에 잔존하여 성능을 저하시키는 '메모리 누수'가 발생할 위험이 크다.
나는 컴포넌트의 생명주기가 끝나는 시점에 지도 엔진과 관련된 모든 참조를 제거하도록 설계했다. useEffect의 클린업 함수를 통해 네이버 지도 이벤트를 해제(removeListener)하고, ResizeObserver 연결을 끊으며, 돔(DOM) 참조를 초기화하여 가비지 컬렉터(GC)가 점유된 메모리를 원활히 회수할 수 있도록 최적화했다.
페이지 전환 전후의 메모리 상태를 크롬 개발자 도구의 Comparison 모드로 분석했다.

페이지 이탈 후 힙 스냅샷 비교: Map 관련 객체의 # Delta가 음수(-29)를 기록하며 자원이 회수된 모습
분석 결과, 지도 페이지를 이탈한 뒤 가비지 컬렉션을 거치면 Map 관련 인스턴스들이 메모리 상에서 유효한 참조를 남기지 않고 소멸되는 것을 확인했다. 이를 통해 반복적인 페이지 이동에도 메모리 점유율이 일정하게 유지되는 안정적인 서비스 구조를 확립했다.