Redux 톺아보기

혹시 궁금해봤니❓

Redux를 사용하면서 다음과 같은 물음을 가진 경험이 있습니까?

  • 나는 액션을 dispatch만 하였는데 redux는 어떻게 알고 뒤에서 middleware를 실행하는 것 일까?
  • redux는 전달해준 액션을 어떻게 내가 원하는 reducer에 전달해주는 것일까?
  • reducer에서 반환하는 값을 왜 항상 새로 만들어야 하는 것일까? 순수함수는 무엇인가?
  • redux state가 변경되면 모든 컴포넌트가 re-render 되는가? 아니면 해당 변경된 데이터를 참조하고 있는 컴포넌트만 re-render 되는가? 후자라면 이 또한 어떻게 그 컴포넌트만을 찾아서 re-render 시키는가?

이런 물음을 한 적이 없다면 혹은 “뭐 뒤에서 알아서 해주겠지 이건 매직 코드야”라고 생각만 했다면 이참에 글쓴이와 함께 오픈소스를 분석하면서 이런 물음에 답을 찾음과 동시에 매직 코드에 대한 막연함, 두려움을 없애고 단순 라이브러리 개발자가 아닌 진짜 생각하는 개발자, 스스로 발전할 수 있는 개발자가 되기 위한 기초를 다질 수 있는 능력을 함께 길러갔으면 좋겠습니다.

해당 포스트에서 언급하는 코드들은 많은 부분이 생략된 코드 스니펫이므로 전체 코드는 Redux Github에서 확인해 보실 수 있습니다.

1. 나는 액션을 dispatch만 하였는데 redux는 어떻게 알고 뒤에서 middleware를 실행하는 것일까❓

들어가기에 앞서 하나 미리 짚어가야할 점은 middleware 형태는 다음과 같이 약속되어 있습니다.

const middleware = ({ dispatch, getState }) => (next) => (action) => {/* something.. */}

해당 섹션의 최종 목적은 위 middleware의 인자들이 언제 소비되는지 알아보는 것입니다.

위와 같은 형식을 Currying이라 부릅니다.
필요한 데이터를 클로저를 이용하여 각각의 의미가 있는 함수에 인자로 잡아두고 lazy execution하는 방식으로 자세한 설명은 넘어가지만, 반드시 따로 찾아보시기 바랍니다.

redux의 middleware는 어떻게 동작하는 것일까?
이 물음의 해답을 찾아가기 위해서는 middleware를 맨 처음 사용하는 applyMiddleware 함수를 먼저 볼 필요가 있습니다. 우리가 들여다봐야 할 코드는 다음 두 줄입니다.

function applyMiddleware(...middlewares){
  const middlewareAPI = {
    getState: store.getState,
    dispatch: (action, ...args) => dispatch(action, ...args)
  }
  const chain = middlewares.map(middleware => middleware(middlewareAPI))  dispatch = compose(...chain)(store.dispatch)  return {
    ...store,
    dispatch
  }
}

첫 시작은 middlewareAPI를 모든 middleware ({ dispatch, getState }) =>에 넘겨주어 redux의 핵심 기능을 middleware에서 사용 할 수 있도록 해줍니다.
redux-thunk의 경우 넘겨준 위 dispatch를 이용하여 비동기 처리를 합니다.

compose는 함수를 합성하는데 이는 javascript function이 first-class objects인 점을 활용한 방법으로 함수형에서 많이 사용합니다.

first-class

  • 변수에 할당할 수 있다.
  • 함수의 인자값으로 전달 할 수 있다.
  • 함수의 반환 값으로 반환할 수 있다.

또한 decorator패턴 입니다.

이 패턴은 변경에는 닫혀 있고 확장에는 열려 있는 원칙이 묻어 있는 패턴입니다. 기능 추가에 기존 코드 변경이 필요가 없으며 기능을 무한히 추가할 수 있습니다. middleware를 추가함에 있어 redux 코드에 전혀 영향이 가지 않고 변경 또한 필요하지 않습니다.

넘겨준 middleware가 compose안에서 어떻게 합성되는지 알아보겠습니다. 이런 방식이 익숙지 않은 분들은 머리가 조금 아플수도 있습니다. 하지만 하나씩 찬찬히 뜯어볼 생각이므로 포기하지 마시길 바랍니다.

