개발(Development)/React(리액트)

[React] Next.js + ReduxToolkit + TypeScript 설정 방법 (next-redux-wrapper HYDRATE 사용 이유)

카레유 2022. 8. 23. 20:22

# Next.js와 Redux 설정에 대하여

Next.js와 Redux를 연동하는 것은 생각보다 간단하지 않다.

왜냐하면, 본질적으로 Next.js 와  Redux 의 출신성분이 다르기 때문이다.

 

단순하게 정리하면,

Next.js는 서버에서 돌아가고, Redux는 클라이언트(브라우저)에서 돌아간다.

여러 브라우저들에서 제각각 돌아가는 Redux의 전역상태를 서버에서 돌아가는 Next.js 에서 사용하려면 일이 복잡해진다.

 

이 글에서는 이에 대해 좀더 자세히 알아보고, 실제 설정하는 방법을 정리한다.

(Next.js + Redux-Toolkit + TypeScript 연동 코드는 하단에 따로 정리해두었다. )


# Next.js 와  Redux의 관계

1. Next.js 는 "서버" 프레임워크다.

1) Next.js는 리액트로 작성된 코드를 "서버 상에서" Pre-Rendering(Static Generation + Serve Side Rendering) 하고,

2) 완성된 HTML 결과물과 JS, CSS코드를 브라우저에게 응답해준다.

 

2. Redux는 "브라우저" 의 전역상태 관리 라이브러리다.

1) Redux는 "브라우저 상에서" 전역적으로 상태를 관리한다.

2) 당연히, 접속한 사용자(브라우저)마다 모두 다른 상태를 갖는다.

 

예를 들어,

Next.js 서버 하나가 구동 중인 상태에서, 수많은 브라우저들이 접속하는 경우를 상상해보자.

 

1) Next.js 서버는 열심히 Pre-Rendering 한 HTML페이지를 "모든 브라우저에게 각각" 보낸다.

2) 이 때, Redux 의 전역상태 관리 코드가 포함된 JS도 "모든 브라우저에게 각각" 전달된다.

 

그런데 갑자기 Next.js 서버에서 특정 브라우저의 Redux 상태를 사용해야 한다면?

 

다른 브라우저의 리덕스 상태는 건드리지 않고!

오직 해당 브라우저의 리덕스 store 상태만 가져와 수정한 다음,

다시 해당 브라우저의 store와만 동기화 해주는 등의 작업을 해야할 것이다.

 

그런데 생각만해도 복잡한 이 작업을 아주 쉽게 해주는 라이브러리가 있다.

바로 next-redux-wrapper 라이브러리다.

 

Next.js 서버 상에서

특정 브라우저의 리덕스 상태값을 변경하는 등의 Action을 Dispatch하면,

=> next-redux-wrapper가 내부적으로 HYDRATE 라는 액션을 발생시킨 다음,

=> 복잡한 작업을 알아서 해주고, 개발자가 디스패치한 액션까지 잘 처리 및 동기화 해준다.

(실제로는 브라우저와 동일한 상태의 Store를 서버에서 새로 생성해서 수정하고 동기화 한다고 한다.)

 

개발자는 next-redux-wrapper의 매뉴얼대로 몇가지 세팅 작업만 해주고,

평소처럼 리듀서를 코딩하고 액션을 디스패치주면 나머지는 이 라이브러리가 알아서 처리해준다.

 

이에 대한 더 자세한 설명과 매뉴얼은 next-redux-wrapper 깃헙 페이지를 참고하자.

(redux 뿐만 아니라, redux-saga 등의 미들웨어를 사용하는 방법도 정리되어 있다)

https://github.com/kirill-konshin/next-redux-wrapper

 

결론은

Next.JS의 서버단에서 Redux 를 사용하려면 next-redux-wrapper를 사용하면 편하다는 것이다.

 

그럼 이제 실제 Next.js 와 Redux-Toolkit을 TypeScript로 연동하는 방법을 정리해 보자.

(@reduxjs/toolkit, react-redux 등은 설치되어 있어야 한다.)


