๐Ÿ˜€ Next.js + TypeScript + Redux + Redux-Saga ์ ์šฉ

๋ฐ•์ƒ์€ยท2022๋…„ 7์›” 2์ผ
0

๐Ÿƒ blegram

๋ชฉ๋ก ๋ณด๊ธฐ
2/20
post-thumbnail

Next.js + Redux + Redux-Saga + TypeScript๋ฅผ ์‚ฌ์šฉํ•œ ํ”„๋กœ์ ํŠธ์— ๋Œ€ํ•œ ํฌ์ŠคํŠธ์ž…๋‹ˆ๋‹ค.

๐Ÿค” ์‚ฌ์šฉ ์ด์œ 

ํ˜„์žฌ ํ”„๋กœ์ ํŠธ์— ์‚ฌ์šฉํ•˜๋Š” ๋ฐ์ดํ„ฐ๋“ค์„ ๋ณด๋‹ค ๋” ํšจ์œจ์ ์ด๊ณ  ํŽธํ•˜๊ฒŒ ๊ด€๋ฆฌํ•˜๊ธฐ ์œ„ํ•ด์„œ ์ƒํƒœ ๊ด€๋ฆฌ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์ธ Redux๋ฅผ ์„ ํƒํ–ˆ์Šต๋‹ˆ๋‹ค. ๋˜ํ•œ ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ์™€ ๊ฐ์ข… ์œ ์šฉํ•œ ์ดํŽ™ํŠธ๋“ค์„ ์ด์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ Redux-Saga๋ฅผ ์„ ํƒํ–ˆ์Šต๋‹ˆ๋‹ค.

๐Ÿง Redux + Redux-Saga + Next.js ์„ธํŒ…

๊ธฐ๋ณธ์ ์ธ Redux์˜ ํ˜•ํƒœ๋Š” ์—ฌ๊ธฐ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๋งŒ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค.

1. ํด๋” ๊ตฌ์กฐ

  1. /src/store/actions: ์•ก์…˜ ํฌ๋ฆฌ์—์ดํ„ฐ ํ•จ์ˆ˜์™€ ์•ก์…˜๋“ค์˜ ํƒ€์ž…์„ ์ •์˜ํ•˜๋Š” ๊ณต๊ฐ„
  2. /src/store/api: ajax ์š”์ฒญ ํ•จ์ˆ˜๋ฅผ ์ •์˜ํ•˜๋Š” ๊ณต๊ฐ„
  3. /src/store/reducers: ๋ฆฌ๋“€์„œ๋“ค์„ ์ •์˜ํ•˜๊ณ  index.ts์—์„œ rootReducer๋ฅผ ์ •์˜
  4. /src/store/sagas: ์‚ฌ๊ฐ€๋“ค์„ ์ •์˜ํ•˜๊ณ  index.ts์—์„œ rootSaga๋ฅผ ์ •์˜
  5. /src/configureStore.ts: ๋ฆฌ๋“€์„œ, ์‚ฌ๊ฐ€, ๋ฏธ๋“ค์›จ์–ด๋ฅผ ํ•ฉ์ณ ์Šคํ† ์–ด๋ฅผ ์ƒ์„ฑํ•˜๋Š” ๊ณต๊ฐ„
  6. /src/store/types.ts: ๋ฆฌ๋•์Šค์—์„œ ์‚ฌ์šฉํ•˜๋Š” ๋ชจ๋“  ํƒ€์ž…๋“ค์„ ์ •์˜ํ•˜๋Š” ๊ณต๊ฐ„

2. store ์ƒ์„ฑ ๋ฐ ์„ธํŒ…

import {
  legacy_createStore as createStore,
  applyMiddleware,
  compose,
} from "redux";
import createSagaMiddleware from "redux-saga";
import { createWrapper } from "next-redux-wrapper";
import { composeWithDevTools } from "redux-devtools-extension";

import rootReducer from "./reducers";
import rootSaga from "./sagas";

const configureStore = () => {
  const sagaMiddleware = createSagaMiddleware();
  const middlewares = [sagaMiddleware];
  const enhancer =
    process.env.NEXT_PUBLIC_NODE_ENV === "production"
      ? compose(applyMiddleware(...middlewares))
      : composeWithDevTools(applyMiddleware(...middlewares));

  const store = createStore(rootReducer, enhancer);
  // typescript์—์„œ ์˜ค๋ฅ˜๊ฐ€ ์•ˆ๋‚˜๊ธฐ ์œ„ํ•ด์„œ "redux.d.ts"์ƒ์„ฑ
  store.sagaTask = sagaMiddleware.run(rootSaga);

  return store;
};

