Redux Toolkit - extraReducers 활용하기 (with. createAsyncThunk)

eonisal·2024년 2월 18일
0

Redux Toolkit을 사용하여 초미니 프로젝트를 하다가 슬라이스의 리듀서만으로는 해결이 안되는 문제가 있어 해결법을 찾아보다가 extraReducers 라는 것을 알게 되었다. 그리고 extraReducers와 createAsyncThunk 라는 Redux Toolkit 에서 제공하는 api를 이용한 비동기 처리 방법에 대해서도 알게되었다.

extraReducers와 reducers의 차이를 알아보고 extraReducers의 두가지 용도를 알아보며 extraReducers에 대해 정리해본다.

🎏 reducers vs extraReducers

reducers와 extraReducers의 차이를 간단히 말하자면, 슬라이스를 정의할 때 reducers는 슬라이스의 상태를 어떻게 업데이트할지에 대한 로직을 정의하는 반면 extraReducers는 외부에서 생성된 액션에 대한 리듀서 로직을 정의한다는 것이다.

reducers는 슬라이스의 상태를 갱신하는 일반적인 동기적 작업을 다루고, extraReducers는 비동기적인 작업이나 외부 액션과의 상호작용을 다루는 데 사용한다.

이게 무슨 말인지 예시들을 보며 살펴보자.

📬 createAsyncThunk를 이용한 비동기 처리

extraReducers의 용도중 하나는 createAsyncThunk 함수를 이용한 state의 비동기 처리이다. 이 함수를 사용하지 않고 서버로부터 데이터를 받아와 state를 업데이트하는 것과 createAsyncThunk를 이용해 state를 업데이트하는 예시를 살펴보자.


서버로부터 물품 정보들을 받아서 화면에 나타내주는 간단한 order 페이지이다. 이 order 페이지에서 나타내는 물품 정보를 담을 state를 다음과 같이 products 라는 슬라이스로 정의하여 사용하고 있었다.

// ProductSlice.ts

import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import axios from "axios";
import { RootState } from "..";

export interface ProductType {
  id: string;
  name: string;
  event: 0 | 1;
  materialType: number;
  price: number;
};

const initialState: ProductType[] = [];

const productsSlice = createSlice({
  name: 'products',
  initialState,
  reducers: {
    setProducts: (state, action: PayloadAction<ProductType[]>) => {
      return action.payload;
    }
  }
});

export const { setProducts } = productsSlice.actions;
export const selectProducts = (state: RootState) => state.products;
export default productsSlice.reducer;

ProductType 이라는 타입의 객체로 이루어진 배열 형태의 state를 정의한 products 슬라이스다. 그리고 이를 컴포넌트에서 가져와 다음과 같이 업데이트 해주는 상황이었다.

// Order.tsx

import axios from "axios"
import { useEffect, useState } from "react"
import { setProducts, selectProducts } from "../../store/slices/productsSlice";
import { OrderArea, ProductCardWrapper } from "./Order.styles";
import ProductCard from "../../components/ProductCard/ProductCard";
import OrderFooter from "../../components/OrderFooter/OrderFooter";
import { useAppDispatch, useAppSelector } from "../../store/hooks";
import Loading from "../../components/Loading/Loading";

export default function Order() {
  const products = useAppSelector(selectProducts);
  const dispatch = useAppDispatch();
  const [ isLoading, setIsLoading ] = useState(true);

  useEffect(() => {
    const getDate = async () => {
      const response = await axios.get("http://localhost:3001/items");
      dispatch(setProducts(response.data));
      setIsLoading(false);
    };

    getDate();
  }, []);

  return (
    <OrderArea>
      {isLoading ? 
        <Loading /> :
        <ProductCardWrapper>
          {products.map(({ id, name, event, price }, idx) => (
            <ProductCard key={id} name={name} event={event} price={price} idx={idx} />
          ))}
        </ProductCardWrapper>}
      <OrderFooter loading={isLoading ? "yes" : "no"} />
    </OrderArea>
  )
}

useEffect 훅을 이용해 컴포넌트가 처음 마운트될 때 서버로부터 데이터를 받아와 setProducts 리듀서로 products를 업데이트하고 isLoading 이라는 state를 products 업데이트 시 false로 바꿔 로딩 스피너를 추가해주었다.

