Zustand는 상태 관리 라이브러리 중 하나로, 작은 패키지 크기와 직관적인 사용법 덕분에 ReduxMobx와 더불어 많은 개발자들로부터 선택받고 있습니다.

Zustand는 일반적으로 위의 예시 코드처럼 사용합니다.
개발자가 할 일은 State의 타입을 선언하고, create 함수의 파라미터에 함수 형태로 state의 초기값과 state를 변경하는 함수를 선언하는 것뿐입니다.
Zustand는 이 코드만으로 자동으로 state를 생성하고 state가 변경될 때마다 React 컴포넌트를 업데이트합니다.

이 글에서는 Zustand의 핵심 기능 2가지를 살펴보며, 그 동작 방식을 이해하는 것을 목표로 합니다.
먼저 State의 생성 및 변경 방법을 살펴본 뒤, React 컴포넌트가 Zustand의 상태 변화를 어떻게 감지하는지에 대해 알아보겠습니다.
이어서, Zustand의 핵심 기능을 직접 구현하며, 동작 원리를 실질적으로 이해해 보겠습니다.

state 생성 및 변경

위의 코드는 예시 코드에서 사용한 create 함수의 코드입니다.
create 함수는 createStore 함수를 호출하는데, 바로 이 함수가 state 생성 및 변경을 담당합니다.

createStore

createStore 함수는 2개의 로컬 변수, statelisteners를 선언하고, 이 변수들을 참조하는 클로저 4개를 만듭니다.
함수의 마지막 부분에서는 createState 함수를 실행하여 state의 초기값을 설정하고 이 4개의 클로저를 반환합니다.
createState는 개발자가 처음 store를 생성할 때 create 함수의 파라미터로 전달한 함수입니다.

setState

4개의 클로저 중 가장 복잡한 setState 를 살펴보겠습니다.

createStore 함수의 코드를 살펴보니, Zustand는 클로저를 통해 상태를 관리한다는 사실을 알 수 있습니다.
그런데 이것만으로는 React의 상태 관리 라이브러리라고 말하기엔 부족한 점이 있습니다.
클로저만 사용해서는 컴포넌트가 상태의 변화를 감지하지 못하기 때문입니다.
React 컴포넌트는 어떻게 store의 상태가 바뀌었음을 감지하고 업데이트 되는 걸까요?

React 컴포넌트의 store 변화 감지

위의 코드는 앞에서 봤던 create 함수의 코드입니다.
create 함수는 createStore 함수를 호출한 후, useBoundStore 함수를 반환하는데요.
useBoundStore 함수 내부에서 useStore 함수를 호출하도록 되어 있습니다.
바로 이 함수가 컴포넌트가 store의 변화 감지를 담당합니다.

useStore 함수는 앞에서 생성한 4개의 클로저를 파라미터로 받으며 useSyncExternalStoreWithSelector 함수를 호출합니다.
이 함수는 Zustand가 아니라 React에서 제공하는 함수이며 컴포넌트가 external store를 구독할 수 있도록 합니다.

external store란,

React에서 제공하는 prop, useState, useReducer, Context API 등을 제외한 것을 의미합니다.
Redux, Zustand 같은 서드파티 상태 관리 라이브러리와 전역 변수 등을 예로 들 수 있습니다.

subscribeToStore

useSyncExternalStoreWithSelector 함수는 내부적으로 굉장히 복잡한 코드를 가지고 있는데,
우리가 확인하고 싶은 것은 React 컴포넌트가 store의 변화를 감지하는 부분이기 때문에 해당 부분과 관련된 코드만 알아보겠습니다.

위의 코드는 useSyncExternalStoreWithSelector 함수 중 React 컴포넌트와 store의 변화를 감지하고, 리랜더링 시키는 함수의 코드입니다.
코드를 살펴보면, subscribeToStore 함수는 createStore에서 선언한 subscribe 클로저를 통해서 handleStoreChange함수를 콜백 함수로 등록하고 있습니다.
이렇게 listeners Set에 등록된 handleStoreChange 함수는 setState 함수가 호출될 때마다 실행됩니다.(setState 설명 참고)
handleStoreChange 함수는 createStore함수에서 생성한 getState 클로저를 이용해서 store의 과거 값과 현재의 값을 비교하고, 값에 변동이 있다면 컴포넌트를 다시 랜더링하는 함수를 호출합니다.

다시 정리하면, setState 함수가 호출될 때마다 listeners에 담긴 함수들이 모두 실행되는데, 이 함수들 중에는 store의 과거, 현재 값을 비교하며 컴포넌트를 랜더링하는 handleStoreChange 함수도 포함되어 있습니다.
handleStoreChange 함수는 useSyncExternalStoreWithSelector 함수가 subscribe 클로저를 이용해서 listeners에 추가한 함수입니다.

함수의 관계를 그림으로 표현하면 아래와 같습니다.