# Next.js + ReduxToolkit + TypeScript 설정  방법

Next.js와 Redux-Toolkit 을 연동하는 코드를 아래의 2가지 케이스로 나누어서 정리한다.

 

1. 서버에서 Redux를 사용하지 않는 경우(브라우저에서만 Redux 사용)

2. 서버에서 Redux를 사용하는 경우

 

1. 브라우저에서만 Redux를 사용하는 경우 ( next-redux-wrapper 필요X )

- Next.js 서버에서 브라우저의 Redux 에 접근하지 않는다면,

- next-redux-wrapper 없이, 그냥 Redux-Toolkit 을 사용해주면 된다.

- next-redux-wrapper는 필수가 아니다! 서버단에서 리덕스를 사용하는 경우에만 필요하다.

* 예를 들어, 서버에서 구동되는 getServerSideProps, getStaticProps 등에서 Redux를 사용하지 않는 경우다.

 

1) counterSlice.ts 파일

- 슬라이스를 생성해준다. 

// *** counterSlice.ts 파일
// slice(액션+슬라이스 통합본) 생성한다.

import { createSlice, PayloadAction } from '@reduxjs/toolkit';

// initalState 타입 정의
type StateType = {
  value: number;
};

// initalState 생성
const initialState: StateType = { value: 0 };

// 슬라이스생성
export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    // action의 타입은 PayloadAction<제네릭> 으로 정의해준다.
    plusCounter: (state: StateType, action: PayloadAction<number>) => {
      // immer가 내장되어 있어서, 불변성 신경 쓰지 않고 바로 수정해주면 된다.
      state.value += action.payload;
    },
    minusCounter: (state: StateType, action: PayloadAction<number>) => {
      state.value -= action.payload;
    }
  }
});

// 액션을 export 해준다.
export const { plusCounter } = counterSlice.actions;

// 슬라이스를 export 해준다.
export default counterSlice;

 

2) store.ts 파일

- configureStore를 통해 슬라이스 통합하는 store를 생성한다.

// *** store.ts 파일
// 슬라이스들을 통합한 store를 만들고, RootState를 정의해준다.

import { configureStore, Action, getDefaultMiddleware } from '@reduxjs/toolkit';
import numberSlice from './numberSlice';
import counterSlice from './counterSlice';
import logger from 'redux-logger';

// 리덕스 store 생성함수
const makeStore = () => {
  // 미들웨어 추가(필요 없을 경우 생략)
  const middleware = getDefaultMiddleware();
  if (process.env.NODE_ENV === 'development') {
    middleware.push(logger);
  }

  // 슬라이스 통합 store 생성
  const store = configureStore({
    reducer: {
      counter: counterSlice.reducer,
      number: numberSlice.reducer
      // [counterSlice.name]: counterSlice.reducer, // 위와 동일한 코드다.
      // [numberSlice.name]: numberSlice.reducer
    },
    middleware, // 미들웨어 불필요시 생략
    // middleware: [...getDefaultMiddleware(), logger]
    devTools: process.env.NODE_ENV === 'development' // 개발자도구 설정
  });

  return store;
};

// store 생성
const store = makeStore();

// store 엑스포트
export default store;

// RootState 엑스포트
export type RootState = ReturnType<typeof store.getState>;


// 아래와 같이 간단하게 store를 생성해도 된다. 
/*
const store = configureStore({
  reducer: {
    counter: counterSlice.reducer,
  },
  middleware: [...getDefaultMiddleware(), logger]
  devTools: process.env.NODE_ENV === 'development'
});
*/

 

3) 리액트컴포넌트.tsx 파일

- useSelector, useDispatch 를 통해 리덕스를 사용한다.

// *** reduxPage.tsx 파일
// 실제로 Redux 를 사용하는 리액트 컴포넌트이다. (클라이언트 브라우저에서 작동한다.)
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { plusCounter } from '../redux/counterSlice';
import { RootState } from '../redux/reduxStore';

