LogoSEO Jing
  • All Posts
  • SEO Jing
  • okayJing
  • KD Team
  • CLab CoreTeam
  • Study

Contact Me

© 2026 SEOJing. All rights reserved.

SpotReactPerformanceProfilerframer-motionNaver Maps

60fps 페르소나가 맵을 뛰어다닐 때, React 를 어떻게 덜 깨우나

2026년 4월 24일·23분 읽기

문제: 지도를 드래그하면 끊겼다

Spot 의 /map 에는 페르소나 시뮬레이션이 있다. ?sim=swarm 쿼리를 붙이면 수원시 전역의 bbox 안에 페르소나 500~1000명이 뿌려지고, 각자가 자기 "집" 근처에서 랜덤 좌표로 이동한다. 백엔드가 아직 없기 때문에 프론트에서 가짜 실시간을 만들어 데모와 디자인 검증을 하는 용도다.

그런데 이걸 켜놓고 지도를 드래그하면 뚝뚝 끊겼다. 페르소나들이 한 번씩 멈추고, 클러스터 blob 애니메이션이 부자연스럽게 점프했다. 처음엔 "맵 SDK 가 느린가 보다" 싶었는데, 진짜 원인은 내가 쓴 코드 쪽에 훨씬 많았다.

이 글은 그걸 해결하기 위해 React DevTools Profiler 로 병목을 찾고, 매 프레임 state 를 갱신하는 기본 패턴을 ref + 외부 pub/sub으로 갈아엎은 과정이다. 그리고 왜 useSyncExternalStore 같은 "정공법"을 쓰지 않았는지도 같이 정리한다.


React DevTools Profiler 가 뭔가

Chrome 확장으로 설치되는 React DevTools 안에 Profiler 탭이 있다. 녹화 버튼을 누르고 문제 인터랙션을 한 뒤 멈추면, 그 구간 동안 일어난 모든 렌더(commit)를 플레임차트로 보여준다.

commit 이라는 단위

React 의 렌더는 두 단계다. Render phase는 컴포넌트 함수를 호출해서 VDOM 을 만드는 단계이고, Commit phase는 그 결과를 실제 DOM 에 반영하는 단계다. Profiler 가 보여주는 한 막대 하나는 commit 한 번이다. 즉 "이 순간에 DOM 에 뭔가 반영됐다"는 뜻이고, 그 아래에 어느 컴포넌트가 얼마나 걸렸는지가 쌓인다.

왜 이 컴포넌트가 렌더됐나 (why-did-this-render)

Profiler 설정에서 "Record why each component rendered while profiling" 을 켜면, 각 컴포넌트에 커서를 올렸을 때 왜 렌더됐는지가 뜬다. props 변경, state 변경, 부모가 렌더돼서, Hook 이 바뀌어서 등. 이게 핵심이다. 내가 본 첫 번째 이상한 신호가 이거였다.

페르소나가 움직이는 매 프레임마다 FeedBottomSheet 가 리렌더 되고 있었다. 페르소나 좌표는 바텀시트 내용과 전혀 관계가 없는데 말이다. 왜? 공통 부모인 MapClient 가 매 프레임 setState 로 좌표 Map 을 갱신하고 있었고, 그 setState 가 자식 트리 전체의 reconciliation 을 끌고 들어가고 있었기 때문이다.

"Highlight updates when components render"

같은 설정에 있는 옵션. 이걸 켜면 렌더되는 컴포넌트 주변에 초록~빨간 테두리가 깜빡인다. 자주 렌더될수록 빨갛게. 이게 눈으로 가장 빠르게 확인되는 신호다. 페르소나가 움직이는 동안 바텀시트 헤더가 60Hz 로 깜빡이고 있었다 — 잘못된 설계의 증거.


매 프레임 setState 가 비싸다는 게 무슨 뜻인가

계산을 한번 해보자. 페르소나 500명, 60fps 로 움직인다고 하면 초당 30,000번의 위치 업데이트가 발생한다. 이걸 React state 에 올리는 두 가지 선택지가 있었다.

  1. 각 페르소나가 독립 state — 500개의 state, 각자 setter 호출. React batching 이 자동으로 묶어주긴 하지만 여전히 500개 fiber 각각이 dirty 표시됨.
  2. 부모 Map state 에 모아두기 — state 는 하나, 매 프레임 새 Map 만들어 setState. 자식 트리 전체가 부모 리렌더에 딸려감.

