본문으로 건너뛰기
개발 머꼬
개발 노트React
hohyeon.dev20

브라우저 API를 구독했다가 getSnapshot 무한 루프를 만난 이유

  • #Common Pitfall
  • #Engineering Note
  • #React

문제 발생

온라인·오프라인 상태를 화면에 표시하려고 useState + useEffect로 구독했더니, 하이드레이션 경고가 뜨고 초기 상태가 한 프레임 어긋났습니다.

const [isOnline, setIsOnline] = useState(navigator.onLine);   // 서버에는 navigator가 없음

useSyncExternalStore로 바꿨더니 이번엔 무한 렌더링이 났습니다.

The result of getSnapshot should be cached to avoid an infinite loop

원인 분석

브라우저 API나 외부 스토어는 React가 모르는 곳에서 값이 바뀝니다. useState로 복사해두면 그 복사본이 언제 어긋나는지 알 수 없고, 동시성 렌더링에서는 렌더 도중 값이 바뀌어 화면 일부만 옛 값인 상태(tearing)가 될 수 있습니다.

useSyncExternalStore는 그 문제를 위해 있는 훅입니다. 인자는 셋입니다.

  • subscribe — 콜백을 받아 스토어에 구독시키고, 구독을 해제하는 함수를 반환합니다.
  • getSnapshot — 컴포넌트에 필요한 데이터의 스냅샷을 반환합니다. 스토어가 바뀌어 반환값이 달라지면(Object.is 비교) React가 리렌더합니다.
  • getServerSnapshot (선택) — 서버 렌더링과 하이드레이션에서만 쓰이는 초기 스냅샷입니다. 이 인자를 생략하면 서버에서 렌더링할 때 오류가 납니다.

무한 루프의 원인은 두 번째 인자에 있습니다. 문서가 명확히 적습니다 — getSnapshot이 반환하는 스냅샷은 불변이어야 합니다. 데이터가 가변이라면 바뀌었을 때만 새 불변 스냅샷을 반환하고, 그렇지 않으면 캐시된 마지막 스냅샷을 반환해야 합니다. 매번 새 객체를 만들면 Object.is 비교가 항상 실패해 리렌더가 끝나지 않습니다.

해결 방안

  1. 브라우저 API 구독은 이 형태가 표준입니다.
import { useSyncExternalStore } from "react";

function subscribe(callback) {
  window.addEventListener("online", callback);
  window.addEventListener("offline", callback);
  return () => {
    window.removeEventListener("online", callback);
    window.removeEventListener("offline", callback);
  };
}

function getSnapshot() {
  return navigator.onLine;
}

function OnlineIndicator() {
  const isOnline = useSyncExternalStore(subscribe, getSnapshot, () => true);
  return <span>{isOnline ? "온라인" : "연결 끊김"}</span>;
}
  1. getSnapshot에서 객체를 새로 만들지 않습니다.
function getSnapshot() {
  return { todos: store.todos };   // ❌ 매번 새 객체 → 무한 루프
}

function getSnapshot() {
  return store.todos;              // ✅ 같은 참조
}

여러 값이 필요하면 값마다 훅을 따로 부르거나, 스토어 쪽에서 불변 스냅샷을 미리 만들어 캐시해 둡니다.

  1. subscribe를 컴포넌트 밖에 정의합니다. 안에 두면 렌더마다 새 함수가 되어 React가 매번 재구독합니다. 안에 두어야 한다면 useCallback으로 감쌉니다.

  2. 서버 스냅샷은 클라이언트 첫 렌더와 같아야 합니다. 문서가 명시합니다 — getServerSnapshot서버에서 반환한 것과 정확히 같은 데이터를 클라이언트 초기 렌더에서도 반환하게 하십시오. 다르면 하이드레이션 불일치입니다. 서버에서 알 수 없는 값은 "보수적인 기본값"을 고릅니다(위 예제의 () => true).

  3. 화면 크기, 미디어 쿼리, 스토리지도 같은 패턴입니다.

const isNarrow = useSyncExternalStore(
  (callback) => {
    const query = window.matchMedia("(max-width: 48rem)");
    query.addEventListener("change", callback);
    return () => query.removeEventListener("change", callback);
  },
  () => window.matchMedia("(max-width: 48rem)").matches,
  () => false,
);
  1. React state로 표현할 수 있으면 이 훅은 필요 없습니다. 이건 React 밖에 있는 데이터를 위한 도구입니다. 컴포넌트가 소유한 값에는 useState가 맞습니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.