programming language/react

[React] redux-saga 연습하기 (reducer + redux-saga 조합) + immer 적용하기

공대키메라 2022. 7. 2. 16:22

지난 시간에 redux-saga에 대해 간단히 알아보았다.

(궁금하면 여기 클릭!)

 

이번 시간에는 다시 좀 더 나은 예제로 다시 연습해보려고 한다. 

 

사실 전에 예시는 무언가 reducer도 나뉘어 있지 않아서 작업을 하는데 있어서 사용함에 있어서 매우 불편했다. 

 

이번에는 좀 더 깔끔하게 구조를 잡고 학습할 것이다.

 

reducer와 redux-saga를 다시 잘 적용하면서 말이다.

 

참고한 내용은 다음과 같다. 

 

출처:

https://www.inflearn.com/course/%EB%85%B8%EB%93%9C%EB%B2%84%EB%93%9C-%EB%A6%AC%EC%95%A1%ED%8A%B8-%EB%A6%AC%EB%89%B4%EC%96%BC

https://react.vlpt.us/basic/23-immer.html

https://ant.design/components/form/#header

 

1. 연습 프로젝트 세팅 / 화면 구성

사실 redux-saga를 사용하는 이유는 서버와 연동해서 데이터를 받아올 때, 비동기 처리를 깔끔하게 처리하기 위해서 많이 사용한다. 

 

그렇기에 필자 키메라는 로그인 폼에서 로그인을 클릭시 데이터를 받아와서 성공, 실패 여부를 판단하는 saga를 작성할 것이다. 

 

그러면 다음 명령어를 실행한다. 

npx create-react-app redux-saga-practice

npm i redux react-redux
npm i antd redux-devtools-extension redux-saga

 

빠르고 보기 좋은 UI component 사용을 위해 antd를 다운받았다.

 

 

완성된 화면은 다음과 같다.

 

다음은 로그인 화면이다.

 

 

다음은 로그인에 성공한 화면이다. 

 

 

2. 코드 구성하기

새로운 기술이 나오면 그것을 쓰는 이유도 동일하겟지만, 

 

결국 우리가 redux-saga를 사용하는 이유는 action을 효율적으로 다루기 위해서이다. 

 

로그인, 로그아웃 기능을 backend와 연락을 주고받아서 성공했다고 가정할 것이다. 

 

이러한 일련의 흐름은 비동기적으로 실행될 것이고, 이를 saga에서 지원해주는 기능을 이용하 작업할 것이다. 

src/index.js

import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import { Provider } from "react-redux";
import { applyMiddleware, createStore } from "redux";
import rootReducer from "./reducers";
import { composeWithDevTools } from "redux-devtools-extension";
import createSagaMiddleware from "redux-saga";
import myLogger from "./Logger/MyLogger";
import rootSaga from "./saga";
const sagaMiddleware = createSagaMiddleware();

const middlewares = [myLogger, sagaMiddleware];

const store = createStore(
  rootReducer,
  composeWithDevTools(applyMiddleware(...middlewares))
);
sagaMiddleware.run(rootSaga); // 루트 사가를 실행

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <Provider store={store}>
    <App />
  </Provider>
);

 

redux-saga를 npm에 install 하게 되면  createSagaMiddleware() 메소드를 사용할 수 있다.

 

redux-saga도 미들웨어인 라이브러리이다. 그래서 필자는 myLogger와 함께 사용하기 위해 middlewares라는 배열에 sagaMiddleware 전에 myLogger를 담아주고, createStore시에 rootReducer를 저장소로 저장한다.

 

이것을 composeWithDevtools( chrome react dev-tools사용 )에 넣어준 후에 Provider내에 우리가 어디서든지 접근할 수 있는 store로 할당해주면 된다. 

src/App.js

import { useSelector } from "react-redux";
import LoginForm from "./Login/LoginForm";
import Profile from "./Login/Profile";
function App() {
  const { loginDone } = useSelector((state) => state.user);
  return <>{loginDone ? <Profile /> : <LoginForm />}</>;
}