포스트 목록

/spot
파일 7개, 폴더 0개
탭을 버리고 지도를 남겼다 — URL 이 상태의 진실이 된 이유Spot 을 만들며 반복해서 내린 판단 세 가지지도가 회색 화면만 찍던 날 — async useEffect 와 StrictMode 의 raceSpot — 지도 위에 만든 로컬 커뮤니티, 그 기록의 시작60fps 페르소나가 맵을 뛰어다닐 때, React 를 어떻게 덜 깨우나70만 줄짜리 시뮬레이터 로그를 지도에 띄우기 — 줄이고, 쪼개고, 줌 레벨로 가르기점들이 모였다고 모임이 아니다 — 클러스터의 모양과 원인을 갈라낸 기록

처음엔 2번을 썼다. personaPositions: Map<string, GeoCoord> 이라는 state 를 useMockPersonaSwarm 훅이 반환했고, MapClient 가 그걸 받아서 오버레이 좌표로 뿌렸다. 이 구조의 문제는 Profiler 가 정확히 보여줬다 — MapClient 의 commit 이 16ms 에 한 번씩 찍힌다.

물론 각 자식 컴포넌트는 React.memo 로 감싸면 실제 render 함수 호출은 스킵된다. 하지만 React 는 commit phase 자체는 여전히 돌고, 부모 fiber 를 순회하며 자식의 props 비교를 수행한다. 그 비교를 500번 한다. 드래그 중에는 지도 SDK 도 panning 연산을 하고 있다. 둘이 겹치면 프레임 버짓 16.6ms 가 넘어가면서 끊김이 보인다.


대안 세 가지를 비교했다

A. 각 오버레이가 자기만의 local state 를 갖는다

페르소나마다 useState(coord) 를 두고, 훅에서 내려보내는 coord 를 props 로 받아 자기가 갱신한다. React 스럽다. 하지만 부모가 새 props 를 매 프레임 내려보내는 순간 자식이 매 프레임 props 로 인해 리렌더된다. 루트 병목이 해결되지 않는다.

B. useSyncExternalStore

React 18 부터 추가된 공식 API. 외부 store 를 subscribe 하고 값을 snapshot 하는 정공법이다. 이걸 쓰면 store 가 바뀔 때 그 Hook 을 부른 컴포넌트만 리렌더된다. 맞는 도구처럼 보인다.

그런데 문제는 여전히 있다. snapshot 을 반환해야 한다는 점이다. 500개 페르소나의 좌표 Map 을 매 프레임 스냅샷으로 만들면 그만큼 객체 할당이 일어나고, getSnapshot 의 결과가 참조 동일성으로 비교 되기 때문에 매번 새 Map 을 만들면 결국 리렌더가 발생한다. 페르소나별로 개별 store 를 두는 식으로 회피할 수는 있는데 그건 500개의 구독 슬롯을 만드는 일이고, 그때쯤 되면 그냥 DOM 을 직접 건드리는 게 더 명료하다.

C. ref + 외부 pub/sub + imperative redraw — 채택

좌표는 "그리는 데만 필요하고, 로직 분기에는 안 쓰인다". 이게 핵심 관찰이었다. 로직 분기(스팟 타겟 지정, 클러스터 derive 등)는 state 기반으로 두되, 좌표 자체는 React 바깥의 Ref에 두고, 변경을 알리는 구독 콜백을 별도 제공한다. 구독자는 받은 콜백에서 positionsRef.current.get(id) 를 읽어 DOM 의 style.transform 을 직접 갱신한다.

이러면 React 의 render/commit 은 개입하지 않는다. rAF 루프와 React 렌더 사이클이 완전히 분리된다. 이게 이번 최적화의 척추다.


구현: 훅이 반환하는 타입부터 바뀌었다

useMockPersonaSwarm 이 원래 반환하던 타입과, 바뀐 타입을 나란히 두면 설계 변화가 그대로 보인다.

ts
// before
type UseMockPersonaSwarmReturn = {
  personas: Persona[];
  personaPositions: Map<string, GeoCoord>; // state. 매 프레임 새 Map.
};
ts
// after
type UseMockPersonaSwarmReturn = {
  personas: Persona[];
  /** 매 rAF 마다 업데이트되는 현재 위치 ref. React state 가 아니므로 리렌더 미유발. */
  positionsRef: React.RefObject<Map<string, GeoCoord>>;
  /** notify 간격(기본 ~100ms) 마다 호출되는 구독. unsubscribe 함수 리턴. */
  subscribe: (cb: () => void) => () => void;
  /** 스팟 타겟 지정. personaId → 목적지. null 이면 wander 복귀. */
  setSpotTargets: (targets: Map<string, GeoCoord>) => void;
};