// ๋‚˜์ค‘์— "_app.tsx"์—์„œ ๊ฐ์‹ธ์คŒ
const wrapper = createWrapper(configureStore, {
  debug: process.env.NEXT_PUBLIC_NODE_ENV === "development",
});

export default wrapper;
  • @types/redux.d.ts
import "redux";
import { Task } from "redux-saga";

declare module "redux" {
  export interface Store {
    sagaTask?: Task;
  }
}
  • pages/_app.tsx
import type { AppProps } from "next/app";

// store
import wrapper from "@src/store/configureStore";

function MyApp({ Component, pageProps }: AppProps) {
  // redux๊ด€๋ จ ์ฝ”๋“œ ์ œ์™ธํ•˜๊ณ  ๋ชจ๋‘ ์ƒ๋žต
  return (
    <>
      <Component {...pageProps} />
    </>
  );
}

// "/src/configureStore.ts"์—์„œ ์ƒ์„ฑํ•œ wrapper
export default wrapper.withRedux(MyApp);

3. SSR ์ ์šฉ ( SSG, ISR ๋“ฑ )

import type {
  GetServerSideProps,
  GetServerSidePropsContext,
  NextPage,
} from "next";

// redux + server-side-rendering
import wrapper from "@src/store/configureStore";
import { END } from "redux-saga";
import { axiosInstance } from "@src/store/api";

// actions
import { loadToMeRequest, loadPostsRequest } from "@src/store/actions";

// redux์™€ ๊ด€๋ จ ์—†๋Š” ์ฝ”๋“œ ์ƒ๋žต
const Home: NextPage = () => {
  return (<></>);
};

export const getServerSideProps: GetServerSideProps =
  wrapper.getServerSideProps(
    (store) => async (context: GetServerSidePropsContext) => {
      /**
       * front-server์™€ backend-server๊ฐ€ ์„œ๋กœ ๋‹ค๋ฅด๊ธฐ๋•Œ๋ฌธ์—
       * axios์˜ "withCredentials" ์˜ต์…˜์œผ๋กœ ๋ธŒ๋ผ์šฐ์ €์˜ ์ฟ ํ‚ค๋ฅผ ์ „๋‹ฌํ•  ์ˆ˜ ์—†์Œ
       * ๋”ฐ๋ผ์„œ ์ง์ ‘ axios์— ์ฟ ํ‚ค๋ฅผ ๋„ฃ์–ด์ฃผ๊ณ  ์„œ๋ฒ„๋กœ ์š”์ฒญ ํ›„ ๋‹ค์‹œ axios์˜ ์ฟ ํ‚ค๋ฅผ ์ œ๊ฑฐํ•ด์ฃผ๋Š” ๊ณผ์ •์„ ๊ฑฐ์นจ
       * ํด๋ผ์ด์–ธํŠธ๋Š” ์—ฌ๋Ÿฌ ๋Œ€์ง€๋งŒ ์„œ๋ฒ„๋Š” ํ•œ๋Œ€์ด๊ธฐ ๋•Œ๋ฌธ์— ์„œ๋ฒ„ ์‚ฌ์šฉํ•œ ์ฟ ํ‚ค๋Š” ๋ฐ˜๋“œ์‹œ ์ œ๊ฑฐํ•ด ์ค˜์•ผ ํ•จ
       */
      let cookie = context.req?.headers?.cookie;
      cookie = cookie ? cookie : "";
      axiosInstance.defaults.headers.Cookie = cookie;

      // ์„œ๋ฒ„ ์‚ฌ์ด๋“œ์—์„œ dispatchํ•  ๋‚ด์šฉ์„ ์ ์–ด์คŒ
      store.dispatch(loadToMeRequest());
      store.dispatch(loadPostsRequest({ lastId: -1, limit: 15 }));

      // ๋ฐ‘์— ๋‘ ๊ฐœ๋Š” REQUEST์ดํ›„ SUCCESS๊ฐ€ ๋  ๋•Œ๊นŒ์ง€ ๊ธฐ๋‹ค๋ ค์ฃผ๊ฒŒ ํ•ด์ฃผ๋Š” ์ฝ”๋“œ
      store.dispatch(END);
      await store.sagaTask?.toPromise();

      // ์œ„์—์„œ ๋งํ•œ๋Œ€๋กœ axios์˜ ์ฟ ํ‚ค ์ œ๊ฑฐ
      axiosInstance.defaults.headers.Cookie = "";
      
      // ์œ„์˜ ์ž‘์—…๋“ค์ด ์ •์ƒ์ž‘๋™์„ ํ•œ๋‹ค๋ฉด ํด๋ผ์ด์–ธํŠธ์—์„œ ๋ Œ๋”๋งํ•  ๋•Œ ์ด๋ฏธ redux์˜ store์— ๋ฐ์ดํ„ฐ๊ฐ€ ๋“ค์–ด๊ฐ€ ์žˆ์Œ
	  // ๋”ฐ๋ผ์„œ props์— ๋ฐ์ดํ„ฐ๋ฅผ ์ „๋‹ฌํ•  ํ•„์š” ์—†์Œ
      return {
        props: {},
      };
    }
  );

