리덕스(Redux)


redux-logo.png

리덕스(Redux)의 필요성

리액트(React)는 상태(State)를 가지고 어떻게 돔(DOM)으로 잘 변형할지에 대해 다루고 있습니다. 여기서 상태란 애플리케이션(Application)이 기본적으로 가지고 있는 아주 중요한 요소인데요. 애플리케이션의 규모가 커질수록 상태 즉 데이터를 관리하기가 매우 까다롭기 때문에 상태를 잘 다루고 실제 돔(DOM)으로 업데이트하기까지 이를 도와주는 라이브러리가 필요합니다.

0.png

리액트(React) 애플리케이션(Application)을 이루는 각각의 컴포넌트(Component) 요소들은 외부 상태를 공유받고 싶을 때 프롭스(Props)를 통해서만 전달받을 수 있습니다. 이러한 형태적인 특징 때문에 여러 개의 컴포넌트를 중첩시키고 조합시키면서 복잡한 계층 구조를 가지게 되는 상황에서는 상태(State)에 대한 흐름을 추적하기 어렵고 UI가 변경될 때마다 상태에 대한 의존성을 가진 컴포넌트들에 영향이 없는지 전달 구조를 일일이 확인해야 하는 등 관리하는 데 큰 어려움을 겪게 됩니다. 이러한 문제점을 극복하기 위해 상태를 전역적으로 관리하고 가공해 UI로 쉽게 변형할 수 있도록 도와주는 라이브러리인 리덕스(Redux)를 사용하게 되었습니다.

그렇다면 리덕스는 어떤 방식으로 상태를 관리하는지에 대해 간단하게 설명해드리겠습니다.

flux-simple-f8-diagram-1300w.png

단방향으로 데이터가 흐르게 하는 Flux 아키텍처 아이디어를 잘 구현한 리덕스의 구조는 상태(State)를 저장하는 스토어(Store), 상태를 조작하는 리듀서(Reducer), 액션(Action)을 전달하는 디스패처(Dispatcher) 함수로 이뤄져 있습니다.

*Flux에 대한 자세한 소개는 Flux 공식 사이트-개요 페이지를 참고해주세요

상태값을 저장하는 저장소 : 스토어(Store)
  • 스토어(Store)는 모든 상태 값을 저장하며 상태 값을 조작할 리듀서(Reducer) 함수를 인자로 받습니다.
상태를 조작하는 함수 : 리듀서(Reducer)
  • 리듀서(Reducer) 함수는 초기 상태 값과 액션(action)을 인자로 받아 액션에 조작할 상태(State)를 지정하는 역할을 합니다.
액션을 전달하는 함수 : 디스패처 함수(Dispatcher)
  • 디스패처(Dispatcher) 함수는 액션 값과 상태에 관한 데이터를 리듀서(Reducer) 함수에 전달하는 역할을 합니다.
어떤 변화가 필요할 때 발생시키는 신호 객체 : 액션(Action)
  • 어떤 데이터를 어떻게 바꿀 것이냐에 대한 일종의 신호를 의미합니다. 어떤 동작에 대해 선언된 액션(Action) 객체는 type이라는 필드를 변화가 필요한 상황을 인지하기위해 사용하며 이 객체에 변화할 때 필요한 데이터를 담을 수도 있습니다.

다음 이미지처럼 사용자에게 보이는 리액트(React) 컴포넌트(Component)에서 특정 이벤트 즉 액션(Action)이 발생하면 액션이 스토어(Store)에 디스패치(Dispatch) 됩니다. 스토어에서는 다시 리듀서(Reducer)에 액션을 전달하고 리듀서는 상태(State)를 가공해서 새로운 상태를 스토어로 전달하게 됩니다.

redux흐름.png

리덕스(Redux) 사용 시에는 따라야 할 세 가지 원칙이 있습니다.

  • 리덕스를 사용하는 어플리케이션엔 하나의 스토어(Store)만이 존재할 것
  • 상태(State)를 직접 변경할 수 없음, 상태를 변경하기 위해서는 액션(Action)이 디스패치(dispatch)되어야 할 것
  • 리듀서(Reducer)는 ‘순수 함수’로 작성할 것

