minguri brain is busy

Saga패턴 vs Redux-Saga / 간단한 예제로 살펴보기 본문

FE/개발

Saga패턴 vs Redux-Saga / 간단한 예제로 살펴보기

minguri.k 2022. 7. 22. 23:09
반응형
이 글에선 SAGA 패턴과 Redux-Saga에 대한 이해를 해봤다.
Saga 패턴은 마이크로 서비스의 등장과 함께 주목받기 시작한 디자인패턴이다. Saga 패턴에 대해 알아보던 중 프론트에서 적용 가능한 Redux-Saga로 간단한 데모 프로젝트를 올려보고 싶었고 두 가지의 개념에 대한 정리를 해봤다.

 

SAGA 패턴이란?

각 서비스에는 자체 데이터베이스가 있다. 그러나 일부 비즈니스 트랜젝션은 여러 서비스에 걸쳐 있으므로 서비스에 걸쳐 트랜잭션을 구현하는 매커니즘이 필요하다.

 

🤓 예시

고객에게 신용 한도가 있는 전자 상거래 상점을 구축한다고 가정했을 때 애플리케이션은 새 주문이 고객의 신용 한도를 초과하지 않도록 해야한다. OrdersCustomers는 서로 다른 서비스가 소유한 서로 다른 데이터베이스에 있으므로 애플리케이션은 단순히 ACID 트랜젝션을 사용할 수 없다.

이럴 때 서비스를 포괄하는 트랙잭션을 구현하는 사가패턴을!

 

🤓 ACID 트랜젝션?

ACID(원자성, 일관성, 고립성, 지속성)는 데이터베이스 트랜잭션이 안전하게 수행된다는 것을 보장하기 위한 성질을 가리키는 약어이다.

 

Redux-Saga란?

Redux-Saga란 사이드 이펙트를 핸들링하기 위한 미들웨어 라이브러리이다.

 

🤓 사이드 이펙트?

여기서 사이드 이펙트는 데이터를 불러오는 비동기 처리나 브라우저 캐시를 말한다. 이것에 접근하는 행위들을 쉽게 관리하고 효과적인 실행, 손쉬운 테스트 그리고 에러 핸들링을 쉽게 해주도록 설계된 패턴이다.

 

🤓 미들웨어?

미들웨어는 전통적으로는 OS와 소프트웨어 중간에서 조정과 중개 역할을 하는 중간 소프트웨어라고 말한다. Redux에서 동작하는 Redux-Saga, Redux Thunk는 어떠한 두 요소 중간에서 동작하는 소프트웨어라고 생각하면 그 의미가 대략 같다.
리덕스의 동작 순서는 액션이 dispatch가 된 후 리듀서를 호출하는데 기존에 있던 state를 dispatch한 액션을 바꾼다. React는 Redux액션을 수행하면 Redux-Saga에서 dispatch하여 Redux의 액션을 가로챈다.

🤓 왜 Redux-Saga인가

  • 선언적 효과: Redux-Saga의 모든 작업은 일반 Javascript 개체를 생성하므로 비지니스 로직의 테스트가 쉽다.
  • 비동기 제어 흐름: 동기화 스타일과 친숙한 제어 흐름 구성으로 비동기 흐름을 간단히 설명한다.
  • 동시성 관리: 작업 간 동시성을 관리하기 위한 기본 및 연산자 제공한다. 여러 백그라운드 작업을 병렬로 분기하고 실행 중인 작업을 취소할 수 있다.
  • 사이드 이펙트 핸들러: 모든 사이드 이펙트는 Saga로 옮겨지고, UI 구성 요소는 비즈니스 로직을 수행하지 않고 발생한 일을 알리기 위한 작업만 dispatch한다.

 

Redux-Saga 데모 프로젝트 만들기

Redux-Saga 공식 초심자 튜토리얼
https://redux-saga.js.org/docs/introduction/BeginnerTutorial/
uzihoon.com(데모 실습 코드 참조)
https://uzihoon.com/post/181be130-63a7-11ea-a51b-d348fee141c4

