Published on

React useSyncExternalStore로 렌더링 폭주 막기

Authors

서드파티 SDK, WebSocket, 브라우저 이벤트, 전역 싱글톤 스토어 같은 “외부 상태(external store)”를 React 컴포넌트에서 직접 구독하다 보면, 어느 순간부터 렌더링이 폭주(render storm)합니다. 증상은 흔히 아래처럼 나타납니다.

  • 타이핑 한 번에 컴포넌트가 수십 번 리렌더
  • 스크롤/마우스 이동만으로 FPS 급락
  • 상태는 맞는데 화면이 순간적으로 “찢어져(tearing)” 보임
  • Strict Mode에서 더 심해짐(구독이 두 번 붙거나, 정리가 늦어짐)

React 18부터는 동시성 렌더링(concurrent rendering) 환경이 기본 가정이 되었고, 외부 스토어를 안전하게 읽기 위한 표준 훅이 useSyncExternalStore입니다. 이 글에서는 “왜 폭주가 생기는지”를 먼저 해부하고, useSyncExternalStore로 구독을 재구성해 렌더링 폭주와 tearing을 동시에 줄이는 실전 패턴을 정리합니다.

렌더링 폭주의 전형적인 원인

1) useEffect로 직접 구독 + setState 난사

외부 이벤트가 매우 자주 발생하는데(예: mousemove, scroll, WebSocket tick), 매번 setState를 호출하면 React는 해당 컴포넌트를 계속 렌더링합니다.

특히 “구독 콜백”에서 아래처럼 매번 새로운 객체를 만들어 넣으면, 값이 실질적으로 동일해도 리렌더가 발생합니다.

// 나쁜 예: 이벤트가 올 때마다 새로운 객체를 setState
useEffect(() => {
  const onMove = (e: MouseEvent) => {
    setPos({ x: e.clientX, y: e.clientY });
  };
  window.addEventListener('mousemove', onMove);
  return () => window.removeEventListener('mousemove', onMove);
}, []);

2) 동시성 렌더링에서의 tearing

외부 스토어 값을 “렌더 중간”에 읽고, 렌더가 완료되기 전에 스토어가 변경되면 컴포넌트 트리의 일부는 이전 값, 일부는 최신 값을 보게 될 수 있습니다. 이게 tearing의 핵심입니다.

React는 내부 상태에 대해서는 이를 방지하지만, 외부 스토어는 개발자가 “일관된 스냅샷(snapshot)”을 제공해야 합니다.

3) 구독/해제 타이밍 불안정(Strict Mode에서 두드러짐)

개발 모드 Strict Mode는 의도적으로 이펙트를 두 번 실행해 부작용을 찾습니다. 구독 코드가 정리(cleanup) 관점에서 완전하지 않으면 구독이 누적되거나, 이벤트 핸들러가 중복 등록되어 폭주가 심해집니다.

useSyncExternalStore가 해결하는 것

useSyncExternalStore는 아래 계약을 강제합니다.

  • subscribe(listener): 스토어 변경 시 listener를 호출하고, 해제 함수(unsubscribe)를 반환
  • getSnapshot(): “현재 스냅샷”을 반환(React 렌더링 중에도 안정적으로 읽을 수 있어야 함)
  • (선택) getServerSnapshot(): SSR에서의 스냅샷

React는 이 훅을 통해 외부 스토어를 동시성 렌더링에 안전하게 구독하고, 변경이 생겼을 때 필요한 컴포넌트만 갱신하도록 최적화할 수 있습니다.

핵심 포인트는 “렌더링은 getSnapshot 결과에 의해 결정되고, 구독은 subscribe가 담당한다”는 분리입니다.

최소 구현: 외부 스토어를 useSyncExternalStore로 감싸기

아래는 아주 작은 전역 스토어(싱글톤) 예시입니다. 중요한 건 subscribe가 리스너를 등록하고, 상태 변경 시 리스너를 호출한다는 점입니다.

// store.ts
export type State = {
  count: number;
};

type Listener = () => void;

let state: State = { count: 0 };
const listeners = new Set<Listener>();