export default App;

 

로그인 여부를 loginDone  데이터를 이용해서 판단할 것이다. 

 

true이면 <Profile />을 보여주고, false이면 <LoginForm/>을 출력하는 것이다.

 

참 리액트 좋은게 이렇게 비동기적으로 원하는 component출력 여부를 선택할 수 있으니 얼마나 User Experience가 높을지 안봐도 알겠지 않은가?

src/reducers/index.js

import { combineReducers } from "redux";

import user from "./user";

const rootReducer = combineReducers({
  user,
});

export default rootReducer;

 

여러개의 reducer를 한곳에서 담아서 찾아갈 수 있도록 combineReducer를 index.js에 생성했다. 

src/reducers/user.js

export const initialState = {
  loginHandling: false,
  loginDone: false,
  loginError: null,
};

export const LOG_IN_REQUEST = "LOG_IN_REQUEST";
export const LOG_IN_SUCCESS = "LOG_IN_SUCCESS";
export const LOG_IN_FAILURE = "LOG_IN_FAILURE";

export const LOG_OUT_REQUEST = "LOG_OUT_REQUEST";
export const LOG_OUT_SUCCESS = "LOG_OUT_SUCCESS";
export const LOG_OUT_FAILURE = "LOG_OUT_FAILURE";

export const loginRequestAction = (data) => ({
  type: LOG_IN_REQUEST,
  data,
});

export const logoutRequestAction = (data) => ({
  type: LOG_OUT_REQUEST,
});

const reducer = (state = initialState, action) => {
  switch (action.type) {
    case LOG_IN_REQUEST:
      return {
        ...state,
        loginHandling: true,
      };
    case LOG_IN_SUCCESS:
      return {
        ...state,
        loginHandling: false,
        loginDone: true,
      };
    case LOG_IN_FAILURE:
      return {
        ...state,
        loginHandling: false,
      };
    case LOG_OUT_REQUEST:
      return {
        ...state,
        loginHandling: true,
      };
    case LOG_OUT_SUCCESS:
      return {
        ...state,
        loginHandling: false,
        loginDone: false,
      };
    case LOG_OUT_FAILURE:
      return {
        ...state,
        loginHandling: false,
      };
    default:
      return {
        ...state,
      };
  }
};

export default reducer;

 

불변성을 유지하면서 원하는 상황에 맞는 작업이 일어나도록 initialState의 값을 action의 type에 따라 변화시키고 있다. 

src/saga/index.js

import { all, fork } from "redux-saga/effects";
import userSaga from "./user";

export function* rootSaga() {
  yield all([fork(userSaga)]); // all 은 배열 안의 여러 사가를 동시에 실행시켜줍니다.
    /*
        fork
        fork는 비동기 함수 호출(non blocking)
        fork를 하면 비동기이기 때문에 요청 보내버리고 결과와 상관없이 바로 다음것이 실행됨
    */
}

export default rootSaga;

 

saga도 reducers/index.js와 같이 여러가지 saga를 한곳에서 정리해서 받을 수 있도록 all effect를 이용하였다. 

src/saga/user.js

import { all, delay, fork, put, takeLatest } from "redux-saga/effects";
import {
  LOG_IN_FAILURE,
  LOG_IN_REQUEST,
  LOG_IN_SUCCESS,
  LOG_OUT_FAILURE,
  LOG_OUT_REQUEST,
  LOG_OUT_SUCCESS,
} from "../reducers/user";

function* logIn(action) {
  try {
    console.log("saga logIn");
    // const result = yield call(logInAPI);
    yield delay(1000);
    yield put({ // put : 특정 action을 dispatch 해준다.
      type: LOG_IN_SUCCESS,
      data: action.data,
    });
  } catch (err) {
    console.error(err);
    yield put({
      type: LOG_IN_FAILURE,
      error: err.response.data,
    });
  }
}

