이번 포스팅은 리덕스 미들웨어를 통한 비동기 작업 관리에 대해 알아보겠습니다. 이번 챕터도 마찬가지로 '미들웨어'라는 생소한 내용이 나왔기에 서적 내용을 공부해보도록하겠습니다.
먼저 리덕스를 위한 코드를 준비해줍니다.
//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 = 0; // 상태는 꼭 객체일 필요 없습니다. 숫자도 작동해요.
const counter = handleActions(
{
[INCREASE]: (state) => state + 1,
[DECREASE]: (state) => state - 1,
},
initialState
);
export default counter;
//modules/index.js
import { combineReducers } from "redux";
import counter from "./counter";
const rootReducer = combineReducers({
counter,
});
export default rootReducer;
//index.js
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { legacy_createStore as createStore } from "redux";
import rootReducer from "./modules";
import { Provider } from "react-redux";
const store = createStore(rootReducer);
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);
reportWebVitals();
어김없이 createStore는 퇴물 함수라 쓰지말라지만 일단 사용해봅시다. 원리를 이해하는 것이 중요하니까요~
이제 카운터 컴포넌트와 카운터 컨테이너 컴포넌트를 만들어줍니다.
//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;
//containers/CounterContainer.js
import React from "react";
import { connect } from "react-redux";
import { increase, decrease } from "../modules/counter";
import Counter from "../components/Counter";
const CounterContainer = ({ number, increase, decrease }) => {
return (
<Counter number={number} onIncrease={increase} onDecrease={decrease} />
);
};
export default connect(
(state) => ({
number: state.counter,
}),
{
increase,
decrease,
}
)(CounterContainer);
미들웨어(Middleware)란?
이제 본격적으로 미들웨어가 뭔지 알아봅시다.
리덕스 미들웨어는 액션을 디스패치 했을 때 리듀서에 이를 처리하기에 앞서 사전이 지정된 작업들을 실행합니다. 액션과 리듀서 사이의 중간자라고 볼 수 있습니다.
실제 개발을 하면서 미들웨어를 직접 만들 일은 거의 없지만 이번엔 교재를 따라가면서 직접 만드는 시간을 가져보겠습니다.
//lib/loggerMiddleware.js
const loggerMiddleware = (store) => (next) => (action) => {};
export default loggerMiddleware;
미들웨어의 기본구조입니다. 위 코드를 function 키워드로 풀어서 작성하면 다음과 같습니다.
const loggerMiddleware = function loggerMiddleware(store) {
return function (next) {
return function (action) {};
};
};
즉 미들웨어는 함수를 반환하는 함수를 반환하는 함수입니다. 말장난도 아니고 어질어질하네요.
위 그림은 미들웨어 동작 원리를 도식화한 것입니다.
미들웨어 내부에서 store.dispatch를 사용하면 첫 번째 미들웨어부터 다시 처리합니다. 만약 미들웨어에서 next를 사용하지 않으면 액션이 리듀서에 전달되지 않습니다. 즉 액션이 무시됩니다.
이제 자세히 알아봅시다. 미들웨어를 마저 구현한 모습입니다.
//loggerMidderware.js
const loggerMiddleware = (store) => (next) => (action) => {
console.group(action && action.type); // 액션 타입으로 log 를 그룹화함
console.log("이전 상태", store.getState());
console.log("액션", action);
next(action); // 다음 미들웨어 혹은 리듀서에게 전달
console.log("다음 상태", store.getState()); // 업데이트 된 상태
console.groupEnd(); // 그룹 끝
};
export default loggerMiddleware;
미들웨어는 스토어를 생성하는 과정에서 적용합니다.
//index.js
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import loggerMiddleware from "./lib/loggerMiddleware";
import { applyMiddleware, legacy_createStore as createStore } from "redux";
import rootReducer from "./modules";
import { Provider } from "react-redux";
const store = createStore(rootReducer, applyMiddleware(loggerMiddleware));
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);
reportWebVitals();
콘솔에 무언가가 잘 찍히는 것을 확인할 수 있습니다.
redux-logger 활용
redux-logger 라는 친구를 사용해봅시다. 방금 만든 loggerMidderware보다 더 좋다고 합니다.
//index.js
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { applyMiddleware, legacy_createStore as createStore } from "redux";
import rootReducer from "./modules";
import { Provider } from "react-redux";
import { createLogger } from "redux-logger";
const logger = createLogger();
const store = createStore(rootReducer, applyMiddleware(logger));
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);
reportWebVitals();
콘솔이 좀 더 이쁘게 찍히네요ㅎㅎ 액션 디스패치 시간도 나타나고 여러 부가 기능이 있는 듯 합니다.
redux-thunk
redux-thunk는 리덕스를 사용하는 프로젝트에서 비동기 작업을 처리할 때 가장 기본적으로 사용하는 미들웨어 입니다. 여기서 Thunk란 특정 작업을 나중에 할 수 있도록 미루기 위해 함수 형태로 감싼 것을 의미합니다.
//index.js
생략;
import { thunk } from "redux-thunk";
const logger = createLogger();
const store = createStore(rootReducer, applyMiddleware(logger, thunk));
생략;
위 코드를 추가해 스토어를 만들 때 redux-thunk를 적용해줍니다.
redux-thunk는 액션 생성 함수에서 일반 액션 객체를 반환하지않고 함수를 반환합니다. 즉 중간에 어떠한 과정을 하나 더 거치도록 함수를 반환하고 그 함수가 실행되든 뭐하든 어떠한 작업을 거친 뒤에 액션 객체가 나오거나 디스패치가 취소되거나 하는 것이죠.
카운터값을 비동기적으로 수행하도록 변경해줍니다. 리덕스 모듈을 수정해줍시다.
//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);
//1초 뒤 increase 혹은 decrease 함수를 디스패치함
export const increaseAsync = () => (dispatch) => {
setTimeout(() => {
dispatch(increase());
}, 1000);
};
export const decreaseAsync = () => (dispatch) => {
setTimeout(() => {
dispatch(decrease());
}, 1000);
};
const initialState = 0; // 상태는 꼭 객체일 필요 없습니다. 숫자도 작동해요.
const counter = handleActions(
{
[INCREASE]: (state) => state + 1,
[DECREASE]: (state) => state - 1,
},
initialState
);
export default counter;
CounterContainer에서 호출하던 액션 생성 함수도 변경해줍니다.
//container/CounterContainer.js
import React from "react";
import { connect } from "react-redux";
import { increaseAsync, decreaseAsync } from "../modules/counter";
import Counter from "../components/Counter";
const CounterContainer = ({ number, increaseAsync, decreaseAsync }) => {
return (
<Counter
number={number}
onIncrease={increaseAsync}
onDecrease={decreaseAsync}
/>
);
};
export default connect(
(state) => ({
number: state.counter,
}),
{
increaseAsync,
decreaseAsync,
}
)(CounterContainer);
이제 브라우저에서 버튼을 눌러 1초뒤에 작업이 수행되는지 확인해봅시다.
액션 기록을 콘솔을 통해 확인해봅시다.
아주 잘 되네요ㅎㅎ
처음 디스패치 되는 액션은 함수형태, 두번째 액션은 객체 형태 입니다.
웹 비동기 요청 작업 처리
이제 api를 불러와보겠습니다.
//lib/api.js
import axios from "axios";
export const getPost = (id) =>
axios.get(`https://jsonplaceholder.typicode.com/posts/${id}`);
export const getUsers = (id) =>
axios.get(`https://jsonplaceholder.typicode.com/users`);
새로운 리듀서 sample 리듀서를 만들어줍시다. api에서 데이터를 받아와 상태를 관리할 리듀서입니다.
//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";
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 = (id) => 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; //나중에 컴포넌트단에서 에러를 조회 가능하게 해줌
}
};
//초기상태 선언
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, //요청 완료
},
post: action.payload,
}),
[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: true, //요청 완료
},
users: action.payload,
}),
},
initialState
);
export default sample;
다 작성했다면 해당 리듀서를 루트 리듀서에 포함시키세요.
//modules/index.js
import { combineReducers } from "redux";
import counter from "./counter";
import sample from "./sample";
const rootReducer = combineReducers({
counter,
sample,
});
export default rootReducer;
이제 데이터를 렌더링할 프레젠테이션 컴포넌트를 만들어줍니다.
//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;
이제 컨테이너 컴포넌트를 만들어줍니다.
//containers/SampleContainer.js
import React from "react";
import { connect } from "react-redux";
import Sample from "../components/Sample";
import { getPost, getUsers } from "../modules/sample";
const { useEffect } = React;
const SampleContainer = ({
getPost,
getUsers,
post,
users,
loadingPost,
loadingUsers,
}) => {
// 클래스 형태 컴포넌트였더라면, componentDidMount
useEffect(() => {
getPost(1);
getUsers(1);
}, [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);
이제 app 컴포넌트에서 SampleContainer를 렌더링해봅시다.
다 좋지만 코드 가독성이 너무 구립니다. 리팩토링이 시급해 보입니다.
지금 만드는 유틸 함수는 api 요청을 해주는 thunk 함수를 한줄로 생성할 수 있게 해줍니다.
//lib/createRequestThunk.js
export default function 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,
error: true,
}); // 에러 발생
throw e;
}
};
}
// 사용법: createRequestThunk('GET_USERS',api.getUsers);
이제 thunk 함수를 대체해봅시다.
//modules/sample.js
생략;
// 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 = id => 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 //나중에 컴포넌트단에서 에러를 조회 가능하게 해줌
// }
// }
//위 주석처리한 내용을 밑 2줄로 작성한 겁니다.
export const getPost = createRequestThunk(GET_POST, api.getPost);
export const getUsers = createRequestThunk(GET_POST, api.getUsers);
생략;
코드가 줄어들어 보기 좋네요.
이번엔 요청의 로딩 상태를 관리하는 작업을 개선하겠습니다. 로딩 상태만 관리하는 모듈을 따로 만들어 주겠습니다.
//modules/loading.js
import { createAction, handleActions } from "redux-actions";
const START_LOADING = "loading/START_LOADING";
const FINISH_LOADING = "loading/FINISH_LOADING";
/*
요청을 위한 액션 타입을 payload 로 설정합니다 (예: "sample/GET_POST")
*/
export const startLoading = createAction(
START_LOADING,
(requestType) => requestType
);
export const finishLoading = createAction(
FINISH_LOADING,
(requestType) => requestType
);
const initialState = {};
const loading = handleActions(
{
[START_LOADING]: (state, action) => ({
...state,
[action.payload]: true,
}),
[FINISH_LOADING]: (state, action) => ({
...state,
[action.payload]: false,
}),
},
initialState
);
export default loading;
다음은 요청이 시작될 때 디스패치할 액션 입니다.
{
type :'loading/START_LOADING',
payload : 'sample/GET_POST'
}
위 액션이 디스패치 되면 sample/GET_POST 값이 true로 설정해줍니다. 만약 기존 상태에서 sample/GET_POST 필드가 존재하지 않으면 새로 값을 설정해줍니다. 그리고 요청이 끝나면 다음 액션을 디스패치 해줍니다.
{
type :'loading/FINISH_LOADING',
payload : 'sample/GET_POST'
}
그럼 기존에 true 였던 값이 false가 됩니다.
이제 리듀서를 루트 리듀서에 포함시켜봅시다.
//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;
loading 리덕스 모듈에서 만든 액션 생성 함수는 앞에서 만든 createRequestThunk에서 사용해줍니다.
//lib/createRequestThunk.js
import { startLoading, finishLoading } from "../modules/loading";
export default function createRequestThunk(type, request) {
// 성공 및 실패 액션 타입을 정의합니다.
const SUCCESS = `${type}_SUCCESS`;
const FAILURE = `${type}_FAILURE`;
return (params) => async (dispatch) => {
dispatch({ type }); // 시작됨
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(startLoading(type));
throw e;
}
};
}
// 사용법: createRequestThunk('GET_USERS',api.getUsers);
이제 SampleContainer에서 로딩 상태를 조회할 수 있습니다.
//container/SampleContainer.js
import React from "react";
import { connect } from "react-redux";
import Sample from "../components/Sample";
import { getPost, getUsers } from "../modules/sample";
const { useEffect } = React;
const SampleContainer = ({
getPost,
getUsers,
post,
users,
loadingPost,
loadingUsers,
}) => {
// 클래스 형태 컴포넌트였더라면, componentDidMount
useEffect(() => {
// useEffect 에 파라미터로 넣는 함수는 async 로 할 수 없기 때문에
// 그 내부에서 async 함수를 선언하고 호출해줍니다.
const fn = async () => {
try {
await getPost(1);
await getUsers(1);
} catch (e) {
console.log(e); // 에러 조회
}
};
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 리듀서에서 불필요한 코드를 지워봅시다.
//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 함수를 생성합니다. 함수 내부에서 시작할 떄, 성공했을 때, 실패했을 떄 다른 액션을 디스패치합니다.
export const getPost = createRequestThunk(GET_POST, api.getPost);
export const getUsers = createRequestThunk(GET_USERS, api.getPost);
//초기상태 선언
//요청의 로딩상태는 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;
코드가 훨씬 깔끔해졌군요. 이제 sample 리듀서는 성공했을 때 케이스만 관리해주면 됩니다. 로딩중 상태관리는 loading 객체가 하니까요.
try / catch문을 적용한다면 다음과 같습니다.
//containers/SampleContainer.js
import React from "react";
import { connect } from "react-redux";
import Sample from "../components/Sample";
import { getPost, getUsers } from "../modules/sample";
const { useEffect } = React;
const SampleContainer = ({
getPost,
getUsers,
post,
users,
loadingPost,
loadingUsers,
}) => {
// 클래스 형태 컴포넌트였더라면, componentDidMount
useEffect(() => {
// useEffect 에 파라미터로 넣는 함수는 async 로 할 수 없기 때문에
// 그 내부에서 async 함수를 선언하고 호출해줍니다.
const fn = async () => {
try {
await getPost(1);
await getUsers(1);
} catch (e) {
console.log(e); // 에러 조회
}
};
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);
redux-saga 활용
redux-saga는 다음과 같은 상황에 유용합니다.
- 기존 요청을 취소 처리 해야할 때
- 특정 액션이 발생했을 때 다른 액션을 발생시키거나 api요청 등 리덕스와 관계없는 코드를 실행할 때
- 웹소켓 사용 시
- api 요청 실패 시 재요청할 때
제너레이터 함수
redux-saga 는 제너레이터 함수를 사용합니다. 한번 알아봅시다.
제너레이터 함수의 핵심은 함수를 작성할 때 함수를 특정 구간에 멈춰 놓을 수도 있고, 원할 때 다시 돌아가게 할 수도 있다는 것입니다.
다음 예시를 봅시다.
function weirdFunction() {
return 1;
return 2;
return 3;
return 4;
}
하나의 함수는 하나의 값만 반환합니다. 하나의 함수에서 여러개의 값을 반환할 수 없기때문에 위 코드는 에러가 납니다.
하지만 제너레이터를 사용하면 함수에서 여러 개의 값을 순차적으로 반환할 수 있습니다.
function* generatorFunction() {
console.log("안녕");
yield 1;
console.log("제너레이터함수");
yield 2;
console.log("깔깔");
yield 3;
return 4;
}
const generator = generatorFunction();
제너레이터 함수를 만들 때는 function* 키워드를 사용합니다.
제너레이터 함수를 호출 했을 때 반환되는 객체를 제너레이터 객체라고 합니다.
generator.next(); 가 호출되면 다음 yield가 있는 곳까지 호출되고 또 호출되면 다음 yield가 있는 곳까지 호출되는 형태입니다.
사진을 보면 yield 2까지 호출된 모습을 볼 수 있습니다.
next()함수에 파라미터를 넣으면 제너레이터 함수에서 yield를 사용해서 해당 값을 조회할 수도 있습니다.
그럼 본격적으로 redux-saga를 비동기 카운터를 구현해봅시다.
INCREASE_ASYNC , DECREASE_ASYNC 액션 타입을 선언해주고 해당 액션에 대한 액션 생성함수, 제너레이터 함수를 만들어줍니다. 이 제너레이터 함수를 saga라고 부릅니다.
//modules/counter.js
import { createAction, handleActions } from "redux-actions";
import { delay, put, takeLatest, takeEvery } from "redux-saga/effects";
const INCREASE = "counter/INCREASE";
const DECREASE = "counter/DECREASE";
const INCREASE_ASYNC = "counter/INCREASE_ASYNC";
const DECREASE_ASYNC = "counter/DECREASE_ASYNC";
export const increase = createAction(INCREASE);
export const decrease = createAction(DECREASE);
// 마우스 클릭 이벤트가 payload 안에 들어가지 않도록 () => undefined 를 두번째 파라미터로 넣어줍니다.
export const increaseAsync = createAction(INCREASE_ASYNC, () => undefined);
export const decreaseAsync = createAction(DECREASE_ASYNC, () => undefined);
function* increaseSaga() {
yield delay(1000); // 1초를 기다립니다.
yield put(increase()); // 특정 액션을 디스패치 합니다.
}
function* decreaseSaga() {
yield delay(1000); // 1초를 기다립니다.
yield put(decrease()); // 특정 액션을 디스패치 합니다.
}
export function* counterSaga() {
// takeEvery 는 들어오는 모든 액션에 대하여 특정 작업을 처리해줍니다.
// yield takeEvery(INCREASE_ASYNC, increaseSaga);
// 첫번째 파라미터: n초 * 1000
yield takeEvery(INCREASE_ASYNC, increaseSaga);
// takeLatest 는 만약 기존에 진행중이던 작업이 있다면 취소처리 하고
// 가장 마지막으로 실행된 작업만을 수행합니다.
yield takeLatest(DECREASE_ASYNC, decreaseSaga);
}
const initialState = 0; // 상태는 꼭 객체일 필요 없습니다. 숫자도 작동해요.
const counter = handleActions(
{
[INCREASE]: (state) => state + 1,
[DECREASE]: (state) => state - 1,
},
initialState
);
export default counter;
다음으로 루트 사가를 만들어줍니다.
//modules/index.js
import { combineReducers } from "redux";
import { all } from "redux-saga/effects";
import counter, { counterSaga } from "./counter";
import sample from "./sample";
import loading from "./loading";
const rootReducer = combineReducers({
counter,
sample,
loading,
});
export function* rootSaga() {
// all 함수는 여러 사가를 합쳐주는 역할을 합니다.
yield all([counterSaga()]);
}
export default rootReducer;
이제 스토어에 redux-saga 미들웨어를 적용해줍니다.
//index.js
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { applyMiddleware, legacy_createStore as createStore } from "redux";
import rootReducer, { rootSaga } from "./modules";
import { Provider } from "react-redux";
import { createLogger } from "redux-logger";
import { thunk } from "redux-thunk";
import createSagaMiddleware from "redux-saga";
const sagaMiddleware = createSagaMiddleware();
const logger = createLogger();
const store = createStore(
rootReducer,
applyMiddleware(logger, thunk, sagaMiddleware)
);
sagaMiddleware.run(rootSaga);
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);
reportWebVitals();
이제 CounterContainer 컴포넌트를 app 컴포넌트에 렌더링해서 잘 동작하는지 확인해봅시다.
여기서 리덕스 개발자 도구 라이브러리를 설치하고 +1 버튼을 빠르게 두번 눌러 콘솔과 리덕스 개발 툴 을 확인해봅시다.
//index.js
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { applyMiddleware, legacy_createStore as createStore } from "redux";
import rootReducer, { rootSaga } from "./modules";
import { Provider } from "react-redux";
import { createLogger } from "redux-logger";
import { thunk } from "redux-thunk";
import createSagaMiddleware from "redux-saga";
import { composeWithDevTools } from "redux-devtools-extension";
const sagaMiddleware = createSagaMiddleware();
const logger = createLogger();
const store = createStore(
rootReducer,
composeWithDevTools(applyMiddleware(logger, thunk, sagaMiddleware))
);
sagaMiddleware.run(rootSaga);
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);
reportWebVitals();
+1 버튼을 두 번 누르면 INCREASE ASYNC 액션이 두 번 디스패치되고, 이에 따라 INCREASE 액션도 두 번 디스패치됩니다.
takeEvery를 사용하여 increasesaga를 등록했으므로 디스패치되는 모든 INCREASE ASYNC 액션에 대해 1초 후 INCREASE 액션을 발생시켜 줍니다.
이번에는 페이지를 새로고침한 뒤에 -1 버튼을 두 번 눌러서 어떤 액션이 디스패치되는지 확인해보세요.
조금 전과는 다르게 DECREASE ASYNC 액션이 두 번 디스패치되었음에도 불구하고 DECREASE 액션은단 한 번 디스패치되었습니다.
조금 전에 decreasesaga를 등록할 때 takelatest를 사용했기 때문입니다.
여러 액션이 중접되어 디스패치되었을 때는 기존의 것들은 무시하고 가장 마지막 액션만 제대로 처리합니다.
api 요청 상태 관리
이제 redux-saga를 이용해서 api 요청을 해보겠습니다.
sample 리덕스 모듈을 다음과 같이 수정해줍니다.
//modules/sample.js
import { handleActions, createAction } from "redux-actions";
import * as api from "../lib/api";
import { call, put, takeLatest } from "redux-saga/effects";
import { startLoading, finishLoading } from "./loading";
// 액션 타입들을 선언합니다.
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 함수를 생성합니다. 함수 내부에서 시작할 떄, 성공했을 때, 실패했을 떄 다른 액션을 디스패치합니다.
export const getPost = createAction(GET_POST, (id) => id);
export const getUsers = createAction(GET_USERS);
function* getPostSaga(action) {
yield put(startLoading(GET_POST)); // 로딩 시작
// 파라미터로 action 을 받아오면 액션의 정보를 조회 할 수 있습니다.
try {
// call 을 사용하면 Promise 를 반환하는 함수를 호출하고, 기다릴 수 있습니다.
// 첫번째 파라미터는 함수, 나머지 파라미터는 해당 함수에 넣을 인수입니다.
const post = yield call(api.getPost, action.payload); // api.getPost(action.payload) 를 의미
yield put({
type: GET_POST_SUCCESS,
payload: post.data,
});
} catch (e) {
// try/catch 문을 사용하여 에러도 잡을 수 있습니다.
yield put({
type: GET_POST_FAILURE,
payload: e,
error: true,
});
}
yield put(finishLoading(GET_POST)); // 로딩 완료
}
function* getUsersSaga() {
yield put(startLoading(GET_USERS));
try {
const users = yield call(api.getUsers);
yield put({
type: GET_USERS_SUCCESS,
payload: users.data,
});
} catch (e) {
yield put({
type: GET_USERS_FAILURE,
payload: e,
error: true,
});
}
yield put(finishLoading(GET_USERS));
}
export function* sampleSaga() {
yield takeLatest(GET_POST, getPostSaga);
yield takeLatest(GET_USERS, getUsersSaga);
}
//초기상태 선언
//요청의 로딩상태는 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;
여기서 GET_POST 액션의 경우 API 요청 할 때 어떤 id로 조회할지 정해 줘야 합니다. redux-saga를 사용할 때는 id처럼 요청이 필요한 값을 액션의 payload로 넣어줘야합니다.
그러면 이 액션을 처리하기 위한 사가를 작성할 때 payload 값을 APT를 호출하는 합수의 인수로 넣어 주어야 합니다.
API를 호출해야 하는 상황에는 사가 내부에서 직접 호출하지 않고 ca11 함수를 사용합니다.
call함수의 경우, 첫 번째 인수는 호출하고 싶은 함수이고, 그 뒤에 오는 인수들은 해당 함수에 넣어주고 싶은 인수입니다.
지금 getpostsaga의 경우에는 id를 의미하는 action.payload가 인수가 됩니다.
이제 samplesaga를 루트 사가에 등록하세요.
//modules/index.js
import { combineReducers } from "redux";
import { all } from "redux-saga/effects";
import counter, { counterSaga } from "./counter";
import sample, { sampleSaga } from "./sample";
import loading from "./loading";
const rootReducer = combineReducers({
counter,
sample,
loading,
});
export function* rootSaga() {
// all 함수는 여러 사가를 합쳐주는 역할을 합니다.
yield all([counterSaga(), sampleSaga()]);
}
export default rootReducer;
이제 app 컴포넌트에 SampleContainer를 렌더링해줍니다.
잘 나오네요ㅎㅎ
리팩토링
이제 복잡한 코드를 리팩토링 하는 시간을 가져보겠습니다.
createRequestSaga.js 라는 함수를 만들어줍니다.
//lib/createRequestSaga.js
import { call, put } from "redux-saga/effects";
import { startLoading, finishLoading } from "../modules/loading";
export default function createRequestSaga(type, request) {
const SUCCESS = `${type}_SUCCESS`;
const FAILURE = `${type}_FAILURE`;
return function* (action) {
yield put(startLoading(type)); // 로딩 시작
try {
const response = yield call(request, action.payload);
yield put({
type: SUCCESS,
payload: response.data,
});
} catch (e) {
yield put({
type: FAILURE,
payload: e,
error: true,
});
}
yield put(finishLoading(type)); // 로딩 끝
};
}
이제 사가를 짧은 코드로 구현할 수 있습니다.
//modules/sample.js
import createRequestSaga from "../lib/createRequestSaga";
생략;
// 액션 타입들을 선언합니다.
const GET_POST = "sample/GET_POST";
const GET_POST_SUCCESS = "sample/GET_POST_SUCCESS";
//fail 처리 삭제
const GET_USERS = "sample/GET_USERS";
const GET_USERS_SUCCESS = "sample/GET_USERS_SUCCESS";
//fail 처리 삭제
생략;
// function* getPostSaga(action) {
// yield put(startLoading(GET_POST)); // 로딩 시작
// // 파라미터로 action 을 받아오면 액션의 정보를 조회 할 수 있습니다.
// try {
// // call 을 사용하면 Promise 를 반환하는 함수를 호출하고, 기다릴 수 있습니다.
// // 첫번째 파라미터는 함수, 나머지 파라미터는 해당 함수에 넣을 인수입니다.
// const post = yield call(api.getPost, action.payload); // api.getPost(action.payload) 를 의미
// yield put({
// type: GET_POST_SUCCESS,
// payload: post.data
// });
// } catch (e) {
// // try/catch 문을 사용하여 에러도 잡을 수 있습니다.
// yield put({
// type: GET_POST_FAILURE,
// payload: e,
// error: true
// });
// }
// yield put(finishLoading(GET_POST)); // 로딩 완료
// }
// function* getUsersSaga() {
// yield put(startLoading(GET_USERS));
// try {
// const users = yield call(api.getUsers);
// yield put({
// type: GET_USERS_SUCCESS,
// payload: users.data
// });
// } catch (e) {
// yield put({
// type: GET_USERS_FAILURE,
// payload: e,
// error: true
// });
// }
// yield put(finishLoading(GET_USERS));
// }
//위 주석처리된 코드들을 밑 두줄로 대체해줍니다.
const getPostSaga = createRequestSaga(GET_POST, api.getPost);
const getUsersSaga = createRequestSaga(GET_USERS, api.getUsers);
생략;
마지막으로 시가 내부에서 현재 상태를 조회하는 방법, 시가 실행 주기 제한 방법을 알아보겠습니다.
현재 상태 조회 방법
//modules/counter.js
import { delay, put, takeLatest, takeEvery, select } from "redux-saga/effects";
생략;
function* increaseSaga() {
yield delay(1000); // 1초를 기다립니다.
yield put(increase()); // 특정 액션을 디스패치 합니다.
const number = yield select((state) => state.counter);
console.log(`현재 값은 ${number}입니다.`);
}
생략;
CounterContainer를 렌더링하고 버튼을 눌러보면
현재 상태를 참조할 수 있습니다.
시가 실행 주기 제한 방법
다음은 시가 실행 주기 제한 방법입니다. 코드르 다음과 같이 수정하면 increaseSaga는 3초에 단 한번 호출됩니다.
//modules/counter.js
import {
delay,
put,
takeLatest,
takeEvery,
select,
throttle,
} from "redux-saga/effects";
생략;
export function* counterSaga() {
// takeEvery 는 들어오는 모든 액션에 대하여 특정 작업을 처리해줍니다.
// yield takeEvery(INCREASE_ASYNC, increaseSaga);
// 첫번째 파라미터: n초 * 1000
yield throttle(3000, INCREASE_ASYNC, increaseSaga);
// takeLatest 는 만약 기존에 진행중이던 작업이 있다면 취소처리 하고
// 가장 마지막으로 실행된 작업만을 수행합니다.
yield takeLatest(DECREASE_ASYNC, decreaseSaga);
}
생략;
3줄 요약
- 리덕스 미들웨어는 redux-thunk, redux-saga가 대표적이다.
- 미들웨어에는 호출 제한 기능, 현재 상태 조회 등 다양한 부가기능이 있다.
- 불편하다면 굳이 안써도 된다.