export default function ReduxPage() {
  // 리덕스 상태를 불러온다.
  const counterValue = useSelector((state: RootState) => state.counter.value);

  // 버튼 클릭시, 리덕스 상태를 업데이트 하는 액션을 디스패치한다.
  const dispatch = useDispatch();
  const handlePluseCounter = () => dispatch(plusCounter(10));
  
  return (
    <div>
        <p>counterValue : {counterValue}</p>
        <button onClick={handlePluseCounter}>counter 증가</button>
    </div>
  );
}

 

4) _app.tsx 파일

- 전역에서 리덕스 store를 사용할 수 있도록 Provider store 설정해준다.

// *** _app.tsx 파일
// 전역에서 사용할 수 있도록 Provider 를 통해 store를 주입해준다.

import '../styles/globals.css';
import type { AppProps } from 'next/app';
import { Provider } from 'react-redux';
import store from '../redux/store';

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <div className="App">
    
      {/* store를 주입해준다. 일반 리덕스 사용법과 동일하다. */}
      <Provider store={store}>
        <Component {...pageProps} />
      </Provider>
      
    </div>
  );
}

export default MyApp;

 

 

브라우저에서만 리덕스를 사용하는 경우는 이렇게 쉽게 설정이 끝난다.

Next.js 공식홈에서도 샘플을 제공하니 참고하자.

https://github.com/vercel/next.js/tree/canary/examples/with-redux

 

 

2. 서버 상에서 Redux를 사용하는 경우( next-redux-wrapper 사용 필요!)

- next.js 서버 상에서 redux 를 컨트롤하는 경우다.

- 즉, getServerSideProps나 getStaticProps 등에서 리덕스 액션을 디스패치 한다면,

- next-redux-wrapper를 사용하는게 편리하다.

 

- 일단, npm install next-redux-wrapper 설치해주고 시작하자.

 

1) xxxSlice.ts 파일

- 슬라이스를 생성한다. thunk 도 여기서 생성해주면 된다.

// *** counterSlice.ts 파일
// 슬라이스를 생성해준다.

import { createSlice, PayloadAction, createAsyncThunk, AnyAction } from '@reduxjs/toolkit';
import { RootState } from '../store/store';

// initialState 타입 정의
export interface CounterState {
  value: number;
  status: 'idle' | 'loading' | 'failed';
}

// initialState 생성
const initialState: CounterState = {
  value: 0,
  status: 'idle'
};

// Thunk 예시
export const fetchAsync = createAsyncThunk('counter/fetchAsync', async (text: string) => {
  console.log('thunk...', text);
  const resp = await fetch('https://api.countapi.xyz/hit/opesaljkdfslkjfsadf.com/visits');
  const data = await resp.json();
  return data.value;
});

// slice 생성
const couterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    // action의 타입은 PayloadAction<제네릭> 으로 지정해준다.
    plusCounter: (state: CounterState, action: PayloadAction<number>) => {
      state.value += action.payload;
    },
    minusCounter: (state: CounterState, action: PayloadAction<number>) => {
      state.value -= action.payload;
    }
  },
  // thunk 처리
  extraReducers: {
    [fetchAsync.pending.type]: (state) => {
      state.status = 'loading';
    },
    [fetchAsync.fulfilled.type]: (state, action) => {
      state.status = 'idle';
      state.value = action.payload;
    },
    [fetchAsync.rejected.type]: (state) => {
      state.status = 'failed';
    }
  }
});

// Action 익스포트
export const { plusCounter, minusCounter } = couterSlice.actions;

// slice 익스포트
export default couterSlice;


// *** 참고: extraReducers 를 buidler.addCase 로 작성해도 된다.
// const couterSlice = createSlice({
//     name: 'counter',
//     initialState,
//     reducers: {
//         plusCounter: (state: CounterState, action: PayloadAction<number>) => {
//             state.value += action.payload;
//         },
//         minusCounter: (state: CounterState, action: PayloadAction<number>) => {
//             state.value -= action.payload;
//         }
//     },
//     extraReducers: (builder) => {
//         builder
//             .addCase(fetchAsync.pending, (state) => {
//                 state.status = 'loading';
//             })
//             .addCase(fetchAsync.fulfilled, (state, action) => {
//                 state.status = 'idle';
//                 state.value = action.payload;
//             })
//             .addCase(fetchAsync.rejected, (state) => {
//                 state.status = 'failed';
//             });
//     }
// });

 