export default Home;

โœจ ์˜ˆ์‹œ

๊ฐ„๋‹จํ•˜๊ฒŒ ๋กœ๊ทธ์ธ๊ด€๋ จ ์ฝ”๋“œ๋งŒ ์ถ”๊ฐ€ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ๋˜ํ•œ rootReducer์™€ rootSaga ์ƒ์„ฑ๊ณผ ๊ด€๋ จ๋œ ์ฝ”๋“œ๋Š” ์ œ์™ธํ–ˆ์Šต๋‹ˆ๋‹ค.

1. type

export const LOCAL_LOGIN_REQUEST = "LOCAL_LOGIN_REQUEST" as const;
export const LOCAL_LOGIN_SUCCESS = "LOCAL_LOGIN_SUCCESS" as const;
export const LOCAL_LOGIN_FAILURE = "LOCAL_LOGIN_FAILURE" as const;
export type LogInBody = {
  id: string;
  password: string;
};
export type LogInResponse = {
  ok: boolean;
  message: string;
  user: SimpleUser;
};

2. action

import {
  LOCAL_LOGIN_FAILURE,
  LOCAL_LOGIN_REQUEST,
  LOCAL_LOGIN_SUCCESS,
  LogInBody,
  LogInResponse,
} from "@src/store/types";

// 2022/05/06 - ๋กœ์ปฌ ๋กœ๊ทธ์ธ ์•ก์…˜ ํฌ๋ฆฌ์—์ดํ„ฐ - by 1-blue
export const localLoginRequest = (data: LogInBody) => ({
  type: LOCAL_LOGIN_REQUEST,
  data,
});
export const localLoginSuccess = (data: LogInResponse) => ({
  type: LOCAL_LOGIN_SUCCESS,
  data,
});
export const localLoginFailure = (data: FailureResponse) => ({
  type: LOCAL_LOGIN_FAILURE,
  data,
});

3. reducer

import {
  RESET_MESSAGE,
  LOCAL_LOGIN_REQUEST,
  LOCAL_LOGIN_SUCCESS,
  LOCAL_LOGIN_FAILURE,
  LOCAL_LOGOUT_REQUEST,
  LOCAL_LOGOUT_SUCCESS,
  LOCAL_LOGOUT_FAILURE,
  SIGNUP_REQUEST,
  SIGNUP_SUCCESS,
  SIGNUP_FAILURE,
} from "@src/store/types";
import type { AuthActionRequest } from "../actions";

type StateType = {
  loginLoading: boolean;
  loginDone: null | string;
  loginError: null | string;
};

const initState: StateType = {
  // 2022/05/06 - ๋กœ๊ทธ์ธ ๊ด€๋ จ ๋ณ€์ˆ˜ - by 1-blue
  loginLoading: false,
  loginDone: null,
  loginError: null,
};