여기서 “스토어(Store)의 상태 변화를 다루는 리듀서(Reducer)는 순수 함수로 작성해야 한다”라는 원칙 때문에 리덕스 미들웨어(Redux Middleware)의 필요성이 생기게되었는데요.

리듀서에서 부수 효과(Side Effect)를 가진 함수를 실행할 경우 순수 함수 사용 원칙을 위배하게 됩니다. 그로 인한 문제를 사전에 방지하기 위해 리덕스는 리덕스 미들웨어를 통해 부수 효과를 처리하기를 원합니다.

리덕스 미들웨어(Redux Middleware)


다운로드.png

미들웨어(Middleware)는 리덕스(Redux)의 기능을 확장하기 위한 수단으로, 디스패치(dispatch)함수를 감싸는 역할을 수행합니다. 여러 미들웨어는 서로 체이닝이 되고 최종적으로 체이닝된 함수가 스토어의 디스패치 함수로 주입됩니다. 이 디스패치 함수에 액션(Action) 객체를 담아 호출하게 되면 맨 마지막에 감싸진 미들웨어가 액션을 전달받아 작업을 수행하고 작업을 마치면 다음 감싸진 미들웨어를 실행하는 동작을 반복합니다. 이 모든 미들웨어를 지나면 리듀서(Reducer)로 액션이 전달되어 상태가 업데이트될 것입니다.

참고. 미들웨어를 리덕스 스토어에 연결하는 코드
function applyMiddleware() {
  for (var _len = arguments.length, middlewares = new Array(_len), _key = 0; _key < _len; _key++) {
    middlewares[_key] = arguments[_key];
  }

  return function (createStore) {
    return function () {
      var store = createStore.apply(void 0, arguments);

      var _dispatch = function dispatch() {
        throw new Error("Dispatching while constructing your middleware is not allowed. " + "Other middleware would not be applied to this dispatch.");
      };

      var middlewareAPI = {
        getState: store.getState,
        dispatch: function dispatch() {
          return _dispatch.apply(void 0, arguments);
        }
      };
      var chain = middlewares.map(function (middleware) {
        return middleware(middlewareAPI);
      });
      _dispatch = compose.apply(void 0, chain)(store.dispatch);
      return _objectSpread({}, store, {
        dispatch: _dispatch
      });
    };
  };
}
 

리덕스 미들웨어 (Redux Middleware) 소개


1.리덕스 프로미스 미들웨어(Redux Promise Middleware)
$ yarn add redux-promise-middleware

이 미들웨어는 프로미스(Promise) 기반의 비동기 작업을 조금 더 편하게 해주는 미들웨어입니다. 리덕스 프로미스(Redux promise)를 사용하면 페이로드(Payload)로 전달된 객체가 만약 프로미스(Promise) 객체라면 통신에 대한 응답이 올 때까지 기다린 이후 결과값을 리듀서(Reducer)에게 전달합니다. 비교적 코드가 단순하기 때문에 기본적인 비동기 또는 조건부로 작업을 수행해야 하는 경우 유용합니다. 해당 미들웨어를 사용하지 않으면 액션(Action) 생성함수는 페이로드로 프로미스(Promise) 객체 자체를 실어 보내므로 리듀서에 상태(State)를 정상적으로 전달할 수 없습니다.

2.리덕스 썽크(Redux-Thunk)
$ yarn add redux-thunk

리덕스 창시자인 Dan Abramov(댄 아브라모프)가 만든 가장 많이 사용되는 비동기 작업 미들웨어입니다. 리덕스 썽크(Redux Thunk)는 객체가 아닌, 동기 또는 비동기 작업을 수행할 수 있는 함수를 말합니다.

리덕스 썽크(Redux Thunk)를 사용하면 액션 생성자가 반환하는 객체로는 처리하지 못했던 작업을 함수를 반환할 수 있게 되면서 반환받은 함수를 통해 다양한 작업이 가능해졌다는 점이 장점입니다.

3.리덕스 로거(Redux logger)
$ yarn add redux-logger

리덕스 로거(Redux Logger)를 사용하면 리덕스 미들웨어를 사용해 개발을 진행할 때, 리듀서(Reducer)가 실행되기 전과 실행된 후를 로그(log)로 편리하게 확인하여 비교할 수 있습니다.

