[Redux] 리덕스의 내부 파헤치기

Suh, Hyunwook·2022년 1월 30일
0

리덕스의 원칙은 상태관리를 하는 스토어는 하나라는 것, 상태는 읽기 전용, 즉 상태에 변화를 가할 수 없다는 것, 순수함수인 리듀서를 통해 변화시킨다는 것입니다.

읽기 전용이지만, 리듀서를 통해 참조가 아예 다른 다른 상태를 반환한다는 점에서 다릅니다. 새로운 상태로 갱신이 되지만, 이전 상태에 덮어씌우기를 하지는 않는다는 뜻일 것입니다. 이 말 뜻은 곧, 이전 상태와의 비교가 가능하다는 뜻이기도 합니다. 실제로 리덕스 미들웨어인 logger를 통해 보면, 새로운 상태 및 이전상태를 쉽게 비교할 수 있습니다.

스토어

function createStore(reducer, preloadedState) {
  let state = preloadedState 
  const listeners = []

  function getState() {
    return state
  }

  function subscribe(listener) {
    listeners.push(listener)
    return function unsubscribe() {
      const index = listeners.indexOf(listener)
      listeners.splice(index, 1)
    }
  }

  function dispatch(action) {
    state = reducer(state, action)
    listeners.forEach(listener => listener())
  }

  dispatch({ type: '@@redux/INIT' })

  return { dispatch, subscribe, getState }
}

스토어는 리덕스 내장함수인 createStore를 통해 만들 수 있는데, 첫번째 인자로 reducer를 받고, 두번째 인자로 preloaded state를 받는데, 두번째 인자의 경우 로컬스토리지 또는 서버에서 받은 초기 데이터로서 리덕스에 넣을 수 있는 값으로서 필수값은 아닙니다.

먼저 리턴값을 보면 구조를 보기 쉬운데, store는 즉 dispatch, subscribe, getState라는 메서드로 이뤄진 객체입니다. 이 메소드들을 통해 클로저로 있는 state를 관리하게 됩니다 (let state = preloadedState (또는 null))

간단히 메소드를 보면,다음과 같습니다.

  • getState: 가장 마지막으로 업데이트된 상태를 리턴해줌
  • subscribe: dispatch될 때 실행될 이벤트 리스너들을 등록하고, 함수를 반환하는데 이 함수는 등록할 리스너를 해제. 이 경우 리스너는 클로저 처리가 되어있음.
  • dispatch: 기존 상태와 액션을 받아, 새로운 상태를 반환받으며, 리스너들을 실행함.

미들웨어

createStore에는 세번째 인자로 enhancer라는 인자를 하나 더 받을 수 있는데, 이것이 리덕스 미들웨어입니다. 물론 인자로 취하는 것 외에, 랩핑 후 createStore를 리턴하는 방법도 있습니다.

const enhancer = createStore => {
  return createStore(reducer, initialState, enhancer);
}

const createStoreEnhanced = enhancer(createStore)
const enhancedStore = createStoreEnhanced(reducer, initialState)

// 또는 아래처럼 enhancer를 인자로 사용할 수도 있다.

const enhancedStore = createStore(reducer, initialState, enhancer)

미들웨어는 스토어 메소드를 대체하거나 보강하거나 하는데, 실제로는 dispatch 함수에 관한 사항이 많습니다. 예를 들면 dispatch logging을 하거나, try-catch 에러핸들링을 하기도 합니다. 마치 express에서 기능을 추가하기 위해 npm을 다운받아, 써드파티 미들웨어를 끼우는 것과 비슷한 것 같습니다.

미들웨어 구조는 아래와 같습니다.

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

위 구조가 나오게 된 계기는 아래와 같습니다. 예를 들어, 로깅을 하는 경우, 미들웨어 사용 전에는 액션 리듀싱하기 전 앞 뒤로 콘솔을 찍어야합니다.

const action = addTodo('Use Redux')

console.log('dispatching', action)
store.dispatch(action)
console.log('next state', store.getState())

그러나, 디스패치 함수마다 콘솔로그를 찍어보는 것은 매우 번거로운 일입니다. 그래서 유틸함수로 빼서 처리하는 방법이 고안되었습니다.

function dispatchAndLog(store, action) {
	console.log('dispatching', action);
	store.dispatch(action);
    console.log('next state', store.getStore());
}

이를 통해 dispatchAnd(store, addTodo('Use Redux'))와 같이 간결하게 사용할 수는 있게 되었습니다. 그러나, 매번 유틸을 import하기도 번거러운 일입니다. 이에, 아예 dispatch 함수를 바꿔버리자라는 생각으로 도달하게 됩니다 (이를 공식문서에서는 몽키패칭이라고합니다).

const next = store.dispatch
store.dispatch = function dispatchAndLog(action) {
  console.log('dispatching', action)
  let result = next(action)
  console.log('next state', store.getState())
  return result
}

그러나, 한 개 이상의 미들웨어를 꽂고 싶다면 어떻게 해야할까요? 예를 들면 에러핸들링하는 미들웨어 같은 것을 추가할 수 있을 것입니다. 이상적으로는 모듈 분리를 하는게 젤 좋지만, 이것 역시 쉽지는 않습니다.

function patchStoreToAddLogging(store) {
  const next = store.dispatch
  store.dispatch = function dispatchAndLog(action) {
    console.log('dispatching', action)
    let result = next(action)
    console.log('next state', store.getState())
    return result
  }
}

