JS로 반응형 다루기: matchMedia, useMediaQuery, ResizeObserver와 SSR 하이드레이션 함정

Frontend

이 글은 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: 변화를 구독(여기선 matchMediachange 이벤트)
  • 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 구조가 진짜 달라질 때만 최후의 수단으로 씁니다.
  • useMediaQueryuseSyncExternalStore + getServerSnapshot 으로 서버 기본값을 명시해 깜빡임을 통제합니다.
  • 요소 크기 기준 반응은 ResizeObserver, 측정 전에는 null로 "아직 모름"을 드러냅니다.
  • 첫 렌더 깜빡임이 신경 쓰이면 mount 전 공통 UI/스켈레톤으로 감춥니다.

다음 글에서는 다시 CSS 영역으로 돌아와, 반응형에서 자주 빠뜨리는 이미지와 타이포그래피를 다룹니다. 레이아웃이 잘 반응해도 이미지가 무겁거나 글자가 안 읽히면 반응형은 절반만 완성된 것입니다.

같이 보면 좋은 글