useMediaQuery & useWindowSize: 자바스크립트로 반응형 디자인 제어하기

크리스가 반응형 대시보드를 만들고 있다.
디자이너의 요구사항은 이렇다. "모바일 화면에서는 무거운 사이드바 컴포넌트를 아예 렌더링 하지 말아 주세요. display: none으로 숨기는 건 성능 낭비니까요."
CSS의 @media 쿼리로는 스타일만 제어할 수 있지, 리액트의 조건부 렌더링(Conditional Rendering)은 제어할 수 없다.
크리스는 급한 대로 window.innerWidth를 사용해 코드를 짰다.
// ❌ 크리스의 반쪽짜리 반응형
function Dashboard() {
// 컴포넌트가 처음 뜰 때(Mount) 딱 한 번만 체크한다.
const isMobile = window.innerWidth <= 768;
return (
<div>
{!isMobile && <Sidebar />} {/* 모바일이 아닐 때만 렌더링 */}
<MainContent />
</div>
);
}테스트를 해보니 PC에서는 잘 나온다. 그런데 브라우저 창 크기를 줄여서 모바일 사이즈로 만들어도 사이드바가 사라지지 않는다.
리액트에게 "창 크기가 변했으니 다시 그려!"라고 알려주는 로직(이벤트 리스너)이 없기 때문이다.
오늘은 자바스크립트 영역으로 반응형 로직을 가져오는 두 가지 방법, useWindowSize와 useMediaQuery를 만들고 성능까지 최적화해 본다.
1. useWindowSize: 실시간 픽셀 추적
첫 번째 접근은 브라우저의 resize 이벤트를 구독하여 너비와 높이를 상태로 관리하는 것이다.
// useWindowSize.ts
import { useState, useEffect } from 'react';
export function useWindowSize() {
const [windowSize, setWindowSize] = useState({
width: typeof window !== 'undefined' ? window.innerWidth : 0,
height: typeof window !== 'undefined' ? window.innerHeight : 0,
});
useEffect(() => {
const handleResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
// 창 크기가 변할 때마다 상태 업데이트 -> 리렌더링 유발
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return windowSize;
}이 훅은 캔버스(Canvas)를 그리거나, 화면 비율에 따라 복잡한 계산이 필요할 때 유용하다.
하지만 단순히 "모바일인가?"를 알기 위해 쓰기에는 성능 비용이 너무 크다. 사용자가 창 크기를 조절하는 동안 handleResize가 1초에 수십 번 실행되고, 그때마다 리렌더링이 발생하기 때문이다.
2. useMediaQuery: CSS 문법 그대로 사용하기
우리가 원하는 건 "768px 이하인가?"라는 질문에 대한 Yes/No 대답뿐이다. 픽셀 단위의 변경 사항은 알 필요가 없다.
브라우저는 이를 위해 window.matchMedia라는 강력한 API를 제공한다.
이 API는 CSS 미디어 쿼리 문법을 그대로 사용할 수 있으며, 조건이 바뀔 때만(예: 769px -> 768px) 이벤트를 발생시킨다. 성능 면에서 압도적으로 유리하다.
구현: useMediaQuery
// useMediaQuery.ts
import { useState, useEffect } from 'react';
export function useMediaQuery(query: string): boolean {
// SSR 환경 대비: 기본값은 false로 설정 (혹은 외부에서 주입)
const [matches, setMatches] = useState(false);
useEffect(() => {
// 1. 미디어 쿼리 매처 생성
const media = window.matchMedia(query);
// 2. 초기값 설정
if (media.matches !== matches) {
setMatches(media.matches);
}
// 3. 이벤트 리스너 등록
const listener = () => setMatches(media.matches);
// 모던 브라우저: addEventListener, 구형: addListener
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}, [query]);
return matches;
}사용법: 크리스의 대시보드 수정
이제 CSS를 작성하듯이 자바스크립트에서 반응형 로직을 제어할 수 있다.
// Dashboard.tsx
function Dashboard() {
// ✅ 창 크기를 1px씩 추적하는 게 아니라, 기준점을 넘을 때만 렌더링 된다.
const isMobile = useMediaQuery('(max-width: 768px)');
const isDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
return (
<div style={{ background: isDarkMode ? '#333' : '#fff' }}>
{/* 이제 화면을 줄이면 사이드바가 즉시 언마운트(Unmount) 된다. */}
{!isMobile && <Sidebar />}
<MainContent />
</div>
);
}3. 심화: SSR과 Hydration Mismatch
이 훅들을 Next.js 같은 SSR 환경에서 사용할 때는 주의가 필요하다.
서버에는 window가 없으므로 서버는 isMobile을 false로 가정하고 HTML을 그린다(사이드바 포함).
하지만 클라이언트가 모바일 기기라면 isMobile은 true가 되고, 리액트는 "서버랑 화면이 다르네?"라며 하이드레이션 에러를 낸다.
이를 해결하는 정석적인 방법은 두 가지다.
4. 언제 무엇을 써야 할까?
핵심 정리
화면의 크기를 다루는 법을 알았다. 이제는 화면의 "보이는 영역"을 다룰 차례다.
페이스북이나 인스타그램처럼 스크롤을 내리면 새로운 글이 계속 나오는 무한 스크롤(Infinite Scroll).
복잡한 스크롤 이벤트 계산 없이, "바닥에 닿았는가?"를 우아하게 감지하는 훅을 만들어보자.
"useIntersectionObserver: 무한 스크롤과 레이지 로딩의 핵심" 편에서 계속된다.