리덕스 사가


리덕스 사가(Redux Saga)란?

리덕스 썽크(Redux Thunk) 다음으로 가장 많이 사용되고 있는 리덕스 사가(Redux Saga)는 리액트/리덕스 애플리케이션에서 비동기적으로 API를 호출하여 데이터를 가져오는 일과 같은 부수 효과(Side Effect)를 쉽게 처리하기 위해 사용하는 라이브러리입니다. 때에 따라 기존 요청을 취소 처리해야 한다거나 여러 개의 API를 순차적으로 호출해야 하는 등의 좀 더 까다로운 비동기 작업을 다루는 상황에 유용합니다.

리덕스 사가(Redux Saga)는 애플리케이션에서 전적으로 부수 효과(Side Effect)만을 담당하여 처리합니다. 비동기 함수 호출 결과 데이터를 통해 성공, 실패 여부를 판단하고 상태를 업데이트시키는 등의 작업(Task)을 제어할 수 있으며, 스토어에 접근하거나 특정 액션(Action)을 디스패치(Dispatch) 하여 다른 사가함수를 실행시킬 수 있습니다.

리덕스 사가(Redux Saga)는 부수 효과들을 처리하기 위해 제너레이터(Generator)라는 ES6 기능을 사용합니다. 제너레이터를 사용하게되면 비동기의 흐름을 표준 동기 코드처럼 보이게 하여 비동기 흐름을 쉽게 읽고 쓰고 테스트할 수 있어 복잡한 워크플로를 관리하는 데 매우 효과적입니다.

제너레이터(Generator)란?
  • 제너레이터(Generator)에 대한 간단한 설명

    function* 키워드를 사용하여 만든 제너레이터 함수를 호출했을 때는 한 객체가 반환되는데요, 이를 제너레이터(Generator) 또는 이터레이터(iterator) 객체라고도 부릅니다. 제너레이터의 내부 코드를 실행하기 위해서는 이 객체가 가지고 있는 next 메서드를 호출 해야만 하는데요. next 메서드의 호출 시마다 순차적으로 원소들을 탐색하고, yield키워드를 반환 포인트로 여기며 valuedone 프로퍼티를 가진 새로운 객체를 반환합니다.

    다시 말해서 제너레이터 안에서 yield 키워드를 사용하면 yield 키워드를 포인트로 코드의 흐름이 멈추게 되고 멈춘 코드의 흐름을 이어 나가기 위해서는 다시 next 메서드를 호출하는 방식으로 일시 정지와 재시작 기능을 사용할 수 있습니다.

    화면-기록-2022-03-09-오후-4.09.13.gif

    제너레이터(Generator)의 이러한 흐름제어를 이용하여 리덕스 사가(Redux saga)에서는 액션(Action)을 모니터링하고 있다가 구독하고 있는 특정 액션이 발생했을 때 원하는 비동기 함수를 실행시킬 수 있습니다.

    다음은 사가함수가 어떻게 액션(Action)의 발생여부를 알아내어 원하는 함수를 실행할 수 있는지에 대해 설명하기 위해 간단히 구현한 예시입니다.

    화면-기록-2022-03-09-오후-8.04.00 (1).gif

    간단히 설명하자면 watch라는 사가 함수는 watchSaga로부터 제너레이터를 전달받고 next 메서드를 통해 내부 코드를 실행시킵니다. watchSaga 내부에서는 while문을 통해 액션을 기다리고, next 메서드의 인자를 통해 전달받은 액션을 계속해서 모니터링하고 있다가 특정 액션이 디스패치된 경우 실행해야 하는 worker 함수를 호출할 수 있습니다.

리덕스 사가 API


import { createStore, applyMiddleware } from 'redux'
import createSagaMiddleware from 'redux-saga'

import reducer from './reducers'
import mySaga from './sagas'

// saga 미들웨어를 생성합니다.
const sagaMiddleware = createSagaMiddleware()

// 스토어에 적용시킵니다.
const store = createStore(
  reducer,
  applyMiddleware(sagaMiddleware)
)

// 그리고 saga를 실행합니다.
sagaMiddleware.run(mySaga)