세 가지가 눈에 띈다. (1) positions 가 state 에서 ref 로. (2) subscribe 라는 pub/sub 인터페이스 추가. (3) setSpotTargets 라는 명령형 API — "이 페르소나는 저 스팟으로 가" 를 외부에서 지시할 수 있다.

rAF 루프 안에서 하는 일

ts
const tick = (now: number) => {
  if (now - lastStep >= emitThrottleMs) {
    const states = motionStatesRef.current;
    const positions = positionsRef.current;
    const spotTargets = spotTargetsRef.current;
    for (const s of states.values()) {
      const spotTarget = spotTargets.get(s.persona.id) ?? null;
      advanceMotion(s, now, durationOptsRef.current, spotTarget);
      positions.setspersonaid scurrentCoord
몇 가지 선택이 들어가 있다.

첫째, emitThrottleMs 로 step 자체를 묶었다. advance 를 60fps 로 돌리면 N = 1000 일 때 초당 60,000번 연산이다. 눈으로는 100ms 간격(10fps 업데이트) 도 충분히 부드럽게 보이는데, 이건 뇌가 CSS transition 으로 자연 보간한다고 착각해서 그렇다. 실제로는 CSS 쪽에 transition: transform 100ms linear 를 걸어두지 않았는데도, 눈으로는 매끄럽게 보인다. 페르소나 dot 크기가 충분히 작아서 100ms 간 점프가 시각적으로 한 프레임 정도의 차이로만 인식되기 때문이다.

둘째, ref 에 직접 쓴다. positions.set(id, coord) 는 React 에게 아무 신호도 보내지 않는다. 순수한 JS Map 변형이다.

셋째, 구독자들에게 순차 호출. for (const cb of subscribersRef.current) cb(). 각 구독자는 자기가 필요한 좌표를 ref 에서 읽어 알아서 처리한다.


NaverOverlay 를 뚫어 imperative redraw 를 받게 했다

구독한 측(= 오버레이)이 좌표를 받아서 어떻게 DOM 에 반영할 것인지가 남은 문제다. Naver Maps 의 OverlayView 는 draw() 메서드를 호출해야 실제 DOM 이 좌표에 맞게 재배치된다. 그래서 오버레이 래퍼에 positionSubscribe 라는 옵셔널 prop 을 열어, 구독이 있으면 ref 만 갱신하고 draw() 를 명령형으로 부르게 했다.

tsx
// NaverOverlay 내부
useEffect(() => {
  if (!positionSubscribe) return;
  return positionSubscribe((coord) => {
    positionRef.current = coord;
    const ov = overlayRef.current;
    if (ov && ov.getMap()) ov.draw();
  });
}, [positionSubscribe]);

React 의 렌더 함수는 이 경로에서 한 번도 호출되지 않는다. NaverOverlay 컴포넌트가 처음 마운트될 때 구독을 걸어두고, 이후로는 subscribe 콜백 → draw() → 네이버 SDK 가 container.style.left/top 갱신, 이 줄만 탄다.

왜 positionRef.current = position 을 조건부로 만들었나

tsx
if (!positionSubscribe) {
  positionRef.current = position;
}

이 한 줄이 미묘하다. 구독 경로가 아닐 때(스팟 마커 등 정적 좌표)는 props position 이 ref 의 주인이다. 구독 경로일 때는 subscribe 콜백이 ref 의 주인이라, 렌더 시점에 position prop 으로 덮어쓰면 rAF 가 방금 쓴 최신 좌표를 과거 값으로 되돌려버린다. 이걸 Profiler 로 처음 봤을 때 "왜 가끔 위치가 튀지?" 싶었고, 한참 뒤에야 원인을 찾았다. 구독 경로의 ref 소유권은 단일해야 한다.


ClusterBlob 에는 React.memo custom equality 가 필요했다

페르소나 개별 좌표는 ref 로 우회했지만, 클러스터는 이야기가 다르다. 클러스터는 "반경 80m 안에 같은 카테고리 페르소나가 2명 이상" 같은 규칙으로 파생되는 객체이고, 이건 state 로 관리해야 한다. 매 프레임 derive 를 돌릴 수도 없고(비쌈), 매 100ms 마다 throttled 로 재계산해서 새 배열을 만든다.

그 결과 clusters.map((c) =&gt; &lt;ClusterBlob cluster={c} ... /&gt;) 의 cluster 객체는 매 주기 새 참조다. React.memo 를 그냥 걸면 의미가 없다 — 얕은 비교는 참조가 다르면 바로 "달라졌음" 으로 판정해서 렌더를 통과시킨다.

그래서 custom equality

tsx
export const ClusterBlob = memo(ClusterBlobImpl, (prev, next) => {
  if (prev.selected !== next.selected) return false;
  if (prev.absorbing !== next.absorbing) return false;
  const a = prev.cluster;
  const b = next.cluster;
  return (
    a.id === b.id &&
    a.isPulse === b.isPulse &&
    a  b 

여기서 내가 선언하는 건 "이 props 객체 전체가 같으면 렌더 안 한다" 가 아니라 "이 필드들이 같으면 렌더 안 한다"는 의미다. personas 배열 안의 요소는 비교하지 않고 length 만 본다. 어차피

어떤 페르소나가 클러스터에 속했는지는 blob 이 그리는 그림에 영향이 없다

. 중심 좌표, 개수, 카테고리만 같으면 똑같이 그린다.

React.memo 는 "리렌더를 막아주는 마법" 이 아니다. props 의 의미 있는 단위를 내가 정의한다는 선언이다. 얕은 비교가 맞는 경우에만 default 를 쓰고, 안 맞으면 직접 짜야 한다.


transformTemplate 로 맵 줌과 애니메이션 transform 을 합성했다

문제 하나가 더 있었다. 지도를 줌아웃하면 클러스터도 같이 작아져야 "멀리서 본다" 는 감각이 난다. 이걸 CSS 변수 --overlay-scale 로 컨테이너에 주입했고, Naver 오버레이의 draw() 안에서 매번 갱신한다.

ts
// NaverOverlay.draw() 안
const zoom = mapInst.getZoom();
const scale = Math.min(1.5, Math.max(0.3, Math.pow(1.3, zoom - 15)));
container.style.setProperty("--overlay-scale", scale.toString());

그런데 ClusterBlob 자체는 framer-motion 으로 birth 애니메이션(scale 0 → 1) 을 재생한다. 즉두 개의 scale 이 동시에 걸려야 한다. framer-motion 이 만들어주는 scale 과, 내가 CSS 변수로 주입하는 scale.

CSS 는 transform 속성 하나에 문자열로 들어간다. framer-motion 이 매 프레임 transform: translate(-50%,-50%) scale(0.42) 같은 문자열을 만들어 DOM 에 쓰면, 내가 따로 scale(var(--overlay-scale)) 를 뒤에 붙일 방법이 없다 — 덮어써버리기 때문이다.

transformTemplate 의 역할

framer-motion 은 이럴 때 쓰라고 transformTemplate 이라는 prop 을 제공한다. 각 transform 값을 문자열로 조립하기 직전에 내가 개입할 수 있는 훅이다.

tsx
<motion.div
    animate={{ scale: 1, opacity: 1 }}
    transformTemplate={({ scale: birthScale }) =>
        `translate(-50%, -50%) scale(${birthScale ?? 1}) scale(var(--overlay-scale, 1))`
    }
    style={{
        width: VIEW,
        height: VIEW,

framer-motion 이 매 프레임 scale 값을 계산한 뒤, 이 함수를 통해 최종 transform 문자열을 내가 조립한다. CSS 변수는 함수 바깥에서 주입되는 값이고, framer-motion 이 덮어쓸 일이 없다. 결과: birth 애니메이션과 맵 줌 스케일이 수학적으로 곱해져 자연스럽게 같이 동작한다.


도착 순간을 포착하려 useReducer 를 썼다

마지막 디테일. 클러스터에 새 참여자가 도착하는 순간 "join burst" 라는 링 애니메이션이 퍼지게 하고 싶었다. assigned 된 순간이 아니라, 실제로 그 좌표까지 걸어와서 도착한 순간이다. 이게 사용자에게 의미 있는 이벤트다. "누군가가 스팟에 도착했다".

클러스터에 arrivedCount 필드를 더했다. 이게 증가할 때마다 burst 를 한 번 재생해야 한다. 그런데 burst 는 AnimatePresence 로 감싼 motion.div 의 key 를 바꿔야 재생된다. 즉 "이벤트가 일어날 때마다 key 를 올려주는 수단"이 필요하다.

tsx
const arrivedCount = cluster.arrivedCount ?? 0;
const [joinBurstKey, bumpJoinBurst] = useReducer((n: number) => n + 1, 0);
const prevArrivedRef = useRef(arrivedCount);

useEffect(() => {
  if (arrivedCount > prevArrivedRef.current && !dying) {
    bumpJoinBurst();
  }
  prevArrivedRef.current = arrivedCount;
}, [arrivedCount dying

왜 setState(n + 1) 가 아니라 useReducer 인가? 세 가지 정도의 이유가 있다.

  1. 의도 명확성. "숫자를 증가시킬 뿐, 다른 값이 오지 않는다" 가 reducer 에 고정된다.
  2. 클로저 안정성. setState((n) =&gt; n + 1) 도 동일하지만, reducer 는 dispatch 함수가 identity 를 유지한다고 보장된다. deps 에 넣을 때 안심이다.
  3. useState 대비 리렌더 횟수가 같다는 점이 분명하다. useReducer 라고 뭐가 느린 게 아니다. 다만 읽는 사람에게 "이건 counter 다" 가 한 눈에 들어온다.

하나만 더. arrivedCount 가 state 로 내려왔다면 이미 "props 변경 → 리렌더" 사이클을 탔을 것이다. 여기서는 그게 의도다. burst 는 자주 안 일어나고(몇 초에 한 번), 일어날 때만 리렌더가 되는 건 허용 가능한 비용이다. 모든 최적화의 목표가 "리렌더 0" 이 아니라, "의미 없는 리렌더 0"이라는 걸 다시 확인한 지점이다.


결과

Profiler 에서 드래그 구간을 다시 녹화했다. MapClient 의 commit 이 드래그 내내 거의 안 찍힌다. 페르소나가 움직이는 동안 유의미한 리렌더는 (1) 클러스터 재계산 주기(~100ms) (2) burst 트리거 (3) URL state 변경 (4) 실제 사용자 인터랙션. 이것뿐이다.

체감상 드래그가 부드러워졌다. SWARM_MAX_N 을 500 에서 1000 으로 올렸는데도 이전보다 가볍다. 페르소나 개수와 렌더 비용이 탈동조(decouple) 됐기 때문이다.


배운 것 정리

  1. Profiler 는 추측을 죽인다. "바텀시트가 왜 깜빡이지?" 는 육안으로 안 보이는 정보다. Highlight updates 옵션 하나로 상황이 바뀐다.
  2. state 에 올릴지 말지는 "이 값이 로직 분기에 쓰이는가" 로 판단

    한다. 로직에 안 쓰이고 그리기에만 쓰이면 ref + pub/sub 이 정답일 때가 많다.
  3. React.memo 는 props 의 의미를 선언하는 도구다. 기본 얕은 비교가 안 맞으면 직접 짠다. 짜는 게 부담이면 이미 구조가 잘못된 거다.
  4. framer-motion transformTemplate 은 transform 합성이 필요할 때의 공식 해법

    이다. CSS transform 이 한 속성이라는 제약을 우회한다.
  5. 최적화는 "리렌더 0" 이 아니라 "의미 없는 리렌더 0". 리렌더가 필요한 이벤트(burst 같은)는 그냥 두는 게 맞다.

코드는 refactor(map): 페르소나 및 클러스터 최적화 커밋에 다 들어가 있다. Profiler 플레임차트를 따로 캡쳐해서 before/after 로 붙이면 더 좋았을 텐데, 그건 다음 최적화 때 남겨두기로 한다.

(
.
.
,
.
)
;
}
for (const cb of subscribersRef.current) cb();
lastStep = now;
}
raf = requestAnimationFrame(tick);
};
.
isDying
===
.
isDying
&&
a.personas.length === b.personas.length &&
a.centerCoord.lat === b.centerCoord.lat &&
a.centerCoord.lng === b.centerCoord.lng &&
a.category === b.category &&
a.intent === b.intent &&
(a.arrivedCount ?? 0) === (b.arrivedCount ?? 0)
);
});
transformOrigin: 'center',
}}
>
,
]
)
;