[Redux] redux-saga

Dodam·2024년 1월 30일
0

[Redux]

목록 보기
11/11
post-thumbnail

redux-saga

thunk가 함수를 디스패치 할 수 있게 해주는 미들웨어였다면,

saga는 액션을 모니터링 하고 있다가 특정 액션이 발생했을 때, 미리 정해둔 로직에 따라 특정 작업이 이루어지는 액션에 대한 리스너이다.

여기서 특정 작업이란, 특정 자바스크립트 코드를 실행하는 것일 수도 있고,
다른 액션을 dispatch 하는 것일 수도 있으며, 현재 상태(state)를 불러오는 것일 수도 있다.

redux-saga는 redux-thunk로 하지 못하는 다양한 작업들을 처리할 수 있다.

예를 들면,

  1. 비동기 작업을 할 때, 기존 요청을 취소할 수 있다.
  2. 특정 액션이 발생했을 때,
    이에 따라 다른 액션이 디스패치 되도록 하거나, 특정 자바스크립트 코드를 실행할 수 있다.
  3. 웹 소켓을 사용하는 경우, Channel이라는 기능을 사용해 더욱 효율적으로 코드를 관리할 수 있다.
  4. API 요청이 실패했을 때, 재요청하는 작업이 가능하다.

Saga는 제너레이터(Generator)라는 특수한 형태의 함수로 구현된다.

이 제너레이터 함수를 구현할 때, 함수의 실행을 특정 구간에 멈추게 하거나 원하는 시점으로 돌아가게 할 수 있다.
또한 결과값을 여러 번 리턴하게 할 수도 있다.

제너레이터(Generator)

다음과 같은 함수가 있을 때, 이 함수는 호출할 때마다 무조건 1을 반환한다.

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

하지만, 제너레이터 함수를 사용하면 값을 순차적으로 반환할 수 있다.

심지어는 함수의 흐름을 도중에 멈춰놓았다가 나중에 이어서 진행할 수도 있다.

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 뒤에 오는 표현식은 제너레이터를 관찰하고 있던 호출자(caller)에게 반환된다.

쉽게 생각해서 일반 함수의 return과 유사한 것이다.

즉, 제너레이터 함수는 yield 부분에서 특정 값을 반환하고 그 실행을 잠시 멈추는 것이다.
이후에 이 함수를 마저 실행시키려면 next라는 함수를 통해 구현할 수 있다.

// 제너레이터 함수를 호출
generatorFunction.next();

// 또는 다음과 같은 형태로 작성할 수 있다.
const generator = generatorFunction();
generator.next();

위의 코드처럼 제너레이터를 생성하고 generator().next() 를 호출해야만 코드가 실행되며, yield를 한 값을 반환하고 코드의 흐름을 멈춘다.

코드의 흐름이 멈추고 난 후, generator.next() 를 다시 호출하면 흐름이 이어진다.

Saga 사용법 (with Redux-Toolkit)

외부 통신을 통해 데이터를 불러오는 비동기 함수 예시코드를 작성해보자.

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/rootReducer';

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

// rootSaga를 실행
sagaMiddleware.run(rootSaga);

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

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

export default store;

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

// rootSaga.ts
import createSagaMiddleware from 'redx-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;

// fetchDataSaga.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:8000'
	);
};

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 = false;
		},
		// 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;

redux-saga effects

redux-saga에는 saga의 활용을 돕기 위한 다양한 effects들이 존재한다.
이 effects들은 미들웨어에서 활용할 수 있는 정보들을 담고 있는 자바스크립트 객체의 일종으로,
이 effects들을 활용하여 saga를 보다 효과적으로 사용할 수 있다.

다음은 가장 많이 쓰이는 대표적인 effects 들이다.

all

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

call

call effect는 함수를 실행시키는 effect이다.
첫 번째 인자에는 함수를 넣고, optional로 나머지 인자에 해당 함수에 넣을 인자를 넣을 수 있다.
일반 자바스크립트의 바인딩 함수 call과 유사하다.

fork

fork effect 역시 함수를 실행시키는 effect로,

call과 fork의 차이점은
fork는 함수를 비동기 실행하며, call은 함수를 동기 실행한다는 점이다.

따라서 순차적으로 함수가 실행되어야 하는 api 요청 함수 등의 경우에는 call을 사용하며,
그 외의 비동기 로직에는 fork를 사용한다.

put

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

takeEvery / takeLastest

takeEvery와 takeLatest는 인자로 들어온 액션에 대해 특정 로직을 실행시켜주는 effect이다.

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

언제, 어떤 미들웨어를 사용할까?

Redux-thunks vs Redux-saga

어떤 상황에서 어떤 미들웨어를 쓰는 것이 좋을까?

우선 Thunk는 Saga에 비해 Boilerplate 코드가 적고 이해하기 쉽다는 장점이 있다. 그만큼 서비스에 빠르게 적용할 수 있다. 따라서 서비스의 로직이 작거나 규모가 작은 경우에는 Thunk를 사용하는 것이 좀 더 나은 선택지가 될 수 있다.

하지만 Thunk의 경우 초보자가 잘못 사용할 경우, 너무나 많은 async 로직을 구현하게 될 수도 있으며, 또한 테스트를 하기 어려운 구조로 되어있어 unit test를 자주 하는 환경에서는 적용하기 어렵다는 단점을 가지고 있다.

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

profile
Good things take time

0개의 댓글