1. createSagaMiddleware

리덕스 사가(Redux Saga)에서 제공하는 createSagaMiddleware API를 사용하면 사가 미들웨어(Saga Middleware)를 생성할 수 있습니다. 그리고 생성된 사가 미들웨어는 리덕스(Redux)에서 제공하는 applyMiddleware API를 호출할 때 인자로 넘겨 리덕스 미들웨어(Redux Middleware)로 추가할 수 있습니다.

2.middleware.run(saga, …args)

사가 미들웨어(Saga Middleware)의 run 메서드를 통해 사가(Saga)를 실행할 수 있습니다. 사가가 여러 개가 존재하는 경우 진입점(Entry Point)에 해당하는 루트 사가(Root Saga)를 만들고 이 루트 사가를 실행시켜줄 수 있습니다.

사가(Saga)가 실행되면 해당 제너레이터(Generator) 함수를 호출하여 반복 가능한 제네레이터를 획득하게 되는데 해당 제네레이터의 next 메서드를 통해 이펙트(Effect) 타입을 확인하고 해당 이펙트에 대해 지시된 동작을 수행하는 작업을 반복하게 됩니다.

리덕스 사가 이펙트(Effect) 함수


앞서 언급한 이펙트(Effect)라고 하는 것은 어떤 기능을 수행하기 위해 주어진 함수와 인자들을 담은 명령 객체를 미들웨어에게 전달하는데 이러한 명령 객체를 말합니다. 이펙트를 전달받은 미들웨어는 yield 된 이펙트들을 확인하며 정확한 명령이 포함되었는지 검사하고, 이펙트 타입에 따라 어떻게 이펙트를 수행할지 결정합니다.

다음은 이펙트를 설명하기 위한 단순한 예제입니다.

import { put, takeEvery, delay } from 'redux-saga/effects'

// Our worker Saga: will perform the async increment task
export function* incrementAsync() {
  yield delay(1000)
  yield put({ type: 'INCREMENT' })
}

// Our watcher Saga: spawn a new incrementAsync task on each INCREMENT_ASYNC
export function* watchIncrementAsync() {
  yield takeEvery('INCREMENT_ASYNC', incrementAsync)
}

다음은 위 코드의 흐름을 이미지화 한것입니다.

redux_saga_flow.png

사가(Saga)는 INCREMENT_ASYNC 액션(Action)을 모니터링하고 있다가 delayput이라는 이펙트(Effect)를 yield하고 자바스크립트 객체를 반환합니다. 따라서 INCREMENT_ASYNC 액션이 디스패치 되면 처음 1초를 기다렸다가 1초가 지나면 { type: ‘INCREMENT’ } 액션을 디스패치(Dispatch) 하라는 각각의 이펙트 객체가 yield 되고 미들웨어에서는 해당 명령에 대한 수행이 이루어지게 됩니다.

이러한 이펙트를 생성할 때는 redux-saga/effects 패키지에 있는 라이브러리들이 제공하는 함수들을 사용하며 주요하게 사용하는 이펙트 함수 타입은 다음과 같습니다.

이펙트(Effect) 함수 타입


1.fork(fn, …args)

매개변수로 전달된 함수를 비동기적으로 실행합니다. 비동기적으로 실행되기 때문에 블로킹(Blocking)이 발생하지 않는 새로운 맥락의 사가(Saga) 작업(Task) 생성하게 됩니다.

2.call(fn, …args)

매개변수로 전달된 동기 혹은 비동기 함수를 실행합니다. 전달받은 함수가 비동기 함수인 경우 해당 함수가 수행(resolve)될 때까지 기다렸다가 결과값을 반환하므로 블로킹(Blocking)이 발생하게 됩니다.

3.put(action)

액션(Action)을 디스패치(dispatch) 합니다. 일반적으로 워커 사가(Worker Saga)에서 API 성공/실패 여부에 따라 상태를 반영하기 위해 리듀서에 액션을 디스패치 하는 용도로 많이 사용합니다.

4.takeEvery(pattern, saga, …args)

액션(Action)이 디스패치(dispatch) 될 때마다 새로운 작업(Task) 분기(fork)합니다. 작업이 동시에 중복으로 발생해도 문제가 없는 경우에 사용해야 합니다.