export const store = {
  getSnapshot(): State {
    return state;
  },
  subscribe(listener: Listener) {
    listeners.add(listener);
    return () => listeners.delete(listener);
  },
  setCount(next: number) {
    if (state.count === next) return;
    state = { ...state, count: next };
    for (const l of listeners) l();
  },
};

컴포넌트에서는 useSyncExternalStore로 읽습니다.

import { useSyncExternalStore } from 'react';
import { store } from './store';

export function Counter() {
  const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);

  return (
    <div>
      <p>count: {snapshot.count}</p>
      <button onClick={() => store.setCount(snapshot.count + 1)}>+1</button>
    </div>
  );
}

이 구조만으로도 다음이 개선됩니다.

  • 구독/해제의 안정성 상승
  • 동시성 렌더링에서 tearing 방지에 필요한 패턴을 확보
  • “어디서 상태를 읽는지”가 getSnapshot으로 고정되어 디버깅이 쉬워짐

하지만 렌더링 폭주를 진짜로 잡으려면 한 단계가 더 필요합니다. 바로 “스냅샷의 범위를 줄이기”입니다.

렌더링 폭주를 잡는 핵심: 스냅샷을 잘게 쪼개기

외부 스토어가 큰 객체를 반환하면, 그 객체 안의 작은 값 하나만 바뀌어도 해당 스냅샷을 쓰는 모든 컴포넌트가 리렌더될 수 있습니다.

패턴 1) 스냅샷을 “선택자(selector) 단위”로 분리

아래처럼 getSnapshot을 “전체 상태”가 아니라 “필요한 조각”으로 제공하면, 변경 영향 범위를 줄일 수 있습니다.

// store-selectors.ts
import { store } from './store';

export const selectors = {
  getCount: () => store.getSnapshot().count,
};
import { useSyncExternalStore } from 'react';
import { store } from './store';
import { selectors } from './store-selectors';

export function CountOnly() {
  const count = useSyncExternalStore(store.subscribe, selectors.getCount);
  return <p>count: {count}</p>;
}

이때 중요한 조건이 있습니다.

  • getSnapshot은 “동일한 값이면 동일하게 취급”되어야 합니다.
  • 원시값(number, string)처럼 비교가 쉬운 값은 특히 유리합니다.

패턴 2) 이벤트 폭주에는 “발행 빈도”를 줄여라

useSyncExternalStore는 구독을 안전하게 만들지만, 이벤트가 초당 수백 번 발생하면 결국 리렌더도 그에 비례해 늘 수 있습니다.

예: 마우스 위치를 실시간으로 보여주는 UI가 아니라면, requestAnimationFrame으로 발행 빈도를 프레임 단위로 제한하는 것이 효과적입니다.

// mouse-store.ts
type Listener = () => void;

let pos = { x: 0, y: 0 };
const listeners = new Set<Listener>();

let scheduled = false;
let nextPos = pos;

function emit() {
  scheduled = false;
  pos = nextPos;
  for (const l of listeners) l();
}

window.addEventListener('mousemove', (e) => {
  nextPos = { x: e.clientX, y: e.clientY };
  if (scheduled) return;
  scheduled = true;
  requestAnimationFrame(emit);
});

export const mouseStore = {
  subscribe(listener: Listener) {
    listeners.add(listener);
    return () => listeners.delete(listener);
  },
  getSnapshot() {
    return pos;
  },
};
import { useSyncExternalStore } from 'react';
import { mouseStore } from './mouse-store';

export function MousePosition() {
  const { x, y } = useSyncExternalStore(mouseStore.subscribe, mouseStore.getSnapshot);
  return <div>({x}, {y})</div>;
}

이렇게 하면 초당 수백 번 들어오는 mousemove라도 실제 publish는 화면 프레임에 맞춰 제한됩니다. 즉 “렌더링 폭주”가 “렌더링 가능한 속도”로 정규화됩니다.

SSR 환경이라면 getServerSnapshot을 반드시 제공

Next.js처럼 SSR이 있는 환경에서는 서버 렌더링 시점에 외부 스토어가 브라우저 API에 의존하면 문제가 생깁니다. useSyncExternalStore는 세 번째 인자로 getServerSnapshot을 받습니다.

import { useSyncExternalStore } from 'react';
import { store } from './store';

