thumbnail
기술아티클
Redux Deep Dive 4. Middleware(feat. redux-thunk)
Redux에서 Middleware가 어떤 방식으로 동작하는지 살펴봅니다.
November 13, 2024

이번 아티클에서는 dispatch함수를 호출하면 비동기 API를 호출하거나 로깅을 수행하는 middleware 대해서 살펴보겠습니다. middleware가 어떻게 동작하는지, 실제 middleware가는 어떻게 만드는지 살펴볼것이므로 이번 아티클을 읽고나면 middleware 오픈소스(redux-thunk, redux-saga등)의 분석뿐만 아니라, 나만의 middleware도 만들수 있을것입니다.

본격적으로 middleware를 살펴보기에 앞서서 이전 아티클에서 createStore를 분석할때 생략했던 enhancer를 설명하는것으로 이번 아티클을 시작해보겠습니다.

Enhancer

일반적인 createStore 함수 사용
일반적인 createStore 함수 사용

앞서 살펴본것처럼 storecreateStore함수에 reducerpreloadedState를 넣어 실행하면 얻을 수 있습니다. 그리고 이렇게 얻은 storegetState, dispatch, subscribe함수를 이용해 상태를 관리 할 수 있습니다.

storeEnhancer가 포함된 createStore 함수 사용
storeEnhancer가 포함된 createStore 함수 사용

그런데 createStore함수는 세번째 인수로 createStore함수로 전달하는 인자나 반환하는 함수들의 동작을 수정할 수 있도록 해주는 enhancer함수를 받을 수 있습니다.

// 실제 createStore 함수내부
if (typeof enhancer === "function") {
  return enhancer(createStore)(reducer, preloadedState)
}

실제로 creatStore함수가 enhancer함수를 받을 경우 위와 같이 enhancer함수에 createStore함수를 넣어 실행한뒤 반환된 강화된 creatStore함수에 기존 인자를 넣어 실행한뒤 반환하는것을 볼 수 있습니다.

실제 createStore함수의 타입 정의를 살펴보면, preloadState는 생략이 가능하므로 두번째 인자로 enhancer함수를 전달해 줄수도 있습니다.

export function createStore<
  S,
  A extends Action,
  Ext extends {} = {},
  StateExt extends {} = {}
>(
  reducer: Reducer<S, A>,
  enhancer?: StoreEnhancer<Ext, StateExt>
): Store<S, A, UnknownIfNonSpecific<StateExt>> & Ext

createStore에 enhacer를 적용하기

enhacer함수는 store로 들어오는 인자들(reducer, state등)과 store가 반환하는 함수 (dispatch, subscribe, getState등)의 프록시 함수를 정의할 수 있습니다. reducerdispatch를 강화하는 예제를 한번 살펴보겠습니다.

import { createStore } from "redux";

const enhancer = (createStore) => {
    return (reducer, state) => {
      // 넘겨받은 reducer 함수가 실행되는 시간을 측정할 수 있도록 reducer 함수를 새롭게 정의합니다.
      const newReducer = (state, action) => {
      		const start = performance.now()
      		const newState = reducer(state, action)
      		const end = performance.now()
      		const diff = round(end - start)

      		console.log('reducer 실행시간:', diff)

      		return newState
    	}

        const store = createStore(newReducer, state)

        // dispatch의 실행여부를 알수 있도록 dispatch 함수를 새롭게 정의합니다.
        const newDispatch = (action) => {
            console.log(`dispatch가 발생했습니다! action:${action} `)
            return store.dispatch(action)
        }

        return {
            ...store
            dispatch: newDispatch
        }
    }
}

const store = createStore(rootReducer, undefined, enhancer);

여러 enhancer를 동시에 사용하기

위 예제처럼 하나의 enhacer함수만 사용하는 경우도 있지만, 이미 만들어진 여러 enhancer함수를 적용하기 위해서 enhacer함수를 여러번 사용해야할수 있습니다. 앞서 보았던것처럼 enhancer함수는 createStore함수를 받아 강화된 createStore함수를 반환하기 때문에 이전 enhancer함수의 결과를 다음 enhancer에 넣어 실행하면 여러 enhacer함수를 동시에 사용할 수 있게됩니다. 예제코드와 함께 어떤방식으로 여러 enhacer를 적용하는지 살펴봅시다.

