useSyncExternalStore로 useIsMobile 재구성

2026.08.19

useSyncExternalStore로 useIsMobile 재구성

뷰포트가 모바일 폭인지 알려주는 흔한 훅 하나를 리팩토링했다. 결과만 보면 코드 줄 수는 비슷한데, 고쳐진 문제가 네 개쯤 된다. 정리해둔다.

Before

"use client";
import * as React from "react";

const MOBILE_BREAKPOINT = 550;

export function useIsMobile() {
  const [isMobile, setIsMobile] = React.useState(
    () => window.innerWidth < MOBILE_BREAKPOINT
  );

  React.useEffect(() => {
    if (typeof window === "undefined") return;
    const onResize = () => {
      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
    };
    window.addEventListener("resize", onResize);
    onResize();
    return () => window.removeEventListener("resize", onResize);
  }, []);

  return isMobile;
}

동작은 한다. 다만 몇 가지 이유로 동작한다.

이 코드의 문제 네 가지

1. SSR 가드가 엉뚱한 곳에 있음

useEffect 안에 typeof window === "undefined" 체크가 있다. 그런데 effect는 서버에서 아예 실행되지 않는다. 이 가드는 영원히 false인 조건문이다.

정작 서버에서 터지는 건 가드가 없는 쪽이다.

React.useState(() => window.innerWidth < MOBILE_BREAKPOINT)
//               ^^^^^^ 렌더 중 실행 → 서버에서 ReferenceError

useState의 초기화 함수는 "지연 실행"이라 안전해 보이지만, 지연되는 건 첫 렌더 이후로가 아니라 첫 렌더 시점까지다. 렌더 자체는 서버에서 일어나므로 그대로 터진다.

이게 드러나지 않았던 건 이 훅을 쓰는 컴포넌트가 전부 dynamic(..., { ssr: false })로 로드되고 있기 때문이었다. 즉 런타임 설정에 의존해 우연히 살아 있는 코드다. 누가 나중에 ssr: false를 떼는 순간 빌드가 깨진다.

2. 구독 등록 전의 변경을 한번 놓침

useState + useEffect 패턴의 타임라인은 이렇다.

렌더 → 커밋 → 페인트 → effect 실행 → addEventListener
                       ↑
                  여기까지 구독 없음

이 갭 사이에 뷰포트가 바뀌면 그 변경은 이벤트로 잡히지 않는다. 원래 코드가 addEventListener 직후에 onResize()를 한 번 더 부르는 게 바로 이 갭을 메우려는 시도다. 필요해서 넣은 코드지만, 패턴 자체가 갭을 만들기 때문에 생긴 땜질이다.

3. 초기 프레임이 깜빡임 가능성

ssr: false 덕분에 지금은 초기값이 실제 폭이라 괜찮다. 하지만 SSR을 켜는 순간 흔한 대안인 "초기값 false로 두고 effect에서 맞추기" 방식으로 가게 되고, 그러면 모바일에서 데스크톱 레이아웃이 한 프레임 그려진 뒤 모바일로 바뀐다. 페인트 이후에 effect가 돌기 때문이다.

4. Tearing 가능성

React 18의 동시성 렌더링에서는 렌더가 중간에 중단되고 나중에 재개될 수 있다. React state는 React가 소유하니 렌더 도중 안 바뀐다고 보장되지만, window.innerWidth 같은 외부 값은 아니다.

중단된 사이에 뷰포트가 바뀌면 같은 트리 안에서 컴포넌트 A는 옛날 값, B는 새 값을 읽어 화면이 찢어(tearing)질 수 있다.


After

"use client";
import { useSyncExternalStore } from "react";

const MOBILE_BREAKPOINT = 550;

/** 렌더마다 재구독하지 않도록 모듈 스코프에 고정한다 */
function subscribe(onChange: () => void) {
  window.addEventListener("resize", onChange);
  return () => window.removeEventListener("resize", onChange);
}

const getSnapshot = () => window.innerWidth < MOBILE_BREAKPOINT;

/** 서버는 뷰포트를 알 수 없다 — 데스크톱으로 가정한다 */
const getServerSnapshot = () => false;