이렇게 비동기 통신과 로딩 스피너의 렌더링 여부를 컴포넌트 내에서 모두 구현한 방식이었다. 하지만 이를 createAsyncThunk과 extraReducers를 이용하여 컴포넌트 외부에서 비동기 통신을 하는 방식으로 구현할 수 있었다.

createAsyncThunk

Redux Toolkit은 thunk를 내장하고 있어서 다른 미들웨어를 사용하지 않고도 비동기 처리가 가능하다. 이 때 사용하는게 createAsyncThunk 이다.

createAsyncThunk는 thunk action creator를 반환하는 함수로, 비동기 작업을 수행하는 함수를 생성하는 유틸리티 함수이다. 이 함수는 세 가지 액션을 자동으로 생성하고, 이 액션들을 디스패치할 수 있는 액션 크리에이터를 반환한다.

여기서 세 가지 액션은 프로미스의 세가지 상태인 pending, fulfilled, rejected 상태에 해당하는 액션이다.

createAsyncThunk 함수는 첫번째 인자로는 액션 타입 문자열, 두번째 인자로는 프로미스를 반환하는 비동기 함수, 세번째 인자로는 추가 옵션을 받는다.

createAsyncThunk에 대한 자세한 내용은 많은 분들이 잘 정리해주신 아래의 포스팅들을 참고하면 좋을 듯 하다.

React _ Redux Toolkit의 thunk를 활용한 비동기 통신 이해하기
thunk가 뭔데 도대체
Redux Toolkit의 createAsyncThunk로 비동기 처리하기

위 코드에서 useEffect에 정의한 비동기 통신 로직을 createAsyncThunk 함수로 나타내면 다음과 같다.

export const getProducts = createAsyncThunk<ProductType[]>('asyncThunk/getProducts', async () => {
  const response = await axios.get("http://localhost:3001/items");
  return response.data;
});

타입스크립트 환경에선 createAsyncThunk에 타입을 지정해줘야 하는데, 제네릭의 첫번째 인자로 createAsyncThunk 함수의 리턴 타입을 지정해줄 수 있다.

타입 지정 관련 참고
[Redux Toolkit] 3 - 리덕스 툴킷에서 타입스크립트 사용하기

이렇게 createAsyncThunk 함수로 thunk action creator를 getProducts에 할당한 후, thunk action creator가 생성한 pending, fulfilled, rejected 상태에 해당하는 세가지 액션을 다음과 같이 getProducts 변수로 접근할 수 있다.

  • getProduct.pending - 'asyncThunk/getProducts/pending' 액션
  • getProduct.fulfilled - 'asyncThunk/getProducts/fulfilled' 액션
  • getProduct.rejected - 'asyncThunk/getProducts/rejected' 액션

비동기 통신에 대한 세가지 상태의 액션을 얻었으니 이 액션들에 대한 리듀서를 정의하면 되는데, createAsyncThunk 함수로 얻은 세가지 비동기 액션에 대한 리듀서는 reducers 필드에 정의할 수가 없고 extraReducers 필드에 정의해야 한다.

일반적인 동기 처리 시의 리듀서(reducers 필드에 지정한 함수들)는 reducers 필드에 지정한 함수 명에 해당하는 액션이 디스패치되면 자동으로 그 함수가 실행되도록 리덕스 툴킷이 맵핑하는데 createAsyncThunk에 의한 세가지 비동기 액션들은 그렇게 자동으로 그 액션에 해당하는 리듀서 함수들을 맵핑해주지 못한다. 그래서 이 액션들을 담당할 리듀서는 extraReducers 필드에 지정해야 한다.

export interface ProductType {
  id: string;
  name: string;
  event: 0 | 1;
  materialType: number;
  price: number;
};

export type stateObj = {
  status: string;
  productList: ProductType[];
};

const initialState: stateObj = {
  status: '',
  productList: [],
};

export const getProducts = createAsyncThunk<ProductType[]>('asyncThunk/getProducts', async () => {
  const response = await axios.get("http://localhost:3001/items");
  return response.data;
});