2) store.ts 파일

1) 루트리듀서 생성: HYDRATE 액션 처리 + 슬라이스를 통합한다.

2) 리덕스 store 생성 : 리듀서, 미들웨어, 개발자도구를 설정한다.

3) wrapper 생성: next-redux-wrapper의 wrapper를 생성하고, _app.tsx 파일에서 사용한다.

// *** store.ts 파일
// 1. 루트리듀서를 만든다 : HYDRATE 액션을 처리하고, 슬라이스들을 통합한다.
// 2. store 생성함수를 만든다.
// 3. next-redux-wrapper 라이브러리의 wrapper를 만들어 export 해준다.

import { configureStore, Reducer, AnyAction, ThunkAction, Action, CombinedState, getDefaultMiddleware } from '@reduxjs/toolkit';
import { HYDRATE, createWrapper } from 'next-redux-wrapper';
import { combineReducers } from 'redux';
import logger from 'redux-logger';

import couterSlice, { CounterState } from '../reducers/counterSlice';
import numberSlice, { NumberState } from '../reducers/numberSlice';

// ### 리듀서 State 타입 정의
export interface ReducerStates {
  counter: CounterState;
  number: NumberState;
}

// ### 루트 리듀서 생성
// 1) next-redux-wrapper의 HYDRATE 액션을 정의해주고,
// 2) 슬라이스들을 통합한다.
// next-redux-wrapper의 사용 매뉴얼이므로 그냥 이대로 해주면 알아서 처리된다.
const rootReducer = (state: ReducerStates, action: AnyAction): CombinedState<ReducerStates> => {
  switch (action.type) {
    // next-redux-wrapper의 HYDRATE 액션 처리(그냥 이렇게만 해주면 된다.)
    case HYDRATE:
      return action.payload;

    // 슬라이스 통합
    default: {
      const combinedReducer = combineReducers({
        counter: couterSlice.reducer,
        number: numberSlice.reducer
        // [couterSlice.name]: couterSlice.reducer,
        // [numberSlice.name]: numberSlice.reducer
      });
      return combinedReducer(state, action);
    }
  }
};

// ### store 생성 함수
const makeStore = () => {
  // 미들웨어 추가 (필요 없으면 생략)
  const middleware = getDefaultMiddleware();
  if (process.env.NODE_ENV === 'development') {
    middleware.push(logger);
  }

  // store 생성
  const store = configureStore({
    reducer: rootReducer as Reducer<ReducerStates, AnyAction>, // 리듀서
    middleware, // 미들웨어
    // middleware: [...getDefaultMiddleware(), logger]
    devTools: process.env.NODE_ENV === 'development' // 개발자도구
  });

  // store 반환
  return store;
};

// ### 타입 익스포트
export type AppStore = ReturnType<typeof makeStore>; // store 타입
export type RootState = ReturnType<typeof rootReducer>; // RootState 타입
// export type RootState = ReturnType<AppStore['getState']>; // RootState 타입(위와 동일함)
export type AppDispatch = AppStore['dispatch']; // dispatch 타입
export type AppThunk<ReturnType = void> = ThunkAction<ReturnType, RootState, unknown, Action>; // Thunk 를 위한 타입

// ### next-redux-wrapper의 wrapper 생성
const wrapper = createWrapper<AppStore>(makeStore, {
  debug: process.env.NODE_ENV === 'development'
});

// wrapper 익스포트
export default wrapper;

 

3) [옵션] 커스텀훅.ts 파일

- RootState 및 Thunk 를 쉽게 사용하기 위한 커스텀훅을 생성해 사용한다.

- Thunk 를 사용하지 않는다면 굳이 생성하지 않아도 된다.

