Thunk Middleware의 작동 방식 살펴보기

import thunkMiddleware from 'redux-thunk'

const middlewareEnhancer = applyMiddleware(thunkMiddleware);
export default const store = createStore(rootReducer, middlewareEnhancer);

 Thunk 미들웨어(thunkMiddleware)는 위와 같이 Redux Store에 마운트할 수 있는데, 그 첫 과정으로 thunkMiddleware는 아래와 같은 함수에서 반환됨.

 

 

 

1. redux-thunk에서의 thunkMiddleware #

// 'redux-thunk' index.js
function createThunkMiddleware(extraArgument) {
    const middleware = ({ dispatch, getState }) => (next) => (action) => {
        if (typeof action === "function") {
            return action(dispatch, getState, extraArgument);
        }

        return next(action);
    };
    return middleware;
}

const thunk = createThunkMiddleware() & {
    withExtraArgument(extraArgument);
};

thunk.withExtraArgument = createThunkMiddleware;

export default thunk;  // 이 thunk가 위에서 import한 thunkMiddleware

 우리가 사용할 Thunk 미들웨어는 마운트되기 전에 아래와 같은 구조를 가짐.

function middleware(storeAPI) {
    // 나중에 store.dispatch, store.getState가 storeAPI 객체로서 argument에 들어옴
    const dispatch = storeAPI.dispatch;
    const getState = storeAPI.getState;

    return function (next) {
    
        return function (action) {
            if (typeof action === "function") {
                return action(dispatch, getState, extraArgument);
            }
            
            return next(action);
        };
    };
}

 

 

 

2. Thunk Middleware 마운트: applyMiddleware() #

1에서 만든 Thunk 미들웨어를 사용하기 위해서는 Store에 미들웨어를 마운트해야 하는데 redux에서 제공하는 applyMiddleware()를 이용할 수 있음. applyMiddleware 함수는 아래와 같은 코드로 이루어짐.

// redux/src/applyMiddleware.js
export default function applyMiddleware(...middlewares) {
    return createStore => (...args) => {
        const store = createStore(...args)
        let dispatch = () => {
            throw new Error(
                'Dispatching while constructing your middleware is not allowed. ' +
                'Other middleware would not be applied to this dispatch.'
            )
        }

        const middlewareAPI = {
            getState: store.getState,
            dispatch: (...args) => dispatch(...args)
        }
        // ⓐ 각각의 미들웨어에 { dispatch, getState } 객체를 전달하고 실행 후, 각각을 chain 배열에 할당함
        // middlewareAPI는 middleware의 storeAPI에 해당함
        const chain = middlewares.map(middleware => middleware(middlewareAPI))
        // ⓑ 합성 함수에 dispatch()를 전달해 실행될 수 있도록 함
        dispatch = compose(...chain)(store.dispatch)

        return {
            ...store,
            dispatch  // ⓒ ⓐ~ⓑ에서 만든 함수로 기존 dispatch를 교체
        }
    }
}

 

 

 

3. thunkMiddleware를 마운트했을 때의 dispatch() 함수

미들웨어는 둘 이상 사용할 수 있지만 좀 더 간단하게 이해하기 위해 thunkMiddleware 하나만 사용하는 경우를 생각해 봄.

// thunkMiddleware 함수의 형태
function middleware(storeAPI) {
    const dispatch = storeAPI.dispatch;
    const getState = storeAPI.getState;

    return function (next) {
    
        return function (action) {
            if (typeof action === "function") {
                return action(dispatch, getState, extraArgument);
            }
            
            return next(action);
        };
    };
}

그 전에 thunkMiddleware에서 반환된 미들웨어 함수의 형태를 기억해두면 이해하기 쉬움. 

const middlewares = [ (storeAPI) => (next) => (action) => { ... } ];

 applyMiddleware(middlewares);

applyMiddleware()에 thunkMiddleware가 전달된다면,

 

 

ⓐ 각각의 미들웨어에 { dispatch, getState } 객체를 전달하고 실행 후, 각각을 chain 배열에 할당

dispatch, getState가 담긴 객체 middlewareAPI를 전달해 실행하게 되므로 middleware는 아래와 같은 형태가 됨.

// middleware1은 클로저를 이루기 때문에 dispatch, getState를 지속적으로 참조함
const dispatch = storeAPI.dispatch;
const getState = storeAPI.getState;

// middleware1 === middleware(middlewareAPI)
function middleware1(next) {

    return function (action) {
        if (typeof action === "function") {
            return action(dispatch, getState, extraArgument);
        }
            
        return next(action);
    };
}

 

 

ⓑ 합성 함수에 dispatch()를 전달해 실행

현재 미들웨어로 thunkMiddleware 하나만 마운트되어 있으므로 middleware1(store.dispatch)로 Store의 dispatch()가 reference 형태로 전달됨. 만약, 둘 이상의 미들웨어가 있다면 마지막 미들웨어 f3이 store.dispatch를 전달받아 f2, f1 순서로 합성되고, dispatch될 때는 반대로 f1 → f2 → f3 순으로 실행됨.

// middleware1은 클로저를 이루기 때문에 dispatch, getState를 지속적으로 참조함
const dispatch = storeAPI.dispatch;
const getState = storeAPI.getState;

// middleware2 === middleware(middlewareAPI)(store.dispatch)
function middleware2(action) {
    if (typeof action === "function") {
        return action(dispatch, getState, extraArgument);
    }

    return store.dispatch(action);
};

 

 

ⓒ ⓐ~ⓑ에서 만든 함수를 새로운 dispatch()로 만듦

applyMiddleware() 마지막 부분을 보면 이렇게 만든 middleware2를 Store의 dispatch로 재할당하게 됨.

 

 

 

4. Thunk Middleware 이용하기