const productsSlice = createSlice({
  name: 'products',
  initialState,
  reducers: {},
  // 비동기 통신의 상태에 따른 액션에 해당하는 리듀서를 extraReducers에 정의
  extraReducers: (builder) => {
    builder
      .addCase(getProducts.pending, (state) => {
        state.status = "pending";
      })
      .addCase(getProducts.fulfilled, (state, action: PayloadAction<ProductType[]>) => {
        state.status = "fulfilled";
        state.productList = action.payload;
      })
      .addCase(getProducts.rejected, (state) => {
        state.status = "rejected";
      });
  }
});

extraReducers를 정의할 때 "빌더 콜백" 방식과 "맵 개체 표기법" 방식이 있는데, 일반적으로는 빌더 콜백 방식이 권장된다고 한다.

"빌더 콜백" 방식과 "맵 개체 표기법" 방식은 createReducer를 사용할 때와 같은 상황인 것 같은데 자세한 내용은 아래의 포스팅을 참고해보면 좋을 것 같다.

Redux 톺아보기 1 - (redux-toolkit)

이렇게 createAsyncThunk 함수에 비동기 로직을 정의하고, 그 비동기 처리의 상태에 따른 액션에 해당하는 리듀서를 extraReducers 필드에 정의함으로서 데이터를 받아오기 전(pending), 데이터를 받아온 후(fulfilled), 데이터를 받아오는데 실패했을 때(rejected) 각각 상태를 정의해줄 수 있다.

createAsyncThunk와 extraReducers를 이용해 외부에서 비동기처리를 정의한 후 order 페이지 코드는 다음과 같다.

import { useEffect } from "react"
import { getProducts, selectProducts } from "../../store/slices/productsSlice";
import { OrderArea, ProductCardWrapper } from "./Order.styles";
import ProductCard from "../../components/ProductCard/ProductCard";
import OrderFooter from "../../components/OrderFooter/OrderFooter";
import Loading from "../../components/Loading/Loading";
import { useAppDispatch, useAppSelector } from "../../store/hooks";
import Error from "../Error/Error";

export default function Order() {
  const { status, productList } = useAppSelector(selectProducts);
  const dispatch = useAppDispatch();

  useEffect(() => {
    dispatch(getProducts());
  }, []);

  console.log(status, productList);

  if (status === "rejected") {
    return <Error />
  };

  return (
    <OrderArea>
      {status === "pending" ? 
        <Loading /> :
        <ProductCardWrapper>
          {productList.map(({ id, name, event, price }, idx) => (
            <ProductCard key={id} name={name} event={event} price={price} idx={idx} />
          ))}
        </ProductCardWrapper>}
      <OrderFooter loading={status === "pending" ? "yes" : "no"} />
    </OrderArea>
  )
}

그럼 굳이 이렇게 createAsyncThunk를 이용해서 비동기 처리를 하는 방식의 장점이 무엇이냐?

일단 컴포넌트 외부에서 비동기 처리를 할 수 있기 때문에 관심사 분리가 가능하다는 점이 있다. 서버로부터 데이터를 받아오기 위한 비동기 통신 로직이 컴포넌트 내부에 있는 것 보다 외부에 정의해두는것이 컴포넌트의 코드도 깔끔하고, 만약 다른 컴포넌트에서도 동일한 비동기 통신을 해야할 일이 있다면 코드의 중복이 생길 일도 없으니 이런 점에서 효율적이다.

그리고 위의 코드에서 status 변수처럼 state 객체에서 실제 데이터를 담을 속성 외에 비동기 통신 상태를 나타내는 용도의 변수를 하나 사용해서 pending 시에 로딩 스피너, rejected 시에 에러 페이지를 나타내는 등의 처리에도 용이하다. 이또한 물론 기존의 방식처럼 컴포넌트 내에서 useEffect 에서 비동기 처리와 함께 state 변수로 지정할 수 있지만 이렇게 하는 편이 코드가 더 깔끔하다.

또한 Redux DevTools를 통해 비동기 액션의 상태를 더 쉽게 추적하고 디버깅할 수 있다고 한다.

🛎️ 외부 슬라이스의 액션에 대한 상호작용

