redux-toolkit, redux-saga로 api 요청하기 (with Typescript)

Redux와 API 요청

컴포넌트에서 화면에 보여줄 데이터를 API요청을 통해 받아와야 하는 경우가 있다. 컴포넌트별 statefetch 등을 통해서 API 요청을 할 수도 있다.

useEffect(() => {
  // API 요청
}, []);

하지만 컴포넌트 별로 데이터를 요청하는 코드를 작성하면 컴포넌트가 복잡해지고 재사용성이 떨어지길 마련이다. Redux를 통해서 API 호출을 모듈화할 수 있다. 그리고 컴포넌트에서는 다음과 같이 dispatch 하면 된다.

useEffect(() => {
  // getProducts()는 Action creator
  dispatch(getProducts());
}, []);

redux에서 async 요청을 할 수 있도록 도와주는 라이브러리에는 대표적으로 redux-thunk, redux-saga 등이 있다. 그 중에서도 redux-toolkit과 redux-saga를 이용하여 API 호출을 하는 모듈을 작성하고자 한다.

dependencies 설치하기

yarn add @reduxjs/toolkit react-redux redux redux-saga axios

Folder strucure

redux-toolkit에서는 다음과 같은 폴더 구조를 사용하고 있다.

src
    app
        store.ts
    features
        products
            productsSlice.ts
            ...
        ...

redux store를 구성하는 코드는 store.ts에, reducer와 action에 관한 코드는 slice에 정의한다. 그리고 saga에 관련된 코드는 productsSaga.ts처럼 파일을 하나 더 만들어 모듈화 한다.

Store 구성

redux를 리액트 컴포넌트에서 사용하기 위해서는 다음과 같이 제공해야 한다.

import store from "./app/store";

<Provider store={store}>
  <App />
</Provider>;

store.ts은 다음과 같은 구조로 이루어져 있다.


function createStore() {
    // 여기서 store를 생성한다.
    const store = configureStore({
        reducer: {
            // 여기서 reducer를 mapping한다.
        }
        middleware: [/*미들웨어를 넣어준다. sagaMiddleware가여기 들어간다.*/]
    })

    return store
}

const store = createStore()

// RootState type. useSelector 사용 시 state 타입을 위해 필요하다.
export type RootState = ReturnType<typeof store.getState>

export default store

여기에 redux-saga 미들웨어를 추가해줘야 한다. saga 미들웨어는 rootSaga로 통합한다.

import createSagaMiddleware from "redux-saga";

const sagaMiddleware = createSagaMiddleware();

function* rootSaga() {
  yield all([call(/*여기에 개별 saga가 들어간다*/)]);
}

function createStore() {
  const store = configureStore({
    // ...
    middleware: [sagaMiddleware],
  });
  sagaMiddleware.run(rootSaga);

  return store;
}

이렇게 작성하면 redux와 redux-saga 셋팅이 완료되었다.

Slice 생성

slice는 redux-toolkit에서 제안하는 redux 코드 작성 방식인듯 하다. redux-toolkitcreateSlice()라는 함수를 제공한다. 이 함수를 통해서 Action, Action creator와 reducer를 한꺼번에 만들 수 있다.

slice를 만들기 전에 state 타입과 initialState를 정의한다.

// types.ts
export interface Product {
  id: number;
  title: string;
  price: number;
  description: string;
  category: string;
  image: string;
}
// productsSlice.ts
interface ProductsState {
  loading: boolean;
  data: Product[] | null;
  erorr: AxiosErorr | null;
}

const initialState: ProductsState = {
    loading: false,
    data: null
    error: null
};

slice를 작성한다.

const productsSlice = createSlice({
  name: "products",
  initialState,
  reducers: {
    // 액션에 따른 reducer 로직을 작성한다.
    // createSlice가 자동으로 state의 타입을 추론한다.
    // 또한 immer를 사용하고 있어 함수 몸체 안에서 직접 변경해도 불변성을 유지한다.
    getProducts: (state) => {
      // loading => true
      state.loading = true;
    },
    getProductsSuccess: (state, { payload }) => {
      // API 요청이 성공적이면 데이터를 payload에 들어온다.
      state.data = payload;
      state.loading = false;
    },
    getProductsError: (state, { payload }) => {
      state.error = payload;
      state.loading = false;
    },
  },
});

// 정의한 액션과 리듀서를 export한다.
export const productsActions = productsSlice.actions;
export default productsSlice.reducer;

Saga 생성

액션과 리듀서를 정의했으면 실제 API 호출을 하는 saga를 작성해야 한다. saga는 Effect description이라고 하는 자바스크립트 객체를 generate하는 Generator함수를 말한다.

// productsSaga.ts
import { productsActions } from "./productsSlice";
import * as productsAPI from "../api/productsAPI";
import { call, put, takeLatest } from "redux-saga/effects";
function* getProducts() {
  // yield과정에서 발생하는 error는 catch에서 걸린다.
  try {
    // API 요청을 한다.
    // call(fn)에서 만약 fn이 Promise를 반환한다면 resovle될 때 까지 기다리고 결과를 generate한다.
    // 따라서 response에는 API 요청 응답이 담기게 된다.
    // axios를 통해 요청하기 때문에 AxiosResponse로 타입을 명시해준다.
    const response: AxiosResponse = yield call(productsAPI.getProducts);
    // put(action)은 action을 dispatch를 한다.
    yield put(productsActions.getProductsSuccess(response.data));
  } catch (error) {
    // 위 과정에서 에러가 발생하면 여기서 다룬다.
    yield put(productsActions.getProductsError(error));
  }
}

// 그 다음 getProducts 액션을 감지하는 saga를 작성한다.
function* watchGetProducts() {
  // 만약 getProducts액션 (패턴이라고도 하는데)이 감지되면, getProductsSaga를 호출한다.
  yield takeLatest(productsActions.getProducts, getProducts);
}

// watchGetProducts를 바로 export 해서 rootSaga에 넣어도 되는데 saga가 여러개 인 경우 saga로 한번더 감싸준다.
export default function* getProductsSaga() {
  yield all([fork(watchGetProducts)]);
}

RootSaga

방금 정의한 saga를 rootSaga에 포함시켜준다.

// store.ts

function* rootSaga() {
  yield all(call(getProductsSaga));
}

여기까지가 API를 호출하는 saga를 작성하고 rootSaga에 포함시키는 과정이다.

dispatch

API 호출 로직을 담당하는 saga를 작성했으니 사용할 차례다. 컴포넌트 마운트 시, 비동기 요청은 useEffect() 안에 넣어준다.

// ProductsContainer.tsx
import { useDispatch } from "react-redux";
import { productsActions } from "../features/products/productsSlice";

const ProductsContainer = () => {
  const dispatch = useDispatch();
  useEffect(() => {
    dispatch(productsActions.getProducts());
  }, []);
  return (
    // ...
  )
};