function patchStoreToAddCrashReporting(store) {
  const next = store.dispatch
  store.dispatch = function dispatchAndReportErrors(action) {
    try {
      return next(action)
    } catch (err) {
      console.error('Caught an exception!', err)
      Raven.captureException(err, {
        extra: {
          action,
          state: store.getState()
        }
      })
      throw err
    }
  }
}

몽키패칭을 하면서 로깅함수 다음에 에러핸들링 함수를 적용한다고 하면, 먼저 store.dispatch가 몽키패칭에 의해서 dispatchAndLog로 변합니다. 다음에 에러핸들링 함수를 적용할 때에는, dispatchAndReportErrors 함수까지 포함되는 dispatch 함수로 변하게 되는 것이죠. 즉 앞에서 봤던 enhancedStore가 되는 것입니다. 순조롭습니다. 그러나 몽키패칭은 함수를 변경시킨다는 점에서 API의 본래 취지에 어긋납니다. 그래서 나오게 된 게 새로운 dispatch함수를 리턴하는 방법입니다.

function logger(store) {
  const next = store.dispatch

  // Previously:
  // store.dispatch = function dispatchAndLog(action) {

  return function dispatchAndLog(action) {
    console.log('dispatching', action)
    let result = next(action)
    console.log('next state', store.getState())
    return result
  }
}

새로운 함수를 리턴하고, next 함수는 클로저에 의해 baseDispatch함수를 참조하게 되었습니다. 그럼 리턴되는 함수를 받아 enhancing을 도와주는 대상은 누구일까요?? 이 시점에서 helper함수가 도입되는데, 이는 아래와 같습니다.

function applyMiddlewareByMonkeypatching(store, middlewares) {
  middlewares = middlewares.slice()
  middlewares.reverse()

  // Transform dispatch function with each middleware.
  middlewares.forEach(middleware => (store.dispatch = middleware(store)))
}

//사용례: applyMiddlewareByMonkeypatching(store, [logger, crashReporter])

그러나 변한 것이 있나요? 여전히 store.dispatch에 enhanced dispatch를 할당하는 몽키패칭이 계속되고 있습니다.

function logger(store) {
  // Must point to the function returned by the previous middleware:
  const next = store.dispatch

  return function dispatchAndLog(action) {
    console.log('dispatching', action)
    let result = next(action)
    console.log('next state', store.getState())
    return result
  }
}

그럼 store.dispatch를 몽키패칭(할당을 통한 변경)을 하지 않고도, 어떻게 체이닝을 할 수 있을까요??

function logger(store) {
  return function wrapDispatchToAddLogging(next) {
    return function dispatchAndLog(action) {
      console.log('dispatching', action)
      let result = next(action)
      console.log('next state', store.getState())
      return result
    }
  }
}

이를 위해, store 인스턴스에 읽는 것 대신 next라는 dispatch function을 인자로 받는 방법이 있습니다. 아래는 es6 버전의 화살표 함수를 사용한 미들웨어 구조입니다.

const logger = store => next => action => {
  console.log('dispatching', action)
  let result = next(action)
  console.log('next state', store.getState())
  return result
}

const crashReporter = store => next => action => {
  try {
    return next(action)
  } catch (err) {
    console.error('Caught an exception!', err)
    Raven.captureException(err, {
      extra: {
        action,
        state: store.getState()
      }
    })
    throw err
  }
}

그래서 위의 미들웨어 구조를 적용하면, 다음과 같게 적용할 수 있습니다. 공식문서의 warning과 같이 이해를 돕기 위한 예시일 뿐입니다.

// Warning: Naïve implementation!
// That's *not* Redux API.
function applyMiddleware(store, middlewares) {
  middlewares = middlewares.slice()
  middlewares.reverse()
  
  let dispatch = store.dispatch
  
  middlewares.forEach(middleware => (dispatch = middleware(store)(dispatch)))
  
  return Object.assign({}, store, { dispatch })
}

스토어와 갱신되가는 dispatch 함수를 인자로 넘기면, store는 불변성을 유지하면서 (물론 여기서는 store.getState())만 사용하고 있지만..), 모든 미들웨어가 참조할 수 있으며, dispatch는 계속해서 enhance 되는 구조가 됩니다. 물론 위 구조는 실제 applyMiddleware와는 다르며, 실제로는 아래와 같습니다.

function applyMiddleware(...middlewares) {
  // applyMiddleware는 기존 createStore의 고차 함수를 반환한다.
  return (createStore) => (reducer, preloadedState, enhancer) => {
    const store = createStore(reducer, preloadedState, enhancer)
    let dispatch = store.dispatch
    let chain = []

    // 미들웨어들에게 인자로 전달되는 객체이다.
    // dispatch가 단순히 store.dispatch의 참조를 전단하는 것이 아니라,
    // 함수를 한번 더 감싸 사용하는 점을 기억하자.
    const middlewareAPI = {
      getState: store.getState,
      dispatch: (action) => dispatch(action)
    }

    // 미들웨어들이 반환하는 체인 함수들(= wrapDispatch)을 가져온다.
    chain = middlewares.map(middleware => middleware(middlewareAPI))

    // 미들웨어가 반환하는 체인 함수들을 중첩시킨 후 새로운 dispatch 함수를 만든다.
    dispatch = compose(...chain)(store.dispatch)

    // applyMiddleware를 통해 반환된 createStore 고차 함수는
    //  기존 스토어와 동일한 API, 그리고 새로 만들어진 dispatch 함수를 반환한다.
    return {
      ...store,
      dispatch
    }
  }
}

위 예시에서는 ...store, dispatch로 이뤄진, 객체를 반환하나, 일반적으로는 createStore의 3번째 인자로 꽂아서 적용하기도 합니다.

0개의 댓글