const dispatch = storeAPI.dispatch;
const getState = storeAPI.getState;

function storeDisptach(action) {
    if (typeof action === "function") {
        return action(dispatch, getState, extraArgument);
    }

    return store.dispatch(action);
};

이렇게 만들어진 storeDisptach는 인수로 전달된 action이 객체인지, 함수인지 판단해 dispatch() 실행을 다르게 하는 역할밖에 없어 이러한 미들웨어가 무슨 의미가 있는지 의문이 들 수 있음.

// redux/src/createStore.js 
function dispatch(action) {
    if (!isPlainObject(action)) throw new Error(/* ... */);
    if (typeof action.type === "undefined") throw new Error(/* ... */);
    if (isDispatching) throw new Error(/* ... */);

    try {
        isDispatching = true;
        currentState = currentReducer(currentState, action);
    } finally {
        isDispatching = false;
    }

    const listeners = (currentListeners = nextListeners);
    for (let i = 0; i < listeners.length; i++) {
        const listener = listeners[i];
        listener();
    }

    return action;
}

미들웨어 없이 disptach()에 함수를 전달했을 때

하지만, Store의 dispatch()는 일반 객체만 받을 수 있도록 설계되어있기 때문에 비동기 작업에 대한 Action Creator는 비동기가 아닌 Action Creator와 형태가 달라져 Store에 접근할 때 의도치 않은 실수를 할 수 있음.

// 1. Thunk Middleware를 사용하지 않았을 때
export const saveNewTodo = async (text, dispatch) => {
    const initialTodo = { text };
    const response = await client.post("/fakeApi/todos", { todo: initialTodo });
    dispatch(todoAdded(response.todo));
};

// 비동기 Action Creator 사용
const dispatch = store.dispatch;
saveNewTodo("새 할일", dispatch);
// 2. Thunk Middleware를 사용했을 때
export const saveNewTodo = (text) => async (dispatch) => {
    const initialTodo = { text };
    const response = await client.post("/fakeApi/todos", { todo: initialTodo });
    dispatch(todoAdded(response.todo));
};

// 비동기 Action Creator 사용
const dispatch = store.dispatch;
dispatch(saveNewTodo("새 할일"));

Thunk 미들웨어를 사용했을 때와 아닐 때, Thunk 함수는 둘 다 동일하지만 Thunk 함수를 사용하는 방법이 다름!

 

 

※ redux-toolkit은 redux-thunk의 thunkMiddleware를 기본 미들웨어로 장착하고 있기 때문에 별도로 thunkMiddleware를 마운트할 필요가 없음.

 

 

 

5. 기타

A. compose 함수

// redux/src/compose.js 
export default function compose(...funcs) {
    if (funcs.length === 0) {
        return arg => arg
    }
    
    // 함수가 하나만 전달되었다면 해당 함수만 반환
    if (funcs.length === 1) {
        return funcs[0]
    }
    
    // 둘 이상의 함수를 합성
    return funcs.reduce((a, b) => (...args) => a(b(...args)))
}

compose()는 함수의 배열 funcs의 함수들을 왼쪽부터 모두 합성한 함수를 반환하는 함수. 만약, applyMiddleware(f1, f2, f3, ...)처럼 여러개의 함수(middleware)를 전달했다면, reduce() 메서드를 적용했을 때 아래와 같은 방식으로 합성 함수가 생성됨. 

// 첫번째 단계
(x₁) => f1(f2(x₁));

// 두번째 단계
(x₂) => ( (x₁) => f1(f2(x₁)) )(f3(x₂));  // 함수 f3(x₂)이 x₁으로서 전달되어 실행됨
(x₂) => f1(f2(f3(x₂));

 따라서, funcs = [f1, f2, f3]이라면 최종적으로 f3 → f2 → f1 순서로 실행됨.

 

 

B. 미들웨어의 실행 순서

const f1 = (x) => {
   return x - 1;
};
const f2 = (x) => {
   return x * 2;
};

compose(f1, f2)(1);  // 1

compose()로 함수를 합성했을 때, f2 → f1로 뒤에 있는 함수부터 실행되는 것을 확인할 수 있음. 하지만, Redux에서 미들웨어는 f1 → f2 순서로 실행됨!? 결론부터 말하면 미들웨어가 커링 함수이기 때문에 위 예시와는 다른 순서로 실행됨.

const mw1 = (next) => (action) => {
   doSomething1();
   return next(action);
};
const mw2 = (next) => (action) =>  {
   doSomething2();
   return next(action);
};

compose()에 의해 합성되기 전에 두 미들웨어 mw1, mw2는 위와 같은 형태의 함수. 

/* 1. compose(mw1, mw2) 실행 결과  */
(...args) => mw1(mw2(...args))

compose(mw1, mw2)로 위와 같은 함수가 반환되는데, compose의 결과에 store.dispatch를 args로 전달함

/* 2-1. ...args = disptach를 전달해 실행 */
mw1(mw2(dispatch)); 

/* 2-2. mw2(dispatch)는? */
( (next) => (action) => {
   doSomething2();
   return next(action);
} )(dispatch);  // next = disptach
⏬
(action) => {
   doSomething2();
   return dispatch(action);
}

/* 2-3. mw1(mw2(dispatch))는? */
( (next) => (action) => {
   doSomething1();
   return next(action);
} )( (action) => { /* ... */ });  // next = (action) => { /* ... */ }
⏬
(action) => {
   doSomething1();
   return ( (action) => { /* ... */ } )(action);
}
(action) => {
   doSomething1();
   doSomething2();
   
   return dispatch(action);
}

이렇게 변형된 dispatch가 각 컴포넌트에서 dispatch(action)처럼 사용되므로 doSomething1() doSomething2() dispatch(action) 순서로 미들웨어가 실행됨.