React는 왜 외부 상태에 전용 훅을 만들었나

2026년 9월 7일

shadcn carousel 오픈소스 이슈에서 시작한 useSyncExternalStore

shadcn/ui 의 캐러셀 컴포넌트를 읽다가 한 줄에 눈이 걸렸다.

const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)

const onSelect = React.useCallback((api: CarouselApi) => {
  if (!api) return
  setCanScrollPrev(api.canScrollPrev())
  setCanScrollNext(api.canScrollNext())
}, [])

React.useEffect(() => {
  if (!api) return
  onSelect(api)
  api.on("reInit", onSelect)
  api.on("select", onSelect)

  return () => {
    api?.off("select", onSelect)
  }
}, [api, onSelect])

api.on("select", onSelect) 으로 구독을 걸어놓고, 바로 위에서 같은 콜백을 손으로 한 번 더 부른다. 구독을 했으면 값이 바뀔 때 알아서 불릴 텐데 왜 미리 부르는가.

답 자체는 시시하다. 구독은 앞으로의 변화만 알려주지 지금 값이 무엇인지는 알려주지 않기 때문이다. 그래서 초기값을 손으로 밀어넣어야 한다.

더 흥미로운 건 그다음 질문이다. 왜 초기값을 손으로 밀어넣어야 하는 상황이 만들어졌는가. 이걸 따라가면 React 18이 useSyncExternalStore 라는 훅을 왜 새로 내놓았는지까지 이어진다.

값이 두 군데에 산다

캐러셀이 왼쪽이나 오른쪽으로 더 갈 수 있는지는 embla 가 이미 알고 있다. api.canScrollPrev() 를 부르면 즉시 답이 나온다. 이 정보의 원본은 embla 인스턴스 안에 있다.

그런데 위 코드는 그 값을 useState 에 한 번 더 담는다. 이제 값이 두 군데에 산다. 원본은 embla 내부에, 복사본은 React fiber 안에.

화면은 복사본을 그린다. 원본이 바뀌면 복사본이 따라가야 하고 그 동기화를 이벤트 리스너가 맡는다. 리스너가 하는 일을 정확히 쓰면 이렇다. 원본을 읽어서 복사본에 저장한다.

이 구조는 잘 돌아간다. 다만 대가가 둘 있다.

구독이 걸리기 전 구간이 비어 있다

useEffect 는 브라우저가 화면을 그린 뒤에 실행된다. 시간순으로 늘어놓으면 이렇다.

렌더       canScrollNext = false        (useState 초기값)
페인트      두 버튼 다 비활성으로 화면에 나옴
    ↑ 이 사이에 값이 바뀌면 들을 사람이 없다
useEffect  api.on("select", onSelect)   ← 이제야 구독

렌더와 구독 사이에 틈이 있다. 그 틈에서 벌어진 일은 리스너가 없으니 아무도 모른다. onSelect(api) 한 줄은 정확히 이 틈을 메우려고 있는 코드다. 구독이 놓친 구간을 사람이 한 번 훑어주는 것.

첫 페인트가 틀린다

useState(false) 로 시작하니 첫 화면에서는 양쪽 버튼이 모두 비활성이다. 실제로는 오른쪽으로 갈 수 있는데도 그렇다. effect 가 돌면서 값이 고쳐지고 렌더가 한 번 더 일어난다. 사용자 눈에는 다음 버튼이 잠깐 흐렸다가 켜지는 것으로 보인다.

두 대가 모두 뿌리가 같다. 값을 복사했기 때문이다. 복사본은 원본보다 늘 한 박자 늦고, 그 박자를 맞추는 코드를 사람이 써야 한다.

구독과 값 읽기를 분리한다

React 18에 들어온 useSyncExternalStore 는 이 복사를 없앤다.

const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)

subscribe 는 콜백을 받아 스토어가 바뀔 때 그 콜백을 부르도록 등록하고 해제 함수를 돌려준다. getSnapshot 은 지금 값을 돌려준다. 셋째 인자는 SSR 용이라 여기서는 넘어간다.

