-
Nextjs에 Redux Toolkit와 Redux Saga 적용하기Front-end/Nextjs (12버전) 2022. 12. 1. 00:51
Nextjs에 RTK와 Saga로 전역상태관리하기
Redux는 유용하게 쓰기 위해선 복잡한 설정들을 해야 했으며 많은 패키지를 설치해야 했습니다.
이런 문제들을 해결하기 위해 Redux-Toolkit (=RTK)이 등장했습니다.
다만 RTK는 Redux-Saga를 지원하지 않으니 이를 따로 설치해서 적용해야 합니다.
Redux에서 액션을 dispatch하고 비동기 작업까지 다 작성+컨트롤하면 화면단에 소스가 엄청 길어질 수 있습니다.
UI는 최대한 간결해야되며 가능하면 렌더링 관련 로직만 있는 것이 좋습니다.
이런 비동기 작업을 해결해 주는게 Redux-Saga입니다.
프로젝트 소스가 많이 길고 이해가 힘들 수 있으니 티스토리용 제 깃헙 링크 공유드립니다
링크: https://github.com/biglol10/tistory_source/tree/main/next_rtk
우선 만들고자 하는 영역의 slice들을 만들어줍니다. 저는 유저slice와 카운터slice를 만들었습니다
// UserSlice.js import { createSlice } from '@reduxjs/toolkit'; const initialState = { userId: '', password: '', }; const userSlice = createSlice({ name: 'user', initialState, reducers: { setUser(state, action) { state.userId = action.payload.userId; state.password = action.payload.password; }, clearUser(state) { state.userId = ''; state.password = ''; }, }, }); export const { setUser, clearUser } = userSlice.actions; export default userSlice.reducer;
// CounterSlice.js import { createSlice } from '@reduxjs/toolkit'; const initialState = { count: 0 }; // 기존 리듀서를 생각해보면 현재 state의 값을 바로 바꾸지않고 concat, assign등을 사용하여 복사하고 그 복사한 state를 리턴해줬는데, createSlice의 리듀서 안에서는 불변성을 직접 작업해주기 떄문에 바로 state에 접근이 가능합니다. const counterSlice = createSlice({ name: 'counter', initialState, reducers: { increment(state) { state.count++; }, decrement(state) { state.count--; }, incrementByAmount(state, action) { state.count += parseInt(action.payload, 10); }, decrementByAmount(state, action) { state.count -= parseInt(action.payload, 10); }, }, }); export const { increment, decrement, incrementByAmount, decrementByAmount } = counterSlice.actions; export default counterSlice.reducer;
UserSlice의 setUser을 호출하면 유저 정보를 세팅하고 clearUser을 호출하면 유저 정보를 초기화합니다.
CounterSlice는 숫자 더하기 빼기 등의 기능을 구현했습니다.
이후 Dispatch타입에 따른 작업들을 수행할 Saga들을 작성해줍니다 (UserSaga.js, CounterSaga.js)
// UserSaga.js import { all, call, fork, put, takeLatest } from 'redux-saga/effects'; import { setUser, clearUser } from '@store/userSlice'; const setUserFunc = function* ({ param, callbackFn }) { const { userId, password } = param; yield put( setUser({ userId, password, }), ); if (callbackFn) yield call(callbackFn, 'tokenValue_aw32j9973'); }; const clearUserFunc = function* () { yield put(clearUser()); }; const setUserDispatch = function* () { yield takeLatest('SETUSER', setUserFunc); }; const clearUserDispatch = function* () { yield takeLatest('CLEARUSER', clearUserFunc); }; export default function* userSaga() { yield all([fork(setUserDispatch), fork(clearUserDispatch)]); }
// CounterSaga.js import { all, fork, put, takeLatest, call, delay } from 'redux-saga/effects'; import { increment, decrement, incrementByAmount, decrementByAmount } from '@store/counterSlice'; // Can I use ES6's arrow function syntax with generators? => NO // The function* statement (function keyword followed by an asterisk) defines a generator function. const fetchRandomNumber = () => { return new Promise((resolve) => { setTimeout(() => { resolve(Math.floor(Math.random() * 30)); }, 1500); }); }; const addOneFunction = function* () { yield put(increment()); }; const subtractOneFunction = function* () { yield put(decrement()); }; const addByAmountFunction = function* (action) { console.log(action); // {type: 'ADDBYAMOUNT', data: 2} yield put(incrementByAmount(action.data)); }; const randomComputationFunction = function* (action) { yield call(action.setLoading, true); // true를 인자로 action.setLoading 호출 const randomNumber1 = yield call(fetchRandomNumber); yield put(incrementByAmount(randomNumber1)); // 액션을 dispatch (loading 인데 숫자가 바뀌는 걸 볼 수 있음 [yield덕분]) const randomNumber2 = yield call(fetchRandomNumber); yield delay(1000); yield put(decrementByAmount(randomNumber2)); yield call(action.setLoading, false); }; const addOne = function* () { yield takeLatest('ADDONE', addOneFunction); }; const subtractOne = function* () { yield takeLatest('SUBTRACTONE', subtractOneFunction); }; const addByAmount = function* () { yield takeLatest('ADDBYAMOUNT', addByAmountFunction); }; const randomComputation = function* () { yield takeLatest('RANDOMCOMPUTATION', randomComputationFunction); }; export default function* counterSaga() { yield all([fork(addOne), fork(subtractOne), fork(addByAmount), fork(randomComputation)]); }
takeLatest는 아주 빠르게 여러번 호출하면 내부적으로 debounce를 걸어 제일 마지막 호출 부분만 작동하게 됩니다.
put은 보통 전역상태를 변경하는 slice 에서 정의한 함수를 호출할 때 같이 쓰이며
call은 어떤 비동기 작업을 작동시킬 때 사용합니다.
이후 Saga로 작성한 것들을 한 곳에 다 모아줍니다
// index.js import { all, fork } from 'redux-saga/effects'; import userSaga from './userSaga'; import counterSaga from './counterSaga'; export default function* rootSaga() { yield all([fork(userSaga), fork(counterSaga)]); }
즉, 위의 Saga들은 어떤 Dispatch action을 호출했을 때 어떤 작업들을 할 것인지 정의했습니다.
setUserFunc을 보시면 파라미터도 보낼 수 있으며 함수도 보낼 수 있으니 콜백함수로 써도 됩니다.
이후 이들을 다 총괄할 루트 reducer을 하나 만들어줍니다.
- rootReducer.js 소스
import { combineReducers, configureStore } from '@reduxjs/toolkit'; import { createWrapper, HYDRATE } from 'next-redux-wrapper'; // nextjs friendly import createSagaMiddleware from 'redux-saga'; import rootSaga from '@saga/index'; import counterSlice from './counterSlice'; import userSlice from './userSlice'; const devMode = process.env.NODE_ENV === 'development'; const rootReducer = (state, action) => { switch (action.type) { case HYDRATE: { const nextState = { ...state, // use previous state ...action.payload, }; return nextState; } default: { const combineReducer = combineReducers({ counter: counterSlice, user: userSlice, }); return combineReducer(state, action); } } }; const loggerMiddleware = // console.log를 위한 custom middleware () => (next) => (action) => { console.log(action); return next(action); }; const sagaMiddleware = createSagaMiddleware(); const devMiddleware = [sagaMiddleware, loggerMiddleware]; const finalMiddleware = process.env.NODE_ENV === 'development' ? devMiddleware : sagaMiddleware; export const store = configureStore({ reducer: rootReducer, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ // async Saga를 위해 resolve, reject를 반환해서 에러가 생기는 이슈로, // serializableCheck 미들웨어를 사용안함 thunk: false, serializableCheck: false, }).concat(finalMiddleware), devTools: devMode, // redux devtools 확장 프로그램 사용 가능여부 }); store.sagaTask = sagaMiddleware.run(rootSaga); const wrapper = createWrapper(() => store, { debug: devMode }); export default wrapper;
[ HYDRATE의 역할 ]
우선 기억해야 할 것이 redux 전역상태값들은 브라우저에서만 유효한 겁니다. Node에서 유효한 것이 아닙니다. 특정 페이지에서 getStaticProps나 getServerSideProps안에서 store을 호출할 때마다 Node에서 호출하는 것이니 HYDRATE라는 action type이 작동합니다. 이때 store에 어떤 변경을 일으킬 경우 기존 클라이언트 상태와 병합할 필요가 있으니 작업을 해줘야 합니다.
-- Node에서도 client-side 전역상태값들을 온전하게 다 써보려고 며칠을 노력해봤으나 결국 해내지 못했습니다ㅠ
loggerMiddleware은 제가 만든 커스텀 미들웨어이며 단순하게 console.log만 찍습니다.
확인해보면 아래 사진과 같은 콘솔로그가 찍힙니다
[ store 자체를 export하는 이유 ]
useSelector로 전역상태값들을 가져올 수 있지만 이건 hook이기 때문에 .jsx / .tsx 파일에서만 쓸 수 있습니다.
그런데 .js 나 .ts 파일에서 hook을 못 쓰기에 전역상태값들을 쓰려면 store 자체를 import하고 store.getState() 로 가져와서 쓸 수 있습니다.
Nextjs는 서버사이드 렌더링을 지원하기에 위에서 선언한 Wrapper자체로 App을 감싸야 합니다.
- _app.js 소스
import wrapper from '@store/rootReducer'; import '../styles/globals.css'; const MyApp = ({ Component, pageProps }) => { return <Component {...pageProps} />; }; export default wrapper.withRedux(MyApp);
이렇게 하면 세팅은 다 끝났으며 화면에서 쓰면 됩니다.
- 첫 화면 소스
import { useState } from 'react'; import Head from 'next/head'; import { useRouter } from 'next/router'; import { useDispatch } from 'react-redux'; import styles from '../styles/Home.module.css'; export default function Home() { const router = useRouter(); const dispatch = useDispatch(); const [idValue, setIdValue] = useState(''); const [passwordValue, setPasswordValue] = useState(''); const handleSubmit = (event) => { event.preventDefault(); const userObj = { userId: idValue, password: passwordValue, }; localStorage.setItem('userInfo', JSON.stringify(userObj)); dispatch({ type: 'SETUSER', param: userObj, callbackFn: (tokenValue) => { localStorage.setItem('token', tokenValue); router.push('/welcome'); }, }); }; return ( <div className={styles.container}> <Head> <title>Create Next App</title> <meta name="description" content="Generated by create next app" /> <link rel="icon" href="/favicon.ico" /> </Head> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '500px', flexDirection: 'column', }} > <h1>로그인 해주세요</h1> <br /> <form onSubmit={handleSubmit}> <label htmlFor="inputId">아이디</label> <input id="inputId" value={idValue} onChange={(e) => setIdValue(e.target.value)} /> <br /> <label htmlFor="password">비밀번호</label> <input id="password" value={passwordValue} onChange={(e) => setPasswordValue(e.target.value)} /> <br /> <button type="submit">로그인</button> </form> </div> </div> ); }
로그인 버튼을 클릭하여 dispatch type: 'SETUSER' 을 호출하면 yield takeLatest('SETUSER', setUserFunc); 가 작동되며 setUserFunc 함수를 호출하게 됩니다.
- 로그인 이후의 페이지 소스
import { useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; const Index = () => { const dispatch = useDispatch(); const userObj = useSelector((state) => state.user); const countValue = useSelector((state) => state.counter.count); const [loading, setLoading] = useState(false); const [inputValue, setInputValue] = useState(0); const changeCountValue = (isUp = true) => { dispatch({ type: `${isUp ? 'ADDONE' : 'SUBTRACTONE'}`, }); }; const changeByAmount = (num) => { try { dispatch({ type: 'ADDBYAMOUNT', data: parseInt(num, 10), }); } catch (err) { console.log('err'); } }; const changeByRandom = () => { dispatch({ type: 'RANDOMCOMPUTATION', setLoading, }); }; return ( <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '500px', flexDirection: 'column', }} > <h1>Hello {userObj.userId} try to use counter example</h1> <br /> {loading && <h1 style={{ color: 'red' }}>it is loading right now!!!!</h1>} <div style={{ width: '200px', height: '200px', border: '1px solid blue' }}> current count is : {countValue} </div> <br /> <input value={inputValue} onChange={(e) => setInputValue(e.target.value)} /> <br /> <div style={{ display: 'flex' }}> <button onClick={() => changeCountValue(true)}>up</button> <button onClick={() => changeCountValue(false)}>down</button> <button onClick={() => changeByAmount(inputValue)}>숫자만큼 더하기</button> <button onClick={() => changeByRandom()}>랜덤숫자 더하기</button> </div> </div> ); }; export default Index;
changeCountValue, changeByAmount함수는 단순히 숫자를 더하고 빼는 로직이기에 이해가 쉽지만
changeByRandom 함수는 조금 더 자세히 살펴볼 필요가 있습니다.
- CounterSaga.js 소스 일부
const fetchRandomNumber = () => { return new Promise((resolve) => { setTimeout(() => { resolve(Math.floor(Math.random() * 30)); }, 1500); }); }; const randomComputationFunction = function* (action) { yield call(action.setLoading, true); // true를 인자로 action.setLoading 호출 const randomNumber1 = yield call(fetchRandomNumber); yield put(incrementByAmount(randomNumber1)); // 액션을 dispatch (loading 인데 숫자가 바뀌는 걸 볼 수 있음 [yield덕분]) const randomNumber2 = yield call(fetchRandomNumber); yield delay(1000); yield put(decrementByAmount(randomNumber2)); yield call(action.setLoading, false); };
fetchRandomNumber은 promise라 제대로 된 값을 갖고 싶으면 .then을 쓰거나 await를 써야 하지만 그런 것을 쓰지 않고도 값을 제대로 가져옵니다.
MDN의 설명 일부를 가져와보면
https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Operators/yield yield는 제네레이터를 멈추게 하고 값을 반환하기에 .then이나 await를 안 써도 제대로 된 값을 가져오는 것으로 보입니다.
useState로 선언한 setLoading도 parameter로 넘겼으니 이를 활용하면 아래와 같은 화면을 구현할 수 있습니다.
'Front-end > Nextjs (12버전)' 카테고리의 다른 글
Storybook & SASS 설정 (0) 2022.12.11 Protected Routes & useRouter exception (0) 2022.11.27 Nextjs Redirects 관련 팁 (0) 2022.11.27 Dynamic Layout 적용 + Svg as Component (0) 2022.11.27