// *** hooks.ts 파일
// 타입스크립트에서 useSelector와 useDisaptch 를 편하게 사용하게 해주는 커스텀 훅이다.
// 필요없다면 생략해도 된다.

import { useDispatch, useSelector } from 'react-redux';
import type { TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from './store';

// # useDispatch 대용 훅 : Thunk 사용을 쉽게 해준다.
export const useAppDispatch: () => AppDispatch = useDispatch;

// # useSelector 대용 훅: useSelector 사용시 state 뒤에 붙여야 하는 RootState를 안붙여도 된다.
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

 

4) _app.tsx 파일

- wrapper 로 컴포넌트를 감싸준다. (HOC 개념)

- 이로써 Next.js  서버단에서도 브라우저의 리덕스 상태를 관리할 수 있게 된다.

- wrapper가 HYDRATE액션 처리는 물론, Provider store = {store} 까지 알아서 등록해준다.

// *** _app.tsx 파일
// wrapper 로 컴포넌트를 감싸준다.
import '../styles/globals.css';
import type { AppProps } from 'next/app';
import wrapper from '../store/store';

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <div className="App">
      <Component {...pageProps} />
    </div>
  );
}

export default wrapper.withRedux(MyApp);

/* 
wrapper 로 App 컴포넌트를 감싸준다.
브라우저의 redux 상태 동기화는 물론, Provider store 까지 알아서 주입해준다.
*/

 

5) 리액트컴포넌트.tsx 파일

- 이제 next.js 어느 영역에서나 리덕스를 자유롭게 사용할 수 있다.

// *** index.tsx 파일
// 리액트 컴포넌트 내부는 물론,
// 서버 구동 영역(getServerSideProps 등) 에서도 리덕스를 사용할 수 있다.

import type { GetServerSideProps, InferGetServerSidePropsType, NextPage } from 'next';
import Head from 'next/head';
import { useSelector, useDispatch } from 'react-redux';
import { RootState } from '../store/store';
import { fetchAsync, minusCounter, plusCounter } from '../reducers/counterSlice';
import { minusNumber, plusNumber } from '../reducers/numberSlice';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import wrapper from '../store/store';

// 리액트 컴포넌트 함수
const Home: NextPage = (props: InferGetServerSidePropsType<typeof getServerSideProps>) => {

  // thunk 사용을 위해 useDispatch 대신, 커스텀훅으로 정의한 useAppDispatch 를 사용한다.
  const dispatch = useAppDispatch();

  // useSelector 대신, 커스텀훅으로 정의한 useAppSelector를 사용하면, RootState 를 생략할 수 있다.
  const counter = useAppSelector((state) => state.counter.value);
  const number = useAppSelector((state) => state.number.value);
  const response = useAppSelector((state) => state.counter.value);

  // thunk 미사용시, 기존의 useSelector, useDispatch를 사용해도 된다.
  // const counter = useSelector((state: RootState) => state.counter.value);
  // const number = useSelector((state: RootState) => state.number.value);
  // const dispatch = useDispatch();

  return (
    <div>
      <p>counter: {counter}</p>
      <button onClick={() => dispatch(plusCounter(1))}>Plus</button>
      <button onClick={() => dispatch(minusCounter(1))}>Minus</button>

      <p>number: {number}</p>
      <button onClick={() => dispatch(plusNumber(1))}>Plus</button>
      <button onClick={() => dispatch(minusNumber(1))}>Minus</button>

      {/* thunk 사용 예시 */}
      <p>thunk : {response}</p>
      <button onClick={() => dispatch(fetchAsync('hello'))}>Thunk</button>

    </div>
  );
};

// SSR: 서버에서 구동되는 영역
export const getServerSideProps: GetServerSideProps = wrapper.getServerSideProps((store) => async (context) => {

  // 서버 영역에서 Redux 사용
  store.dispatch(plusNumber(10));
  store.dispatch(minusCounter(50));

  return { props: { message: 'Message from SSR'} };
});

export default Home;

 

서버에서 리덕스를 사용하기 위한 