import { createStore, compose } from "redux"
import rootReducer from "./reducer"
import {
  sayHiOnDispatch,
  includeMeaningOfLife,
  checkRedcuerFnTime,
} from "./exampleAddons/enhancers"

const composedEnhancer = compose(
  sayHiOnDispatch,
  includeMeaningOfLife,
  checkRedcuerFnTime
)

const store = createStore(rootReducer, undefined, composedEnhancer)

export default store

코드를 보면 compose함수에 여러개의 enhacer함수를 넣어주고 있는데, 앞서 언급한것처럼 이전 enhancer함수의 결과를 다음 enhancer함수에 넣어 실행할 수 있도록 연결된 함수를 만들어주는것입니다. 즉 const composedEnhancer = (...arg)=>sayHiOnDispatch(includeMeaningOfLife(checkRedcuerFnTime(...arg))) 와 동일하다고 볼수 있습니다. 따라서 composedEnhancer함수는 createStore함수를 받아 여러 enhacer함수가 적용되어 강화된 createStore함수를 반환하므로 원본 createStore함수의 enhacer함수로 사용할수 있습니다. compose함수는 간단한 함수이지만, 어떻게 함수를 연결하는 기능을 할 수 있는지 분석해보겠습니다.

export default function compose(...funcs: Function[]) {
  return funcs.reduce(
    (a, b) =>
      (...args: any) =>
        a(b(...args))
  )
}

이 함수는 배열로 받은 인자에 reduce함수를 적용한 값을 반환하는 한가지 역할을 가집니다. 따라서 중요한것은 reduce함수를 적용한 결과가 무엇인가? 라는것 입니다. reduce함수의 두번째 인자가 없기 때문에, 배열의 첫번째 요소가 첫번째 인자가 되므로 배열의 두번째 요소먼저 살펴보면 a는 첫번째 요소이자 초기값이자 이전값인 checkRedcuerFnTime함수, bincludeMeaningOfLife함수가 될것입니다. 즉 reduce함수가 반환하는 값은 (...args:any) => checkRedcuerFnTime(includeMeaningOfLife(...args)) 임을 알 수 있습니다. 세번째 요소에서는 a는 이전 값인 (...args:any) => checkRedcuerFnTime(includeMeaningOfLife(...args)) , b는 세번째 요소인 checkRedcuerFnTime함수가 될것입니다. 즉 reduce함수가 반환하는 값은 ((...args:any) => checkRedcuerFnTime(includeMeaningOfLife(...args)))(checkRedcuerFnTime(...args)) 입니다. 따라서 오른쪽에서 왼쪽으로 이전 함수의 실행값을 인자로 받아(처음에는 외부에서 넘겨준 인자사용) 실행할 수 있는 함수가 생성됨을 알 수 있습니다.

조금 복잡할수는 있겠지만, 가능한 위 로직을 이해하고 넘어가시는것을 추천드립니다. 그래야 다음에 설명할 내용도 쉽게 이해하실수 있기 때문입니다.

Middleware

앞서 살펴본 enhancer함수는 store의 모든 요소를 수정할 수 있는데 일반적으로는 dispatch의 기능을 강화하는것만으로도 충분합니다. 이때 등장하는것이 바로 middleware입니다. middleware는 enhancer함수의 일종으로 모든 기능을 강화하지 않고 오로지 dispatch만을 강화하는데 사용되는 도구입니다.

const middleware =
  ({ dispatch, getState }) =>
  next =>
  action => {
    /* ... 특정 작업을 수행합니다. */
    return next(action)
  }

const middlewareEnhancer = applyMiddleware(
  middleware1,
  middleware2,
  middleware3
)

const store = createStore(rootReducer, middlewareEnhancer)

일반적으로 middleware의 타입은 위와 같으며 이러한 middleware를 applyMiddleware함수에 넣어 사용하게 됩니다. 따라서 middleware의 동작을 분석하는것은 곧 applyMiddleware함수를 분석하는것과 동일하므로 applyMiddleware함수를 살펴보겠습니다.

applyMiddleware분석

앞서 예제코드에서 확인한것처럼 applyMiddleware함수는 enhacer함수의 타입을 리턴해야한다는점과, 여러 middleware를 순서대로 결합해주어야한다는 사실을 기억하면서 분석을 시작해보겠습니다.

export default function applyMiddleware(
  ...middlewares: Middleware[]
): StoreEnhancer {
  return createStore => (reducer, preloadedState) => {
    const store = createStore(reducer, preloadedState)
    let dispatch: Dispatch = () => {}

    const middlewareAPI: 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,
    }
  }
}