export function useIsMobile() {
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

useSyncExternalStore 동작 원리

기원

React 18의 동시성 렌더링이 도입되면서, "React 바깥의 값을 안전하게 읽는" 공식 창구가 필요해졌다. 계보는 대략 아래와 같다.

단계설명
use-subscriptionFacebook 내부 라이브러리. 사실상 전신
useMutableSource RFC너무 복잡해서 폐기
useSyncExternalStoreReact 18 working group에서 확정, 2022년 정식 출시
use-sync-external-store shimReact 16.8/17용 백포트. Redux 8, Zustand, Jotai 등이 이걸로 마이그레이션

이름의 Sync가 중요. 외부 스토어를 구독하는게 아니라 외부스토어를 동기적으로 읽는다는 뜻

세 개의 인자

useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)

subscribe(onStoreChange)

React가 마운트 시 호출하고 정리 함수를 돌려받는다. 스토어가 바뀔 때 onStoreChange()를 부르면, React가 getSnapshot()을 다시 읽어 이전 값과 Object.is로 비교하고 다를 때만 리렌더를 예약한다.

주의: subscribe참조가 바뀌면 React는 기존 구독을 해제하고 재구독한다. 컴포넌트 안에 인라인으로 두면 매 렌더마다 unsubscribe/subscribe가 반복된다. 모듈 스코프로 빼거나 useCallback으로 고정해야 한다.

getSnapshot()

단순한 "초기값 읽기"가 아니고, 렌더링 도중 React가 여러 번 호출한다. 이게 tearing 방지의 실제 메커니즘이라고 할 수 있다. React는 커밋 직전에 스냅샷을 다시 읽어 렌더 시작 시점과 달라졌는지 확인하고, 달라졌으면 진행 중이던 렌더를 버리고 동기 우선순위로 다시 렌더한다. 그래서 트리 전체가 반드시 같은 값을 본다.

여기서 두 가지 제약이 발생한다.

  • 렌더 중 호출되므로 부수효과 금지
  • 값이 같으면 반드시 같은 참조를 반환해야 함

두 번째가 실무에서 자주 밟을 만한 지뢰... 매번 새 객체나 배열을 만들면 Object.is 비교가 항상 실패해서 무한 렌더 루프에 빠지고, The result of getSnapshot should be cached 경고가 뜬다. 이 훅은 boolean 원시값이라 안전하다.

getServerSnapshot()

SSR 그리고 하이드레이션 렌더에서 쓰인다. 주의! 이 부분이 오해하기 쉽다! 클라이언트도 하이드레이션 중에는 getSnapshot이 아니라 getServerSnapshot을 읽는다.

이건 Next.js 등 써보면 흔히 만날 수 있는 Hydration Miss 관련이다. 서버 HTML과 첫 클라이언트 렌더 결과가 일치해야 하이드레이션 미스가 없는것.. 하이드레이션이 끝난 직후 React가 실제 스토어를 다시 읽어 다르면 리렌더한다.

"클라이언트 첫 렌더부터 실제 폭"은 ssr: false일 때만 참이다. SSR되는 컴포넌트에서는 하이드레이션 렌더가 false로 시작하고 한 번 더 렌더가 돈다. 다만 이건 하이드레이션 커밋과 같은 사이클에서 동기적으로 처리되므로, 페인트 이후에 도는 useEffect 방식보다 깜빡임이 훨씬 덜하다.

문제별 대응 정리

문제useSyncExternalStore의 해결
SSR 크래시getServerSnapshot이 서버 경로를 API 레벨에서 강제 분리
구독 갭구독 등록 직후 스냅샷을 재검사해 갭을 메움 (onResize() 땜질 불필요)
초기 깜빡임클라이언트 전용이면 첫 렌더부터 실제 값, SSR이어도 동기 처리
Tearing커밋 직전 스냅샷 재검증 → 트리 전체 일관성 보장
보일러플레이트예전엔 각 라이브러리가 forceUpdate / useReducer 해킹으로 각자 구현

트레이드오프

  • startTransition의 이점을 잃는다. 외부 스토어 업데이트는 정의상 동기로 처리되므로 논블로킹 트랜지션으로 낮출 수 없다. React 공식 문서도 "가능하면 React state를 쓰고, 이 훅은 외부 소스와 통합할 때만"이라고 권한다.
  • 파생 객체를 반환해야 한다면 useSyncExternalStoreWithSelector(shim 패키지 제공)를 쓰거나 직접 메모이즈해야 한다.

여담: resize vs matchMedia

일반적으로는 matchMedia가 낫다(shadcn 기본 훅도 mql 사용)