Next.js + 리덕스 툴킷(Thunk 포함) + TypeScript  설정 방법은 이걸로 끝!

 

next-redux-wrapper 공식 깃헙에서 제공하는 리덕스 툴킷 연동 샘플 코드는 아래 링크를 참고하자.

https://github.com/kirill-konshin/next-redux-wrapper#redux-toolkit


※ 추가로 몇 가지를 더 정리해 보고자 한다.

 

1. 루트 리듀서 없이, Next.js + ReduxToolkit + TypeScript 연동 방법.

2. 리덕스 사가(Redux-Saga) 사용시 주의 사항.


1. Next.js + ReduxToolkit + TypeScript 연동 방법. (루트 리듀서 X)

- 루트 리듀서에서 처리해주던 HYDRATE 액션을 각 슬라이스에서 처리해주기만 하면 된다.

 

1) xxxSlice.ts 파일

- 슬라이스 생성시, creatSlice() 내부의 extraReducers 를 통해 HYDRATE 액션을 처리해준다.

- "action.payload.속성" 을 리턴해줘야 정상적으로 리덕스 state가 변경 처리되는 점에 주의하자.

// *** counterSlice.ts 파일
// 슬라이스를 정의해준다.
// 매뉴얼에 따라 extraReducers에서 HYDRATE 액션을 처리해준다.

import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit';
import { HYDRATE } from 'next-redux-wrapper';
import { RootState } from '../store/store';

// initialState 타입 정의
export interface CounterState {
  value: number;
  status: 'idle' | 'loading' | 'failed';
}

// initialState 생성
const initialState: CounterState = {
  value: 0,
  status: 'idle'
};

// Thunk 예시
export const fetchAsync = createAsyncThunk('counter/fetchAsync', async (text: string) => {
  console.log('thunk...', text);
  const resp = await fetch('https://api.countapi.xyz/hit/opesaljkdfslkjfsadf.com/visits');
  const data = await resp.json();
  return data.value;
});

// slice 생성
const couterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    // action의 타입은 PayloadAction<제네릭> 으로 지정해준다.
    plusCounter: (state: CounterState, action: PayloadAction<number>) => {
      state.value += action.payload;
    },
    minusCounter: (state: CounterState, action: PayloadAction<number>) => {
      state.value -= action.payload;
    }
  },

  extraReducers: {
    // extraReducers를 통해 HYDRATE 액션을 처리해준다.
    [HYDRATE]: (state, action) => {
      // "action.payload.속성" 을 리턴해줘야 한다.
      return {
        ...state,
        ...action.payload.counter
      };
    },
    // thunk 처리 (Thunk가 없다면 생략해도 된다)
    [fetchAsync.pending.type]: (state) => {
      state.status = 'loading';
    },
    [fetchAsync.fulfilled.type]: (state, action) => {
      state.status = 'idle';
      state.value = action.payload;
    },
    [fetchAsync.rejected.type]: (state) => {
      state.status = 'failed';
    }
  }
});

// addCase 로 아래와 같이 정의해줘도 된다.
// const couterSlice = createSlice({
//     name: 'counter',
//     initialState,
//     reducers: {
//         plusCounter: (state: CounterState, action: PayloadAction<number>) => {
//             state.value += action.payload;
//         },
//         minusCounter: (state: CounterState, action: PayloadAction<number>) => {
//             console.log('minusCounter 리듀서 실행');
//             state.value -= action.payload;
//         }
//     },
//     extraReducers: (builder) => {
//         builder
//             .addCase(HYDRATE, (state, action: AnyAction) => {
//                 return {
//                     ...state,
//                     ...action.payload.counter
//                 };
//             })
//             .addCase(fetchAsync.pending, (state) => {
//                 state.status = 'loading';
//             })
//             .addCase(fetchAsync.fulfilled, (state, action) => {
//                 state.status = 'idle';
//                 state.value = action.payload;
//             })
//             .addCase(fetchAsync.rejected, (state) => {
//                 state.status = 'failed';
//             });
//     }
// });

// Action 익스포트
export const { plusCounter, minusCounter } = couterSlice.actions;

