리액트 웹 애플리케이션에서 API 서버를 연동할 때는 API 요청에 대한 상태도 잘 관리해야 합니다. 예를 들어 요청이 시작되었을 때는 로딩 중임을, 요청이 성공하거나 실패했을 때는 로딩이 끝났음을 명시해야 합니다. 요청이 성공하면 서버에서 받아 온 응답에 대한 상태를 관리하고, 요청이 실패하면 서버에서 반환한 에러에 대한 상태를 관리해야 합니다.
리액트 프로젝트에서 리덕스를 사용하고 있으며 이러한 비동기 작업을 관리해야 한다면, '미들웨어'를 사용하여 매우 효율적이고 편하게 상태 관리를 할 수 있습니다.
작업 환경 준비
// src/components/Counter.js
import React from "react";
const Counter = ({ onIncrease, onDecrease, number }) => {
return (
<div>
<h1>{number}</h1>
<button onClick={onIncrease}> + 1 </button>
<button onClick={onDecrease}> - 1 </button>
</div>
);
};
export default Counter;
// src/containers/CounterContainer.js
import React from "react";
import { connect } from "react-redux";
import Counter from "../components/Counter";
import { increase, decrease } from "../modules/counter";
const CounterContainer = ({ number, increase, decrease }) => {
return (
<div>
<Counter number={number} onIncrease={increase} onDecrease={decrease} />
</div>
);
};
export default connect(({ counter }) => ({ number: counter.number }), {
increase,
decrease,
})(CounterContainer);
// src/modules/counter.js
import { createAction, handleActions } from "redux-actions";
const INCREASE = "counter/INCREASE";
const DECREASE = "counter/DECREASE";
export const increase = createAction(INCREASE);
export const decrease = createAction(DECREASE);
const initialState = {
number: 0,
};
const counter = handleActions(
{
[INCREASE]: (state, action) => ({
number: state.number + 1,
}),
[DECREASE]: (state, action) => ({
number: state.number - 1,
}),
},
initialState
);
export default counter;
// src/modules/index.js
import { combineReducers } from "redux";
import counter from "./counter";
const rootReducer = combineReducers({
counter,
});
export default rootReducer;
// src/App.js
import CounterContainer from "./containers/CounterContainer";
import React from "react";
import "./App.css";
function App() {
return (
<div className="App">
<CounterContainer />
</div>
);
}
export default App;
// src/index.js
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import { createStore } from "redux";
import "./index.css";
import { Provider } from "react-redux";
import rootReducer from "./modules";
const store = createStore(rootReducer);
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById("root")
);
미들웨어 만들기
// src/lib/loggerMiddleware.js
const loggerMiddleware = (store) => (next) => (action) => {
console.group(action && action.type);
console.log("이전 상태", store.getState());
console.log("액션", action);
next(action);
console.log("다음 상태", store.getState());
console.groupEnd();
};
export default loggerMiddleware;
미들웨어는 함수를 반환하는 함수를 반환하는 함수입니다. 여기에 있는 함수에서 파라미터로 받아오는 store는 리덕스 스토어 인스턴스를, action은 디스패치된 액션을 가리킵니다. 또한 next는 함수 형태이며, store.dispatch와 같은 역할을 합니다. next(action)을 호출하면 그다음 처리해야 할 미들웨어에게 액션을 넘겨주고, 만약 그다음 미들웨어가 없다면 리듀서에게 액션을 넘겨줍니다.
즉 이는 아래와 같은 형태로 진행됩니다.
- 'counter/INCREASE'
- loggerMiddleware
- console.log('current state')
- next(action)
- 그다음 처리해야할 미들웨어 x
- 리듀서
- state.counter.number + 1
- console.log('current state')
와 같이 처리됩니다.
미들웨어에서는 여러 종류의 작업을 처리할 수 있습니다. 특정 조건에 액션을 무시하게 할 수도 있고, 특정 조건에 따라 액션 정보를 가로채서 변경한 후 리듀서에 전달해 줄 수도 있습니다. 아니면 특정 액션에 기반하여 새로운 액션을 여러 번 디스패치할 수도 있습니다.
이러한 미들웨어 속성을 사용하여 네트워크 요청과 같은 비동기 작업을 관리하면 매우 유용합니다.
redux-logger
더 정교화된 opensource를 사용해 봅시다.
// src/index.js
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import { createStore, applyMiddleware } from "redux";
import "./index.css";
import { Provider } from "react-redux";
import rootReducer from "./modules";
// import loggerMiddleware from "./lib/loggerMiddleware";
import { createLogger } from "redux-logger";
const logger = createLogger();
const store = createStore(rootReducer, applyMiddleware(logger));
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById("root")
);
리덕스에서 미들웨어를 사용할 떄는 이렇게 이미 완성된 미들웨어를 라이브러리로 설치해서 사용하는 경우가 많습니다.
비동기 작업을 처리하는 미들웨어
- redux-thunk
- 비동기 작업을 처리할 때 가장 많이 사용하는 미들웨어입니다. 객체가 아닌 함수 형태의 액션을 디스패치할 수 있게 해 줍니다.
- redux-saga
- redux-thunk 다음으로 가장 많이 사용되는 비동기 작업 관련 미들웨어 라이브러리. 특정 액션이 디스패치되었을 때 정해진 로직에 따라 다른 액션을 디스패치시키는 규칙을 작성하여 비동기 작업을 처리할 수 있게 해줍니다.
< redux-thunk >
thunk란 특정 작업을 나중에 할 수 있도록 미루기 위해 함수 형태로 감싼 것을 의미합니다.
const addOne = x => x + 1;
const addOneThunk = x => () => addOne(x);
const fn = addOneThunk(1);
setTimeout(() => {
const value = fn();
console.log(value);
}, 1000);
const sampleThunk = () => (dispatch, getState) => {
// 현재 상태를 참조할 수 있고,
// 새 액션을 디스패치할 수도 있습니다.
}
< 코드에 적용 >
// src/index.js
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import { createStore, applyMiddleware } from "redux";
import "./index.css";
import { Provider } from "react-redux";
import rootReducer from "./modules";
import thunk from "redux-thunk";
const store = createStore(rootReducer, applyMiddleware(thunk));
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById("root")
);
// src/containers/CounterContainer.js
import React from "react";
import { connect } from "react-redux";
import Counter from "../components/Counter";
import { increaseAsync, decreaseAsync } from "../modules/counter";
const CounterContainer = ({ number, increaseAsync, decreaseAsync }) => {
return (
<div>
<Counter
number={number}
onIncrease={increaseAsync}
onDecrease={decreaseAsync}
/>
</div>
);
};
export default connect(({ counter }) => ({ number: counter.number }), {
increaseAsync,
decreaseAsync,
})(CounterContainer);
// src/modules/counter.js
import { createAction, handleActions } from "redux-actions";
const INCREASE = "counter/INCREASE";
const DECREASE = "counter/DECREASE";
export const increase = createAction(INCREASE);
export const decrease = createAction(DECREASE);
export const increaseAsync = () => (dispatch) => {
setTimeout(() => {
dispatch(increase());
}, 1000);
};
export const decreaseAsync = () => (dispatch) => {
setTimeout(() => {
dispatch(decrease());
}, 1000);
};
const initialState = {
number: 0,
};
const counter = handleActions(
{
[INCREASE]: (state, action) => ({
number: state.number + 1,
}),
[DECREASE]: (state, action) => ({
number: state.number - 1,
}),
},
initialState
);
export default counter;
logger 미들웨어까지 장착하게 되면, 처음 디스패치되는 액션은 함수 형태이고, 두 번째 액션은 객체 형태입니다.
< 웹 요청 비동기 작업 처리하기 >
# 포스트 읽기(:id는 1~100 숫자)
GET https://jsonplaceholder.typicode.com/posts/:id
# 모든 사용자 정보 불러오기
GET https://jsonplaceholder.typicode.com/users
// src/modules/sample.js
import { handleActions } from "redux-actions";
import * as api from "../lib/api";
// 액션타입을 선언
// 한 요청당 세개를 만들어야한다.
const GET_POST = "sample/GET_POST";
const GET_POST_SUCCESS = "sample/GET_POST_SUCCESS";
const GET_POST_FAILURE = "sample/GET_POST_FAILURE";
const GET_USERS = "sample/GET_USERS";
const GET_USERS_SUCCESS = "sample/GET_USERS_SUCCESS";
const GET_USERS_FAILURE = "sample/GET_USERS_FAILURE";
// thunk 함수를 생성한다.
// thunk 함수 내부에서 시작할 때, 성공했을 때, 실패했을 때 다른 액션을 디스패치한다.
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,
error: true,
}); // 에러발생
throw e;
}
};
export const getUsers = () => async (dispatch) => {
dispatch({ type: GET_USERS }); //요청 시작을 알림
try {
const response = await api.getUsers();
dispatch({
type: GET_USERS_SUCCESS,
payload: response.data,
}); // 요청 성공
} catch (e) {
dispatch({
type: GET_USERS_FAILURE,
payload: e,
error: true,
});
throw e;
}
};
// 초기상태 선언
// 요청 로딩 중 상태는 loading이라는 객체에서 관리
const initialState = {
loading: {
GET_POST: false,
GET_USERS: false,
},
post: null,
users: null,
};
const sample = handleActions(
{
[GET_POST]: (state) => ({
...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, // 요청 완료
},
}),
[GET_USERS]: (state) => ({
...state,
loading: {
...state.loading,
GET_USERS: true, // 요청 시작
},
}),
[GET_USERS_SUCCESS]: (state, action) => ({
...state,
loading: {
...state.loading,
GET_USERS: false, // 요청 완료
},
users: action.payload,
}),
[GET_USERS_FAILURE]: (state, action) => ({
...state,
loading: {
...state.loading,
GET_USERS: false, // 요청 완료
},
}),
},
initialState
);
export default sample;
// src/modules/index.js
import { combineReducers } from "redux";
import counter from "./counter";
import sample from "./sample";
const rootReducer = combineReducers({
counter,
sample,
});
export default rootReducer;
// src/containers/SampleContainer.js
import React, { useEffect } from "react";
import { connect } from "react-redux";
import Sample from "../components/Sample";
import { getPost, getUsers } from "../modules/sample";
const SampleContainer = ({
getPost,
getUsers,
post,
users,
loadingPost,
loadingUsers,
}) => {
useEffect(() => {
const fn = async () => {
await Promise.all([getPost(1), getUsers()]);
};
fn();
}, [getPost, getUsers]);
return (
<Sample
post={post}
users={users}
loadingPost={loadingPost}
loadingUsers={loadingUsers}
/>
);
};
export default connect(
({ sample }) => ({
post: sample.post,
users: sample.users,
loadingPost: sample.loading.GET_POST,
loadingUsers: sample.loading.GET_USERS,
}),
{ getPost, getUsers }
)(SampleContainer);
// src/components/sample.js
import React from "react";
const Sample = ({ post, users, loadingPost, loadingUsers }) => {
return (
<div>
<section>
<h1>포스트</h1>
{loadingPost && "로딩중..."}
{!loadingPost && post && (
<div>
<h3>{post.title}</h3>
<h3>{post.body}</h3>
</div>
)}
</section>
<hr />
<section>
<h1>사용자 목록</h1>
{loadingUsers && "로딩중..."}
{!loadingUsers && users && (
<ul>
{users.map((user) => (
<li key={user.id}>
{user.username} ({user.email})
</li>
))}
</ul>
)}
</section>
</div>
);
};
export default Sample;
// src/App.js
// import CounterContainer from "./containers/CounterContainer";
import SampleContainer from "./containers/SampleContainer";
import React from "react";
import "./App.css";
function App() {
return (
<div className="App">
<SampleContainer />
{/* <CounterContainer /> */}
</div>
);
}
export default App;
잘 작동한다
< 리팩토링 >
API를 요청해야 할 때마다 17줄 정도 되는 thunk함수를 작성하는 것과 로딩 상태를 리듀서에서 관리하는 작업은 귀찮을 뿐 아니라 코드도 길어지게 만듭니다. 그러므로 반복되는 로직을 따로 분리하여 코드의 양을 줄여야 합니다.
// src/lib/createRequestThunk.js
import { startLoading, finishLoading } from "../modules/loading";
const createRequestThunk = (type, request) => {
const SUCCESS = `${type}_SUCCESS`;
const FAILURE = `${type}_FAILURE`;
return (params) => async (dispatch) => {
dispatch(startLoading(type));
try {
const response = await request(params);
dispatch({
type: SUCCESS,
payload: response.data,
});
dispatch(finishLoading(type));
} catch (e) {
dispatch({
type: FAILURE,
payload: e,
error: true,
});
dispatch(finishLoading(type));
throw e;
}
};
};
export default createRequestThunk;
/*
example >> createRequestThunk('GET_USERS', api.getUsers);
*/
// src/modules/loading.js
import { createAction, handleActions } from "redux-actions";
const START_LOADING = "loading/START_LOADING";
const FINISH_LOADING = "loading/FINISH_LOADING";
/**
* 액션을 위한 액션 타입을 payload로 설정합니다.
*/
export const startLoading = createAction(
START_LOADING,
(requestType) => requestType
);
export const finishLoading = createAction(
FINISH_LOADING,
(requestType) => requestType
);
const initialState = {};
const loading = handleActions(
{
[START_LOADING]: (state, { payload }) => ({
...state,
[payload]: true,
}),
[FINISH_LOADING]: (state, { payload }) => ({
...state,
[payload]: false,
}),
},
initialState
);
export default loading;
/*
{
type: 'loading/START_LOADING',
payload: 'sample/GET_POST'
}
*/
// 만약 위 액션이 디스패치되면 loading 리듀서가 관리하고 있는 상태에서
// 'sample/GET_POST'의 값을 true로 만들어 줍니다.
// 만약 기존 상태에서 'sample/GET_POST'가 없다면 새로 값을 설정해 줍니다.
/*
{
type: 'loading/FINISH_LOADING',
payload: 'sample/GET_POST'
}
*/
// 기존에 true로 했던 값을 다시 false로 설정해줍니다.
// src/modules/sample.js
import { handleActions } from "redux-actions";
import * as api from "../lib/api";
import createRequestThunk from "../lib/createRequestThunk";
// 액션타입을 선언
// 한 요청당 세개를 만들어야한다.
const GET_POST = "sample/GET_POST";
const GET_POST_SUCCESS = "sample/GET_POST_SUCCESS";
const GET_USERS = "sample/GET_USERS";
const GET_USERS_SUCCESS = "sample/GET_USERS_SUCCESS";
// thunk 함수를 생성한다.
// thunk 함수 내부에서 시작할 때, 성공했을 때, 실패했을 때 다른 액션을 디스패치한다.
export const getPost = createRequestThunk(GET_POST, api.getPost);
export const getUsers = createRequestThunk(GET_USERS, api.getUsers);
// 초기상태 선언
// 요청 로딩 중 상태는 loading이라는 객체에서 관리
const initialState = {
post: null,
users: null,
};
const sample = handleActions(
{
[GET_POST_SUCCESS]: (state, action) => ({
...state,
post: action.payload,
}),
[GET_USERS_SUCCESS]: (state, action) => ({
...state,
users: action.payload,
}),
},
initialState
);
export default sample;
// src/modules/index.js
import { combineReducers } from "redux";
import counter from "./counter";
import sample from "./sample";
import loading from "./loading";
const rootReducer = combineReducers({
counter,
sample,
loading,
});
export default rootReducer;
// src/containers/SampleContainer.js
import React, { useEffect } from "react";
import { connect } from "react-redux";
import Sample from "../components/Sample";
import { getPost, getUsers } from "../modules/sample";
const SampleContainer = ({
getPost,
getUsers,
post,
users,
loadingPost,
loadingUsers,
}) => {
useEffect(() => {
const fn = async () => {
await Promise.all([getPost(1), getUsers()]);
};
fn();
}, [getPost, getUsers]);
return (
<Sample
post={post}
users={users}
loadingPost={loadingPost}
loadingUsers={loadingUsers}
/>
);
};
export default connect(
({ sample, loading }) => ({
post: sample.post,
users: sample.users,
loadingPost: loading["sample/GET_POST"],
loadingUsers: loading["sample/GET_USERS"],
}),
{ getPost, getUsers }
)(SampleContainer);
이렇게 되면 sample리듀서에서는 로딩 중에 대한 상태를 관리할 필요가 없습니다. 성공했을 때의 케이스만 잘 관리해 주면 됩니다.
'React > ReactJs' 카테고리의 다른 글
React - React.lazy & Suspense & Loadable Components (0) | 2022.02.22 |
---|---|
React - Redux MiddleWare >> redux-saga (0) | 2022.02.22 |
React - redux ( util_Version ) (0) | 2022.02.21 |
React - redux (0) | 2022.02.21 |
React - Context API (0) | 2022.02.21 |