createAsyncThunk의 액션에 대한 리듀서를 정의한 방식으로 외부 슬라이스의 액션에 대한 리듀서도 정의할 수 있다.

위에서 예시로 든 order 페이지를 보면 각 상품마다 상품 개수가 0으로 나와있다. 상품의 개수는 서버에서 받아오는 정보는 아니고 처음 order 페이지에 들어오면 모든 상품이 다 0개인 상황이다.

이 상품의 개수를 나타내는 배열 state를 다음과 같이 따로 만들어 이 state를 순회해 화면에 나타내주었다.
(원래 위의 productSlice.ts 에서 ProductType 타입에 amount 속성을 추가해 각 상품 정보에 그냥 개수 정보를 추가했었는데 어떤 이유로 이렇게 개수를 별도의 state로 따로 빼는 방식을 택하게 되었다. 근데 왜 그랬는지 이유가 생각이 안난다..)

// amountSlice.ts

import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { getProducts } from "./productsSlice";
import { RootState } from "..";

const initialState: number[] = Array.from({ length: 10 }, () => 0);;

const amountSlice = createSlice({
  name: 'amount',
  initialState,
  reducers: {
    resetAmount: (state) => {
      return initialState;
    },
    increaseAmount: (state, action: PayloadAction<number>) => {
      state[action.payload] += 1;
    },
    decreaseAmount: (state, action: PayloadAction<number>) => {
      state[action.payload] -= 1;
    }
  }
});

export const { resetAmount, increaseAmount, decreaseAmount } = amountSlice.actions;
export const selectAmount = (state: RootState) => state.amount;
export default amountSlice.reducer;

그런데 지금은 initialState를 길이가 10인 0으로 이루어진 배열로 만들고있다. 이는 서버로부터 정보를 받아오는 상품이 10개라고 딱 정해져 있을때나 가능한 방법이다. 상품의 개수가 유동적으로 바뀌는 경우에는 이렇게 length를 미리 정해서 배열을 만들 수가 없다.

그래서 initialState 배열의 길이는 서버로부터 상품 정보를 받아온 후의 products 상태 배열의 길이여야 하는데, 정보를 받은 후의 products의 길이는 이 amountSlice.ts 파일에서는 알 방법이 없다.

이를 extraReducers를 이용해 해결할 수 있다.

const initialState: number[] = [];

const amountSlice = createSlice({
  name: 'amount',
  initialState,
  reducers: {
    resetAmount: (state) => {
      return initialState;
    },
    increaseAmount: (state, action: PayloadAction<number>) => {
      state[action.payload] += 1;
    },
    decreaseAmount: (state, action: PayloadAction<number>) => {
      state[action.payload] -= 1;
    }
  },
  extraReducers: (builder) => {
    builder.addCase(setProducts, (state, action) => {
      return Array.from({ length: action.payload.length }, () => 0);
    })
  }
});

createAsyncThunk를 사용해 pending, fulfilled, rejected 액션에 대한 리듀서를 정의했을 때처럼 extraReducers의 builder의 addCase의 첫번째 인자로 products가 업데이트되는 setProducts 액션을 지정한다.
(맨 처음 productsSlice.ts 코드처럼 thunk를 이용한 비동기처리가 아닌 경우)

그러면 setProducts 액션이 디스패치될 때 이 setProducts 함수의 action 객체를 동일하게 사용할 수 있기 때문에 action.payload.length 로 상품 배열의 길이를 알 수 있다.

만약 createAsyncThunk로 비동기 처리를 한 productsSlices인 경우라면 setProducts가 아닌 getProduct.fulfilled을 첫번째 인자에 지정해주면 된다.
(products가 업데이트된 후여야 되니까 fulfilled 이벤트)

extraReducers: (builder) => {
    builder.addCase(getProducts.fulfilled, (state, action) => {
      return Array.from({ length: action.payload.length }, () => 0);
    })
  }

이렇게 extraReducers는 createAsyncThunk를 이용한 비동기 처리시의 액션이나 다른 슬라이스의 액션에 대한 리듀서를 정의할때 활용할 수 있다.

profile
언제까지_이렇게_살아야돼_

2개의 댓글

comment-user-thumbnail
2024년 6월 26일

감사합니다!

1개의 답글

관련 채용 정보