function authReducer(prevState: StateType = initState, action: AuthActionRequest): StateType {
  switch (action.type) {
    // 2022/05/13 - ๋ฆฌ์…‹ ๋ฉ”์‹œ์ง€ - by 1-blue
    case RESET_MESSAGE:
      return {
        ...prevState,
        loginLoading: false,
        loginDone: null,
        loginError: null,
      };

    // 2022/05/06 - ๋กœ๊ทธ์ธ - by 1-blue
    case LOCAL_LOGIN_REQUEST:
      return {
        ...prevState,
        loginLoading: true,
        loginDone: null,
        loginError: null,
      };
    case LOCAL_LOGIN_SUCCESS:
      return {
        ...prevState,
        loginLoading: false,
        loginDone: action.data.message,
      };
    case LOCAL_LOGIN_FAILURE:
      return {
        ...prevState,
        loginLoading: false,
        loginError: action.data.message,
      };

    default:
      return prevState;
  }
}

export default authReducer;

4. api

const axiosInstance = axios.create({
  baseURL: process.env.NEXT_PUBLIC_SERVER_URL + "/api",
  withCredentials: true,
  timeout: 10000,
});

// type
import type { LogInBody, LogInResponse, SignUpBody } from "../types";

export const apiLocalLogin = (body: LogInBody) => axiosInstance.post<LogInResponse>("/auth", body);

5. saga

import { all, call, fork, put, takeLatest } from "redux-saga/effects";

// types
import type { AxiosResponse } from "axios";
import {
  LOCAL_LOGIN_FAILURE,
  LOCAL_LOGIN_REQUEST,
  LOCAL_LOGIN_SUCCESS,
  LogInResponse,
} from "@src/store/types";

// api
import { apiLocalLogin } from "@src/store/api";

function* localLogin(action: any) {
  try {
    const { data }: AxiosResponse<LogInResponse> = yield call(
      apiLocalLogin,
      action.data
    );

    yield put({ type: LOCAL_LOGIN_SUCCESS, data });
    yield put({ type: LOAD_TO_ME_SUCCESS, data });
  } catch (error: any) {
    console.error("authSaga localLogin >> ", error);

    const message =
      error?.name === "AxiosError"
        ? error.response.data.message
        : "์„œ๋ฒ„์ธก ์—๋Ÿฌ์ž…๋‹ˆ๋‹ค. \n์ž ์‹œํ›„์— ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”";

    yield put({ type: LOCAL_LOGIN_FAILURE, data: { message } });
  }
}
function* watchLocalLogin() {
  yield takeLatest(LOCAL_LOGIN_REQUEST, localLogin);
}

export default function* authSaga() {
  yield all([fork(watchLocalLogin)]);
}

๐Ÿ‘ ๋งˆ๋ฌด๋ฆฌ

์˜ˆ์‹œ๋ฅผ ๋ณด๋ฉด ์•Œ ์ˆ˜ ์žˆ๋“ฏ์ด ํ•˜๋‚˜์˜ ์ƒํƒœ๋ฅผ ๋งŒ๋“ค๊ธฐ ์œ„ํ•ด์„œ๋Š” ์ •๋ง ๋งŽ์€ ์ฝ”๋“œ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.
์•ก์…˜, ๋ฆฌ๋“€์„œ, api์š”์ฒญ, ์‚ฌ๊ฐ€, ํƒ€์ž… ์ƒ์„ฑ์˜ ์ •๋ง ๋งŽ์€ ์ฝ”๋“œ๊ฐ€ ๋Š˜์–ด๋‚˜๋Š” ๊ฒŒ ์ƒ๋‹นํžˆ ๋ถˆํŽธํ•˜๊ณ , ์ˆ˜์ •ํ•  ๋•Œ์™€ ์ถ”๊ฐ€ํ•  ๋•Œ๋งˆ๋‹ค ์—ฐ๊ด€๋œ ์ฝ”๋“œ๊ฐ€ ๋„ˆ๋ฌด ๋งŽ๊ณ  ๋‹ค๋ฅธ ํŒŒ์ผ์— ๋‚˜๋‰˜์–ด์žˆ์–ด์„œ ์ž‘์—…ํ•˜๊ธฐ ์ •๋ง ๋ถˆํŽธํ•˜๊ณ  ํž˜๋“ค์—ˆ์Šต๋‹ˆ๋‹ค.
๊ทธ๋ž˜์„œ ๋‹ค์Œ์— redux์—์„œ ์ถ”์ฒœํ•˜๋Š” ๋ฐฉ์‹์ธ redux-toolkit์œผ๋กœ ์ „๋ถ€ ๋ฐ”๊ฟ”๋ณด๋ ค๊ณ  ํ•ฉ๋‹ˆ๋‹ค.

0๊ฐœ์˜ ๋Œ“๊ธ€