useSyncExternalStore로 useIsMobile 재구성
2026.08.19
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-subscription | Facebook 내부 라이브러리. 사실상 전신 |
useMutableSource RFC | 너무 복잡해서 폐기 |
useSyncExternalStore | React 18 working group에서 확정, 2022년 정식 출시 |
use-sync-external-store shim | React 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;
장점:
- 브라우저가 임계값 판정을 대신 해준다 → 콜백이 실제로 넘나들 때만 발화
getSnapshot이mql.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;
subscribe와 getSnapshot은 클라이언트에서만 호출되고 getServerSnapshot은 window를 안 건드리므로, 이 구조면 서버에서 matchMedia가 한 번도 평가되지 않는다.
꿀팁: - 0.02는 CSS의 max-width 경계 관례.
min-width: 550px와 max-width: 550px는 정확히 550px에서 둘 다 참이 되어 겹치므로,
소수점 뷰포트까지 고려해 549.98px로 잡는 게 부트스트랩 등에서 쓰는 방식이다.
그런데 우리 팀은 resize를 쓴다
QA 방식이 특이하다. 개발자도구 responsive 뷰에서 폭을 실시간으로 드래그하며 확인하고, 레이아웃에 clamp()를 아주 많이 쓴다.
matchMedia의 change 이벤트는 responsive 뷰 드래그 중에도 임계값을 넘으면 정상 발화한다.
그러니 "동작하지 않는다"는 이유는 아니다. 실제 이유는 두 가지다.
- JS 경계와 CSS 경계의 미묘한 불일치.
clamp()가 깔린 레이아웃을 실시간으로 늘렸다 줄였다 하면서 보면,< 550(JS 숫자 비교)과max-width: 549.98px(CSS 판정)이 소수점 뷰포트 폭·브라우저 줌 상황에서 한 틱 어긋나 보이는 구간이 생긴다. 디버깅할 때 "이게 훅 문제인가 CSS 문제인가"를 가르는 데 시간이 든다.window.innerWidth직접 비교는 최소한 눈에 보이는 숫자와 코드가 1:1로 대응한다. - 훅이 나중에 실제 폭을 필요로 할 가능성.
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가 합리적일 수 있다. 선택 이유를 주석으로 남길 것.