앞선 아티클에서 Redux의 흐름을 소개할때는 UI 라이브러리를 사용하지 않고 바닐라 자바스크립트를 이용해 UI를 구현하였습니다. 하지만 일반적으로 UI를 구현할때는 바닐라 자바스크립트가 아닌 라이브러리(React, Vue 등)를 사용하기 때문에 UI 라이브러리와 Redux를 함께 사용하는 케이스를 분석할 필요가 있습니다.
이번 아티클에서는 가장 보편적인 UI 라이브러리인 React와 Redux를 함께 사용할때 어떻게 Redux의 변경사항이 전파되는지, 최적화는 어떤방식으로 이루어지는지 살펴보겠습니다.
React의 기본적인 내용은 이해하고 있어야 아티클을 쉽게 읽을수 있으므로 React를 많이 사용해보지 않으셨다면 React tutorial를 참고해보세 요.
직접 UI 바인딩하기
이전 아티클에서 살펴본 내용중에서 UI 바인딩과 관련된 내용을 간단하게 요약하면 "HTML 렌더링을 책임지는 render함수가 store를 구독함으로써 store가 변경될때마다 render함수가 실행되어 최신의 상태가 UI에 반영된다" 였습니다. 코드로 정리해보면 아래와 같습니다.
// 1. 스토어 생성
const store = createStore(counterReducer)
// 2. render 함수가 store 구독
store.subscribe(render)
// 3. render 함수
function render() {
const state = store.getState()
document.getElementById("value").innerHTML = state.value.toString()
}
// 4. render실행
render()
// 5. html 요소에 이벤트 리스너 등록
document.getElementById("increment").addEventListener("click", function () {
store.dispatch({ type: "counter/incremented" })
})
바닐라 자바스크립트의 논리를 React에 적용한다면 React의 컴포넌트가 store를 구독하고 store가 변경될때 컴포넌트가 리렌더링 되도록 하는것이므로 어렵지 않게 React와 Redux를 연결할수 있을것으로 보입니다.
다만 바닐라 자바스크립트와 달리 React는 일반적으로 html을 변경하는 렌더링을 개발자가 원하는 타이밍에 직접 수행할수 없으므로(클래스형 컴포넌트의 render
메서드나 함수형 컴포넌트 자체를 우리가 실행하지 않는다는것을 생각해보세요) 렌더링을 제어하는 방법을 알아야합니다.
클래스형의 경우 강제로 렌더링을 시켜주는 forceUpdate
메서드가 존재하며 함수형의 경우 클래스형의 forceUpdate
같은 훅은 없지만 useState
훅을 적절하게 사용해 개발자가 원하는 타이밍에 렌더링을 실행할수 있습니다.
간단한 예시로 클래스형 컴포넌트를 사용하여 React에 Redux를 바인딩 해보겠습니다. 렌더링을 책임지는 forceUpdate
메서드를 store
에 구독시키기만 하면 store
가 변경될때마다 forceUpdate
메서드가 실행되면서 컴포넌트가 리렌더링 되어 최신의 상태가 UI에 반영됩니다.
class App extends React.Component {
componentDidMount() {
this.unsubscribe = store.subscribe(this.handleChange.bind(this))
}
componentWillUnmount() {
this.unsubscribe()
}
handleChange() {
this.forceUpdate()
}
increaseCount() {
store.dispatch({ type: "counter/incremented" })
}
render() {
return (
<div>
<div>{store.getState().count}</div>
<button onClick={this.increaseCount}>+</button>
</div>
)
}
}
하지만 이러한 코드를 모든 컴포넌트에 적용하면 코드 중복과 실수로 인한 누락을 발생시킬수 있으므로 일반적으로 UI와 Redux의 바인딩을 도와주는 라이브러리를 사용합니다. 대표적으로 React와 Redux를 연결하는 React-Redux가 있습니다. 이러한 라이브러리들은 최적화와 같은 작업도 해주기 때문에 사실상 필수적으로 사용하게됩니다.
이제부터 가장 많이 사용하는 UI 바인딩 라이브러리인 React-Redux를 분석해보면서 UI 바인딩 라이브러리가 어떻게 Redux와 UI를 연결하는 로직을 공통화했는지, 리렌더링과정을 어떻게 최적화 했는지 살펴보겠습니다.
react-redux 분석
초기 React-Redux와 달리 현재 버전(9.3.0)은 클래스형과 함수형 컴포넌트를 모두 지원하지만 이번 아티클의 목표는 React-Redux의 코어기능을 살펴보는것이므로 함수형 컴포넌트에서 사용하는 방식만 살펴보겠습니다. 여유가 되신다면 클래스형 컴포넌트도 살펴보시는것을 추천드립니다. 라이브러리가 원래 클래스형에서 출발했던 만큼 많은 것을 얻을수 있을것입니다.
예제코드
본격적인 분석에 들어가기에 앞서, React와 React-Redux, Redux를 같이 사용하는 예제 코드를 살펴보겠습니다.
// reducer 함수
function counterReducer(state = { value: 0 }, action) {
switch (action.type) {
case "counter/incremented":
return { ...state, value: state.value + 1 }
default:
return state
}
}
// store 생성
const store = createStore(counterReducer)
// root 컴포넌트
export function App() {
const count = useSelector(state => state.counter.value)
const dispatch = useDispatch()
return (
<div>
<div>
<button
aria-label="Increment value"
onClick={() => dispatch(increment())}
>
Increment
</button>
<span>{count}</span>
<button
aria-label="Decrement value"
onClick={() => dispatch(decrement())}
>
Decrement
</button>
</div>
</div>
)
}
const root = ReactDOM.createRoot(document.getElementById("root"))
// store가 포함된 Provider로 감싼 root 컴포넌트 렌더링
root.render(
<Provider store={store}>
<App />
</Provider>
)
root 컴포넌트를 store를 인자로 받는 Provider
컴포넌트로 감싸고 있으며 컴포넌트에서는 useDispatch
훅에서 dispatch
함수를 가져오고 useSelector
훅에서 상태를 가져오는 동작만으로 컴포넌트가 스토어를 구독하여 최신의 상태를 받아올 수 있음을 알 수 있습니다. 어떻게 이러한 일이 일어나는지 알아내기 위해 Provider
컴포넌트, useDispatch
훅, useSelector
훅을 하나씩 분석해보겠습니다.
Provider
React-Redux를 사용하기 위해서는 root 컴포넌트를 React-Redux에서 제공하는 Provider
컴포넌트로 감싸주어야 하며 이때 createStore
함수로 생성한 store
를 넘겨야합니다. 이 컴포넌트는 어떤 역할을 하고 있기에 반드시 이러한 작업을 해야할까요? 의문을 풀기위해 Provider
컴포넌트를 살펴보겠습니다.
function Provider({ store }) {
const subscription = createSubscription(store) // 스토어 구독과 관련된 기능 제공
const contextValue = { store, subscription }
const Context = React.createContext(null)
return <Context.Provider value={contextValue}>{children}</Context.Provider>
}
중요한 코드만 남기면 Provider
컴포넌트는 단순히 React Context Api의 Provider 역할을 하고있습니다. 그런데 이렇게 생성한 Provider의 context는 어떻게 사용하게 될까요? 이는 useReduxContext 함수를 살펴보면 쉽게 이해할수 있습니다.
function useReduxContext() {
return React.useContext(context)
}
useContext
를 사용하여 컨텍스트내부의 값을 가져오는 역할을 하는 간단한 코드입니다. 여기서 인자내부에 있는 context
는 Provider
컴포넌트에서 생성한 context
라고 생각하면되겠습니다. 개발자가 직접 사용할일은 없지만 useDispatch
훅, useSelector
훅에서 내부적으로 이용하게 됩니다.
결국 Provider
컴포넌트로 감싸지 않으면 store를 컴포넌트 내부에서 제공받을수 없으므로, React-Redux를 사용하기 위해서는 반드시 root 컴포넌트를 Provider
컴포넌트로 감싸주어야합니다.
useDispatch
dispatch
함수를 리턴하는 useDispatch
훅을 살펴보겠습니다.
const useDispatch = () => {
const { store } = useReduxContext()
return store.dispatch
}
useReduxContext
훅을 이용해 store
를 가져온뒤 store
의 dispatch
를 반환하는 훅으로 이름과 같이 dispatch
함수를 반환하는 기능 외에 별다른 특이점은 없습니다.
useSelector
Provider
컴포넌트, useDispatch
훅에서 컴포넌트 구독 및 리렌더링 관련 로직을 찾지 못했으므로 useSelector
훅에 이러한 로직이 있을것임을 짐작해 볼 수 있습니다. useSelector
훅은 인자로 상태를 인자로 받아 원하는 값을 리턴하는 셀렉터함수와 동등 비교시 사용하는 비교함수를 넘길수 있습니다.
const useSelector = (
selector,
equalityFnOrOptions,
) => {
const equalityFn = equalityFnOrOptions || ((a, b) => a === b)
const {
store,
subscription,
} = useReduxContext()
const wrappedSelector = React.useCallback<typeof selector>(
(state) => {
return selector(state)
},
[selector],
)
const selectedState = useSyncExternalStoreWithSelector(
// store.subscribe 함수와 비슷한 기능을 합니다.
subscription.addNestedSub,
store.getState,
wrappedSelector,
equalityFn,
)
return selectedState
}
실제로 useSelector
훅 자체에는 별다른 코드가 없습니다. useReduxContext
훅을 이용해 store
와 subscription
를 가져온뒤, useSyncExternalStoreWithSelector
함수에 store
, subscription
, selector
,
,equalityFn
를 넘겨 실행한뒤 선택된 상태를 받아오고 있기 때문에 중요한 로직이 useSyncExternalStoreWithSelector
함수에 있음을 알 수 있습니다.
export function useSyncExternalStoreWithSelector(
subscribe,
getSnapshot,
selector,
isEqual
) {
const getSelection = useMemo(() => {
let hasMemo = false
let memoizedSnapshot
let memoizedSelection: Selection
const memoizedSelector = (nextSnapshot: Snapshot) => {
// 처음 Selector 실행시 초기화 하는 로직입니다.
if (!hasMemo) {
hasMemo = true
memoizedSnapshot = nextSnapshot
memoizedSelection = selector(nextSnapshot)
return memoizedSelection
}
// 이전 상태를 재사용할수 있다면 재사용 하기위해 변수에 할당합니다.
const prevSnapshot = memoizedSnapshot
const prevSelection = memoizedSelection
// 이전 상태와 다음 상태를 얕은 비교하여 같은경우 이전 selection 결과를 그대로 리턴합니다.
if (is(prevSnapshot, nextSnapshot)) {
return prevSelection
}
// seletor 함수를 실행해 새로운 selection을 만듭니다.
const nextSelection = selector(nextSnapshot)
// 비교함수가 있는경우 이를 이용해 Selection을 비교한뒤 같다면 이전 Selection을 내보냅니다.
if (isEqual !== undefined && isEqual(prevSelection, nextSelection)) {
return prevSelection
}
//예외 조건에 걸리지 않았다면 스냅샷 결과를 저장합니다.
memoizedSnapshot = nextSnapshot
memoizedSelection = nextSelection
return nextSelection
}
// 앞서 살펴본 Selector 함수를 실행하는 함수를 리턴합니다.
return () => memoizedSelector(getSnapshot())
}, [getSnapshot, selector, isEqual])
const value = useSyncExternalStore(subscribe, getSelection)
return value
}
해당 함수에서 중요한 코드는 가장 마지막줄에서 실행하는 useSyncExternalStore
함수입니다. 이 함수는 subscribe
함수와 getSelection
함수를 넘기면 store
가 변경되었을때 getSelection
함수를 실행해 최신의 선택된 상태를 반환해줍니다. 이 함수는 아래에서 다시 살펴볼 예정이므로 동작만 이해한뒤 인자로 넘기는 getSelection
함수를 살펴보겠습니다.
getSelection
함수의 초기화 로직(if (!hasMemo)
부분)을 지나 첫번째 조건문을 살펴보면 Object.is
를 호출하여 상태의 참조값이 변경되었는지를 확인한뒤 변경되지 않았다면 이전에 선택된 상태값을 반환합니다. 이전에 살펴보았던 combineReducers
함수에서의 동작과 매우 유사합니다. 하지만 이러한 동작이 불변성을 지키지 않았을때 문제를 발생시킬지는 지금 여기서 판단할수 없습니다. 이러한 로직이 있음을 기억해두고 일단 넘어가겠습니다.
두번째 조건문에서는 equal
함수가 있는경우 equal
함수를 이용해 선택된 전후 상태값을 비교한뒤 true
를 반환하였다면 이전에 선택한 상태값을 반환합니다. 만약 false
를 반환하는경우 상태 스냅샷과 선택된 상태를 저장하고 선택된 상태값을 반환합니다.