function* takeEvery(pattern, saga, ...args) {
  const task = yield fork(function* () {
    while (true) {
      const action = yield take(pattern)
      yield fork(saga, ...args.concat(action))
    }
  })
  return task
}
5.takeLatest(pattern, saga, …args)

액션(Action)이 디스패치(을)될 때 이전에 실행 중인 작업(Task)이 있다면 취소하고 새로운 작업을 분기(fork)합니다. 이전 API 요청를 무시하고 최신데이터를 받아올 수 있도록 합니다.

function* takeLatest(pattern, saga, ...args) {
  const task = yield fork(function* () {
    let lastTask
    while (true) {
      const action = yield take(pattern)
      if (lastTask)
        yield cancel(lastTask) // cancel is no-op if the task has already terminated

      lastTask = yield fork(saga, ...args.concat(action))
    }
  })
  return task
}
6.select(selector, …args)

리듀서(Reducer)에 있는 특정 상태를 리덕스 사가(Redux)로 가져와서 사용할 수 있습니다. 블로킹이 발생하여 셀렉터(Selector) 함수가 정보를 가져온 이후에 다음 작업(Task)을 수행할 수 있습니다.

7.cancel(task)

yield fork()는 실행되는 작업에 대한 오브젝트를 반환합니다. 이 오브젝트를 cancel 메서드의 인자로 넘기면 이미 비동기로 실행 중인 작업을 취소할 수 있습니다. 또한 작업을 취소한 경우라도 해당 워커 사가(worker Saga) 내의 finally 구간 안에서는 취소된 작업에 대해 처리할 수 있습니다.

8.추가 설명

모든 이펙트(Effect)는 반드시 yield 키워드와 함께 사용해야 합니다. 리덕스 사가의 이펙트는 Blocking effectNon-Blocking effect로 구분됩니다. Blocking Effect는 처리가 완료될 때까지 기다리며 Non-Blocking Effect는 완료를 기다리지 않고 진행합니다. 대표적인 Blocking Effect로는 call이 있고, Non-blocking Effect에는 fork가 있습니다.

웹앱에서 사용하는 리덕스 사가 패턴 예시



├── container
│   ├── action.js
│   ├── constants.js
│   ├── index.js
│   ├── reducer.js
│   ├── selectors.js
    └── saga.js

workflow.png 출처:https://github.com/react-boilerplate/react-boilerplate

1. useInjectReducer, useInjectSaga 그리고 key

현재 트렌비 프론트에서는 보일러 플레이트(Boilerplate)를 사용하여 컨테이너 컴포넌트에서 고유한 키(key)를 생성하여 useInjectSagauseInjectReducer 유틸함수를 통해 스토어(Store)에 사가(Saga)와 리듀서(Reducer)를 주입하고 있습니다.

이러한 구조를 사용하여 루트 사가(Root Saga)를 별도로 만들지 않고 useInjectReducer 내부에서 모니터링용 사가(Watcher Saga)를 실행시켜주며 특정 액션(Action)이 발생하면 상태(State)를 갱신하여 스토어(Store)에 전달할 수 있고 고유한 키(key)를 통해 특정한 상태를 가져올 수 있습니다.

import Signup from './Signup'
import reducer from './reducer'
import saga from './saga'
import { useInjectReducer } from 'utils/injectReducer'
import { useInjectSaga } from 'utils/injectSaga'
import { signupRequest } from './actions'

function UserPage() {
  useInjectReducer({ key: 'user', reducer })
  useInjectSaga({ key: 'user', saga })

  const onSignUp = (signupInfo) => dispatch(signupRequest(signupInfo))

  const onSignUpRequest = () => {
    // ...생략
    const signupInfo = {
      ...values,
      email,
      sms,
      mailing,
      provider,
      socialId,
      userId,
      tempUserId,
    }
    onSignUp(signupInfo)
  }
  return (
    <Signup>
      <SignUpButton onClick={() => onSignUpRequest()}>가입하기</SignUpButton>
    </Signup>
  )
}



2. action.js