여기서 중요한 건 subscribe 가 값을 전혀 다루지 않는다는 것이다. 콜백은 인자를 받지도 않고 값을 반환하지도 않는다. 하는 일은 다시 그리라는 한마디가 전부다. 초인종에 가깝다. 값을 가져오는 건 전적으로 getSnapshot 몫이고 이건 렌더할 때마다 불린다.

subscribe 는 언제, getSnapshot 은 무엇을 담당한다.

useState 미러 방식에서는 이 둘이 리스너 하나에 뭉쳐 있었다. 리스너가 읽고, 저장하고, 그 저장이 리렌더를 유발했다. 세 가지가 한 덩어리라 초기값 문제가 생긴 것이다. 분리하고 나면 초기값이라는 개념 자체가 사라진다. 매 렌더 getSnapshot 을 부르니 첫 렌더도 그냥 여느 렌더와 다를 바 없다.

스냅샷은 캡처가 아니다

이름 때문에 오해하기 쉬운데 getSnapshot 은 아무것도 보관하지 않는다. 지금 값 하나 뽑아달라는 뜻이다. 사진을 찍어 앨범에 넣는 게 아니라 찍어서 바로 건네주고 끝이다.

굳이 스냅샷이라 부르는 이유는 있다. 외부 스토어는 언제든 변할 수 있는데 하나의 렌더는 일관된 값 하나로 그려져야 한다. 흐르는 물에서 한 컵을 뜨는 것에 가깝고, 그 렌더는 그 컵 하나만 쓴다.

React 가 이 반환값으로 하는 일은 둘이다. 렌더에 쓰고, 직전 반환값과 Object.is 로 비교한다. 두 번째 때문에 자주 발을 헛디딘다.

// 무한 렌더
useSyncExternalStore(sub, () => store.items.filter(i => i.active))
useSyncExternalStore(sub, () => ({ x: store.x }))

내용이 같아도 매번 새 배열이고 새 객체다. Object.is 는 참조만 보니 항상 달라졌다고 판단하고, 리렌더가 다시 getSnapshot 을 부르고, 또 새 참조가 나온다. zustand 셀렉터 안에서 filter 를 돌리거나 ?? [] 를 붙이면 안 되는 이유가 이거다. zustand 는 내부에서 이 훅을 쓰고 셀렉터가 그대로 getSnapshot 자리에 들어간다.

캐싱이 필요하면 스토어 쪽이 안정된 참조를 들고 있어야 한다. getSnapshot 안에서 해결하려 들면 그건 복사본을 다시 만드는 일이다.

왜 하필 React 18인가

여기서 자연스러운 의문이 생긴다. 외부 값을 구독하는 건 React 초기부터 있던 일이다. addEventListeneruseEffect 안에서 부른 코드는 셀 수 없이 많고 그동안 잘 돌아갔다. 왜 이제 와서 전용 훅이 필요해졌을까.

답은 React 16과 18 사이에 렌더링 방식이 근본적으로 바뀌었다는 데 있다.

멈출 수 없던 렌더

React 15의 reconciler 는 재귀로 트리를 순회했다. 부모를 처리하고 자식을 재귀 호출하고, 그 자식이 또 자기 자식을 재귀 호출한다. 진행 상황이 자바스크립트 콜스택에 얹혀 있다.

콜스택에 얹혀 있으면 중간에 멈출 수가 없다. 함수 호출을 반쯤 하다 말고 나중에 이어서 할 방법이 없기 때문이다. 그래서 렌더가 한번 시작되면 트리를 다 그릴 때까지 브라우저에 제어권을 돌려주지 않았다.

성능 문제이긴 했지만 덕분에 공짜로 얻는 게 하나 있었다. 렌더가 도는 동안에는 다른 코드가 아예 돌지 않는다. 자바스크립트는 싱글 스레드니까. 렌더 도중에 외부 값이 바뀐다는 상황이 물리적으로 성립하지 않았다.

Fiber 가 한 일