function* logOut(action) {
  try {
    console.log("saga logOut");
    // const result = yield call(logInAPI);
    yield delay(1000);
    yield put({
      type: LOG_OUT_SUCCESS,
    });
  } catch (err) {
    console.error(err);
    yield put({
      type: LOG_OUT_FAILURE,
      error: err.response.data,
    });
  }
}

function* watchLogIn() {
  console.log("watchLogIn");
  yield takeLatest(LOG_IN_REQUEST, logIn); 
  // takeLatest : 들어오는 요청이 많아도 가장 최근 요청만 수행함.
}

function* watchLogOut() {
  yield takeLatest(LOG_OUT_REQUEST, logOut);
}

export default function* userSaga() {
  yield all([fork(watchLogIn), fork(watchLogOut)]);
}

 

src/saga/index.js에서 rootSaga에 넣을 saga를 user.js에서 만들어준다. 

 

여기는 어떤 특정 action을 실행했을 때, 행해지는 일련의 과정 (예 : 로그인시 로그인 요청, 성공 혹은 실패 등)을 관리하고 있다. 

src/Login/LoginForm.js

import { Button, Checkbox, Form, Input } from "antd";
import "antd/dist/antd.css";
import { useDispatch, useSelector } from "react-redux";
import { loginRequestAction } from "../reducers/user";

const LoginForm = () => {
  const dispatch = useDispatch();
  const { loginHandling } = useSelector((state) => state.user);

  const onFinish = (values) => {
    dispatch(loginRequestAction(values));
  };

  return (
    <Form
      name="basic"
      style={{ width: 300 }}
      initialValues={{
        remember: true,
      }}
      onFinish={onFinish}
      onFinishFailed={onFinishFailed}
      autoComplete="off"
    >
      <Form.Item
        label="Username"
        name="username"
        rules={[
          {
            required: true,
            message: "Please input your username!",
          },
        ]}
      >
        <Input />
      </Form.Item>

      <Form.Item
        label="Password"
        name="password"
        rules={[
          {
            required: true,
            message: "Please input your password!",
          },
        ]}
      >
        <Input.Password />
      </Form.Item>

      <Form.Item
        name="remember"
        valuePropName="checked"
        wrapperCol={{
          offset: 8,
          span: 16,
        }}
      >
        <Checkbox>Remember me</Checkbox>
      </Form.Item>

      <Form.Item
        wrapperCol={{
          offset: 8,
          span: 16,
        }}
      >
        <Button type="primary" htmlType="submit" loading={loginHandling}>
          Submit
        </Button>
      </Form.Item>
    </Form>
  );
};

export default LoginForm;

 

form 생성은 antd의 form components를 그대로 복붙했다. (여기 클릭)

 

여기서 <Form> 태크에 옵션으로 onFinish를 클릭시 loginRequestAction()을 dispatch한다. 

 

그러면 reducer에서 먼저 LOG_IN_REQUEST를 action으로 던진다. 

 

이를 지켜보고 있던 우리의 saga!

 

LOG_IN_REQUEST action을 감지하는 순간 saga에서 키메라가 선언한 login generator 함수를 실행한다. 

 

그 내부에서는 delay, put등의 effect들을 이용해서 action을 다룰 것이며, 이렇게 reducer와 saga 사이에 주거니 받거니 작업이 일어날 것이다. 

 

그러면 우리가 원하는 작업을 이룰 수 있다. 

 

입력을 하고 로그인을 시도하면

 

 

성공할 테고 다시 로그아웃을 누르면

 

 

로그아웃이 실행된다. 

 

3. 불변성을 immer로 관리하기

그런데 불편한 것이 불변성을 유지할 때 일일이 기존의 state을 ...state하는 방식으로 무조건 받아야 한다. 

 