특정 액션(Action)을 생성하는 함수만을 모아놓은 파일입니다. 디스패치(dispatch) 함수에 이 액션 생성 함수를 호출하여 전달하면 미들웨어를 지나 리듀서(Reducer)에게 액션이 전달됩니다.


export function signupRequest(signupInfo) {
  return {
    type: SIGNUP_REQUEST,
    signupInfo,
  }
}

export function signupSuccess(data) {
  return {
    type: SIGNUP_SUCCESS,
    data,
  }
}

export function signupFailure(signupErrorMessage) {
  return {
    type: SIGNUP_FAILURE,
    signupErrorMessage,
  }
}

3. reducer.js

액션(Action)이 발생할 때 상태(State)에 대한 업데이트를 구현하는 리듀서(Reducer) 함수가 있는 파일입니다. 현재 상태와 전달받은 액션을 참고하여 특정 액션 타입에 따라 새롭게 상태를 만들고 이를 반환하여 업데이트합니다. 상태가 전달되지 않은 경우에 초깃값(InitialState)를 기본값으로 지정하고 있습니다. 미들웨어를 사용하는 경우 해당 미들웨어를 지나며 데이터를 추가로 전달받을 수 있습니다.

import { fromJS } from 'immutable'
import {

  SIGNUP_REQUEST,
  SIGNUP_FAILURE,
  AUTH_VERIFY_STATE,
  SIGNUP_SUCCESS,

} from './constants'

export const initialState = fromJS({
  facebookConnects: [],
  processing: false,
  signupErrorMessage: [],
  message: '',
  authState: AUTH_VERIFY_STATE.STANDBY,
  signUpSuccessRedirectData: null,
})

function userReducer(state = initialState, action) {
  switch (action.type) {
    case SIGNUP_REQUEST:
      return state.set('signupErrorMessage', [])
									.set('facebookConnects', [])
									.set('processing', true)
    case SIGNUP_SUCCESS:
      return state.set('signUpSuccessRedirectData', action.data)
									.set('processing', false)

    case SIGNUP_FAILURE:
      return state.set('signupErrorMessage', action.signupErrorMessage)
									.set('processing', false)
    default:
      return state
  }
}
export default userReducer

4.selector.js

셀렉터(Selector)는 스토어에서 필요한 데이터를 가져오거나, 계산을 수행해서 원하는 형태의 데이터를 가져오는 역할을 합니다. reselectcreateSelector 함수에 선택자 함수를 전달하고 그 선택자 함수가 전달한 값이 이전과 같다면 캐싱을 통해 동일한 계산을 방지하는 메모이제이션(memoization) 기능을 동작시킬 수 있습니다

import { createSelector } from 'reselect'

const selectRoute = (state) => state.get('router').toJS()
const makeSelectLocation = () => createSelector(selectRoute, (state) => state.location)

export {
  selectRoute,
  makeSelectLocation,
}
5. Saga.js

일반적으로 사가(Saga)는 액션을 구독하는 모니터링 사가(Watcher Saga)와 실제 작업을 수행하는 워커 사가(Worker Saga)의 구성으로 만듭니다. 모니터링하는 사가는 useInjectSaga 유틸 함수에 키와 함께 전달되어 sagaMiddleware.run에 인자로 담겨 실행되고 이펙트(Effect) 타입에 따라 사가를 실행하는 실행부에게 어떠한 동작을 수행해야 할지 전달합니다. 예를 들어 아래와 같이 이펙트 타입이 takeLatest인 경우 특정 액션을 감시하고 있다가 액션이 발생할 때 인자로 전달된 워커 사가를 분기(fork)합니다.

워커 사가(Worker Saga)가 호출되면 내부 이펙트(Effect) 타입에 따라 어떤 동작을 수행해야 할지 판단하여 실행 부에 전달하고 수행 중에 발생한 에러는 try... catch문을 통해 처리할 수 있습니다. 보통 API를 정상적으로 수행한 경우 리듀서에 해당 데이터를 액션과 함께 실어 보내 상태 업데이트를 진행합니다. 실패한 경우에도 실패한 상태에 대해 업데이트하는 액션을 put 이펙트 함수를 통해 디스패치(dispatch) 할 수 있습니다.

