이번 포스팅에서는 리덕스 사가를 개인 프로젝트에 적용하면서 알게된 것들을 정리해보려 한다.
개인프로젝트로 next.js로 블로그를 만들고 있는데 특정 사용자의 블로그로 접속했을때 해당 블로그의 사용자 정보(계정, 블로그명 등)와 최근 게시글, 인기게시글, 최근 댓글등 과 같은 정보를 왼쪽 영역에서 공통 layout으로 표출해주고 있었다.
이 데이터들은 _app.tsx 파일에서 getInitialProps로 서버사이드에서 데이터를 가져와서 layout 파일에 props로 내려주고 있었는데 해당 레이아웃을 사용하는 파일이 여기저기 있다보니 같은 props 가 무분별하게 사용되는게 보기 좋지 않았다.
이번기회에 리덕스를 프로젝트에 적용해보고 싶기도 해서 리덕스를 사용하여 해당 데이터를 상태로 관리하되 서버사이드에서 데이터를 가져오도록 구현해보기로 했다.
목차
- Redux-Saga 란?
- Redux-Saga 설치 및 사용방법 및 redux-saga/effects 함수 종류
1. Redux-Saga 란 ?
우선 Redux-Saga에 관해 알아볼 필요가 있다.
리덕스로 상태를 업데이트 하기 위한 모든 과정은 동기적으로 진행되기 때문에, 비동기로 상태를 업데이트하기 위해서는 미들웨어를 사용해야 하는데 대표적인 미들웨어로 Redux-Thunk와 Redux-Saga가 있다.
Redux-Thunk와 Redeux-Saga의 가장 큰 차이점은 Redux-Thunk는 비동기 처리를 위한 thunk함수를 dispatch(dispatch 안에 액션 객체가 아닌 함수가 들어감)하고 Redux-Saga는 특정 액션을 모니터링하고 있다가 해당 액션이 dispatch 되면 특정 작업을 수행해준다는 점이다. 결과적으로 특정 action을 dispatch하여 상태를 업데이트 하기전에 비동기 로직을 처리 이후 reducer로 전달해준다는 점은 공통적이다.
redux-saga로 redux-thunk에서는 못하는 다양한 작업을 처리할 수 있는데
1. 비동기 처리 작업을 할 때 기존 요청을 취소 가능
2. 다른 action을 dispatch 가능
3. API요청이 실패했을 경우 재요청 가능
과 같은 이점이 있지만 Generator 문법을 사용하기에 진입장벽이 어느정도 있는 편이다.
2. Redux-Saga 설치 및 사용방법 및 redux-saga/effects 함수 종류
npm i redux // redux 설치
npm i redux-saga // redux saga 설치
npm i next-redux-wrapper// getServerSideProps에서 리덕스를 사용하기 위해 wrapper 설치
우선 redux-saga를 설치하기 위해 위 명령어를 프로젝트가 실행된 경로에서 실행한다.
next-redux-wrapper는 getServerSideProps에서 리덕스를 사용하기 위해 필요하다.
reducer/blogUser.ts
import { call, put, takeEvery } from 'redux-saga/effects';
import { HYDRATE } from 'next-redux-wrapper';
import { combineReducers } from '@reduxjs/toolkit';
import { handleMySql as handleUser } from '@/pages/api/HandleUser';
// 액션 타입
const FETCH_BLOG_USER = 'FETCH_BLOG_USER';
const FETCH_BLOG_USER_SUCCESS = 'FETCH_BLOG_USER_SUCCESS';
const FETCH_BLOG_USER_FAIL = 'FETCH_BLOG_USER_FAIL';
// 액션 생성 함수
export const fetchBlogUser = (userId: string) => ({ type: FETCH_BLOG_USER, id: userId, payload: {} });
//초기값
const initialState = {
userInfo: {},
recentPosts: [],
popularPosts: [],
recentComments: [],
hashtagCnt: [],
};
interface actionType {
type: string;
id: string;
payload: any;
}
type userResultType = {
USER_ID: string;
USER_EMAIL: string;
USER_THMB_IMG_URL: string;
USER_NICKNAME: string;
USER_BLOG_NAME: string;
};
export const getUserInfo = async (userId: string) => {
const params = { type: 'getUser', id: userId };
let user;
try {
user = await handleUser(params).then((res) => {
return res.totalItems === 0 ? {} : JSON.parse(JSON.stringify(res.items[0]));
});
} catch (error) {
console.log(error);
}
return user;
};
//saga 함수
function* fetchBlogUserInfo(action: actionType) {
// 블로그 사용자 ID
const userId = action.id;
try {
const user: userResultType = yield call(getUserInfo, userId); // call은 비동기 함수를 동기적으로 처리
yield put({ type: FETCH_BLOG_USER_SUCCESS, payload: user });// 다른 action을 dispatch하고 결과 데이터를 parameter로 전달
} catch (error) {
if (error) {
yield put({ type: FETCH_BLOG_USER_FAIL, payload: error });
}
}
}
//FETCH_BLOG_USER액션을 dispatch 할때 마다 모든 요청을 처리(매 요청 마다 fetchBlogUserInfo 함수실행)
export function* blogUserSaga() {
yield takeEvery(FETCH_BLOG_USER, fetchBlogUserInfo);
}
//Reducer
const blogUser = (state = initialState, action: actionType) => {
switch (action.type) {
case FETCH_BLOG_USER_SUCCESS:
return applyFetchBlogUser(state, action.payload);
case FETCH_BLOG_USER_FAIL:
return applyFetchBlogUserFail(state, action.payload);
default:
return state;
}
};
const rootReducer = (state: any, action: any) => {
switch (action.type) {
case HYDRATE:
return action.payload;
default: {
const combineReducer = combineReducers({
blogUser,
});
return combineReducer(state, action);
}
}
};
export default rootReducer;
// Reducer Function
const applyFetchBlogUser = (state: any, payload: any) => {
state.userInfo = payload?? null;
return state;
};
const applyFetchBlogUserFail = (state: any, payload: any) => {
const error = JSON.parse(JSON.stringify(payload));
return {
state,
error,
};
};
액션 타입과 액션 생성 함수를 만들어주고 액션 생성 함수에는 필요에 따라 파라미터를 받을 수 있다.
특정 액션을 실행하여 비동기 처리를 하기 위해서 비동기 처리를 위한 fetchBlogUserInfo 함수를 작성하였다.
(generator 함수이므로 function* 로 작성하여야한다.)
fetchBlogUserInfo 함수를 보면 yield call 함수를 실행하는데 call 함수는 파라미터로 들어가는 비동기 함수를 동기적으로 처리해주는 역할을 한다.
이어 put 으로 다른 액션을 dispatch하고 call을 실행하여 받아온 결과 데이터를 payload로 전달한다.
이와 같은 call과 put은 redux-saga/effects 함수들중 하나인데 saga effects에서는 다양한 유틸 함수들을 제공한다.
자주 사용 되는 util 함수들은 아래와 같다.
- delay : 파라미터로 넘긴 시간 만큼 delay 되었다가 실행
- put : put을 이용하여 새로운 action을 dispatch
- call : 첫번째 인자로 들어가는 함수를 동기적으로 실행, 이 후 들어가는 파라미터들은 해당 함수에서 사용할 인자이다.
- fork : call과 반대로 비동기적으로 함수를 실행
- all : 여러 사가들을 합쳐주는 역할, 파라미터로 배열을 받는데 배열 내에 모든 saga들을 실행하기 위해 사용
-takeEvery : 특정 action타입에 대하여 dispatch되는 모든 action들을 처리한다.
-takeLatest : 특정 action타입에 대하여 dispatch 된 가장 마지막 action만을 처리한다. 기존에 진행중이던 작업이 있다면 취소 하고 가장 마지막으로 실행된 작업만 처리한다.
다시 돌아와서 어떤 액션이 dispatch 될때 fetchBlogUserInfo 함수를 실행할지 takeEvery 함수를 사용하여 blogUserSaga 함수 내부에 선언해주었다. blogUserSaga 함수는 reducer store를 생성할때 사용하여야 하므로 export 한다.
store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import createSagaMiddleware from 'redux-saga';
import rootReducer, { blogUserSaga } from '@/reducer/blogUser';
import { all } from 'redux-saga/effects';
import { createWrapper } from 'next-redux-wrapper';
function* rootSaga() {
// 배열안의 사가들을 동시 실행
yield all([blogUserSaga()]);
}
const createStore = () => {
const sagaMiddleware = createSagaMiddleware();// saga 미들웨어 생성
const store = configureStore({
reducer: rootReducer,
middleware: [sagaMiddleware],
devTools: process.env.NODE_ENV !== 'production',
});
//Next.js 서버 사이드에서도 사가를 구동하기 위해 sagaTask를 정의
store.sagaTask = sagaMiddleware.run(rootSaga);
return store;
};
export type AppStore = ReturnType<typeof createStore>; // store 타입
export type RootState = ReturnType<AppStore['getState']>; // RootState 타입
export type AppDispatch = AppStore['dispatch']; // dispatch 타입
//서버사이드 로직에서도 redux를 사용할수 있게 store생성함수를 담아서 wrapper를 export한다.
const wrapper = createWrapper<AppStore>(createStore, { debug: process.env.NODE_ENV === 'development' });
export default wrapper;
generator함수인 rootSaga를 선언하고 all 함수를 사용하여 배열 내 모든 saga 함수들을 실행한다.
createSagaMiddleware 함수로 sagaMiddleware를 생성하여 store 생성시 middleware로 넣어준다.
getServerSideProps에서 동기적으로 상태를 업데이트하기 위해 store.sagaTask를 위와 같이 정의한다.
createWapper로 위와 같이 wrapper를 만들어야하는데 첫번째 인자로 store생성함수를 넣어준다. 이렇게 만든 wrapper로 서버사이드에서도 리덕스를 사용할수 있게 된다.
store.sagaTask 에서 undefined 오류가 날 수 있는데 아래와 같이 redux.d.ts 파일을 생성한다.
redux.d.ts
import 'redux';
import { Task } from 'redux-saga';
declare module 'redux' {
export interface Store {
sagaTask?: Task;
}
}
_app.tsx
import type { AppProps, AppContext } from 'next/app';
import { SessionProvider, getSession } from 'next-auth/react';
import RefreshTokenHandler from '../components/RefreshTokenHandler';
import { useState } from 'react';
import Head from 'next/head';
import Script from 'next/script';
//redux, redux-saga
import wrapper from '@/store';
const App = ({ Component, pageProps }: AppProps) => {
const [sessionRefetchInterval, setSessionRefetchInterval] = useState(10000);
const { session } = pageProps;
return (
<SessionProvider session={session} refetchInterval={sessionRefetchInterval}>
<div className='main_area'>
<Component {...pageProps} />
</div>
<RefreshTokenHandler setSessionRefetchInterval={setSessionRefetchInterval} />
</SessionProvider>
);
};
export default wrapper.withRedux(App); // App 컴포넌트를 wrapper로 감싸준다.
_app.tsx 에서 App 컴포넌트를 wrapper.withRedux에 넣어준다.
pages/posts/[id].tsx
import wrapper from '@/store/index';
import { fetchBlogUser } from '@/reducer/blogUser';
import { END } from 'redux-saga';
const PostDetail = () => {
.
.
.
중략
}
export const getServerSideProps: GetServerSideProps = wrapper.getServerSideProps((store) => async (context) => {
const userId = context.query.userId as string;
store.dispatch(fetchBlogUser(userId as string));
//redux-saga를 사용하여 비동기로 가져오는 데이터의 응답결과를 기다려주는 역할
store.dispatch(END);
await store.sagaTask?.toPromise();
return {
props: {},
};
});
export default PostDetail;
이후 getServerSideProps에서는 위와 같이 wrapper.getServerSideProps를 사용하면되고
store.dispatch(END);
await store.sagaTask?.toPromise();
이 두 줄이 fetchBlogUser 액션 처리가 모두 완료 될 때 까지 기다려주는 역할을 한다.
마지막으로 스토어에 접근하여 상태 값을 가져오는 방법은 아래와 같다.
components/LeftArea.tsx
import { useAppSelector } from '@/hooks/reduxHooks';
const LeftArea = () => {
const blogUser = useAppSelector((state) => state.blogUser);
.
.
.
중략
}
이렇게 개인 프로젝트에 redux-saga를 적용시키고 서버사이드 로직에서 상태를 업데이트하는 방법까지 알아보게 되었는데 개인적으로 전체적인 redux-saga의 흐름과 generator 함수의 사용법 및 서버사이드에서의 사용을 위한 wrapper 의 사용방법까지 오랜 삽질이 있었었지만 구글링을 해보면 레퍼런스가 많기에 막혔던 부분들도 잘 해결할수 있었던 것 같다.