Redux Thunk & Saga

임수현·2021년 11월 23일
0

프론트엔드 개발을 React로 하다보면 다양한 이유로 Redux를 만나게 되고 활용하게 됩니다. 그리고 Redux를 접해서 사용하다보면 자연스레 Redux Thunk와 Redux Saga 역시 접하게 됩니다. 이 두 리덕스 미들웨어들은 프로젝트에서 비동기처리를 담당하는 역할을 하는 걸로 알려져 있습니다. 비슷하게 비동기 로직을 처리하는 이 도구들은 어떤 차이점이 있길래 서로 다른 이름으로, 다르게 쓰이고 있는 것일까요?

이번 포스팅에서는 두 가지 미들웨어의 간단한 사용법과 어떤 상황에서 어떤 미들웨어를 사용하는 것이 조금 더 나을지에 대해 소개해드리도록 하겠습니다.

1. Redux의 미들웨어

우선 이 두 툴들의 이름이 미들웨어라는 것을 짚고 넘어가야할 것 같습니다. 리덕스의 미들웨어는 리덕스가 다른 상태관리 라이브러리들과 차별되는 핵심 기능 중 하나입니다. 리덕스의 Flux 패턴에서 맨 처음 액션을 디스패치 하게 되면, 리듀서에서 그 해당 액션에 대한 정보를 바탕으로 스토어의 상태값을 바꾸게 되는데, 이때 미들웨어를 사용하면 액션이 스토어에서 상태값을 바꾸기 전에 특정 작업들을 수행할 수 있습니다.

예를 들면,

  • 특정 조건에 따라 액션 수행 여부를 판단함
  • 액션을 콘솔에 출력하거나 서버쪽에 로깅함
  • 액션이 디스패치 되었을 때 데이터를 수정/가공하여 리듀서에게 전달함
  • 비동기적인 작업을 수행함

등의 역할을 수행하게 됩니다.

이 중에서 오늘 우리가 알아볼 Redux Thunk와 Redux Saga의 경우에는 마지막 예시인 비동기처리에 주로 사용되는 툴입니다.

2. Redux Thunk

프로그래밍을 하다보면, 동기적으로 어떤 함수가 실행되어야 하는 순간이 있는 반면, 그렇지 않고 비동기적으로 함수가 실행되어야 하는 순간이 존재합니다. Redux는 기본적으로 액션객체만을 디스패치할 수 있습니다. 하지만 Redux Thunk를 활용하면 객체 대신 함수를 생성하는 액션 생성함수를 작성할 수 있게 해줍니다. 이러한 동작방식을 활용하여 Redux에서 비동기적인 프로그래밍을 구현할 수 있습니다.

import axios from "axios";
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";

const fetchData = createAsyncThunk("FETCH_DATA", async () => {
	try {
		const response = await axios.get("http://localhost:8080");

	  return response.data;
	} catch (error) {
		console.error(error);
	}
});

export const rootReducer = createSlice({
  name: "Data",
  initialState: { data: [] },
  reducers: {},
  extraReducers: (builder) => {
    builder
			.addCase(fetchData.pending, (state, action) ={})  // 데이터 통신 대기중일 때
			.addCase(fetchData.fulfilled, (state, action) => {
	      return { ...state, data: [ ...action.payload ] }
	    });                                               // 데이터 통신 성공했을 때
			.addCase(fetchData.reject, (state, action) => {}) // 데이터 통신 실패했을 때
  },        
});

// app.ts
dispatch(fetchData())

위와 같이 코드를 작성하면, 비동기적으로 서버에서 데이터를 불러와 활용할 수 있는 기본적인 구조의 액션 함수를 작성한 것입니다. Redux thunk 에서는 이런 방식으로 비동기 작업 함수를 만들어, 필요한 시점에 불러와 액션을 디스패치합니다.

3. Redux Saga

Redux Saga 역시 비동기 작업을 처리하기 위한 미들웨어입니다. 다만 Thunk와는 방식이 조금 다릅니다. Redux Thunk가 함수를 디스패치 할 수 있게 해주는 미들웨어였다면, Saga는 액션을 모니터링 하고 있다가 특정 액션이 발생했을 때, 미리 정해둔 로직에 따라 특정 작업이 이루어지는 방식으로 이루어집니다. 또한 Sagas라는 순수함수들로 로직을 처리할 수 있는데, 순수함수로 이루어지다보니, 사이드 이펙트도 적고 테스트 코드를 작성하기에도 용이합니다.

