서론
Redux를 사용한 지 1년이 되었다. 회사 입사 전에는 recoil 혹은 useContext만 사용했었는데 회사에서 redux를 사용하면서 redux의 동작원리에 대해 궁금해지게 되었다. 이 글은 회사에서 redux를 사용하면서 공부해 본 redux, react-redux의 동작원리에 대해 간략하게 다루어본다. 코드이해를 돕기 위해 주석과 에러처리 부분, 몇몇 함수들은 제거하였다. 또한 이 글에선 redux store, provider를 최상위 컴포넌트에서 사용하는 경우만 다룬다.
Redux
먼저 간단하게 redux의 store부터 보자.
// https://github.com/reduxjs/redux/blob/master/src/createStore.ts
export function createStore(reducer, preloadedState, enhancer) {
let currentReducer = reducer
let currentState = preloadedState
let currentListeners = new Map()
let nextListeners = currentListeners
let listenerIdCounter = 0
let isDispatching = false
function getState() {
return currentState
}
function subscribe(listener) {
let isSubscribed = true
ensureCanMutateNextListeners()
const listenerId = listenerIdCounter++
nextListeners.set(listenerId, listener)
return function unsubscribe() {
if (!isSubscribed) {
return
}
isSubscribed = false
ensureCanMutateNextListeners()
nextListeners.delete(listenerId)
currentListeners = null
}
}
function dispatch(action) {
try {
isDispatching = true
currentState = currentReducer(currentState, action)
} finally {
isDispatching = false
}
const listeners = (currentListeners = nextListeners)
listeners.forEach(listener => {
listener()
})
return action
}
dispatch({ type: ActionTypes.INIT })
const store = {
dispatch,
subscribe,
getState,
replaceReducer,
[$$observable]: observable
}
return store
}
createstore는 store 객체를 반환하는 함수이다. 이때 주목할 점은 store 객체는 함수들만을 반환한다. useContext + useReducer 조합을 생각해보면 context를 선언하는 컴포넌트에서 context의 변화가 생기면 하위 트리가 전부 리렌더링 되게 된다. 왜냐하면 Provider도 컴포넌트이기 때문이다.
그러나 redux store는 상태를 직접적으로 반환하지 않는다. 즉, store 내부의 상태(이하 currentState)가 변화하더라도 하위 트리가 전부 리렌더링 되지 않을 것이다(그럼 어떤 컴포넌트들이 리렌더링 되는지는 아래에서 다룬다). 하위 트리가 전부 리렌더링 되는 경우는 Provder 컴포넌트를 자식으로 가지는 부모 컴포넌트가 리렌더링 되어 부모 컴포넌트가 재호출되고 이에 store 객체의 참조도 달라지는 경우만 하위 트리가 전부 리렌더링 될 것이다.
currentState에 접근하는 방법은 getState함수를 통해서이다. getState함수는 closure로 currentState에 대한 참조가 동봉되어 있다. 이때 우리가 currentState를 변경하는 방법은, dispatch 함수를 통해서이다. dispatch 함수는 우리가 전달한 action을 받아 reducer들을 통해 새로운 상태를 currentState에 저장한다. 앞서 말햇듯이 currentState는 closure로 currentState에 대한 참조가 동봉되어 있기에 getState는 currentState의 최신값을 항상 읽을 수 있다. 이 글에선 middleware에 대해선 다루지 않지만, middleware는 currying함수로 dispatch와 합성되어 모든 action에 대해 모든 middleware가 실행되는 구조이다.
또한 주목할 점은 subscribe의 동작 방법이다. 대게는 store.subscribe를 호출할 일이 없을 것이다. 그러나 subscribe 함수는 useSelector 동작의 key이기 때문에 간략하게 알아보자. subscribe에 listener, callback 함수,를 등록하면 Map에 등록된다(key-value쌍이 자주 변하기에 Object가 아닌 Map을 사용했을 것이다 이에 대해 더 궁금한 사람은 hidden class에 대해 찾아보자). 등록된 listener 함수들은 dispatch 함수를 실행할때 마다 모두 실행된다.
React-redux
redux에 대해 간략히 알아봤으니 react-redux 패키지를 보도록 하자.
// https://github.com/reduxjs/react-redux/blob/master/src/components/Provider.tsx
function Provider(
providerProps,
) {
const { children, context, serverState, store } = providerProps
const contextValue = React.useMemo(() => {
const subscription = createSubscription(store)
const baseContextValue = {
store,
subscription,
getServerState: serverState ? () => serverState : undefined,
}
return baseContextValue
}, [store, serverState])
const previousState = React.useMemo(() => store.getState(), [store])
useIsomorphicLayoutEffect(() => {
const { subscription } = contextValue
subscription.onStateChange = subscription.notifyNestedSubs
subscription.trySubscribe()
if (previousState !== store.getState()) {
subscription.notifyNestedSubs()
}
return () => {
subscription.tryUnsubscribe()
subscription.onStateChange = undefined
}
}, [contextValue, previousState])
const Context = context || ReactReduxContext
return <Context.Provider value={contextValue}>{children}</Context.Provider>
}
export default Provider
먼저 Provider를 보자. Provider는 크게 context와 store 객체를 전달받아 하위 구독 컴포넌트들에 전달하는 React Context Provider이다.
Provider는 크게 두 가지 일을 한다.
- store를 subscribe하는 함수를 생성한다(currentstate를 추적하기 위해서, 아래에서 추가로 다룬다).
- useIsomorphicLayoutEffect(≈useLayouteffect)에서 subscribe 함수를 실행시켜 currentstate의 변화를 구독 컴포넌트들에 전파될 수 있도록 한다.
그럼 createSubscription 함수는 어떻게 구독 컴포넌트들에 변화를 전파하는지 살펴보자.
// https://github.com/reduxjs/react-redux/blob/master/src/utils/Subscription.ts
export function createSubscription(store, parentSub) {
let unsubscribe
let listeners = nullListeners
let subscriptionsAmount = 0
function addNestedSub(listener) {
trySubscribe()
const cleanupListener = listeners.subscribe(listener)
// cleanup nested sub
let removed = false
return () => {
if (!removed) {
removed = true
cleanupListener()
tryUnsubscribe()
}
}
}
function notifyNestedSubs() {
listeners.notify()
}
function handleChangeWrapper() {
if (subscription.onStateChange) {
subscription.onStateChange()
}
}
function isSubscribed() {
return selfSubscribed
}
function trySubscribe() {
subscriptionsAmount++
if (!unsubscribe) {
unsubscribe = parentSub
? parentSub.addNestedSub(handleChangeWrapper)
: store.subscribe(handleChangeWrapper)
listeners = createListenerCollection()
}
}
function tryUnsubscribe() {
subscriptionsAmount--
if (unsubscribe && subscriptionsAmount === 0) {
unsubscribe()
unsubscribe = undefined
listeners.clear()
listeners = nullListeners
}
}
const subscription = {
addNestedSub,
notifyNestedSubs,
handleChangeWrapper,
isSubscribed,
trySubscribe: trySubscribeSelf,
tryUnsubscribe: tryUnsubscribeSelf,
getListeners: () => listeners,
}
return subscription
}
Provider 컴포넌트의 useIsomorphicLayoutEffect에서 trySubscribe를 호출하는 것을 보았다. trySubscribe는 앞서 redux section에서 본 store.subscribe에 handleChangeWrapper listener를 등록한다. handleChangeWrapper는 Provider 컴포넌트에서 subscription.onStateChange = subscription.notifyNestedSubs를 통해 할당하였다.
notifyNestedSubs는 createListenerCollection의 인스턴스인 listeners의 notify 메서드를 실행한다. listeners는 listener 함수들의 linked list인데, notify 메서드는 linked list를 순회하며 listener 함수를 실행하는 역할을 한다.
따라서 이제 우리는 Provider 컴포넌트에서 실행한 함수 trysubscribe(≈store.subscribe)를 통해 action이 dispatch 될 때마다 등록된 listener 함수들이 모두 실행되는 것을 알 수 있다.
그럼 마지막으로 어디서 listener 함수들이 listeners 인스턴스에 등록되는지 보자.
https://github.com/reduxjs/react-redux/blob/master/src/hooks/useSelector.ts
const refEquality = (a, b) => a === b
export function createSelectorHook(
context = ReactReduxContext,
) {
const useReduxContext =
context === ReactReduxContext
? useDefaultReduxContext
: createReduxContextHook(context)
const useSelector = (
selector,
equalityFnOrOptions = {},
) => {
const { equalityFn = refEquality } =
typeof equalityFnOrOptions === 'function'
? { equalityFn: equalityFnOrOptions }
: equalityFnOrOptions
const reduxContext = useReduxContext()
const { store, subscription, getServerState } = reduxContext
const wrappedSelector = React.useCallback(
{
[selector.name](state) {
const selected = selector(state)
return selected
},
}[selector.name],
[selector],
)
const selectedState = useSyncExternalStoreWithSelector(
subscription.addNestedSub,
store.getState,
getServerState || store.getState,
wrappedSelector,
equalityFn,
)
return selectedState
}
Object.assign(useSelector, {
withTypes: () => useSelector,
})
return useSelector
}
export const useSelector = createSelectorHook()
listener 함수들은 우리가 useSelector를 통해 등록한 selector함수이다. 우리가 사용하는 useSelector함수는 selector를 listeners에 등록하고, selector를 통해 값을 반환하는 함수이다. 값을 listeners에 등록하는건 useSyncExternalStoreWithSelector에서 이루어지는데, 아래에서 보도록 하자.
React
아래는 react package이다.
// https://github.com/facebook/react/blob/main/packages/use-sync-external-store/src/useSyncExternalStoreWithSelector.js
// Same as useSyncExternalStore, but supports selector and isEqual arguments.
export function useSyncExternalStoreWithSelector(
subscribe,
getSnapshot,
getServerSnapshot,
selector,
isEqual,
) {
const instRef = useRef(null);
let inst;
if (instRef.current === null) {
inst = {
hasValue: false,
value: null,
};
instRef.current = inst;
} else {
inst = instRef.current;
}
const [getSelection, getServerSelection] = useMemo(() => {
let hasMemo = false;
let memoizedSnapshot;
let memoizedSelection;
const memoizedSelector = (nextSnapshot) => {
if (!hasMemo) {
hasMemo = true;
memoizedSnapshot = nextSnapshot;
const nextSelection = selector(nextSnapshot);
if (isEqual !== undefined) {
if (inst.hasValue) {
const currentSelection = inst.value;
if (isEqual(currentSelection, nextSelection)) {
memoizedSelection = currentSelection;
return currentSelection;
}
}
}
memoizedSelection = nextSelection;
return nextSelection;
}
const prevSnapshot = memoizedSnapshot;
const prevSelection = memoizedSelection;
if (is(prevSnapshot, nextSnapshot)) {
return prevSelection;
}
const nextSelection = selector(nextSnapshot);
if (isEqual !== undefined && isEqual(prevSelection, nextSelection)) {
memoizedSnapshot = nextSnapshot;
return prevSelection;
}
memoizedSnapshot = nextSnapshot;
memoizedSelection = nextSelection;
return nextSelection;
};
const maybeGetServerSnapshot =
getServerSnapshot === undefined ? null : getServerSnapshot;
const getSnapshotWithSelector = () => memoizedSelector(getSnapshot());
const getServerSnapshotWithSelector =
maybeGetServerSnapshot === null
? undefined
: () => memoizedSelector(maybeGetServerSnapshot());
return [getSnapshotWithSelector, getServerSnapshotWithSelector];
}, [getSnapshot, getServerSnapshot, selector, isEqual]);
const value = useSyncExternalStore(
subscribe,
getSelection,
getServerSelection,
);
useEffect(() => {
inst.hasValue = true;
inst.value = value;
}, [value]);
useDebugValue(value);
return value;
}
useSyncExternalStoreWithSelector는 아주 간단한 함수인데 전달한 subscribe(=subscription.addNestedSub)와 getSnapshot(=store.getstate)와 selector를 이용해 currentState를 반환하는 함수이다.
if (is(prevSnapshot, nextSnapshot)) {
return prevSelection;
}
한가지 주목할 점은 memoization하는 부분인데, getSelection은 useMemo에 의해서 memoization되는 함수이고, store의 snapshot(store.getstate)이 변하지 않았다면 prevSelection을 return한다. 이때 store의 snapshot이 변화하지 않은 상황은 action이 dispatch 된 상황이 아닌 react의 리렌더링에 의해 useSelector를 사용하는 컴포넌트가 재호출된 경우일 것이다.
const nextSelection = selector(nextSnapshot);
if (isEqual !== undefined && isEqual(prevSelection, nextSelection)) {
memoizedSnapshot = nextSnapshot;
return prevSelection;
}
다음으로 snapshot이 변화했다면, selector로 새로운 snapshot을 평가해 prevSelection와 달라졌다면 nextSelection을 반환한다.
따라서 action이 dispatch되면 snapshot이 달라지고, 등록된 모든 selector들을 실행하여 상태가 달라졌는지를 평가할 것이다. 이때 diffing함수는 기본값은 react-redux에서 전달한 '===' 함수로, 얕은 비교 연산이다.
여기서 주목할 점은 이 코드를 통해 redux store에 자주 변화하는 값을 전달하면 안되는 이유를 알게 된 점이다. action이 dispatch 될 때마다 useSelector를 통해 등록된 모든 selector들이 실행되어 값이 달라졌는 지를 평가해야 하기 때문이다.
또한 reselect를 사용하는 이유에 대해서도 알 수 있다. useSelector에서 useSyncExternalStoreWithSelector에 전달한 wrappedSelector는 usecallback으로 참조를 selector의 참조이다. selector를 만약 컴포넌트 내부에 선언하면, 리액트에 의해 컴포넌트가 리렌더링 되어 컴포넌트가 호출되고, selector의 참조가 달라져 wrappedSelector도 다시 생성될 것이다. usecallback을 쓰는 의미가 없게 된다. 또한 useSyncExternalStoreWithSelector에서 useMemo로 반환되는 getSelection에도 selector의 참조가 dep에 있기에, useMemo 내부의 콜백함수가 다시 실행되어 selector가 불필요하게 재실행된다. 이에 의한 리렌더링을 막기 위해 inst라는 ref가 있어 추가적인 리렌더링은 없을것이다.
리액트의 내부 상태가 변화하여 컴포넌트가 재호출하여 selector의 참조가 달라져, usecallback과 usememo가 무력화 되는 것을 막기 위해서는 selector를 컴포넌트 외부에 선언하면 되지만, selector가 컴포넌트 내부의 변수를 참조해야 한다면 그럴 수 없을것이다. 이럴때 reselect를 쓰면 된다.
마지막으로 react의 useSyncExternalStoreWithSelector의 구현부를 간단하게 살펴보도록 하자.
// packages/react-reconciler/src/ReactFiberHooks.js
function updateSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
) {
const fiber = currentlyRenderingFiber;
const hook = updateWorkInProgressHook();
let nextSnapshot = getSnapshot();
const prevSnapshot = (currentHook || hook).memoizedState;
const snapshotChanged = !is(prevSnapshot, nextSnapshot);
if (snapshotChanged) {
hook.memoizedState = nextSnapshot;
markWorkInProgressReceivedUpdate();
}
const inst = hook.queue;
updateEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [
subscribe,
]);
if (
inst.getSnapshot !== getSnapshot ||
snapshotChanged ||
(workInProgressHook !== null &&
workInProgressHook.memoizedState.tag & HookHasEffect)
) {
fiber.flags |= PassiveEffect;
pushSimpleEffect(
HookHasEffect | HookPassive,
createEffectInstance(),
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
null,
);
}
return nextSnapshot;
}
react의 hook은 mount시점과 update시점에 구현 함수가 달라지지만 update시점의 함수만 보도록 하자. 이 함수를 구체적으로 설명하는 것은 이 글의 범위를 벗어난다.
이 함수에서 주목할 부분은 두 부분이다. 첫째는 effect를 통해 subscribe함수를 실행시키는 부분(updateEffect)이다.
let nextSnapshot = getSnapshot();
const prevSnapshot = (currentHook || hook).memoizedState;
const snapshotChanged = !is(prevSnapshot, nextSnapshot);
if (snapshotChanged) {
hook.memoizedState = nextSnapshot;
markWorkInProgressReceivedUpdate();
}
두번째는 snapshot이 변하면 리액트에 렌더링을 요청(markWorkInProgressReceivedUpdate)하는 부분이다. snapshot은 useSyncExternalStoreWithSelector에서 useSyncExternalStore에 전달한 getSelection함수에 의해 생성된다(이때 snapshot은 store.getstae가 아닌 selector를 통해 derived한 state이다). is는 Object.is 함수로 참조를 비교하여 derived state의 변화를 판단하고, 변화했다면 리렌더링을 요청할 것이다.
'Library, Tool' 카테고리의 다른 글
| Redux의 기본 개념 (2) | 2024.11.29 |
|---|---|
| Redux Middleware와 dispatch의 관계 (0) | 2024.11.26 |
| TypeScript: type과 Interface의 차이 (4) | 2024.11.04 |
| Frontend에서의 Bundler (0) | 2024.09.25 |
| 웹 성능을 높일 수 있는 bundler plugins (2) | 2024.09.11 |