작업한 전체 코드는 Github에서 확인할 수 있다.

  • React
  • Redux
  • Redux-Saga
  • Typescript

 

👩‍💻프로젝트 생성

create-react-app으로 React 기본환경 구성
$ npx create-react-app saga-demo-app --template typescript

npm 라이브러리 설치
$ npm i redux react-redux @types/react-redux typesafe-actions redux-saga

  • redux: 상태 관리 도구
  • react-redux:React용 공식 Redux UI 바인딩 라이브러리
  • @types/react-redux: react-redux에서 Typescript 사용
  • typesafe-actions: Typescript로 Redux 활용 시, Redux 아키텍쳐의 다변적인 성격과 복잡성 줄이기 위해 설계된 Typesafe 유틸리티
  • redux-saga: Redux 사이드 이펙트 매니징 라이브러리

폴더 구조 생성
$ mkdir components store hooks

  • components: view 컴포넌트 폴더
  • store: Redux, Redux-Saga 폴더
  • hooks: Redux store에 데이터를 가져오는 등의 역할

 

👩‍💻코드 작성

src/index.tsx

import React from 'react';
import ReactDOM from 'react-dom';
import reportWebVitals from './reportWebVitals';
import Root from './Root';

ReactDOM.render(
  <Root />,
  document.getElementById('root')
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

src/components/App.tsx

import React from 'react';
import Counter from './Counter';

function App() {
  return <Counter />;
}

export default App;

src/Root.tsx

import React from "react";
import App from "./components/App";

function Root() {
  return <App /> ;
}

export default Root;

src/components/Counter.tsx

import React from 'react';
import useCounter from '../hooks/useCounter';
import useCounterActions from '../hooks/useCounterActions';

function Counter() {
  const counterActions = useCounterActions();
  const count = useCounter().count;

  return (
    <div>
      <h1>Counter</h1>
      <div>{count}</div>
      <button onClick={() => counterActions.onPlus({})}>PLUS</button>
      <button onClick={() => counterActions.onMinus()}>MINUS</button>
      <button onClick={() => counterActions.onPlusRandom()}>PLUS RANDOM</button>
      <button onClick={() => counterActions.onPlusAfterOneSeconds()}>PLUS AFTER ONE SECONDS</button>
    </div>
  )
}

export default Counter;

 

👩‍💻 Redux와 Redux-Saga 연결

src/store/redux/coutner.ts

import { createAction, ActionType, createReducer } from "typesafe-actions";

export interface IPlus {
  num?: number;
}

// Actions
export const PLUS = "counter/PLUS";
export const MINUS = "counter/MINUS";
export const PLUS_RANDOM = "counter/PLUS_RANDOM";
export const PLUS_AFTER_ONE_SECONDS = "counter/PLUS_AFTER_ONE_SECONDS";

export const plus = createAction(PLUS)<IPlus>();
export const minus = createAction(MINUS)();
export const plusRandom = createAction(PLUS_RANDOM)();
export const plusAfterOneSeconds = createAction(PLUS_AFTER_ONE_SECONDS)();

// Types
export const actions = { plus, minus, plusRandom, plusAfterOneSeconds };
type CounterAction = ActionType<typeof actions>;
type CounterState = {
  count: number;
};

const initialState: CounterState = {
  count: 0
};

// Reducer
const status = createReducer<CounterState, CounterAction>(initialState, {
  [PLUS]: (state, action) => {
    const { num } = action.payload;
    const { count } = state;
    const add = num || 1;
    const _count = count + add;
    return { count: _count };
  },
  [MINUS]: (state, action) => {
    const { count } = state;
    const _count = Math.max(count - 1, 0);
    return { count: _count };
  }
});

export default status;

src/store/redux/index.ts
reducer들을 묶어준다.

import counter from './counter';
import { combineReducers } from 'redux';

const rootReducer = combineReducers({ counter });

export default rootReducer;

export type r = ReturnType<typeof rootReducer>;

src/store/saga/counter.ts

import { put, delay } from 'redux-saga/effects';
import * as CounterActions from '../redux/counter';

function getRandomInt(min: number, max: number) {
  min = Math.ceil(min);
  max = Math.floor(max);
  return Math.floor(Math.random() * (max - min+1)) + min;
}

/**
 * plusRandom
 * Counter Store에서 counter 값을 가져와 random 값을 더한 후 저장
 */
export function* plusRandom() {
  try {
    const num = getRandomInt(1, 20);
    console.log(num);
    yield put(CounterActions.plus({ num }));
  } catch(error) {
    console.error(error);
  }
};

/**
 * plusAfterOneSeconds
 * 1초 후 plusRandom 액션 발행
 */
export function* plusAfterOneSeconds() {
  try {
    yield delay(1000);
    yield put(CounterActions.plusRandom());
  } catch(error) {
    console.error(error);
  }
}

src/store/saga/index.ts
Saga의 루트 파일로 Saga들을 묶어준다.

// Saga
import { takeEvery, all, fork } from "redux-saga/effects";
import * as CounterSaga from "./counter";
import * as CounterActions from "../redux/counter";

export default function* rootSaga() {
  // Root Saga
  yield all([fork(handleCounter)]);
}

function* handleCounter() {
  yield takeEvery(
    CounterActions.plusAfterOneSeconds,
    CounterSaga.plusAfterOneSeconds
  );
  yield takeEvery(CounterActions.plusRandom, CounterSaga.plusRandom);
}

src/store/configure.tsx

import createSagaMiddleware from "@redux-saga/core";
import { applyMiddleware, compose, createStore } from "redux";
import rootReducer from "./redux";
import rootSaga from './saga';

declare global {
  interface Window {
    __REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: typeof compose;
  }
}

const sagaMiddleware = createSagaMiddleware();
const isDev = process.env.NODE_ENV === 'development';
const devTools = isDev && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__;
const composeEnhancers = devTools || compose;

export default function configura() {
  const store = createStore(
    rootReducer,
    {}, // pre-loaded state
    composeEnhancers(applyMiddleware(sagaMiddleware))
  );
  sagaMiddleware.run(rootSaga);
  return store;
}

src/Root.tsx
configure를 import해서 store 생성후 React와 연결한다.

import React from 'react';
import { Provider } from 'react-redux';
import App from './components/App';
import configure from './store/configure';

const store = configure();

function Root() {
  return (
    <Provider store={store}>
      <App />
    </Provider>
  )
}

export default Root;

 

👩‍💻 로직구현

src/hooks/useCounterActions.tsx

import { useDispatch } from "react-redux";
import { useCallback } from "react";
import { actions, IPlus } from '../store/redux/counter';

export default function useCounterActions() {
  const dispatch = useDispatch();

  const onPlus = useCallback((param: IPlus) => dispatch(actions.plus(param)), [
    dispatch
  ]);
  const onMinus = useCallback(() => dispatch(actions.minus()), [dispatch]);
  const onPlusRandom = useCallback(() => dispatch(actions.plusRandom()), [
    dispatch
  ]);
  const onPlusAfterOneSeconds = useCallback(
    () => dispatch(actions.plusAfterOneSeconds()),
    [dispatch]
  );

  return { onPlus, onMinus, onPlusRandom, onPlusAfterOneSeconds };
}

src/hooks/useCounter.ts

import { useSelector } from "react-redux";
import { RootState } from "../store/redux";

export default function useCounter() {
  const counter = useSelector((state: RootState) => state.counter);
  return counter;
}


npm start하면 버튼액션이 잘 동작하는 모습을 확인할 수 있다.

 

 

 

참고:

Pattern: Saga
https://microservices.io/patterns/data/saga.html
Redux-Saga란
https://uzihoon.com/post/181be130-63a7-11ea-a51b-d348fee141c4
벨로퍼트 10. redux-saga
https://react.vlpt.us/redux-middleware/10-redux-saga.html
Redux 미들웨어란?
https://tried.tistory.com/86

반응형
Comments