JS로 반응형 다루기: matchMedia, useMediaQuery, ResizeObserver와 SSR 하이드레이션 함정
이 글은 React로 반응형 웹 구성하기 시리즈 6편입니다.
지금까지의 반응형은 전부 CSS로 표현했습니다. 그런데 CSS만으로는 안 되는 순간이 있습니다. 화면 크기에 따라 스타일이 아니라 컴포넌트 자체를 다르게 렌더링해야 할 때입니다. 모바일에서는 하단 탭 바, 데스크톱에서는 상단 내비게이션처럼 DOM 구조가 완전히 다르거나, 특정 라이브러리에 isMobile 같은 불리언을 넘겨야 할 때가 그렇습니다.
이때 window.matchMedia로 화면 폭을 JS에서 읽게 됩니다. 편리하지만, 여기에는 SSR 환경에서 거의 반드시 마주치는 함정이 하나 있습니다. 서버는 브라우저 화면 크기를 모른다는 것입니다. 이 글은 JS 기반 반응형의 도구들과, 이 하이드레이션 함정을 안전하게 푸는 법을 다룹니다.
한눈에 보면
window.matchMedia('(min-width: 768px)')로 미디어 쿼리 결과를 JS에서 읽고, 변화도 구독할 수 있습니다.- 서버에는
window가 없어 화면 폭을 모릅니다. 그래서 서버 렌더링 결과와 클라이언트 첫 렌더가 어긋나는 하이드레이션 불일치가 생깁니다. - 순진한
useState + useEffect구현은 첫 프레임에 깜빡임(layout shift)이나 콘솔 경고를 유발합니다. - React 18의
useSyncExternalStore는 이 문제를 위해 만들어진 도구로,getServerSnapshot으로 서버 기본값을 명시해 안전하게 처리합니다. - 요소 자체의 크기 변화가 필요하면
ResizeObserver를 씁니다. (컨테이너 쿼리의 JS 버전에 가깝습니다.) - 대원칙: CSS로 되는 건 CSS로, JS 분기는 DOM 구조가 실제로 달라질 때만 최후의 수단으로 씁니다.
왜 JS 분기가 SSR에서 위험한가
먼저 문제의 본질부터 그림으로 보겠습니다. 서버는 화면 폭을 모르기 때문에 어떤 값이든 "가정"해서 렌더링하고, 클라이언트는 실제 폭으로 다시 렌더링합니다.
sequenceDiagram
participant S as 서버(SSR)
participant B as 브라우저 DOM
participant C as 클라이언트 JS(hydrate)
S->>B: window 없음 → isMobile 값을 모름
Note over S,B: 일단 "데스크톱"으로 가정하고 HTML 생성
B->>C: 데스크톱용 마크업이 화면에 표시됨
C->>C: mount 후 matchMedia로 실제 폭 측정
Note over C: 실제로는 모바일이었음!
C->>B: 모바일용으로 다시 렌더링
Note over B: 화면이 깜빡이며 바뀜(layout shift)<br/>+ 하이드레이션 불일치 경고핵심은 서버가 만든 HTML과 클라이언트의 첫 렌더 결과가 달라진다는 점입니다. React는 하이드레이션 시 이 둘이 같다고 가정하므로, 다르면 경고를 내고 화면이 순간적으로 바뀝니다. 사용자에게는 깜빡임으로, 지표로는 CLS(누적 레이아웃 이동) 악화로 나타납니다.
matchMedia와 순진한 useMediaQuery
가장 흔히 보는 구현부터 봅시다.
// ⚠️ SSR에서 문제가 있는 버전
import { useEffect, useState } from 'react';
export function useMediaQuery(query: string) {
const [matches, setMatches] = useState(false); // 서버·첫 렌더는 무조건 false
useEffect(() => {
const mql = window.matchMedia(query);
setMatches(mql.matches);
const onChange = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, [query]);
return matches;
}이 훅의 문제는 초기값이 항상 false라는 점입니다. 서버와 클라이언트 첫 렌더 모두 false로 시작하고, useEffect가 실행된 뒤에야 실제 값으로 바뀝니다. 그래서 실제로 조건이 참인 사용자는 첫 프레임에 틀린 화면을 본 뒤 깜빡임과 함께 교정됩니다.
useSyncExternalStore로 안전하게
React 18의 useSyncExternalStore는 정확히 이런 "외부 소스(브라우저 API)를 구독하되 서버 스냅샷을 따로 지정"하는 상황을 위해 만들어졌습니다.
import { useSyncExternalStore } from 'react';
export function useMediaQuery(query: string) {
const subscribe = (callback: () => void) => {
const mql = window.matchMedia(query);
mql.addEventListener('change', callback);
return () => mql.removeEventListener('change', callback);
};
const getSnapshot = () => window.matchMedia(query).matches; // 클라이언트: 실제 값
const getServerSnapshot = () => false; // 서버: 명시적 기본값
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}useSyncExternalStore는 세 가지를 받습니다.
subscribe: 변화를 구독(여기선matchMedia의change이벤트)getSnapshot: 클라이언트에서의 현재 값getServerSnapshot: 서버 렌더링 시 사용할 값 — 이걸 명시하는 게 핵심입니다.
이 방식의 이점은, 서버 기본값을 코드에 의도적으로 드러낸다는 것입니다. "서버에서는 모바일을 기본으로 가정한다" 같은 결정을 명확히 하게 되고, React가 하이드레이션을 이 계약에 맞춰 처리합니다.
그래도 남는 진실: 서버는 여전히 화면을 모른다
useSyncExternalStore를 써도 서버가 실제 화면 폭을 알게 되는 건 아닙니다. 여전히 기본값을 "가정"할 뿐입니다. 그래서 JS 분기에는 다음 두 전략을 함께 쓰는 게 좋습니다.
- 초기 렌더는 CSS로 양쪽 다 그려 두고, JS로는 보조적인 판단만 한다.
- 정말 DOM이 갈라져야 한다면, mount 전에는 스켈레톤/공통 UI를 보여 깜빡임을 감춘다.
'use client';
import { useSyncExternalStore } from 'react';
export function Nav() {
const isDesktop = useMediaQuery('(min-width: 768px)');
const mounted = useSyncExternalStore(
() => () => {},
() => true, // 클라이언트
() => false // 서버·첫 렌더
);
// 아직 화면 폭을 신뢰할 수 없는 첫 렌더에는 공통 UI
if (!mounted) return <NavSkeleton />;
return isDesktop ? <DesktopNav /> : <MobileNav />;
}ResizeObserver: 요소 크기에 반응하기
matchMedia가 "뷰포트"를 본다면, ResizeObserver는 특정 DOM 요소의 크기 변화를 봅니다. 4편의 컨테이너 쿼리를 JS로 구현하는 것에 가깝습니다. 차트 라이브러리에 실제 픽셀 크기를 넘겨야 할 때처럼, CSS로는 안 되고 "숫자로 된 크기"가 필요할 때 씁니다.
import { useEffect, useRef, useState } from 'react';
export function useElementWidth<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [width, setWidth] = useState<number | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
// 최신 브라우저는 contentBoxSize, 구형 브라우저는 contentRect로 폴백
const width = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
setWidth(width);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return { ref, width };
}'use client';
export function Chart() {
const { ref, width } = useElementWidth<HTMLDivElement>();
return (
<div ref={ref}>
{/* 라이브러리에 숫자 크기를 넘겨야 하는 경우 */}
{width !== null && <BarChart width={width} height={width * 0.6} />}
</div>
);
}ResizeObserver도 서버에는 없으므로 useEffect 안에서만 다루고, width는 측정 전 null로 두어 "아직 모름" 상태를 명시하는 게 안전합니다. 또한 콜백이 자주 불릴 수 있으니, 무거운 작업이라면 디바운스/requestAnimationFrame으로 다듬는 것을 고려합니다.
CSS로 할 것 vs JS로 할 것
결국 가장 중요한 건 "이걸 JS로 해야 하나?"를 먼저 묻는 것입니다.
flowchart TD
q["화면/요소 크기에 반응해야 한다"]
q --> css{"스타일·배치만<br/>바뀌면 되나?"}
css -->|"예"| useCss["CSS로 해결<br/>미디어/컨테이너 쿼리,<br/>clamp, auto-fit"]
css -->|"아니오"| dom{"DOM 구조나 JS 값이<br/>실제로 달라져야 하나?"}
dom -->|"뷰포트 기준"| mm["matchMedia +<br/>useSyncExternalStore"]
dom -->|"요소 크기 기준"| ro["ResizeObserver"]
style useCss fill:#dcfce7,stroke:#22c55e,color:#111
style mm fill:#dbeafe,stroke:#3b82f6,color:#111
style ro fill:#dbeafe,stroke:#3b82f6,color:#111| 관점 | CSS 기반 반응형 | JS 기반 반응형 (matchMedia 등) |
|---|---|---|
| 표현 범위 | 스타일·배치 변경 | DOM 구조 분기, JS 값 전달 |
| SSR | 안전(깜빡임 없음) | 하이드레이션 불일치 위험 |
| 성능/CLS | 유리 | 첫 렌더 깜빡임·CLS 주의 |
| SEO | 양쪽 마크업이 HTML에 존재 가능 | 조건부 렌더 시 초기 HTML에서 누락 가능 |
| 적합한 경우 | 대부분의 반응형 | 탭바↔상단 내비 등 구조가 진짜 다를 때 |
정리하면
JS로 화면을 읽는 반응형은 강력하지만, SSR에서는 "서버가 화면을 모른다"는 근본 한계를 안고 있습니다.
- CSS로 되는 건 CSS로 — 스타일·배치 변경은 미디어/컨테이너 쿼리로 두는 게 항상 우선입니다.
- JS 분기는 DOM 구조가 진짜 달라질 때만 최후의 수단으로 씁니다.
useMediaQuery는useSyncExternalStore+getServerSnapshot으로 서버 기본값을 명시해 깜빡임을 통제합니다.- 요소 크기 기준 반응은
ResizeObserver, 측정 전에는null로 "아직 모름"을 드러냅니다. - 첫 렌더 깜빡임이 신경 쓰이면 mount 전 공통 UI/스켈레톤으로 감춥니다.
다음 글에서는 다시 CSS 영역으로 돌아와, 반응형에서 자주 빠뜨리는 이미지와 타이포그래피를 다룹니다. 레이아웃이 잘 반응해도 이미지가 무겁거나 글자가 안 읽히면 반응형은 절반만 완성된 것입니다.