여러 복잡한 코드를 생략하긴 했지만 React 컴포넌트가 store의 변화를 감지하는 방식은 단순합니다. 과거 값과 현재 값을 비교하는 것뿐입니다. 우리가 잘 모르는 부분은 store의 변화에 맞춰 컴포넌트를 다시 랜더링 시키는 부분인데, 이미 우린 개발하면서 useState와 useEffect로 이와 비슷한 것을 많이 구현하고 있습니다.

그렇다면 굳이 이 복잡한 함수를 사용하는 이유가 뭘까요?

단순히 값을 비교하고 컴포넌트를 랜더링 시키는 것뿐이라면 우리에게 친숙한 useState, useEffect만으로 구현하는 게 낫지 않을까요? 이에 대한 답은 React 18의 Concurrency와 관련이 있습니다.

Tearing

Tearing은 React 18부터 추가된 Concurrency 기능을 external store와 함께 사용할 때 발생할 수 있는 문제입니다.

이전 버전의 React에서는 컴포넌트의 렌더링이 한 번 시작되면 중간에 멈출 수 없었기 때문에 모든 컴포넌트들은 일관된 state를 가집니다.
그러나 React 18에서는 렌더링 과정을 중간에 멈추고 다른 작업을 우선할 수 있는 기능이 추가되었으며, 이로 인해 동일한 state를 참조하는 서로 다른 컴포넌트들이 각자 다른 값을 출력할 수 있게 되었습니다.

아래는 tearing을 표현한 그림입니다. (What is tearing? · reactwg/react-18 · Discussion #69 · GitHub)

간단히 말하자면 tearing은 동일한 state를 바라보는 컴포넌트들이 각자 다른 값을 출력하고 있는 상태를 뜻하며 이 문제를 해결하기 위해 React에서 제공하는 게 useSyncExternalStore 함수입니다.

Zustand 따라 만들기

지금까지 우리는 Zustand의 핵심 기능, 클로저와 useSyncExternalStore 함수에 대해 살펴보았습니다.
지금부터는 위의 2가지 핵심 기능을 바탕으로 Zustand의 핵심 기능을 구현하며 내부 동작 방식을 좀 더 깊이 있게 살펴보겠습니다.

1. 클로저로 상태 관리하기

createExampleStoreZustandcreateStore 함수와 거의 비슷한 함수입니다.
내부에 로컬 변수를 선언하고, 로컬 변수를 바라보는 클로저를 선언합니다.

이 코드만으로 상태 관리를 시도한다면 어떻게 동작하는지 보겠습니다.

React 컴포넌트가 직접 Title과 Content를 바라보게 만드니 Title과 Content가 바뀌어도 화면에는 변화가 없습니다.
Title과 Content는 React가 관리하는 상태가 아니라서 값의 바뀌어도 React가 알 방법이 없기 때문입니다.
(“랜더링 다시 하기” 버튼은 단순히 useState를 이용해서 화면을 다시 렌더링하기 위한 버튼입니다)

다음은 React 컴포넌트가 store의 변화를 감지할 수 있도록 useState와 연동해 보겠습니다.

2. React 컴포넌트의 store 변화 감지

useExampleStore 함수는 useState를 이용해서 React 컴포넌트가 store의 변화를 감지할 수 있도록 만든 함수입니다.
이렇게 하면 store의 setState가 실행될 때마다 setValue를 포함한 모든 listener가 실행됩니다.

useState를 이용하니 이제 Title과 Content를 바꿀 때마다 컴포넌트가 다시 렌더링 됩니다.
마지막으로 React 18부터 제공하는 useSyncExternalStore 함수를 이용해서 tearing을 예방하는 코드로 수정하겠습니다.

3. tearing 방지

React에서 제공하는 useSyncExternalStore 함수를 사용하면 더 이상 useState와 useEffect 등을 사용할 필요가 없습니다. 함수 내부에서 모든 걸 대신 처리해줍니다.

함수 내부를 useSyncExternalStore 함수로 바꾸어도 동일하게 동작하는 모습을 확인할 수 있습니다.

마지막


지금까지 Zustand의 동작 방식을 분석하고 따라만드는 과정을 통해 Zustand의 핵심 기능을 살펴보았습니다.
이 과정에서 클로저를 이용한 상태 관리, useSyncExternalStoretearing 등을 살펴보았습니다.

Zustand와 같은 라이브러리를 사용하면 단순히 API를 사용하는 것 이상으로 내부 동작 방식을 이해하는 것은 매우 중요합니다.
내부 구조를 이해하면 더 효율적인 코드를 작성할 수 있으며 간혹 문제가 발생하더라도 쉽게 디버깅이 가능합니다.

이 글을 통해 여러분도 Zustand의 동작 방식에 대해 더 깊이 이해하게 되었기를 바랍니다. 감사합니다.

404


출처