2017년 React 16이 reconciler 를 통째로 새로 썼다. Fiber 다.

핵심은 콜스택을 자바스크립트 자료구조로 다시 구현하는 것이었다. 트리의 각 노드를 fiber 라는 객체로 만들고 return / child / sibling 포인터로 연결한다. 그러면 순회가 재귀에서 루프로 바뀐다.

let workInProgress = rootFiber
while (workInProgress !== null) {
  workInProgress = performUnitOfWork(workInProgress)
  if (shouldYield()) break     // 포인터만 남기고 나갔다가
}                              // 나중에 그 자리부터 이어간다

진행 상황이 콜스택이 아니라 변수 하나에 담긴다. 노드 하나를 처리할 때마다 여기서 멈출지 물을 수 있게 됐다. fiber 라는 이름은 운영체제에서 협력적으로 스케줄되는 경량 스레드를 가리키는 말에서 왔다. 가상 스택 프레임을 만든 셈이다.

여기서 흔한 오해 하나를 짚고 가자. Fiber 가 곧 concurrent 렌더링은 아니다. React 16과 17은 Fiber 위에서 돌면서도 shouldYield 를 사실상 켜지 않고 예전처럼 끝까지 동기적으로 렌더했다. 멈출 수 있는 몸을 만들어놓고 안 멈춘 것이다.

스위치를 켜기까지 5년

멈출 수 있는 구조만으로는 부족했다. 언제 멈출지 정하는 스케줄러가 필요했고 무엇을 먼저 처리할지 정하는 우선순위 체계가 필요했다.

React 18이 가져온 게 그것들이다. MessageChannel 로 브라우저에 제어권을 반납하는 Scheduler, 비트마스크로 우선순위를 추적해 서로 다른 급의 업데이트가 동시에 진행되게 하는 Lane 모델, 그리고 createRootstartTransition 같은 API.

버전

시기

내용

15

~2016

Stack reconciler. 재귀라 중단 불가

16

2017

Fiber 도입. 중단 가능한 구조를 마련했으나 켜지 않음

17

2020

새 기능 없음. 점진적 업그레이드용 징검다리

18

2022

Scheduler·Lane·createRoot 로 concurrent 가동. useSyncExternalStore 동시 출시

문제를 만든 기능과 해법이 같은 릴리스에 함께 들어 있다. 우연이 아니다.

한 렌더가 여러 시점에 걸치면

concurrent 렌더링이 만든 새로운 상황은 한 줄로 요약된다. 하나의 렌더가 여러 시점에 나뉘어 일어난다.

React 가 트리를 그리다가 몇 밀리초쯤 지나면 브라우저에 제어권을 넘긴다. 그동안 브라우저는 스크롤을 처리하고 이벤트 핸들러를 돌리고 애니메이션 프레임을 그린다. 그 사이에 외부 스토어가 바뀔 수 있다. 그리고 React 가 돌아와서 하던 렌더를 이어간다.

렌더 도중에 외부 값을 직접 읽는 컴포넌트가 있다면 이런 일이 벌어진다.

t=0    A 렌더 : store.getState() = 1"1" 로 그림
t=5    중단, 브라우저에 제어권 반납
t=6    이벤트 발생. store 가 2 가 됨
t=8    재개. B 렌더 : store.getState() = 2"2" 로 그림
t=9    커밋 : 화면에 12 가 동시에 보인다

같은 데이터를 보는 두 컴포넌트가 서로 다른 값을 표시한다. 이걸 tearing 이라 부른다. 화면이 찢어진다는 뜻이고, React 17까지는 일어날 수 없던 현상이다.

재개인가 재시작인가

짚어둘 게 있다. tearing 의 원인은 리렌더가 아니라 재개다. 리렌더는 오히려 해법 쪽에 있다.