Redux Saga는 그 특성상 Thunk에 비해 많은 기능을 수행할 수 있습니다. 예를 들면,

  • 비동기 작업 진행시, 기존 요청 취소
  • 특정 액션이 발생했을 때, 이를 구독하고 있다가 다른 액션을 디스패치 하거나 특정 자바스크립트 코드를 실행함
  • 웹소켓 사용시, 더 효율적인 코드 관리
  • API요청 실패시 재요청 가능

등의 기능이 있습니다.

Saga는 제너레이터(Generator)라는 특수한 형태의 함수로 구현이 됩니다. 이 제너레이터는 함수를 구현할 때, 함수의 실행을 특정 구간에 멈추게 하거나 원하시는 시점으로 돌아가게 할 수 있습니다. 또한 결과값을 여러번 리턴하게도 할 수 있습니다. 많은 분들께 이 제너레이터라는 개념이 생소할 수 있을 것 같아, 제너레이터에 대한 설명을 조금하고 진행하도록 하겠습니다.

3-1) 제너레이터(Generator)

function exampleFunction () {
	return 1
	return 2
	return 3
	return 4
	return 5
}

위와 같이 함수를 작성한다면, 우리는 이 함수에서 하나의 리턴 값만을 기대할 수 있습니다. 이 경우에는 1만 반환받을 수 있겠죠. 하지만 제너레이터를 이용하여 함수를 작성하면 이 반환값을 모두 받을 수 있습니다. 심지어는 특정 위치에 잠시 정지시켜둘 수도 있죠.

function* generatorFunction () {
	console.log('첫 번째 실행')
	yield 1;
	console.log('두 번째 실행')
	yield 2;
	console.log('세 번째 실행')
	yield 3;
	console.log('네 번째 실행')
	yield 4;
	console.log('다섯 번째 실행')
	yield 5;
}

제너레이터 함수를 만들 때에는 function* 이라는 키워드를 사용하여 만듭니다. 여기서 우리가 잠시 짚고 넘어가야 할 점은 제너레이터 함수와 제너레이터의 차이입니다. 엄밀히 말하면, 위에서 작성한 제너레이터 함수를 통해 제너레이터 객체가 반환됩니다.

위에서 또 한 가지 특이한 점은 yield 라는 표현일 것입니다. yield 는 제너레이터 함수의 실행을 일시적으로 정지시키며, yield 뒤에 오는 표현식은 제너레이터를 관찰하고 있던 호출자(caller)에게 반환됩니다. 쉽게 생각해서 일반함수의 return 과 유사한 것입니다. 즉, 제너레이터 함수는 yield 부분에서 특정 값을 반환하고 그 실행을 잠시 멈추는 것입니다.

그렇다면 이후에 이 함수를 마저 실행시키려면 어떻게 해야 할까요? 이는 next 라는 함수를 통해 구현할 수 있습니다.

generatorFunction.next();

yield 부분에서 함수가 특정 로직과 값을 반환한 후에 코드의 흐름이 멈추고 나면, 이를 다시 진행시키기 위해 위의 예시처럼 generatorFunction.next(); 을 입력하면 그 다음 yield 부분과 만나기 전까지 함수의 로직이 이어서 실행됩니다.

이러한 기본적인 제너레이터에 대한 개념을 가지고 Saga에 대해 알아보도록 하겠습니다.

3-2) Saga 사용법(with Redux-Toolkit)

Saga에 대한 사용법을 익히기 위해 예시 코드를 작성해보도록 하겠습니다. 이번에도 외부 통신을 통해 데이터를 불러오는 비동기 함수를 예시로 작성해보도록 하겠습니다.

Redux Thunk 의 경우 Redux-Toolkit에서 기본적으로 제공하고 있는 기능이기 때문에 Store에 미들웨어로 등록하지 않아도 사용할 수 있습니다. 하지만 Saga의 경우에는 기본적으로 제공하는 기능이 아니기 때문에, 이를 Store에 미들웨어로 등록해야 합니다.

// store.ts
import { configureStore } from "@reduxjs/toolkit";
import { useDispatch } from "react-redux";
import { rootReducer } from "~store/rootReducer";
import sagaMiddleware, { rootSaga } from "~store/rootSaga";