추가적인 설명으로 signup Saga 함수 내에 작성한 yield* 표현식은 다른 제너레이터(generator) 또는 이터러블(iterable) 객체에 yield 를 위임할 때 사용됩니다. 여기서 ‘위임’이라는 단어는 yield* 키워드가 붙은 제너레이터를 대상으로 반복을 수행하고, 산출 값들을 바깥으로 전달한다는 것을 의미합니다.

import { call, put, select, takeLatest } from 'redux-saga/effects'
import { getResource } from 'containers/saga'

import {
  SIGNUP_REQUEST,
} from './constants'

import {

	signupFailure,
  signupSuccess,
} from './actions'

export function* signup({ signupInfo }) {
  try {
    const location = yield select(makeSelectLocation())
     // ...생략

    const res = yield call(request, 'signup', options)

    const resource = yield getResource()
    yield* applyUserResource(resource, res.token)
    
    yield put(signupSuccess())
  } catch (error) {
    TLogger.error(error)
    const fieldError = {
      field: '',
      message: '',
    }

    if (error.body.status === ERROR_CODE.CONFLICTED_DATA) {
      fieldError.field = 'conflict'
      fieldError.message = '해당 이메일은 이미 가입되어 있습니다.'
    } 
    yield put(signupFailure(error))
  }
}
export default function* userRequest() {
  yield takeLatest(SIGNUP_REQUEST, signup)
}

그리고 보통 리덕스 사가 패턴(Redux Saga Pattern)에서 모니터링 역할을 하는 사가(Watcher Saga)와 작업을 수행하는 사가(Worker Saga)를 분리해서 작성하는데 그 이유는 분리해서 작성했을 때 문제가 된 부분이 어딘지 더 빠르게 파악할 수 있고 요구 사항이 변경될 때도 해당되는 한 곳에서만 변경하면 되기 때문입니다.

지금까지 리덕스 사가(Redux Saga)에 대해서 말씀드렸습니다.

저희 팀에서 리덕스 사가를 통해 비동기 통신이 많은 복잡한 구조의 애플리케이션을 관리하면서 느낀 장점은 비동기의 흐름을 동기적으로 읽을 수 있게 해주어 흐름제어를 쉽게 할 수 있다는 점과 다른 라이브러리에서 제공하는 기능보다 다양한 기능들을 제공받아 사용할 수 있다는 점이었습니다.

하지만 한편으로는 리덕스 사가(Redux Saga)를 사용할 때 늘어나는 보일러 플레이트(boilerplate) 코드들과 사가 함수 재사용에 대한 제약들로 인해 쓸데없이 추가되는 코드들이 많아지는 것을 보면서 이 또한 유지보수를 어렵게 하는 것이 아닌가 하는 생각도 하게 되었습니다. 그래서 앞으로의 애플리케이션의 유지보수를 생각했을 때 지금처럼 비동기 API 호출 로직들을 리덕스(Redux) 구조에 의존하기보다는 따로 분리해서 관리할 방법이 있다면 그 방법을 한번 시도해보면 좋을 거 같다고 팀원들과 입을 모으게 되었습니다.

스토어팀은 앞서 말씀드린 리덕스 사가의 단점들을 보완하기 위한 방법으로 비동기 함수를 스토어와 분리할 수 있도록 도움을 주는 SWR과 리액트 쿼리(React Query)라고하는 라이브러리의 존재를 알게 되었습니다. 그리고 다음에 이어지는 내용으로 그 라이브러리들을 간단히 소개해드리고자 합니다.

비동기 함수를 스토어와 분리하기 위한 방법


1. SWR (Stale While Revalidate)

스크린샷 2022-03-07 오후 6.02.25.png

SWR이란?

SWR 은 리액트(React) 프레임워크인 Next.js를 만든 팀에서 개발한 라이브러리로 비동기 요청을 위한 훅(Hook)을 제공합니다.

데이터 재검증을 하는 동안 의도적으로 캐시(Cache) 된 데이터를 보여주고 최종적으로 재검증된 최신화된 데이터를 보여주는 전략을 쓰고 있습니다.