function compose(...middlewares){
  return middlewares.reduce((a, b) => (...args) => a(b(...args))
}

코드는 간단해 보입니다.
여기서 middleware의 (next) =>가 소비됩니다. 예로 [a, b, c] middleware가 있다면 a(b(c()))의 형태로 만들어 주면서 next를 다음 middleware로 할당합니다.
compose가 끝난 형태는 (...args) => a(b(c(...args)))가 됩니다. 더 정확하게는 (...args) => f'(c(...args))이며 f’는 (c반환 값) => a(b(c반환 값))가 됩니다.

compose가 반환한 함수의 형태를 보면 실행하기 전까지 (next) =>가 소비되지 않는 형태입니다. compose(...chain)(store.dispatch)를 통해 store의 dispatch를 넘겨주는데 이때 맨 마지막 middleware의 next를 dispatch로 할당하면서 도화선처럼 나머지의 middleware (next) =>도 연달아 소비시킵니다.

c middleware의 next는 store의 dispatch인 걸 주목합니다. 즉 우리가 쓰는 dispatch는 middleware가 층층이 겹쳐져 있는 함수이지 redux의 dispatch가 아닙니다😲

그리고 우리가 dispatch를 이용하여 Action을 날릴 때 비로소 (action) =>가 소비 됩니다. 계속해서 next(action)를 전달하면 위에서 currying하여 잡아둔 다음 middleware의 (action) =>를 소비하게 되는 것이고 마지막 middleware의 next는 redux의 dispatch이기 때문에 최종적으로 redux에 action이 도달하게 됩니다.

누군가가 나에게 다음과 같은 질문을 한다고 생각해 봅시다.
“액션을 dispatch만 하였는데 redux는 어떻게 알고 뒤에서 middleware를 실행하는 건가요?”
네. 지금 보니 간단한 질문이네요

2. Redux는 전달해준 액션을 어떻게 내가 원하는 reducer에 전달해주는 것 일까❓

이번에도 역시 reducer가 가장 먼저 쓰이는 combineReducers 부터 들여다 보겠습니다.

function combineReducers(reducers) {
  const finalReducers = {}
  for (let i = 0; i < reducerKeys.length; i++) {
    const key = reducerKeys[i]
    if (typeof reducers[key] === 'function') {
      finalReducers[key] = reducers[key]
    }
  }
  const finalReducerKeys = Object.keys(finalReducers)

  return combination(){/* something.. */}
}

reducer의 최소 조건인 function을 검증하면서 key를 잡고 있습니다. 이는 추후에 reducer의 이름, 다시 말해 redux의 state 객체의 각 property 이름이 바로 combineReducers에 넘겨주는 객체의 key에 의해 정해집니다.

그리고 combination 함수를 반환해 줍니다. combineReducers 코드는 환경설정이라 할 수 있고 실 기능은 combination이 담당하고 있으며 이는 redux내부에서 사용되어 집니다. 이렇게 코드가 나누어져 있는 이유는 무엇일까요?

코드의 성질과 역할이 달라서 격리했다고 생각합니다. 역할별로 나누어서 한번 정해지면 변경될 일이 없는 부분과 요구 사항에 따라 추가 변경이 일어날 확률이 높고 주입되는 상태가 언제든지 변경될 수 있으며 재사용이 높은 부분을 나누었다고 볼 수 있습니다. 분명 이렇게 나누지 않았다면 변경될 확률이 낮은 코드들이 자주 변경되는 코드와 혼재되어 변경에 영향을 받는 코드의 범위가 커지면서 버그, 테스트, 유지보수 등 여러 면에서 좋지 않습니다. 이런 역할, 변화에 따른 격리는 여러 오픈소스를 보면 자주 보이는 형식으로 역할 모델별로 나누는 연습을 많이 해야 합니다.

그러면 이 함수가 사용되는 곳은 어디일까요? redux의 dispatch에서 사용됩니다. action을 dispatch에 담아 보내면 middleware -> redux dispatch -> reducer의 순서로 흐릅니다.

function combination(){
  let hasChanged = false
  const nextState = {}
  for (let i = 0; i < finalReducerKeys.length; i++) { // 1    const key = finalReducerKeys[i]
    const reducer = finalReducers[key]
    const previousStateForKey = state[key]
    const nextStateForKey = reducer(previousStateForKey, action) // 2    nextState[key] = nextStateForKey
    hasChanged = hasChanged || nextStateForKey !== previousStateForKey // 3  }
  hasChanged = hasChanged || finalReducerKeys.length !== Object.keys(state).length
  return hasChanged ? nextState : state
}

보이시나요? 모든 reducer를 돌면서 state와 action을 던져줍니다(1, 2). 그리고 변경이 적용되기 위해선 객체를 새로 만들어야 하는 이유도 보입니다(3). 자신이 처리하지 않는 action의 경우 그저 default(switch의 경우)의 반환 값이 반환될 것입니다. 그다음 변경 여부는 값이 아닌 reference 비교로 결정됩니다.
“reference로 변경 여부를 결정 하므로 항상 새로운 객체를 반환해야 해!”라고 여기까지만 생각하고 개발하는 것은 2%가 부족합니다.
“왜?”가 빠져있습니다. redux는 왜 직접 데이터를 변경하지 않고 새로운 객체를 만들까? 그게 더 비용이 들지는 않을까? 여기에 대한 대답은 순수함수에서 스스로 찾아보세요!

순수함수를 쓰는 이유 중 하나는 객체의 투 포인터 참조에서 오는 데이터 공유 때문 입니다. 단순 변수만을 참조하는 원 포인터 참조는 기존 데이터 변경에 영향을 받지 않습니다.

let foo = {};
const bar = foo;
foo = {};
bar === foo // false;

하지만 투 포인터 참조를 할 경우 그렇지 않습니다.

let foo = ["f","o","o"];
let bar = foo;
const reverse = arr => arr.reverse();
reverse(foo);
bar[0] === "f" // false

당연한 얘기 같습니다. 여기서 중요한 점은 foo를 여러 곳에서 물고 있으면 foo의 데이터 변경이 어느 곳에 여파를 미치는지 예측할 수 없습니다. 하지만 새로운 객체를 만들어 데이터 변경을 적용하게 되면 기존에 foo를 물고 있던 곳에 영향을 주지 않습니다. 더불어 그 변경의 여파는 새로운 데이터를 반환받아 적용한 곳부터 시작하기 때문에 추적 또한 용이합니다.

“redux는 전달해준 액션을 어떻게 해당 액션을 처리하는 reducer에 전달해주는 것일까?”
여기에 대한 대답은 해당 action을 처리하는 reducer에게만 넘겨주는 게 아니라 모든 reducer에게 액션을 던진다입니다.
또한 “reducer에서 반환하는 값을 왜 항상 새로 만들어야 하는 것일까? 순수함수는 무엇인가?”
여기에 대한 대답도 할 수 있겠네요😀

3. redux state가 변경되면 모든 컴포넌트들이 re-render되는가❓

마지막 섹션입니다. 코드는 react-redux에서 확인해 보실 수 있습니다. 해당 부분은 class와 funcitonal component에 따라 봐야할 부분이 다르기 때문에 최근에 대두되는 hook을 기준으로 분석해 보겠습니다.

해당 섹션은 hook을 중심으로 이야기 합니다. hook을 모르신다면 먼저 Hooks를 보고오시기 바랍니다.

어디서 부터 접근해야 할 까요? class의 경우 component와의 접점인 connect부터 접근했겠지만 hook은 그런 부분이 없습니다. 그렇다면 그나마 데이터에 접근하는 useSelector가 가장 유력해 보입니다.

function useSelector(selector, equalityFn = refEquality) {
  const { store, subscription: contextSub } = useReduxContext()

  return useSelectorWithStoreAndSubscription(
    selector,
    equalityFn,
    store,
    contextSub
  )
}

useSelector의 두 번째 인자로 비교 함수를 전달해줄 수 있습니다. 기본적으로 shallow compare이며 성능 최적화 등 특정 케이스에서 유용하게 사용될 수 있기 때문에 알아두면 좋습니다.
useReduxContext는 context API를 이용하여 redux의 store와 observer 패턴 을 구현한 Subscription 객체를 전달받습니다. 요놈들의 데이터를 넣어주는 context는 어디에 있느냐? 바로 우리가 쓰는 Provider 컴포넌트에 있습니다.

function Provider({ store, context, children }) {
 // 생략..
 const subscription = new Subscription(store)
 const Context = context || ReactReduxContext
 return <Context.Provider value={{store, subscription}}>{children}Context.Provider>
}

Context는 링크를 통해 정확히 알고 가는 게 좋습니다. 활용도가 매우 다양하며 여러분들이 알게 모르게 라이브러리를 통해 이미 쓰고 있어서 분석에 많은 도움이 됩니다.

useSelector의 useSelectorWithStoreAndSubs를 살펴보기에 앞서 위에 언급한 Subscription을 먼저 봐야 합니다. redux store의 state가 변경되면 redux는 자신을 subscribe하고 있는 listener들을 전부 실행 시켜 줍니다. react-redux는 react와 redux를 연결하기 위해 observable, observer 두 가지 역할을 가진 Subscription을 구현하여 redux 상태 변경을 subscribe 함과 동시에 자신을 subscribe하고 있는 listener를 가지고 있습니다.

class Subscription {
  // 생략..
  handleChangeWrapper() {
    if (this.onStateChange) {
      this.onStateChange()
    }
  }

  trySubscribe() {
    if (!this.unsubscribe) {
      this.unsubscribe = this.store.subscribe(this.handleChangeWrapper)
      // 생략..
    }
  }
  // 생략..
}
// usage
const subscription = new Subscription();
subscription.onStateChange = checkForUpdates
subscription.trySubscribe()

trySubscribe를 통해 store를 subscribe하고 store의 state가 변경되면 onStateChange를 실행해 줍니다. 계층 구조 전파를 위한 코드들이 빠져 있는 스니펫이므로 궁금하시면 직접 확인해 보시기 바랍니다.
드디어 핵심인 useSelectorWithStoreAndSubscription를 살펴보기 위한 준비과정이 끝났습니다.

function useSelectorWithStoreAndSubscription() {
  const [, forceRender] = useReducer(s => s + 1, 0)  const latestSelector = useRef()
  const latestSelectedState = useRef()
  // 생략..
}

forceRender는 component를 강제로 re-render시키기 위한 방법중 하나 입니다. useState를 사용할 수도 있습니다. 그리고 selector와 그 결괏값인 state를 memoizing 하기 위해 변수를 할당합니다.

function useSelectorWithStoreAndSubscription() {
  // 생략..
  if (
    selector !== latestSelector.current ||    latestSubscriptionCallbackError.current
  ) {
    selectedState = selector(store.getState())
  } else {
    selectedState = latestSelectedState.current
  }

  useIsomorphicLayoutEffect(() => {
    latestSelector.current = selector
    latestSelectedState.current = selectedState
  })
  // 생략..
}

강조된 부분을 봤을 때 어떤 생각이 드시나요?
“잘못 사용하면 ref의 memoization 이점을 전혀 못 누리겠구나” 라고 생각이 드시나요?

useSelector(state => state.foo);

대부분 이렇게 사용하실 겁니다. foo를 select 하는 callback은 항상 새로 만들어지는 함수입니다.
그렇기 때문에 selector !== latestSelector.current는 항상 true가 될 것이고 컴포넌트가 re-render 될 때마다 selector는 매번 실행될 겁니다. 간단한 selector의 경우별 차이가 없겠지만 복잡한 연산이 필요한 selector의 경우 불필요한 연산을 추가로 하게 됩니다.

const selectFoo = state => state.foo;
function Component() {
  useSelector(selectFoo);
}

Component가 아무리 re-render 돼도 selectFoo는 항상 같기 때문에 이미 구해놓은 latestSelectedState.current를 사용할 수 있습니다.

selector가 항상 이렇게 간단하지만은 않죠? 외부 인자에 의존할 경우 다른 방법을 강구해야 합니다. 이와 관련하여 이미 아주 좋은 라이브러리(reselect)가 있습니다. 이참에 reselect가 어떻게 결괏값을 memoize 하는지 알아보는 것부터 시작하는 것도 좋습니다!

function useSelectorWithStoreAndSubscription() {
  // 생략..
  function checkForUpdates() {
    const newSelectedState = latestSelector.current(store.getState())

    if (equalityFn(newSelectedState, latestSelectedState.current)) {
      return
    }

    latestSelectedState.current = newSelectedState
    forceRender({})
  }
  // 생략..
}

checkForUpdates에서는 selector를 실행하고 값이 변경되었는지 확인 후 변경된 state를 memoizing 하고 forceRender를 통해 강제로 컴포넌트를 re-render 시킵니다.

function useSelectorWithStoreAndSubscription() {
  // 생략..
  function checkForUpdates() {/* 생략.. */}
  subscription.onStateChange = checkForUpdates
  subscription.trySubscribe()

  checkForUpdates()
  return selectedState
}

trySubscribe를 통해 store의 state 변경을 감지하고 변경 될 경우 checkForUpdates를 실행하기 위해 onStateChange에 할당합니다.

다시 정리하자면 redux는 store의 state가 변경되면 모든 subscriber를 실행합니다. 컴포넌트에서 useSelector를 실행하면 store를 subscribe하게 됩니다. useSelector를 통해 등록한 checkForUpdates에서 selector가 반환한 값과 memoized 된 값을 비교하여 변경된 경우 해당 컴포넌트를 re-render 시키고 그렇지 않을경우 re-render없이 checkForUpdates 함수만 실행되는 것입니다.

“redux state가 변경되면 모든 컴포넌트가 re-render 되는가?”
그렇지않다. useSelector를 사용한 컴포넌트 중 selector에서 반환한 값이 변경된 component만 re-render된다 입니다.

드디어 다 끝났습니다.
글로 풀어 쓸려니 많아 보이고 복잡해 보이지만 코드만 보면 별거 없습니다. 여러분이 익숙지 않아서일 수도 있고 제가 글솜씨가 드럽게 없어서 그럴지도 모릅니다. 하지만 분명한 건 우리가 쓰는 모든 라이브러리는 모두가 다 아는 Javascript로 구현되어 있고 매직 코드란 없다는 겁니다.
라이브러리를 개발하지는 못하더라도 적어도 우리가 쓰는 라이브러리가 대충 어떻게는 돌아가는지 알고 있어야 덜 기분 나쁘지 않겠습니까?
이만~🖐🏻


오픈소스를 톺아보며 매직 코드라 생각했던 부분들의 동작 원리와 의미, 의도를 파악해보고 서로의 생각을 나누기 위한 블로그