중단된 렌더를 처리하는 방법이 둘이다. 하던 걸 이어서 하거나, 버리고 처음부터 다시 하거나. 이어서 하면 앞부분은 옛 값으로 이미 그려진 채 남고 그 뒤에 새 값으로 그린 부분이 붙는다. 시점이 섞인다. 버리고 다시 하면 렌더가 한 번 더 도는 비용을 치르는 대신 전부 같은 시점의 값이 된다.

t=0    A 렌더 : store = 1"1"
t=5    중단
t=6    store 가 2 로 바뀜
t=8    React: 스냅샷이 달라졌다. 이 렌더를 버린다
t=9    A 렌더 : store = 2"2"
       B 렌더 : store = 2"2"
t=12   커밋 : 전부 2

useSyncExternalStore 가 하는 일을 한 문장으로 줄이면 이렇게 된다. 재개하려던 렌더를 감지해서 재시작으로 바꾼다. getSnapshot 을 다시 불러 이 렌더 내내 쓴 값이 아직 유효한지 확인하는 게 그 감지 장치다.

useState 미러 방식으로는 이 확인이 원리적으로 불가능하다. React 입장에서 그 state 는 그냥 자기 값이고, 원본과 어긋났는지 알 방법이 없다.

대부분은 이걸 겪지 않는다

tearing 이 나려면 셋이 겹쳐야 한다. 렌더가 여러 시점으로 쪼개져야 하고, 읽는 값이 React 밖에 있으면서 스스로 변할 수 있어야 하고, 렌더 도중에 그 값을 직접 읽어야 한다.

두 번째 조건이 결정적이다. props 와 state 는 렌더 시작 시점의 값이 fiber 에 박제된다. 중단했다 이어가도 같은 값을 읽는다. React 가 자기 값을 이렇게 관리하는 것 자체가 tearing 방지 설계다. Context 도 마찬가지다. 전역 상태라서 외부처럼 느껴지지만 결국 useState 로 관리되고 Provider 를 통해 흐르므로 React 안이다.

그러니 화면에 그리는 값 대부분은 애초에 후보가 아니다. 문제가 되는 건 React 밖에 살면서 스스로 변하는 값들이다. 브라우저가 들고 있는 navigator.onLine, matchMedia, location. 서드파티 인스턴스인 embla api, 지도 SDK, Monaco. 그리고 Redux, Zustand, Jotai 같은 상태 관리 라이브러리.

판별 기준은 하나로 좁혀진다. setState 를 거치지 않고 값이 변할 수 있는가.

덧붙이면 이 훅을 밀어붙인 건 Redux, MobX, Relay 메인테이너들이었다. React 18 Working Group 에서 concurrent 모드가 켜지면 자기 라이브러리가 깨진다는 문제를 제기했고 그 결과물이 useSyncExternalStore 다. Zustand 나 Jotai 는 그보다 뒤에 나왔지만 같은 범주에 들어간다.

캐러셀로 돌아와서

이제 처음 코드를 고쳐 쓸 수 있다.

// api 가 바뀔 때만 새 참조여야 한다.
// 매 렌더 새 함수를 넘기면 React 가 매번 해제하고 다시 구독한다.
const subscribe = React.useCallback(
  (onStoreChange: () => void) => {
    if (!api) return () => {}
    api.on("select", onStoreChange)
    api.on("reInit", onStoreChange)
    return () => {
      api.off("select", onStoreChange)
      api.off("reInit", onStoreChange)
    }
  },
  [api]
)

const canScrollPrev = React.useSyncExternalStore(
  subscribe,
  () => api?.canScrollPrev() ?? false,
  () => false
)
const canScrollNext = React.useSyncExternalStore(
  subscribe,
  () => api?.canScrollNext() ?? false,
  () => false
)

useState 두 개와 useCallback 하나, useEffect 하나가 사라졌다. 초기 동기화 줄도 없다. 첫 렌더가 곧 정확한 렌더라 깜빡임도 없다. 그리고 구독 해제가 subscribe 의 반환값 하나로 강제되는데, 이게 원래 코드의 또 다른 문제를 자동으로 없앤다.

해제되지 않는 리스너

원래 코드의 cleanup 을 다시 보자.

