항해 플러스 프론트앤드 3주차 과제로 react에서 쓰이는 hook을 직접 만드는 것인데, 그 중 하나가 useSyncExternalStore
를 활용하는 것이다.
useSyncExternalStore
?
태어나서 처음 들어본다. 그래서 지금 과제 해야 하는데 정말 하나도 모르겠어서 이 훅부터 차근차근 알아보고 과제를 하기로 마음먹었다.
검색해 봐도 Concurrent Features
, snapshot
등 나에게는 다소 생소한 단어들이 있어서, 나처럼 단어부터 이해하기 힘든 사람들에게 이 글을 바친다. 이해가 잘되셨으면...
react 18의 새로운 문제는 바로 동시성 렌더링이었다.
예를 들어 보면... 갑자기 TMI지만, 나는 샤워를 하면서 회사에 가지고 갈 도시락을 준비한다. 에어프라이어에 냉동 닭가슴살을 데우고, 샤워를 한다. 그러면 시간이 단축되기 때문이다.
만약 닭가슴살을 다 데울 때까지 기다렸다가 도시락통에 담고, 그때 샤워를 하러 간다면....? 나는 이미 지각이다.
React 18부터 이런 효율성을 위해 Concurrent Features가 도입됐다. 이제 React는 우선순위가 높은 작업이 오면 현재 렌더링을 중단하고 먼저 처리할 수 있게 되었다. 즉, 리액트도 이제부터는 샤워하기 들어가기 전에 에어프라이어에 닭가슴살을 돌리고, 급한 일이 생기면 샤워를 잠깐 멈추고 급한 일부터 처리한다는 것이다.
그런데 이 Concurrent
가 생겨나면서 외부 상태 관리에 문제가 생겼다. Zustand
, Recoil
등 전역 상태 관리 라이브러리와 React 내부 상태가 동기화되지 않는 문제가 발생한 것.
예를 한 가지 들어보자면, 장바구니 상태가 Zustand
에 저장되어 있다고 하자.
사용자가 상품을 추가하는 동안 여러 컴포넌트가 동시에 렌더링되는데, 어떤 X친 사용자가 장바구니 추가 버튼을 엄청나게 연타 하거나, 심지어 한 번만 클릭해도
렌더링 도중에 외부 상태가 변경되어서, 같은 시점임에도 불구하고 어떤 컴포넌트는 장바구니에 담은 상품이 "0개", 어떤 컴포넌트는 "1개"를 보여 주는 혼란스러운 상황이 발생한다. (이것을 Tearing 현상
이라고 한다.)
이러한 문제를 해결하기 위해 생겨난 Hook이 바로useSyncExternalStore
다.
즉, 외부 상태가 변경되면 React에게 야! 상태 바뀌었어! 다시 렌더링해! 라고 즉시 알려 주고, 모든 컴포넌트가 같은 시점의 상태를 보도록 보장하는 역할을 한다.
후술할 내용 중에, store, subscribe, snapshot 등 여러 생소한 단어들이 나온다. 글을 읽으면서 생소한 단어가 나오면 흐름이 깨지니 여기에서 먼저 단어들을 소개하고 가겠다.
저장소
다. 그러니까 특정한 값을 저장하는 객체라고 생각하면 된다.
그런데 이걸 왜
useState
대신store
로 사용할까?
1. useState는 컴포넌트 안에서만 사용할 수 있다. (전역 상태 관리라는 닉값에 부합하지 않음)
2. 컴포넌트가 사라지면 상태도 사라짐. (당연함 컴포넌트 안에서 선언되는 것임)
(셔누 = 몬스타엑스 멤버)
Subscribe
의 사전적인 의미는 구독하다
이다.
그러니까 뭘 구독하는 건데?
유튜버들이 영상 끝나기 전에 구독과좋아요알림설정까지라는 말을 쓴다. 내가 셔누처럼 유튜버를 구독을 하고 나면 나중에 유튜버가 새로운 동영상을 올렸을 때 유튜브가 알림을 준다.
즉, 변경사항이 있으면 알려 달라고 등록하는 것이다. 유튜브가 새로운 동영상(변경사항)이 있으면 구독자에게 알림을 주는 것이다.
유명 훠궈집을 온라인으로 예약하는 과정을 코드로 예시를 들어보자면,
const hotpotStore = {
table: 0, // 고객이 앉을 수 있는 테이블 개수 (0 = 웨이팅 걸어야 함)
customers: [], // 예약자 명단
subscribe(customer) {
// 웨이팅 목록에 고객을 추가한다
this.customers.push(customer);
console.log("웨이팅 신청이 완료되었습니다");
return () => {
// 참다못한 웨이팅을 취소한 고객을 찾아서 웨이팅 목록에 제거하는 함수 반환
// 웨이팅 취소한 고객이 customer에서 몇 번째에 있는지
const index = this.customers.indexOf(customer);
// customer 배열에 있는 고객들 중 웨이팅 취소 고객을 찾아 제거
this.customers.splice(index, 1);
console.log(`${customer.name}님이 웨이팅을 취소하셨습니다.`);
}
},
updateTable(newTable) {
console.log(`테이블 상태 업데이트: ${newTable}개 사용 가능`);
this.table = newTable;
// 웨이팅 대기 중인 모든 고객들에게 알림 발송!
this.customers.forEach((customer) => {
customer.notify(this.table);
});
}
}
...
// 고객
const customer1 = {
name: "에렌예거",
notify: (tableCount) => {
if (tableCount > 0) {
console.log("드디어 훠궈 먹을 수 있겠다!");
} else {
console.log("아직 자리가 없네... 더 기다려야겠다");
}
}
}
// 웨이팅을 거는 고객
const waiting = hotpotStore.subscribe(customer1);
// 드디어 앉을 수 있는 테이블이 10개 생김
hotpotStore.updateTable(10);
// 테이블이 생기면 고객한테 알림이 감 -> 드디어 훠궈 먹을 수 있겠다!
이런 식으로 유튜브가 새 동영상을 알리면 구독자에게 알림이 가듯이 테이블이 업데이트되면 예약자에게 웨이팅 알림이 가는 것이라고 이해하면 쉽다.
Subscribe
= 변경 사항이 있으면 알려 달라고 등록하는 것.
스냅샷
은 특정 순간을 포착한 사진이라는 뜻이다.
즉, 지금 이 순간의 외부 상태값
이다.
React가 '지금 상태 뭐야?'라고 물어보면, 그 순간을 찰칵📸 찍어서 보여 주는 값이라고 생각하면 된다.
용어들이 잘 설명되었기를 바라면서... useSyncExternalStore
의 기본 사용은 아래와 같다.
const 현재_상태 = useSyncExternalStore(
subscribe, // 변경되면 알려 줘
getSnapshot // 지금 상태 뭐야?
)
위에 말했던 훠궈 집 웨이팅 예약을 다시 생각하면서 useSyncExternalStore
의 사용법을 보면,
const hotpot = { ... };
const availableTables = useSyncExternalStore(
hotpotStore.subscribe, // 웨이팅 알림 등록
() => hotpotStore.table // 현재 테이블 수 확인
);
return <span>현재 사용 가능한 테이블: {availableTables}개</span>
이렇게 사용할 수 있다.
테이블의 개수가 변경되었을 때,
hotpotStore.updateTable(5); // 테이블 5개 생김
하면, 자동적으로 구독하고 있는 컴포넌트들이 리렌더링이 된다.
즉,
export default function WaitingCustomer1() { // 2. 이 컴포넌트가 구독자가 됨
const tables = useSyncExternalStore( // 1. 이거 선언하면
hotpotStore.subscribe,
() => hotpotStore.table
);
return <span>대기: {tables}개</span>
}
웨이팅을 걸 컴포넌트에 useSyncExternalStore
를 선언해 주면, 그 즉시 그 컴포넌트가 선언한 스토어를 구독한다.
외부, 아니면 다른 컴포넌트에서 hotpotStore.updateTable(5);
등으로 상태를 변경하면, 구독한 컴포넌트(WaitingCustomer1)는 영향을 받아 리랜더링 된다.
우선, Zustand에서 전역 상태를 생성하고 관리하는 방법은 아래와 같다.
📦 스토어 만들기
const useHotpotStore = create((set) => ({
table: 0, // 테이블 개수
addTable: () => set((state) => ({ table: state.table + 1 })),
subtractTable: () => set((state) => ({ table: state.table - 1 }))
})
🍲 컴포넌트에서 사용하기
function WaitingCustomer() {
const tables = useHotpotStore((state) => state.table);
return <h1>현재 입장 가능한 테이블은: {tables}</h1>
}
function TableController() {
const { addTable, subtractTable } = useHotpotStore();
return (
<div>
<button onClick={addTable}>테이블 추가하기 (손님이 나갔어요)</button>
<button onClick={subtractTable}>테이블 삭제하기 (손님이 들어왔어요)</button>
</div>
)
}
이렇게 사용된다.
중요하게 볼 점은 create
라는 함수인데, 이 부분을 이해하려면 Zustand의 작동 원리를 알아봐야 한다.
createStoreImpl
함수위 코드 블럭처럼 Zustand에서 스토어를 만들려면 create
함수를 사용해야 한다. 이 create
함수는 무엇일까?
글이 엄청 길어지는 것 같지만.. 길어질수록 나도 작성하는 게 힘들지만..
zustand
를 뜯어 보면 react.ts
와 vanilla.ts
파일이 존재한다.
react.ts
는 리액트에서 사용되는 파일인 것 같고, vanilla.ts
라는 javascript 환경에서도 사용할 수 있고, 리액트에서 사용할 수 있게 변환하는 함수를 만들어 주는 기초적인 파일이다.
vanilla.ts
에서 createStoreImpl
함수로 순수한 store를 생성한다.
⬇️
react.ts
에서는 React.useSyncExternalStore
를 활용해서
⬇️
create
함수를 만든다 (우리가 쓰는 것)
vanilla.ts
파일에는 이러한 함수가 있다.
const createStoreImpl = (createState) => {
let state // 현재 상태
const listeners = new Set() // 구독자들 (콜백 함수들)
// 상태 변경하기
const setState = (partial, replace) => {
const nextState = typeof partial === 'function'
? partial(state) // 함수면 실행
: partial // 값이면 그대로
if (!Object.is(nextState, state)) { // 실제로 바뀌었나 체크
const previousState = state
// 새 상태 적용
state = replace
? nextState // 전체 교체
: Object.assign({}, state, nextState) // 부분 병합
// 🔔 모든 구독자에게 알림!
listeners.forEach(listener => listener(state, previousState))
}
}
const getState = () => state // 상태 가져오기
const subscribe = (listener) => { // 구독하기
listeners.add(listener)
return () => listeners.delete(listener) // 구독 해제 함수 반환
}
// 초기 상태 생성
state = createState(setState, getState, { setState, getState, subscribe })
return { setState, getState, subscribe }
}
솔직히 무슨.. 코드인지 한눈에 보기 어려울 것이다.
하나하나씩 뜯어 보자.
let state
: 현재 상태
먼저 우리가 저장소를 만들면 하나의 저장소라는 인스턴스가 생긴다. 그 인스턴스에서의 상태값은 외부에서 접근이 불가능. 이게 클로저
다. 클로저는 현재 인스턴스만의 private 변수다.
const listener = new Set()
: state가 변경되었을 때 알림을 받을 친구들
set
은 중복을 허용하지 않는다. 그리고 Object와 비교하면 추가/삭제도 빠르다. (Object 말고 set을 쓰는 이유)
그 다음 상태를 변경하는 setState
함수
partial
와 replace
를 받는다.
partial 일부만 변경할 건지 말지 함수나 객체 같은 값이고, replace는 전체를 다 바꿀 건지를 boolean
으로 받는다(true면 전체 바꿈, false가 디폴트).
partial
이 function
이면 함수를 실행한 결과값이 nextState
에 담기고,
partial
이 function
이 아닌 다른 타입이면 그 값이 nextState
에 담긴다.
그리고 Object.is
로 nextState
, state
를 비교한다.
실제로 바뀌었으면 이전 값인 previousState
에 현재의 상태값을 넣는다.
그리고 만약 전체 다 바꿀 거면(replace가 true면) nextState로 현재 상태를 바꾸고,
그게 아니라면 부분만 Object.assign
으로 교체한다.
listener
를 반복하여 모든 구독자에게 알림을 보낸다.
getState
은 현재 state
를 리턴한다.
listeners.forEach(listener => listener(state, previousState))
로 listeners의 배열에 있는 함수를 돌면서 state에 맞는 콜백을 실행한다.
useSyncExternalStore
활용이제 순수한 store
를 만드는 함수가 끝났다. 이제 이것을 리액트에서 사용할 수 있게 변형하는 react.ts
파일을 뜯어 보자.
react.ts
에는 우리가 찾던 함수가 있다.export const create = (createState) =>
createState ? createImpl(createState) : createImpl
인자로 받는 createState
가 있다면 createImpl
라는 함수에 createState
인자를 넣어서 실행하고, 아니라면 바로 createImpl
함수를 실행시킨다.
createImpl
함수는 무슨 함수일까?🤔const createImpl = (createState) => {
const api = createStore(createState)
const useBoundStore = (selector) => useStore(api, selector)
Object.assign(useBoundStore, api)
return useBoundStore
}
createStore
함수는 아까 우리가 vanilla.ts
에서 봤던 createStoreImpl
함수의 래핑 함수다. (createStoreImpl
함수로 봐도 된다)
api
라는 변수에는 setState
, getState
, subscribe
등 우리가 위에서 본 함수들과 결과값들이 담긴다.
const useBoundStore = (selector) => useStore(api, selector)
는 useStore라는 함수를 쓴다. useStore라는 함수는 뭐가 문지 모르겠다. 일단 넘어가자.
아무튼 Object.assign
으로 객체를 복사하고 중복값은 빼고 합친다.
그리고 useBoundStore를 리턴한다.
useStore
함수는?function useStore(api, selector) {
const slice = React.useSyncExternalStore(
api.subscribe, // 구독 함수
() => selector(api.getState()), // 스냅샷 함수
() => selector(api.getInitialState()) // SSR용 스냅샷
)
return slice
}
api
라는 인자는 우리가 앞서 살펴본 vanilla store
에서 반환된 객체. (setState, getState, subscribe...)
selector
라는 인자는 상태에서 필요한 부분만 선택한 함수다. state => state.name
처럼 쓰인다.
React.useSyncExternalStore(
api.subscribe, // 구독 함수
() => selector(api.getState()), // 스냅샷 함수
() => selector(api.getInitialState()) // SSR용 스냅샷
)
() => selector(api.getState())
부분은 useSyncExternalStore
가 필요할 때마다 호출할 수 있도록 함수로 감싸야 한다.
함수 없이 selector(api.getState())
라고 쓰면 useStore 호출 시점에 한 번만 실행되고 끝난다.
그리고 slice
라는 변수를 리턴한다. 즉, useSyncExternalStore
에서 만들었던 값들이 그대로 리턴된다.
다시 createImpl
함수를 보면,
const createImpl = (createState) => {
const api = createStore(createState)
const useBoundStore = (selector) => useStore(api, selector)
Object.assign(useBoundStore, api)
return useBoundStore
}
결국 createImpl 함수는 우리가 리액트에서 쓰는 create
함수의 원조? 함수이며,
vanilla store를 react 훅으로 변환해 주는 핵심 역할
을 한다.
const useBearStore = create((set) => ({
count: 0,
increase: () => set(state => ({ count: state.count + 1 })
});
곰돌이 가게 하나 만들어 주세요!
// create 함수: 네! createImpl한테 맡길게요!
// 여기에서 Impl은 implementation(구현)의 준말이다
create(createState) -> createImpl(createState) 호출
알겠습니다! 전문가한테 맡길게요!
const api = createStore(createState)
// 결과: { setState, getState, subscribe } - 순수한 상태 관리 시스템
먼저 기본적인 가게 시설부터 만들어요!
const useBoundStore = (selector) => useStore(api, selector)
이제 React에서 쓸 수 있는 스마트 카운터를 만들어요!
Object.assign(useBoundStore, api)
// useBoundStore.setState, useBoundStore.getState 등 추가
카운터에 가게 기능들도 모두 붙여드릴게요!
function useStore(api, selector) {
return React.useSyncExternalStore(
api.subscribe, // "변경되면 알려주세요!"
() => selector(api.getState()), // "지금 상태 뭐예요?"
() => selector(api.getInitialState()) // "처음 상태는 뭐였죠?"
)
}
React야, 이 가게 상태 변경되면 자동으로 화면 업데이트해줘!
// 사용자가 받는 것:
const useBearStore = /* 마법의 훅 */
// 이제 이렇게 사용 가능:
const count = useBearStore(state => state.count) // 리액티브
useBearStore.setState({ count: 10 }) // 직접 조작
드디어 모든 설명이 끝났다. 대체 몇 시간을 작성한 건지..
결론은 useSyncExternalStore Hook은 외부 상태가 변경되면 React에게 즉각적으로 알리기 위한 함수고,
Zustand는 이런 useSyncExternalStore을 이용하여 전역 상태 관리 라이브러리를 만들었다.
내 글이 도움이 될지는 모르겠지만.. 다들 직접 구현할 생각 말고 그냥 Zustand 썼으면 좋겠다. 끝.
저에게 꼭 필요한 글이네요. 일단 중간 까지 읽었습니다. 다시 읽으러 갑니다.