미들웨어는 리덕스를 사용자 정의 기능으로 확장하여 사용할 수 있는 도구이다. 액션을 디스패치했을 때 이를 리듀서에 전달하기 전에 미들웨어에서 먼저 처리하고 리듀서로 넘겨주는 역할을 하고, 비동기 API 호출을 포함하여 다양한 용도로 사용할 수 있다.(API 호출, Redux Action에 따른 Log 확인 등)
액션 ⇒ 미들웨어 ⇒ 리듀서 ⇒ 스토어
형태로 전달되는 것이다.
다른 개발자가 만들어놓은 미들웨어가 많아서 실제로 사용할 때는 미들웨어를 만들어서 사용한다기보단 만들어진 미들웨어를 가져다가 사용한다. 그런데, 미들웨어가 어떻게 생성되는지를 알면 좀 더 이해하기 좋을 것 같다. 미들웨어는 어떻게 생겼을까?
const middleware = store => next => action => {
/* ... */
}
1) store: 리덕스 스토어 인스턴스
2) next: 함수인데, next(action) 형태로 액션을 호출하면 다음 처리해야 할 미들웨어에게 액션을 넘겨주고 만약 없다면 리듀서에게 액션을 넘겨준다.
3) action: 디스패치된 액션
이 구조를 처음 봤을 때 이해하기가 조금 어려울 순 있지만 결국 미들웨어는 action 파라미터를 가진 함수를 반환하는 next 파라미터를 가진 함수를 반환하는 store 파라미터를 가진 함수이다.
Thunk는 특정 작업을 나중에 할 수 있도록 미루기 위해 함수 형태로 감싼 것을 의미한다. 일반적으로 함수를 호출하면 호출한 순간 바로 실행되는데, 함수 형태로 한번 감싸게 되면 호출하더라도 함수를 반환하게 돼서 바로 작업하지 않고 연산을 미룰 수 있다.
function add(a, b) {
return a + b;
}
add(1, 2); // 호출하는 순간 바로 연산된다.
function thunkAdd(a, b) {
const thunk = () => add(a, b);
return thunk;
}
const func = thunkAdd(1, 2); // 함수를 호출하면 thunk 함수를 반환하게 되므로 연산은 이루어지지 않는다.
func(); // 이 때 반환된 thunk를 호출하므로 연산이 이루어진다.
리덕스를 사용하는 프로젝트에서 비동기 작업을 처리할 때 가장 기본적으로 사용하는 미들웨어로 redux-thunk가 있다. 해당 미들웨어를 사용하면 thunk 함수를 만들어서 디스패치할 수 있게 되고, 미들웨어가 그 함수를 전달받아 dispatch, getState 등으로 상호작용할 수 있다.
/src/components/Sample.js
const Sample = ({ loadingPost, post }) => {
return (
<section>
<h1>포스트</h1>
{loadingPost && '로딩 중...'}
{!loadingPost && post && (
<div>
<h3>{post.title}</h3>
<h3>{post.body}</h3>
</div>
)}
</section>
);
};
export default Sample;
이 컴포넌트는 loading 상태(boolean)와 post({ title: string, body: string })을 Container로부터 Props로 전달받는다. 필요한 post 데이터는 JSONPlaceholder에서 GET /posts
API를 사용한다.
/src/lib/api.js
import axios from 'axios';
const API_END_POINT = 'https://jsonplaceholder.typicode.com';
export const getPost = (id) => axios.get(`${API_END_POINT}/posts/${id}`);
비동기 요청은 axios를 사용하고, 요청 주소는 API_END_POINT로 따로 빼서 작성했다. 이렇게 하면 나중에 API 요청 주소가 변경되어도 해당 변수만 수정하면 되기 때문에 유지 보수 측면에서 더 좋다고 생각한다.
Data의 형태는 다음과 같다.
// 요청 id: 1
{
"userId": 1,
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
"body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
}
src/modules/sample.js
import * as api from 'src/lib/api';
import { handleActions } from 'redux-actions';
const GET_POST = 'sample/GET_POST'; // 시작
const GET_POST_SUCCESS = 'sample/GET_POST_SUCCESS'; // 성공
const GET_POST_FAILURE = 'sample/GET_POST_FAILURE'; // 실패
export const getPost = (id) => async (dispatch) => {
dispatch({ type: GET_POST });
try {
const response = await api.getPost(id);
dispatch({
type: GET_POST_SUCCESS,
payload: response.data,
});
} catch (e) {
dispatch({
type: GET_POST_FAILURE,
payload: e,
});
}
};
const initialState = {
loading: {
GET_POST: false,
},
post: null,
};
const sample = handleActions(
{
[GET_POST]: (state, action) => ({
...state,
loading: {
...state.loading,
GET_POST: true,
},
}),
[GET_POST_SUCCESS]: (state, action) => ({
...state,
loading: {
...state.loading,
GET_POST: false,
},
post: action.payload,
}),
[GET_POST_FAILURE]: (state, action) => ({
...state,
loading: {
...state.loading,
GET_POST: false,
},
}),
},
initialState,
);
export default sample;
최초 호출
| 호출 성공
| 호출 실패
3가지 분기에 따라 dispatch할 예정이므로 3가지 상수를 작성해 줬다./src/modules/index.js
import { combineReducers } from 'redux';
import sample from './sample';
const rootReducer = combineReducers({
sample,
});
export default rootReducer;
지금은 하나의 리듀서밖에 없지만, 다른 모듈로 리듀서가 작성될 수 있으므로 미리 rootReducer를 생성해 놓으면 나중에 확장하기 용이할 것이다.
/src/index.js
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { applyMiddleware, createStore } from 'redux';
import App from './App';
import rootReducer from './modules';
import ReduxThunk from 'redux-thunk';
const store = createStore(rootReducer, applyMiddleware(ReduxThunk));
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root'),
);
여기서 applyMiddleware, ReduxThunk 모듈을 import해서 적용해 주고 있다.
/src/containers/SampleContainer.js
import { useEffect } from 'react';
import { connect } from 'react-redux';
import Sample from 'src/components/Sample';
import { getPost } from 'src/modules/sample';
const SampleContainer = ({
loadingPost,
post,
getPost,
}) => {
useEffect(() => {
getPost(1);
}, [getPost]);
return (
<Sample
post={post}
loadingPost={loadingPost}
/>
);
};
export default connect(
({ sample }) => ({
post: sample.post,
loadingPost: sample.loading.GET_POST,
}),
{
getPost,
},
)(SampleContainer);
해당 컴포넌트가 렌더링 되면 useEffect에 의해 getPost 요청을 보내고, getPost는 정의한 dispatch대로 비동기 요청을 수행한다. 여기까지 작성하고 App.js에 SampleContainer 컴포넌트를 렌더링 시키면 다음과 같이 정상적으로 동작하는 것을 확인할 수 있다.
여기까지만 해도 사실 정상적으로 동작하긴 한다. 그러나 새로운 API 요청이 생겨서 추가해야 할 때 getPost처럼 똑같이 액션 타입 3개, dispatch, try | catch 문을 작성해 줘야 한다. API가 한개 추가되면 그냥 작성해 줘도 문제는 없겠지만 만약 생성해야 하는 게 10개, 100개가 된다면? 똑같은 코드를 치는 것이라서 매우 번거로운 작업이 될 것이다. 이를 따로 util 함수로 빼서 사용해 보자.
/src/lib/createRequestThunk.js
const createRequestThunk = (type, request) => {
const SUCCESS = `${type}_SUCCESS`;
const FAILURE = `${type}_FAILURE`;
return (params) => async (dispatch) => {
dispatch({ type });
try {
const response = await request(params);
dispatch({
type: SUCCESS,
payload: response.data,
});
} catch (e) {
dispatch({
type: FAILURE,
payload: e,
});
}
};
};
export default createRequestThunk;
위에서 작성한 getPost와 유사하다. 액션 타입과 API 요청을 파라미터로 넘겨주면 아까 정의한 getPost처럼 thunk 함수를 반환하고 있다. 이를 getPost에 적용하면 다음과 같다.
/src/modules/sample.js
import * as api from 'src/lib/api';
import { handleActions } from 'redux-actions';
import createRequestThunk from 'src/lib/createRequestThunk';
/* ... */
export const getPost = createRequestThunk(GET_POST, api.getPost);
엄청나게 간단해졌다. 다음에 똑같은 로직으로 API를 추가하더라도 위처럼 함수를 호출해 주기만 하면 된다. 홈페이지를 확인해 보면 마찬가지로 잘 동작한다.
export default function createRequestThunk(type, request) {
const SUCCESS = `${type}_SUCCESS`;
const FAILURE = `${type}_FAILURE`;
return (params) => async (dispatch) => {
dispatch(type); // → 이 부분이 잘못됨..
/* Thunk 비동기 로직 */
};
}
Thunk함수를 매번 사용할 때마다 길게 작성하기보다 하나의 함수로 작성하고 import하면 사용하는 곳에서의 중복 코드를 많이 줄일 수 있다. 그래서 작성을 완료한 후 import하여 사용해 봤는데 아래와 같은 에러가 떴다..
항상 그런 것은 아니지만 이번 에러는 글의 내용에서 살짝 유추가 되어 꽤 금방 해결했다. 에러의 내용은 호출할 action은 객체 형태여야 하는데 문자열 형태로 들어왔다고 한다. 위에 작성한 코드(dispatch(type)
)에서 보이듯이 type은 sample/GET_REQUEST
같은 문자열 형태로 집어넣으려고 헀는데 이를 그대로 호출하니 에러가 발생했다. 이를 그대로 넣어주면 안 되고, **{ type }
처럼 객체 형태로 호출**해야 한다. 문자열뿐만 아니라 다른 형태에서도 발생할 수 있는 에러라고 생각되지만 다행히 에러 내용이 구체적이어서 발견할 수 있을 것 같다.
redux를 처음 학습할 때 redux/toolkit 위주로 봤었고, 이후 사용할 때 configureStore
, createSlice
, useSelector
, useDispatch
등으로 엄청 편하게 사용했었다. 그때는 어려운 게 없다고 생각했었는데 지금 생각해 보면 connect 함수도 잘 몰랐고, 오늘 작성한 미들웨어, thunk 부분에 대해서도 완전히 새로운 것을 배우는 느낌이었다. 그래도 기본이 되는 부분을 한 번 짚고 넘어가는 것 같아 다행이고, 조금씩 응용하면서 실제로 사용할 때 아직 모자란 부분이 많겠지만 조금이나마 이해하고 사용할 수 있을 것 같다.