useSyncExternalStore

우디(박연기)·2025년 8월 6일
post-thumbnail

서론

우아한테크코스 미션을 진행하던 중, 리뷰어 해먼드님께서 useSyncExternalStore 훅에 대해 소개해 주셨다. 당시 나는 리액트의 Context를 이용해 상태를 공유하고 있었고, 이에 대해 피드백을 주시며 이 훅의 존재를 알려주셨다.

이 글에서는 useSyncExternalStore가 어떤 훅인지 알아보고자 한다.

useSyncExternalStore?

useSyncExternalStore 이름부터 사악하다. 차근차근 어떤 훅인지 알아보자.

useSyncExternalStore는 외부 store를 구독할 수 있는 React Hook입니다.
-react 공식문서-https://ko.react.dev/reference/react/useSyncExternalStore

사용법은 아래와 같다.

const snapshot = useSyncExternalStore(subscribe, getSnapshot)

각각의 인자가 어떤 역할을 하는 지 알아보자

1. subscribe

  • 외부 store를 구독하는 함수다.
  • 하나의 callback 인수를 받아 store에 구독을 등록한다.
  • store의 값이 바뀌면 해당 callback이 호출되며, React는 getSnapshot을 다시 호출하고 컴포넌트를 리렌더링한다.
  • 이 함수는 구독 해제 함수(unsubscribe) 를 반환해야 한다.

공식 문서의 설명은 다소 딱딱할 수 있으니, 예시 코드를 통해 조금 더 직관적으로 이해해 보자

// listener는 콜백함수 
subscribe(listener) {
    // 구독 등록
    listeners = [...listeners, listener];
  
    // 구독을 해제하는 함수 리턴
    return () => {
      listeners = listeners.filter(l => l !== listener);
    };
  },

여기서 한 가지 의문이 생길 수 있다.
우리는 useSyncExternalStore을 사용할 때 callback을 직접 넘겨준 적이 없는데, listener는 도대체 어디서 오는 걸까?

정답은 React가 내부적으로 제공해 준다는 것이다. useSyncExternalStore이 호출되면, React는 store가 변경되었을 때 컴포넌트를 리렌더링할 수 있도록 자체적으로 callback을 생성해 subscribe에 넘겨준다.
즉, 우리는 callback 자체를 신경 쓸 필요 없이 subscribe 함수만 올바르게 정의하면 된다.

2. getSnapshot

  • store에서 데이터의 스냅샷을 가져온다.
  • store가 변경되지 않은 상태에서 getSnapshot을 반복적으로 호출하면 동일한 값을 반환해야 한다.

또, 공식 문서의 설명은 조금 딱딱하게 느껴질 수 있다.

여기서 ‘snapshot’이라는 단어부터 헷갈릴 수 있는데, snapshot이란 쉽게 말해 “지금 이 순간의 상태”를 뜻한다.

즉, getSnapshot은 “지금 스토어에 어떤 값이 들어있는지 보여줘!” 라고 말하는 함수라고 보면 된다. 결국 리액트는 subscribe로 스토어의 변화를 감지하고 있고, 변화가 감지되면 getSnapshot을 호출해서 최신 상태 값을 가져와 화면을 업데이트하게 된다.

사용 예시

예제 코드를 보면서 useSyncExternalStore 훅의 사용법을 더 자세히 알아보자
먼저 store부터 알아보자.

let nextId = 0;
let todos = [{ id: nextId++, text: 'Todo #1' }];
let listeners = [];

export const todosStore = {
  addTodo() {
    todos = [...todos, { id: nextId++, text: 'Todo #' + nextId }]
    emitChange();
  },
  
  subscribe(listener) {
    listeners = [...listeners, listener];
    return () => {
      listeners = listeners.filter(l => l !== listener);
    };
  },
  
  getSnapshot() {
    return todos;
  }
};

function emitChange() {
  for (let listener of listeners) {
    listener();
  }
}

todosStore는 React 외부에 데이터를 저장하는 외부 store로 구현되어 있다.

todosStore 객체를 살펴보자.

let nextId = 0;
let todos = [{ id: nextId++, text: 'Todo #1' }];
let listeners = [];

todosStore 객체를 살펴보면, 먼저 중복되지 않는 id를 생성하기 위해 nextId를 선언하고, 외부 상태로 사용할 todos 배열과 이를 구독할 listeners 배열을 정의한다.
이렇게 전역으로 상태와 구독자를 관리하는 이유는 useState처럼 특정 컴포넌트의 생명주기에 따라 초기화되는 것이 아니라, 앱이 동작하는 동안 상태를 지속적으로 유지하기 위함이다.

subscribe(listener) {
    listeners = [...listeners, listener];
    return () => {
      listeners = listeners.filter(l => l !== listener);
    };
  },

