로직 묶어보기: useWindowWidth

282025년 09월 12일4

useStateuseEffect라는 두 개의 강력한 도구를 손에 쥔 리액트 팀은, 마침내 그들이 출발했던 최초의 문제로 돌아왔다. 바로 ‘상태 로직의 재사용’이었다.

어느 날 오후, 한 개발자가 반응형 디자인을 위한 컴포넌트를 만들고 있었다. 그는 브라우저 창의 너비(width)가 바뀔 때마다, 그 값을 감지하여 화면의 레이아웃을 다르게 보여주고 싶었다.

그는 새로 배운 훅을 사용하여 코드를 작성했다.

function ResponsiveComponent() {
  // 1. 창 너비를 저장할 상태를 만든다.
  const [width, setWidth] = useState(window.innerWidth);

  // 2. 창 크기 변경을 감지하는 효과를 만든다.
  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);

    // 이벤트 리스너를 등록한다.
    window.addEventListener('resize', handleResize);

    // 컴포넌트가 사라질 때 리스너를 정리한다.
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []); // 이 효과는 처음 한 번만 실행되면 된다.

  return (
    <div>
      <p>Current window width: {width}px</p>
      {width > 600 ? <p>Desktop Layout</p> : <p>Mobile Layout</p>}
    </div>
  );
}

코드는 완벽하게 작동했다.
useState로 현재 너비를 상태로 관리하고, useEffectresize 이벤트를 구독하고 해지했다. 모든 로직이 명확하고 간결했다.

문제는 다른 개발자가 또 다른 컴포넌트에서 똑같은 기능을 필요로 했을 때 발생했다.
Header 컴포넌트도 창 너비에 따라 메뉴 버튼을 보여주거나 숨겨야 했고, Sidebar 컴포넌트도 너비에 따라 접히거나 펼쳐져야 했다.

결국, 그들은 ResponsiveComponent에 있던 useStateuseEffect 코드를 그대로 복사해서, HeaderSidebar 컴포넌트에 각각 붙여넣어야 했다.
소프트웨어 공학의 제1원칙, ‘반복하지 말라(DRY)’가 또다시 깨지는 순간이었다.

“결국 우리는 다시 원점으로 돌아왔군요.”
소피가 씁쓸하게 말했다.
“훅이 코드를 보기 좋게 만들어준 것은 사실이지만, 이 로직 덩어리를 재사용할 방법이 없다면 HOC나 렌더 프롭 시절과 근본적으로 다를 게 없습니다.”

회의실에 잠시 침묵이 흘렀다.
그때, 댄의 머릿속에 아주 단순하면서도 대담한 아이디어가 스쳐 지나갔다.
그는 키보드를 가져와, ResponsiveComponent의 코드를 수정하기 시작했다.

그는 useStateuseEffect 로직 덩어리를 그대로 잘라내어, 컴포넌트 바깥의 새로운 함수 안으로 옮겼다.

// 창 너비를 추적하는 로직을 별도의 함수로 분리한다.
function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);

  // 그리고 최종적으로 계산된 너비 값을 반환한다.
  return width;
}

새로 만들어진 useWindowWidth 함수는 useStateuseEffect를 호출하고, 그 결과물인 width 값을 반환했다. 지극히 평범한 자바스크립트 함수처럼 보였다.

그리고 그는 원래의 ResponsiveComponent를 아주 간단하게 수정했다.

function ResponsiveComponent() {
  const width = useWindowWidth(); // 방금 만든 함수를 호출한다.

  return (
    <div>
      <p>Current window width: {width}px</p>
      {/* ... */}
    </div>
  );
}

이론상으로는 말이 되지 않는 코드였다.
useStateuseEffect는 리액트 컴포넌트의 컨텍스트 안에서만 호출되어야 했다. useWindowWidth는 컴포넌트가 아닌, 그냥 일반 함수였다.

하지만 댄은 useState가 ‘호출 순서’에 의존한다는 사실을 떠올렸다.
만약 리액트가 컴포넌트 렌더링을 시작하고, ResponsiveComponentuseWindowWidth를 호출하고, 그 안에서 useState가 호출된다면?
리액트 입장에서는 ResponsiveComponent가 직접 useState를 호출한 것과 순서상 아무런 차이가 없었다.

그는 떨리는 마음으로 코드를 실행했다.
브라우저 화면에 ‘Current window width:’라는 문구와 함께 현재 창의 너비가 정확하게 표시되었다. 그가 브라우저 창의 크기를 조절하자, 숫자가 실시간으로 부드럽게 바뀌었다.

작동했다.
평범한 함수 안으로 옮겨진 useStateuseEffect가, 마치 마법처럼 원래의 컴포넌트와 연결되어 살아 움직이고 있었다.

회의실의 모두가 숨을 죽였다.
그들은 방금, 리액트 역사상 가장 위대한 발견 중 하나가 탄생하는 순간을 목격하고 있었다.
이것은 단순한 리팩토링이 아니었다.
이것은 HOC와 렌더 프롭의 시대를 끝낼, 새로운 패러다임의 발견이었다.