resize는 브레이크포인트와 무관한 1px 변화에도 계속 발화한다. 필요한 건 임계값을 넘나드는 순간뿐인데.

const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 0.02}px)`);

function subscribe(onChange: () => void) {
  mql.addEventListener("change", onChange);
  return () => mql.removeEventListener("change", onChange);
}

const getSnapshot = () => mql.matches;

장점:

  • 브라우저가 임계값 판정을 대신 해준다 → 콜백이 실제로 넘나들 때만 발화
  • getSnapshotmql.matches라는 캐시된 boolean을 읽는다. window.innerWidth는 경우에 따라 스타일/레이아웃 계산을 강제할 수 있다
  • CSS 미디어 쿼리와 같은 엔진으로 판정되므로 CSS와 JS의 경계가 어긋나지 않는다

하지만 lazy init이 필요하다

위 코드에는 함정이 있다. mql을 모듈 스코프에서 만들면 모듈이 import되는 순간 window를 건드린다. "use client"가 붙어 있어도 SSR에서 모듈 평가는 일어나므로 그대로 터진다.

let mql: MediaQueryList | null = null;

function getMql() {
  if (mql === null) {
    mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 0.02}px)`);
  }
  return mql;
}

function subscribe(onChange: () => void) {
  const m = getMql();
  m.addEventListener("change", onChange);
  return () => m.removeEventListener("change", onChange);
}

const getSnapshot = () => getMql().matches;
const getServerSnapshot = () => false;

subscribegetSnapshot은 클라이언트에서만 호출되고 getServerSnapshotwindow를 안 건드리므로, 이 구조면 서버에서 matchMedia가 한 번도 평가되지 않는다.

꿀팁: - 0.02는 CSS의 max-width 경계 관례. min-width: 550pxmax-width: 550px는 정확히 550px에서 둘 다 참이 되어 겹치므로, 소수점 뷰포트까지 고려해 549.98px로 잡는 게 부트스트랩 등에서 쓰는 방식이다.

그런데 우리 팀은 resize를 쓴다

QA 방식이 특이하다. 개발자도구 responsive 뷰에서 폭을 실시간으로 드래그하며 확인하고, 레이아웃에 clamp()를 아주 많이 쓴다.

matchMediachange 이벤트는 responsive 뷰 드래그 중에도 임계값을 넘으면 정상 발화한다. 그러니 "동작하지 않는다"는 이유는 아니다. 실제 이유는 두 가지다.

  1. JS 경계와 CSS 경계의 미묘한 불일치. clamp()가 깔린 레이아웃을 실시간으로 늘렸다 줄였다 하면서 보면, < 550(JS 숫자 비교)과 max-width: 549.98px(CSS 판정)이 소수점 뷰포트 폭·브라우저 줌 상황에서 한 틱 어긋나 보이는 구간이 생긴다. 디버깅할 때 "이게 훅 문제인가 CSS 문제인가"를 가르는 데 시간이 든다. window.innerWidth 직접 비교는 최소한 눈에 보이는 숫자와 코드가 1:1로 대응한다.
  2. 훅이 나중에 실제 폭을 필요로 할 가능성. mql.matches는 boolean뿐이라 확장 시 결국 innerWidth를 다시 읽게 된다.

성능 걱정은 생각보다 작다. resize가 초당 수십 번 발화해도, getSnapshot이 반환하는 boolean이 그대로면 Object.is 비교에서 걸러져 리렌더가 일어나지 않는다. 비용은 리스너 호출과 innerWidth 읽기뿐이다. 다만 이건 "이 훅이 boolean만 반환하기 때문"이고, 나중에 실제 폭 숫자를 반환하도록 바꾸면 매 프레임 리렌더가 되니 그때는 rAF 스로틀링이 필요해진다.

정리

  • useState 초기화 함수는 렌더 중 실행된다. window 접근 금지.
  • useEffect 안의 typeof window 가드는 무의미하다. effect는 서버에서 안 돈다.
  • useSyncExternalStore는 SSR 분리 · 구독 갭 · tearing을 API 형태로 강제해서 막는다.
  • subscribe는 참조를 고정할 것. getSnapshot은 순수하고 참조 안정적일 것.
  • 브라우저 API를 모듈 스코프에서 즉시 평가하지 말 것. lazy init으로 감쌀 것.
  • 일반적으로는 matchMedia, 다만 팀의 검증 방식에 따라 resize가 합리적일 수 있다. 선택 이유를 주석으로 남길 것.