api.on("reInit", onSelect)
api.on("select", onSelect)

return () => {
  api?.off("select", onSelect)     // reInit 은?
}

on 을 두 번 하고 off 를 한 번 한다. 대칭이 깨져 있다.

다만 이걸 메모리 누수라고 부르는 건 정확하지 않다. embla 의 이벤트 저장소를 열어보면 등록할 때 중복을 걸러낸다.

setStore(type, (handlers) =>
  handlers.includes(callback) ? handlers : [...handlers, callback]
)

onSelectuseCallback([]) 으로 감싼 안정된 참조라 몇 번을 다시 등록해도 핸들러가 쌓이지 않는다. 게다가 컴포넌트가 언마운트되면 embla 가 destroy 되면서 리스너를 통째로 정리한다. 지금 이 코드에서 관측 가능한 버그는 없다는 뜻이다.

문제는 그 안전이 우연에 기대고 있다는 점이다. onSelect 에 의존성이 하나라도 붙는 순간 참조가 매번 달라지고, 그때부터는 중복 필터가 걸리지 않아 reInit 핸들러가 실제로 쌓인다. off("select") 도 현재 참조만 떼어내니 예전 것들은 그대로 남는다.

on 한 만큼 off 하는 건 한 줄이면 되는 일이다. 대칭을 지키는 비용이 그 정도라면 지키는 편이 낫고, subscribe 로 옮기면 애초에 그 선택을 할 필요조차 없어진다.

언제 쓰고 언제 쓰지 않나

직접 부를 일은 생각보다 적다. Redux, Zustand, React Query 는 이미 내부에서 이 훅을 쓴다. zustand 5의 esm/react.mjs 를 열어보면 그대로 들어 있다. 이런 라이브러리를 쓰면서 그 위에 useSyncExternalStore 를 또 감쌀 이유는 없다.

직접 쓸 자리는 라이브러리가 감싸주지 않는 대상이다. 브라우저 API, 서드파티 인스턴스, 직접 만든 모듈 스코프 스토어. 반대로 props 나 state 에서 파생되는 값은 렌더 중에 그냥 계산하면 되고, 서버 데이터는 React Query 같은 도구의 몫이며, 이벤트에 반응하는 로직은 이벤트 핸들러에 있어야 한다.

발을 헛디디는 지점은 둘로 좁혀진다. getSnapshot 이 매번 새 참조를 반환하면 무한 렌더가 난다. 앞에서 본 filter 와 객체 리터럴이 그 경우고, 반환값은 스토어가 이미 들고 있는 안정된 참조여야 한다. 그리고 subscribe 가 매 렌더 새 함수면 매 렌더 해제하고 다시 구독한다. 컴포넌트 밖 모듈 스코프로 빼거나 외부 값에 의존한다면 useCallback 으로 묶어야 한다. 캐러셀 예시에서 [api] 를 넣은 이유가 그거다.

남는 이야기

tearing 이 이 훅의 명분이긴 하지만, 솔직히 실무에서 tearing 을 만나기는 어렵다. React 18에서도 기본 업데이트는 대체로 동기적으로 처리되고 렌더가 실제로 쪼개지는 건 startTransition 이나 useDeferredValue, Suspense 스트리밍을 쓸 때다. 이런 기능을 안 쓰는 앱이라면 tearing 은 이론상의 위험에 가깝다.

그래서 이 훅을 쓰는 실질적인 이유는 다른 데 있다. 구독이 걸리기 전 구간이 사라지고, 초기 동기화 코드를 쓰지 않아도 되고, 구독 해제가 구조적으로 강제되고, 무엇보다 값이 한 군데에만 산다.

처음의 그 한 줄로 돌아가면, onSelect(api) 는 잘못 쓴 코드가 아니었다. 주어진 도구 안에서는 그게 맞는 해법이었다. 다만 그 줄이 필요했다는 사실 자체가 구조에 대해 무언가를 말하고 있었고, React 18은 그 무언가에 이름과 API 를 붙여준 셈이다.