export const store = configureStore({
  reducer: rootReducer,
	// sagaMiddleware를 configureStore에 등록해줍니다.
  middleware: [sagaMiddleware]
});

// rootSaga를 실행해줍니다.
sagaMiddleware.run(rootSaga);

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

export const useAppDispatch = () => useDispatch<AppDispatch>();

export default store;

위와 같은 방식으로 우리가 만든 sagaMiddleware를 Store에 등록하면 됩니다.

// rootSaga.ts
import createSagaMiddleware from "redux-saga";
import { all, call } from "redux-saga/effects";
import watchGetData from "~store/fetchDataSaga";

// sagaMiddleware를 생성합니다.
const sagaMiddleware = createSagaMiddleware();

// 모든 saga들을 합치는 rootSaga를 만듭니다.
// 여러 saga들을 하나로 합칠 때에는 all()의 인자로 들어있는 배열에 saga들을 넣어주시면 됩니다.
export function* rootSaga() {
  yield all([call(watchGetData)]);
}

export default sagaMiddleware;
// fetchDataSage.ts
import { fetchDataActions } from "./fetchDataSlice";
import { all, fork, call, put, takeLatest } from "redux-saga/effects";
import axios, { AxiosResponse } from "axios";

// 외부 데이터를 불러오는 함수입니다. 
// 코드의 가독성을 위해 다른 파일로 분류하는 것이 더 좋지만, 
// 설명의 편의성을 위해 한 saga 파일에 포함시켰습니다.
const fetch = () => {
  return axios.get(
    "http://localhost:8080"
  );
};

function* fetchData() {
  try {
    const response: AxiosResponse = yield call(fetch);
    yield put(fetchDataActions.getDataSuccess(response.data.articles));
  } catch (error) {
    console.error(error);
    yield put(fetchDataActions.getDataError(error));
  }
}

// getData 액션을 감지하는 함수를 작성합니다.
// 해당 함수는 getData 액션을 감지하고 있다가,
// 액션이 실행되면, 두번째 인자로 들어있는 제너레이터 함수를 실행합니다.
function* watchGetData() {
  yield takeLatest(fetchDataActions.getData, fetchData);
}

export default watchGetData;
// fetchDataSlice.ts
import { createSlice } from "@reduxjs/toolkit";

export interface DataInterface {
  id: string;
  title: string;
}

export interface StateInterface {
  isLoading: boolean;
  data: DataInterface[];
  error: boolean;
}

const initialState: StateInterface = {
  isLoading: false,
  data: [],
  error: false
};

export const dataSlice = createSlice({
  name: "data",
  initialState,
  reducers: {
		// api를 실행하는 액션입니다.
    getData: (state) => {
      state.isLoading = true;
    },
		// api로 데이터를 불러오는데 성공하면 실행되는 액션입니다.
    getDataSuccess: (state, action) => {
      state.isLoading = false;
      state.data = action.payload;
    },
		// api로 데이터를 불러오는데 실패하면 실행되는 액션입니다.
    getDataError: (state, action) => {
      state.isLoading = false;
      state.error = true;
      state.data = action.payload;
    }
  }
});

export const fetchDataActions = dataSlice.actions;
export default dataSlice.reducer;
// index.tsx
import { VFC, useEffect } from "react";
import { fetchDataActions } from "~store/fetchDataSlice";
import { useSelector } from "react-redux";
import { RootState, useAppDispatch } from "~store/configureStore";

const Main: VFC = () => {
  const dispatch = useAppDispatch();
  const state = useSelector((state: RootState) => {
    return state.data;
  });

	// useEffect에서 데이터를 요청하는 액션을 dispatch합니다.
  useEffect(() => {
    dispatch(fetchDataActions.getData());
  }, []);

  return (
    <article>
      <section>
        <p>데이터를 활용하는 페이지입니다.</p>
        <p>{state.isLoading ? "로딩 중" : "로딩 완료"}</p>
				{state.data.map((element, idx) => {
          return <p key={idx}>{element.title}</p>;
        })}
      </section>
    </article>
  );
};

export default Main;

3-3) Redux Saga effects