// slice 익스포트
export default couterSlice;

 

2) store.ts 파일

각 슬라이스에서 HYDRATE 액션을 처리하므로,

루트 리듀서 대신, configureStore으로 슬라이스를 통합 처리만 해준다.

// *** store.ts 파일
// 1. store 생성함수를 만든다.
// 2. next-redux-wrapper 라이브러리의 wrapper를 만들어 export 해준다.

import { configureStore, ThunkAction, Action, getDefaultMiddleware } from '@reduxjs/toolkit';
import { createWrapper } from 'next-redux-wrapper';
import logger from 'redux-logger';

import couterSlice from '../reducers/counterSlice';
import numberSlice from '../reducers/numberSlice';

// ### store 생성함수
// 리듀서 통합, 미들웨어 설정, 개발자 도구 설정
const makeStore = () => {
  // 미들웨어 추가 (필요 없으면 생략)
  const middleware = getDefaultMiddleware(); // 디폴트로 세팅된 미들웨어 배열
  if (process.env.NODE_ENV === 'development') {
    // 개발모드에서만 redux-logger 미들웨어 추가했다.
    middleware.push(logger);
  }

  const store = configureStore({
    reducer: {
      // counter: couterSlice.reducer,
      // number: numberSlice.reducer
      [couterSlice.name]: couterSlice.reducer,
      [numberSlice.name]: numberSlice.reducer
    },
    middleware,
    // middleware: [...getDefaultMiddleware(), logger]
    devTools: process.env.NODE_ENV === 'development'
  });
  return store;
};

// ### 타입 익스포트
export type AppStore = ReturnType<typeof makeStore>; // store 타입
export type RootState = ReturnType<AppStore['getState']>; // RootState 타입
export type AppDispatch = AppStore['dispatch']; // dispatch 타입
export type AppThunk<ReturnType = void> = ThunkAction<ReturnType, RootState, unknown, Action>; // Thunk 를 위한 타입

// ### next-redux-wrapper의 wrapper 생성
const wrapper = createWrapper<AppStore>(makeStore, {
  debug: process.env.NODE_ENV === 'development'
});

// wrapper 익스포트
export default wrapper;

 

나머지는 모두 동일하다.

루트 리듀서의 타입을 지정해주지 않아도 되고, 전체적으로 코드가 짧아짐을 알 수 있다.

단, 모든 슬라이스 에서 HYDRATE 액션을 처리해줘야 하는 불편함이 있다.

 

원리를 알면 이렇게 입맛에 맞게 변형해서 사용할 수 있다.

 

2. redux-saga 미들웨어 사용시 주의 사항

Next.js 서버 환경에서  redux-saga 미들웨어는 아래와 같이 사용할 수 있다.

 

1) store.ts 파일

일단 redux-toolkit과 redux-saga 를 연동 설정해준다.

store 에 sagaTask 를 설정해줘야 Next.js 서버 환경에서 정상적으로 구동할 수 있다.

import { configureStore, Reducer, AnyAction, ThunkAction, Action, CombinedState, getDefaultMiddleware } from '@reduxjs/toolkit';
import { HYDRATE, createWrapper, Context } from 'next-redux-wrapper';
import { combineReducers, Store } from 'redux';
import createSagaMiddleware, { Task } from 'redux-saga';
import couterSlice, { CounterState } from '../reducers/counterSlice';
import numberSlice, { NumberState } from '../reducers/numberSlice';
import rootSaga from "./sagas";

// ### 리듀서 State 타입 정의
export interface ReducerStates {
  counter: CounterState;
  number: NumberState;
}

// ### 루트 리듀서 생성
const rootReducer = (state: ReducerStates, action: AnyAction): CombinedState<ReducerStates> => {
  switch (action.type) {
    case HYDRATE:
      console.log('HYDRATE:', HYDRATE);
      return action.payload;
    default: {
      const combinedReducer = combineReducers({
        counter: couterSlice.reducer,
        number: numberSlice.reducer
      });
      return combinedReducer(state, action);
    }
  }
};