subscribe 함수는 해당 스토어를 구독하는 함수다. 해당 함수는 useSyncExternalStore훅의 첫번째 인자로 전달되는 함수다. 이 함수는 리턴 값으로 구독을 취소하는 함수를 리턴한다.
또한, subscribe 함수 내부에는 방금 전역으로 선언한 listeners 배열에 현재 콜백으로 받은 listener 함수를 추가한다. 이렇게 함으로서 해당 스토어를 구독할 수 있다.
여기서 listener 함수는 우리가 직접 전달하는 것이 아니라 React에서 내부적으로 넣어주는 함수다. 해당 함수는 스토어의 값이 변경되었을 때, React에서 리렌더링을 발생시키는 역할을 한다.

getSnapshot() {
    return todos;
  }

getSnapshot 함수는 현재 스토어의 상태를 반환하는 함수다.
이 함수는 useSyncExternalStore 훅이 구독 중인 컴포넌트를 리렌더링할지 결정할 때, 스토어의 최신 상태값을 가져오는 용도로 사용된다.
즉, React는 내부적으로 이 함수를 호출해 현재 스냅샷, 즉 현재 상태를 읽고 이전 스냅샷과 비교하여 값이 변경되었는지를 판단한다.
만약 이전 스냅샷과 다른 경우에만 컴포넌트를 리렌더링하게 된다.

function emitChange() {
  for (let listener of listeners) {
    listener();
  }
}

emitChange 함수는 스토어의 상태가 변경되었을 때 호출되는 함수로, 현재 스토어를 구독하고 있는 모든 컴포넌트의 listener를 실행한다.
이 함수는 todos와 같은 스토어의 값이 변경되었음을 알리는 역할을 하며, 이를 통해 React는 내부적으로 getSnapshot을 다시 호출해서 상태가 변경되었는지를 확인하고, 필요한 경우에만 컴포넌트를 리렌더링하게 된다.

  addTodo() {
    todos = [...todos, { id: nextId++, text: 'Todo #' + nextId }]
    emitChange();
  },

addTodo 함수는 스토어의 상태인 todos 배열에 새로운 할 일을 추가하고, 그 직후 emitChange 함수를 호출한다.
이를 통해 해당 스토어를 구독 중인 컴포넌트들에게 상태가 변경되었음을 알리고, React 내부에서 getSnapshot을 다시 호출하여 변경 여부를 확인한 후, 필요한 컴포넌트들을 리렌더링하게 만든다.

store을 알아보았으니, 이제는 해당 스토어를 구독하여 사용하는 방법에 대해 알아보자.

import { useSyncExternalStore } from 'react';
import { todosStore } from './todoStore.js';

export default function TodosApp() {
  const todos = useSyncExternalStore(todosStore.subscribe, todosStore.getSnapshot);
  return (
    <>
      <button onClick={() => todosStore.addTodo()}>Add todo</button>
      <hr />
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </>
  );
}

TodosApp 컴포넌트는 useSyncExternalStore훅을 통해 todosStore을 구독하여 todos 데이터를 렌더링하는 컴포넌트다.

 const todos = useSyncExternalStore(todosStore.subscribe, todosStore.getSnapshot);

위 코드는 useSyncExternalStore 훅을 사용해 외부 스토어인 todosStore를 구독하는 예시다.
첫 번째 인자로 subscribe 함수를 넘겨 스토어 변경 사항을 감지하고, 두 번째 인자로 넘긴 getSnapshot 함수의 반환값을 통해 현재 스토어의 상태를 가져온다.
이렇게 얻은 값은 todos 변수에 저장되며, 스토어의 상태가 변경될 때마다 해당 컴포넌트는 자동으로 리렌더링된다.

마무리 하며

이번 글에서는 useSyncExternalStore 훅의 개념과 동작 원리를 실제 예제와 함께 살펴보았다.
이 훅은 외부 스토어의 상태를 React 컴포넌트와 안전하게 동기화할 수 있는 방법을 제공한다.

핵심적으로 기억할 점은 다음과 같다

  • subscribe: 외부 상태가 변경됐을 때 리액트에 알릴 수 있는 구독 함수
  • getSnapshot: 현재 상태 값을 가져오는 함수 (이전 상태와 비교해 리렌더링 여부 결정)
  • React가 내부적으로 listener를 넘겨주기 때문에 우리는 store와 구독 로직만 신경 쓰면 된다

출처

https://ko.react.dev/reference/react/useSyncExternalStore

profile
프론트엔드 개발하는 사람

1개의 댓글

comment-user-thumbnail
2025년 8월 7일

저에게 꼭 필요한 자료네요...

답글 달기