React|React Hook 톱아보기

useSyncExternalStore & useId: 라이브러리 개발자와 SSR을 위한 필수 훅

0
useSyncExternalStore & useId: 라이브러리 개발자와 SSR을 위한 필수 훅

크리스가 이번에는 Next.js 프로젝트에서 접근성(A11y)을 고려한 입력 컴포넌트를 만들고 있다.

스크린 리더를 위해 input과 label을 연결하려면 고유한 id가 필요하다.

"랜덤 숫자로 ID를 만들면 겹칠 일 없겠지?"

typescript
// ❌ 크리스의 위험한 생각
function AccessibleInput() {
  const id = `input-${Math.random()}`; // 매번 랜덤 생성

  return (
    <>
      <label htmlFor={id}>이름</label>
      <input id={id} type="text" />
    </>
  );
}

브라우저 콘솔을 열자마자 Next.js가 빨간색 경고를 띄운다.

"Hydration failed because the initial UI does not match what was rendered on the server."

서버에서 렌더링 할 때 생성된 ID(input-0.123)와, 브라우저에서 하이드레이션 할 때 생성된 ID(input-0.987)가 서로 다르기 때문이다.

이런 SSR 불일치 문제와, 리액트 외부의 데이터(스토어, 브라우저 API)를 안전하게 가져오는 문제를 해결하는 고급 훅 2종을 알아보자.

1. useId: SSR에서도 안전한 고유 ID 생성기

useId는 리액트 18에서 도입된 훅으로, 서버와 클라이언트 간에 일치하는 고유 ID를 생성해 준다.

내부적으로 트리 구조의 위치를 기반으로 ID를 생성하기 때문에(:r1:, :r2: 등), 호출 순서가 같다면 서버와 클라이언트에서 항상 똑같은 문자열을 보장한다.

해결: 접근성 컴포넌트 수정

크리스Math.random()useId()로 교체했다.

typescript
// ✅ useId 적용
import { useId } from 'react';

function AccessibleInput() {
  const id = useId(); // 예: ":r1:" 생성

  return (
    <>
      <label htmlFor={id}>이름</label>
      <input id={id} type="text" aria-describedby={`${id}-hint`} />
      <p id={`${id}-hint`}>실명을 입력해주세요.</p>
    </>
  );
}

이제 하이드레이션 에러는 사라졌다.

useId는 폼 요소의 연결뿐만 아니라, SVG의 그라디언트 정의(defs)나 애니메이션 요소의 ID를 지정할 때도 매우 유용하다.

주의: useId는 리스트의 key로 사용하면 안 된다. (데이터가 변하면 순서가 꼬일 수 있음)

2. useSyncExternalStore: 외부 세계와 안전하게 동기화하기

이 훅은 이름이 좀 길고 어렵다. 하지만 역할은 단순하다.

"리액트 내부의 State가 아닌, 외부(External)의 데이터를 구독할 때 발생할 수 있는 동시성 이슈(Tearing)를 막아준다."

  • 외부 데이터의 예: window.innerWidth, navigator.onLine, Redux/Zustand Store, Firestore 등.
  • 문제: Tearing (화면 찢어짐)

    리액트 18의 동시성 렌더링(Concurrency)은 렌더링을 쪼개서 수행한다.

    만약 렌더링 도중에 외부 데이터(window.width)가 바뀌면 어떻게 될까?

  • 컴포넌트 상단은 width: 1000px로 렌더링.
  • (잠시 멈춤 -> 사용자가 창 크기 조절)
  • 컴포넌트 하단은 width: 500px로 렌더링.
  • 이렇게 한 화면에서 서로 다른 데이터가 보이는 현상을 Tearing(티어링)이라고 한다.

    해결: useSyncExternalStore 사용법

    이 훅은 외부 데이터가 변경되면, 리액트에게 "지금 렌더링 하던 거 멈추고, 즉시 동기화해서 다시 그려!"라고 강제한다. 동시성 기능을 일부 포기하더라도 데이터의 일관성(Consistency)을 지키는 것이다.

    typescript
    const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?);
  • subscribe: 데이터가 변했을 때 호출할 콜백 등록 함수.
  • getSnapshot: 현재 데이터를 반환하는 함수.
  • getServerSnapshot: (옵션) SSR 환경에서 사용할 초기 데이터 반환 함수.
  • 실전 예제: useOnlineStatus 훅 만들기

    브라우저의 네트워크 상태(navigator.onLine)를 구독하는 훅을 만들어보자.

    typescript
    import { useSyncExternalStore } from 'react';
    
    // 1. 현재 상태를 가져오는 함수 (Snapshot)
    function getSnapshot() {
      return navigator.onLine;
    }
    
    // 2. 상태 변경을 구독하는 함수 (Subscribe)
    function subscribe(callback: () => void) {
      window.addEventListener('online', callback);
      window.addEventListener('offline', callback);
      
      // 클린업 함수 반환 필수
      return () => {
        window.removeEventListener('online', callback);
        window.removeEventListener('offline', callback);
      };
    }
    
    export function useOnlineStatus() {
      // ✅ 외부 데이터(navigator.onLine)와 리액트를 안전하게 동기화
      const isOnline = useSyncExternalStore(subscribe, getSnapshot);
      return isOnline;
    }

    이제 리액트 내부에서 useStateuseEffect로 복잡하게 이벤트 리스너를 관리할 필요가 없다. ReduxZustand 같은 상태 관리 라이브러리들도 내부적으로 모두 이 훅을 사용하여 리액트 18/19와 호환성을 맞추고 있다.

    핵심 정리

  • useId: SSR 불일치(Hydration Mismatch)를 방지하기 위해 서버와 클라이언트에서 동일한 고유 ID를 생성한다. 접근성(aria-describedby) 구현에 필수적이다.
  • useSyncExternalStore: 리액트 외부의 데이터(브라우저 API, 전역 스토어)를 구독할 때 사용한다.
  • Tearing 방지: 동시성 렌더링 중 데이터가 변경되어 UI가 깨지는 것을 막기 위해, 외부 데이터 변경 시 동기적으로 렌더링을 강제한다.
  • 용도: 일반 애플리케이션 개발자보다는 라이브러리(Store) 개발자에게 더 중요한 훅이지만, window 이벤트를 훅으로 감쌀 때 매우 유용하다.
  • 이것으로 Part 6. 내장 훅 마스터리 과정을 마쳤다.

    우리는 useState부터 시작해 React 19의 최신 useActionState와 useOptimistic까지, 리액트가 제공하는 모든 무기를 손에 넣었다.

    이제 이 무기들을 조합해 우리만의 필살기를 만들 차례다.

    매번 반복되는 input 핸들러, 지겨운 boolean 토글 로직, 헷갈리는 localStorage 연동...

    이 모든 것을 한 줄로 줄여주는 커스텀 훅(Custom Hook)의 세계로 떠나보자.

  • Essential Custom Hooks의 첫 번째 주제, "useBoolean & useToggle: 가장 작지만 가장 많이 쓰이는 훅" 편에서 계속된다.

  • 🔗 참고 링크

  • React Docs - useId
  • React Docs - useSyncExternalStore
  • What is Tearing? (React 18 Working Group)
  • 댓글 (0)

    0/1000
    댓글을 불러오는 중...