Redux Saga에는 Saga의 활용을 돕기 위한 다양한 effects들이 존재합니다. 이 effects들은 미들웨어에서 활용할 수 있는 정보들을 담고 있는 자바스크립트 객체의 일종입니다. 이 effects들을 활용하여 Saga를 보다 효과적으로 사용할 수 있습니다. 이번 글에서는 가장 많이 쓰이는 대표적인 effects 몇가지를 소개하도록 하겠습니다.

(1) all

all effect는 제너레이터 함수들이 들어있는 배열을 인자로 받습니다. 이렇게 들어온 제너레이터 함수들은 all effect 안에서 병렬적으로 기능을 수행하며, 이 함수들이 모두 resolve 될 때까지 기다립니다. Promise.all 과 비슷한 기능이라고 생각하시면 됩니다.

(2) call

call effect는 함수를 실행시키는 effect입니다. 이 effect의 첫번째 인자에는 함수를 넣고, optional로 나머지 인자에 해당 함수에 넣을 인자를 넣을 수 있습니다.

(3) fork

fork effect 역시 함수를 실행시키는 effect입니다. call 과 fork의 차이점은 fork의 경우에는 함수를 비동기 실행하며, call의 경우에는 함수를 동기 실행한다는 점입니다. 따라서 순차적으로 함수가 실행되어야 하는 api 요청 함수 등의 경우에는 call을 사용하며, 그 외의 비동기 로직에는 fork를 사용합니다.

(4) put

put effect는 특정 액션을 dispatch하는 effect입니다. 위의 예시에서 보면 제너레이터 함수 내부에서 특정 액션을 dispatch 하고 있음을 확인하실 수 있습니다.

(5) takeEvery / takeLatest

takeEvery와 takeLatest는 인자로 들어온 액션에 대해 특정 로직을 실행시켜주는 effect입니다. takeEvery와 takeLatest의 차이는 takeEvery의 경우, 인자로 들어오는 모든 액션에 대해 로직을 실행시켜주는 반면, takeLatest는 기존에 실행 중이던 작업이 있을 경우 이를 취소하고, 가장 마지막으로 실행된 작업만 실행한다는 점입니다.

4. 어떤 상황에서 어떤 미들웨어를 써야 할까?

그렇다면 어떤 상황에서 어떤 미들웨어를 쓰는 것이 가장 좋을까요? 우선 Thunk는 Saga에 비해 Boilerplate 코드가 적고 이해하기 쉽다는 장점이 있습니다. 그만큼 서비스에 빠르게 적용할 수 있습니다. 따라서 서비스의 로직이 작거나 규모가 작은 경우에는 Thunk를 사용하는 것이 좀 더 나은 선택지라고 생각합니다. 하지만, Thunk의 경우 초보자가 잘못 사용할 경우 너무나 많은 async 로직을 구현하게 될 수도 있으며(비동기 로직이 복잡해짐), 또한 테스트를 하기 어려운 구조로 되어있어, unit test를 자주 하는 환경에서는 적용하기 어렵다는 단점을 가지고 있습니다.

반면 Saga는 Thunk에 비해 초기에 구현해야 하는 Boilerplate의 양이 많고, 제너레이터 등의 개념을 알아야 하기 때문에 초기 러닝 커브도 높은 편입니다. 하지만, Thunk에 비해 프로젝트 규모를 키우기 용이하고, 여러 Saga의 effects들을 활용하면 Thunk에 비해 깔끔한 로직을 구현할 수 있습니다. 또한 Saga는 throttling, debouncing, api의 재요청 및 취소와 관련한 로직을 구현하기 용이하기 때문에, Thunk에 비해 활용도가 높다고 할 수 있습니다.

개발 환경은 저마다 다르기 때문에 Thunk와 Saga 어떤 것이 더 낫다고 할 수 없습니다. 두 미들웨어의 사용법이 다른 만큼, 각자의 환경에 맞게 최적의 툴을 선택하여 적용하는 것이 좋은 개발이라고 할 수 있을 것입니다. 하지만 각자의 툴에 맞는 환경이 있듯이, 간단한 코드를 선호하시거나, 서비스 로직이 간단한 경우에는 Thunk를, 서비스 로직이 보다 크고 견고해야한다면 Saga를 쓰는 것을 추천드립니다.

profile
상상을 구현하고픈 프론트엔드 개발자입니다.

0개의 댓글