applyMiddleware가 반환하는 함수를 살펴보면 전형적인 enhancer함수임을 알 수 있습니다. 먼저 middlewares.map(middleware => middleware(middlewareAPI)) 로직을 통해 인자로 받은 middleware에 새로운 getState함수와 dispatch함수를 넣어 실행한 결과를 저장합니다. 결과적으로 [(next)=>(action)=>{/*작업하고 싶은 코드1*/ return next(action)},(next)=>(action)=>{/*작업하고 싶은 코드2*/ return next(action)},(next)=>(action)=>{/*작업하고 싶은 코드3*/ return next(action)}]와 같이 될것입니다. 이 배열을 앞서 살펴본 compose 함수에 넣게되면 (...args)=>{(next)=>(action)=>{/*작업하고 싶은 코드1*/return next(action)}((next)=>(action)=>{/*작업하고 싶은 코드2*/ return next(action)}((next)=>(action)=>{/*작업하고 싶은 코드3*/ return next(action)}(...args)))}와 같이 되고, 이 함수에 store.dispatch를 넣어 실행하면 (action)=>{/*작업하고 싶은 코드1*/ return (action)=>{/*작업하고 싶은 코드2*/return (action)=>{/*작업하고 싶은 코드3*/ return store.dispatch(action)}(action)}(action)}과 같을것입니다. 따라서 middleware가 적용된 dispatch 함수를 실행하면 모든 middleware가 실행된후 마지막으로 store.disaptch가 호출되어 dispatch 전 필요한 작업을 수행할 수 있음을 알 수 있습니다.

redux-thunk

middleware에 대해 이해했다면 이제 오픈소스 middleware 라이브러리중 하나인 Redux-Thunk를 분석해보겠습니다.

사용법

Redux-Thunk의 사용 방식을 예제코드와 함께 알아보겠습니다.

// thunk 액션 생성자 함수
export const fetchTodoById = todoId => async dispatch => {
  const response = await client.get(`/fakeApi/todo/${todoId}`)
  dispatch(todosLoaded(response.todos))
}

// 사용 컴포넌트
function TodoComponent({ todoId }) {
  const dispatch = useDispatch()

  const onFetchClicked = () => {
    dispatch(fetchTodoById(todoId))
  }
}

dispatch 이전에 비동기 호출을 하기 위해 dispatch를 인자로 받는 함수를 dispatch에 넣어 실행합니다. 그리고 비동기나 특정 작업을 수행한뒤에 해당 함수 내부에서 인자로 넘어온 dispatch를 사용해줍니다. 이렇게 사용하게되면 특정 작업수행후 disaptch를 실행할수 있게 됩니다.

코드 분석

middleware를 사용하였을때 Redux-Thunk에서 가져올수 있는 코드는 extraThunkArg를 제외한다면 아래와 같습니다.

const middleware: ThunkMiddleware<State, BasicAction, ExtraThunkArg> =
  ({ dispatch, getState }) =>
  next =>
  action => {
    if (typeof action === "function") {
      return action(dispatch, getState)
    }

    return next(action)
  }

앞서보았던 middleware의 형태와 동일한것을 알 수 있습니다. 첫번째 조건문을 보면 actionfunction일때 actiondispatch, getState를 넣어 실행한뒤 반환해버림을 알 수 있습니다. 이러면 우리가 dispatch를 실행할때 넣은 action은 실행되겠지만, 다음 middleware가 실행되지 않지 않을까 라는 생각을 할수 있지만, action에 넣은 함수에서 정상적으로 dispatch를 실행해주고, 이때 실행한 dispatchactionfunction이 아니므로, 마지막 next(action)이 실행될것입니다.

마치며

이번 아티클에서는 middleware를 살펴보았습니다. 이번 아티클을 기반으로 redux-thunk뿐만 아니라 redux-saga등의 라이브러리도 분석해본다면 미들웨어를 사용하는데 있어서 많은 도움이 될 것입니다.

이번 아티클을 끝으로 Redux Deep Dive 시리즈를 마무리합니다. 시리즈를 기반으로 하여 자신이 사용하는 상태관리 라이브러리를 분석해보는것도 좋은 학습이 될 것입니다.

참고 자료

Redux Fundamentals, Part 4: Store Redux enhancer Exploring ApplyMiddleware: How it works behind the scenes