또한 네트워크 재연결 또는 사용자 이벤트가 잡힐 때마다 컴포넌트는 지속적, 반응적으로 데이터 업데이트 스트림을 받게 되어 최신화된 데이터로 빠르게 UI 업데이트 구현이 가능하다는 점이 장점입니다.

SWR에서 제공하는 useSWR이라는 훅(Hook)을 사용하면 데이터가 필요한 컴포넌트 안에서 단 한 줄의 코드로 데이터를 가져오고 이 데이터를 통해 알아낸 현재 요청 상태(error, isLoading, ready)에 따라 해당하는 UI를 반환할 수 있습니다.

또한 데이터를 재사용해야 할 때도 위와 같이 컴포넌트에서 필요한 데이터에 대해서 명시적으로 불러오기만 하면 되기 때문에 독립적인 컴포넌트에서도 쉽고 간단하게 데이터를 불러올 수 있으며 그 요청이 자동으로 중복 제거, 캐시, 공유되므로, 단 한 번의 요청으로 데이터를 받아서 UI를 업데이트시킬 수 있습니다.

2. 리액트 쿼리(React Query)

스크린샷 2022-03-10 오후 12.26.27.png

리액트 쿼리(React Query)란?

리액트 쿼리(React Query)는 React 앱에서 비동기 로직(서버 상태 가져오기, 캐싱, 동기화 및 업데이트)을 쉽게 다루게 해주는 라이브러리입니다. 패칭(Fetching) 후 성공과 실패에 대한 처리를 리덕스 사가(Redux Saga)에서는 직접 사가(Saga) 안에서 try catch 문을 통해 처리를 해주었다면 리액트 쿼리에서는 성공 여부를 판단할 수 있는 필드값을 전달해 주어 조건부 렌더링을 쉽게 처리할 수 있습니다.

또한 리액트 쿼리는 라이브러리가 알아서 캐싱 및 리페칭(Re-Fetching)을 해주기 때문에 필요할 때마다 뷰(View)에서 최신 데이터를 보여줄 수 있고 무수히 많은 보일러 플레이트(Boilerplate) 코드를 생성했던 리덕스 사가와는 달리 고유한 키값 설정으로 데이터 페칭 요청을 구분하고 있어 작은 보일러 플레이트 코드만으로도 전역적으로 데이터를 가져와 페이지를 빠르게 갱신할 수 있다는 장점이 있는 라이브러리입니다

마치며


두 라이브러리 모두 비동기 통신 후 상태관리가 필요할 때 리덕스(Redux) 구조에 얽매이지 않고 쉽고 빠르게 상태를 업데이트해서 뷰를 완성할 수 있도록 도와주는데요. 저희 팀에서는 리덕스 사가(Redux Saga)처럼 좀 더 복잡한 애플리케이션에 적합한 라이브러리로 SWR보다는 다양한 기능을 제공하는 리액트 쿼리에 관심을 갖게 되었습니다. 그리고 더 나아가 진입장벽이 낮은 리액트 쿼리를 웹앱에 빠르게 도입해 사용해보니 리덕스의 구조를 더 이상 따르지 않고도 상태 업데이트를 편하고 쉽게 할 수 있다는 것을 직접 느끼게 되었고 유지보수 측면에서도 긍정적인 효과를 기대해볼 수 있었습니다.

하지만 여전히 트렌비 웹앱에는 리덕스와 리덕스 사가의 조합으로 상태를 관리하는 페이지들이 많이 있고 당장 모든 페이지에 리액트 쿼리를 도입하는 것은 구조적인 문제가 있기에 쉽지 않은 일일 것입니다. 그래서 저희 스토어팀에서는 리덕스 사가와 리액트 쿼리 두 개를 동시에 사용하면서도 최대한 리덕스 구조에 대한 의존도를 줄일 수 있도록 그 둘을 적재적소에 각각 사용하는 방식으로 더 이상의 유지보수를 어렵게 하는 복잡한 구조를 만들지 않기 위해 노력하고 있습니다.

앞으로도 저희 팀에서는 리액트 쿼리 도입을 시작으로 더 최선이 될 수 있는 코드와 구조 개선을 위해 계속해서 고민하며 좋은 서비스를 제공하기 위해 노력해나갈 예정입니다.

긴 글을 읽어주셔서 감사합니다.

참고한 사이트