이게 여간 불편한게 아니다. 이를 조금 편하게 볼 수 있도록 해주는 라이브러리인 immer를 적용하려고 한다. 

 

우선 다음 명령어를 실행한다. 

 

npm i immer

 

그리고 기존의 src/reducers/user.js를 다음과 같이 변경한다. 

src/reducers/user.js - immer 적용하기 

import produce from "immer";

export const initialState = {
  loginHandling: false,
  loginDone: false,
  loginError: null,
};

export const LOG_IN_REQUEST = "LOG_IN_REQUEST";
export const LOG_IN_SUCCESS = "LOG_IN_SUCCESS";
export const LOG_IN_FAILURE = "LOG_IN_FAILURE";

export const LOG_OUT_REQUEST = "LOG_OUT_REQUEST";
export const LOG_OUT_SUCCESS = "LOG_OUT_SUCCESS";
export const LOG_OUT_FAILURE = "LOG_OUT_FAILURE";

export const loginRequestAction = (data) => ({
  type: LOG_IN_REQUEST,
  data,
});

export const logoutRequestAction = (data) => ({
  type: LOG_OUT_REQUEST,
});

const reducer = (state = initialState, action) => {
  switch (action.type) {
    case LOG_IN_REQUEST:
      return produce(state, (draft) => {
        draft.loginHandling = true;
      });
    case LOG_IN_SUCCESS:
      return produce(state, (draft) => {
        (draft.loginHandling = false), (draft.loginDone = true);
      });
    case LOG_IN_FAILURE:
      return produce(state, (draft) => {
        draft.loginHandling = false;
      });
    case LOG_OUT_REQUEST:
      return produce(state, (draft) => {
        draft.loginHandling = true;
      });
    case LOG_OUT_SUCCESS:
      return produce(state, (draft) => {
        draft.loginHandling = false;
        draft.loginDone = fasle;
      });
    case LOG_OUT_FAILURE:
      return produce(state, (draft) => {
        draft.loginHandling = false;
      });
    // return {
    //   ...state,
    //   loginHandling: false,
    // };
    default:
      return {
        ...state,
      };
  }
};

export default reducer;

 

 

전과 비교해서 좀 더 보기 편한것 같기도 하고... 아닌거 같기도 하고... 

 


이렇게 비교적 쉬운 예제로 redux-saga를 어떻게 활용하면 좋을지 구조를 잘 나누어서 적용해보았다.

 

이게 redux-saga의 단점이라면 단점일 수 있는데 

 

요청을 하고, 성공했는지 아니면 실패했는지에 따라 action이 또 생성되면 결국 3개의 action이 하나의 작업을 위해서 생성이 된다. 

 

그렇기에 코드의 양이 많아질 수 있다는 단점이 있다. 

 

하지만 redux dev tools에서 어떤 작업이 일어났는지 history를 파악할 수 있으니 테스트를 할 때 굉장히 순쉬울 것이다. 


이건 여담인데 문득 생각이 들어서 적는다.

 

이렇게 데이터를 한곳에서 관리하게 되면 너무 편한것이,

 

데이터 추적이 손쉽고, 테스트 데이터를 다루기도 좋다는 것이다. 

 

현재 프로젝트에서 필자가 thymeleaf를 이용하는데, 화면마다 controller에서 무언가를 받고, 넘겨주고 하고 있다. 

 

spring을 물론 필자는 사랑하지만, 필요한 데이터가 있는지 체크하고, 그것을 다시 사용할 수 있도록 js 에서 꺼내고, 이러한 일련의 작업을 react 에서는 reducer 를 사용하고, 그리고 redux-saga를 사용하는 순간 걱정할 일이 없다. 왜? 이미 중앙에서 데이터를 관리하고 있으니...

 

우리는 그저 필요한 reducer 정보를 뽑기만 하면 된다. 애초에 골치거리가 줄어드는 것이다!

 

이래서 react가 좋긴 하구나 하는 생각이 들어서 적어본다.