export function CounterSSR() {
  const snapshot = useSyncExternalStore(
    store.subscribe,
    store.getSnapshot,
    () => ({ count: 0 })
  );

  return <p>count: {snapshot.count}</p>;
}

서버 스냅샷은 “서버에서 렌더링 가능한 안전한 기본값”이어야 합니다. 클라이언트 하이드레이션 후 실제 값으로 동기화됩니다.

실전 체크리스트: 폭주를 멈추는 디버깅 순서

  1. 구독이 중복 등록되는지 확인
  • 개발 모드 Strict Mode에서 특히 확인
  • subscribe가 반환하는 unsubscribe가 확실히 호출되는지 점검
  1. 스냅샷이 불필요하게 큰지 확인
  • 전체 상태 객체 대신 필요한 필드만 반환하도록 selector로 분리
  1. 발행 빈도(throttle) 자체가 필요한지 확인
  • 스크롤, 마우스, resize, WebSocket tick은 requestAnimationFrame 또는 시간 기반 throttle 적용
  1. 동일 값에도 매번 새 객체를 만들고 있지 않은지 확인
  • 예: {x, y}를 매번 새로 만들면 값이 같아도 참조는 달라짐
  • 가능하면 “변경이 있을 때만” 새 객체를 만들도록 구성
  1. React DevTools Profiler로 리렌더 전파 경로 확인
  • 어떤 컴포넌트가 왜 다시 그려지는지 먼저 잡아야 최적화 방향이 명확해집니다.

useSyncExternalStore를 도입할 때 자주 하는 실수

실수 1) subscribe 안에서 상태를 직접 건드리기

subscribe는 “리스너 등록”만 담당하는 쪽이 안전합니다. 구독 시점에 상태를 변경하면, 렌더링-구독-렌더링 사이클이 꼬여 예측 불가능한 리렌더가 생길 수 있습니다.

실수 2) getSnapshot이 비싼 계산을 수행

getSnapshot은 렌더링 중에도 호출될 수 있습니다. 내부에서 무거운 계산(정렬, 필터, deep clone)을 하면 렌더 비용이 급증합니다.

대신 스토어 내부에서 미리 계산해 캐시하거나, selector를 더 잘게 쪼개 “가벼운 읽기”로 유지하세요.

실수 3) “모든 외부 상태”를 무조건 전역 스토어로 밀어 넣기

외부 스토어는 강력하지만, 로컬 UI 상태(예: 모달 열림, 입력값)는 여전히 useState가 단순하고 성능도 좋습니다. 외부 스토어는 “여러 컴포넌트가 공유해야 하고, React 밖에서도 변경될 수 있는 상태”에 집중하는 편이 좋습니다.

마무리: 폭주를 ‘구독 모델’에서 끊어내기

렌더링 폭주는 대개 “React가 느리다”기보다, React 밖에서 발생하는 변경 이벤트를 React가 그대로 다 받아먹는 구조에서 시작됩니다. useSyncExternalStore는 그 경계면을 표준화해서 다음을 가능하게 합니다.

  • 외부 스토어 구독을 동시성 렌더링에 안전하게 만들기
  • 스냅샷을 최소화해 리렌더 전파를 줄이기
  • 이벤트 폭주에는 발행 빈도 제한을 결합해 UI를 안정화하기

성능 문제를 다루는 방식은 프론트엔드뿐 아니라 백엔드/인프라에서도 결국 “원인 추적과 병목 제거”로 수렴합니다. 비슷한 결로 장애 원인을 추적하는 글로는 systemd 서비스 재시작 무한루프 원인추적도 참고할 만하고, 타입 레벨에서 불필요한 변동을 줄이는 관점에서는 TS 5.5+ const 타입 파라미터로 추론 고정하기도 함께 읽으면 좋습니다.

다음 단계로는, 현재 프로젝트에서 “가장 자주 변하는 외부 이벤트 소스” 하나를 골라 useSyncExternalStore로 감싸고, selector 분리와 requestAnimationFrame 기반 발행 제한까지 적용해 보세요. 대부분의 렌더링 폭주는 이 조합만으로 체감이 크게 개선됩니다.