// SagaStore 타입 정의
export interface SagaStore extends Store {
  sagaTask?: Task;
}

// store 생성 함수
export const makeStore = (context: Context) => {

  // Saga 미들웨어
  const sagaMiddleware = createSagaMiddleware();

  const store = configureStore({
    reducer: rootReducer as Reducer<ReducerStates, AnyAction>,
    middleware: [...getDefaultMiddleware({ thunk: false }), sagaMiddleware],
    devTools: process.env.NODE_ENV === 'development'
  });

  // Next.js 서버에서도 사가를 구동한다. sagaTask 정의를 빼먹으면 안 된다.
  (store as SagaStore).sagaTask = sagaMiddleware.run(rootSaga);

  return store;
};

export type AppStore = ReturnType<typeof makeStore>; // store 타입
export type RootState = ReturnType<AppStore['getState']>; // RootState 타입
export type AppDispatch = AppStore['dispatch']; // dispatch 타입
export type AppThunk<ReturnType = void> = ThunkAction<ReturnType, RootState, unknown, Action>; // Thunk 를 위한 타입

// ### next-redux-wrapper의 wrapper 생성
const wrapper = createWrapper<AppStore>(makeStore, {
  debug: process.env.NODE_ENV === 'development'
});

// wrapper 익스포트
export default wrapper;

 

2) 리액트컴포넌트.tsx 파일

getServerSideProps와 같이 Next.js 서버에서 구동되는 환경에서 Saga로 비동기 통신을 수행하는 코드다.

export const getServerSideProps: GetServerSideProps = wrapper.getServerSideProps((store) => async (context) => {

  store.dispatch(비동기통신액션);
  store.dispatch(END);
  await store.sagaTask.toPromise();

  return { props: { message: 'Message from SSR' };
});

 

 

- saga 를 통해 비동기 통신을 수행하고,

- 응답 받은 결과로 리덕스 Store 상태를 업데이트한 다음,

- 이 상태를 기반으로 Server-Side Rendering 한 결과물을 브라우저에 보내려면,

 

=> 아래 2줄 코드를 뒤에 꼭 붙여줘야 한다.

  store.dispatch(END);
  await store.sagaTask.toPromise();

 

위 2줄 코드는 응답결과가 제대로 올 때까지 기다리게 해주는 역할을 한다.

makeStore()  함수에서 설정한 store.sagaTask가바로 여기서 사용된다.(그래서 빼먹으면 안된다)

 

만약 위 코드를 사용하지 않으면, 비동기 통신 결과가 오기도 전에

먼저 Server-Side Rendering 한 결과물을 브라우저에게 보내버리는 사태가 발생할 수도 있으니 주의하자.

 

매뉴얼 상엔 아래와 같이 설명되어 있다.

 

In order to use it with getServerSideProps or getStaticProps you need to await for sagas in each page's handler.

please ensure that you await any and all sagas within any Next.js page methods.

If you miss it on one of pages you'll end up with inconsistent state being sent to client. 

 

참고 링크 : https://github.com/kirill-konshin/next-redux-wrapper#usage-with-redux-saga


이것으로 Next.js 에서 Redux-Toolkit 을 사용하는 방법 정리를 마친다.

 

생각보다 너무나 길어진 글인데,

사실 개인적으로 Redux-Toolkit 은 "브라우저"의 전역상태 관리에만 사용하는 편이며,

서버와 통신하는 데이터 및 상태 관리는 React-Query나 SWR을 사용하려 하고 있다.

(Redux는 경우에 따라 useContext, useReducer 등으로 대체하기도 한다.)

 

Redux를 사용하다보면,

사실상 전역상태 관리보다는, 비동기 통신을 위해 Redux-Saga 같은 미들웨어를 사용하기 위한 용도로 전락하는 경우가 많다.

코드 또한 너무 늘어나서 효율성이 떨어지기도 하고, 흐름을 파악하기도 점점 어려워지곤 한다.

 

상황에 맞게 잘 조합해서 효율적으로 사용하면 좋을듯 하다.