Hits

🌟 도입 배경

FanUP 프로젝트에서는 상태관리를 위해 Redux Tool Kit을 선택하고 사용했습니다.

이번 글에서는 Redux Tool Kit을 사용한 후기와 사용법에 대해서 얘기해보겠습니다.

Redux Tool Kit을 도입한 배경은 다음과 같습니다.

  1. 서비스 특성상 클라이언트 상태 관리와 서버 상태 관리를 모두 해야 하는 경우가 많았습니다.
  2. 서버 상태 관리 라이브러리 + 클라이언트 상태 관리 라이브러리를 따로 적용해도 되지만, Redux Tool Kit 라이브러리 하나만으로도, 클라이언트 상태관리와 RTK Query를 통한 서버 상태관리가 모두 가능하기 때문에, 기술의 통일성을 위하여 Redux Tool Kit을 선택했습니다.
  3. 프로젝트의 클라이언트 개발자들이 모두 Redux 사용 경험이 있기 때문에, 큰 러닝 커브 없이 개발을 진행 할 수 있을 것이라 판단했습니다.

🌟 스토어 연결

RTK Query를 사용하기 위해서는, RTK에서 제공하는 createApi 를 이용해, 하나의 도메인에 대한 service를 만들고, 이를 combineReducer를 통해 만든 rootReducer애 추가하고, Redux의 configureStore를 통해 만든 Root Store와 연결 해야 합니다. (RTK Query Overview)

userService
export const userApi = createApi({
  reducerPath: "userApi",
  baseQuery: customFetchBaseQuery,
  tagTypes: ["User", "SubScribedArtist", "MyTicket"],
  endpoints: (build) => ({
    getUser: build.query<IUser, void>({
      query: () => "/auth/me",
      providesTags: ["User"],
    }),
  }),
});
userService
export const userApi = createApi({
  reducerPath: "userApi",
  baseQuery: customFetchBaseQuery,
  tagTypes: ["User", "SubScribedArtist", "MyTicket"],
  endpoints: (build) => ({
    getUser: build.query<IUser, void>({
      query: () => "/auth/me",
      providesTags: ["User"],
    }),
  }),
});
rootReducer
const reducer = combineReducers({
  artistSlice,
  userSlice,
  [artistApi.reducerPath]: artistApi.reducer,
  [userApi.reducerPath]: userApi.reducer,
});
rootReducer
const reducer = combineReducers({
  artistSlice,
  userSlice,
  [artistApi.reducerPath]: artistApi.reducer,
  [userApi.reducerPath]: userApi.reducer,
});
Root Store
const store = configureStore({
  reducer,
  middleware: (getDefaultMiddleWare) =>
    getDefaultMiddleWare({ serializableCheck: false })
      .concat(artistApi.middleware)
      .concat(userApi.middleware),
});
Root Store
const store = configureStore({
  reducer,
  middleware: (getDefaultMiddleWare) =>
    getDefaultMiddleWare({ serializableCheck: false })
      .concat(artistApi.middleware)
      .concat(userApi.middleware),
});

🌟 Query 사용하기

Query의 경우, 서버의 상태를 가져오는데 사용됩니다.

Store에 연결을 끝낸 후, createApi 메서드의 endPoint Key에 작성한 Query와 Mutation을 사용 할 수 있습니다.

export const ticketApi = createApi({
  reducerPath: "ticketApi",
  baseQuery: customFetchBaseQuery,
  tagTypes: ["Ticket", "MyTicket", "TodayTicket", "TicketDetail"],
  endpoints: (build) => ({
    getTodayTickets: build.query<TicketSales[], void>({
      //TODO : invalidate when 00:00
      query: () => "/ticket/today",
      providesTags: ["TodayTicket"],
    }),
    getAllTickets: build.query<TicketSales[], void>({
      query: () => "/ticket",
      providesTags: ["Ticket"],
    }),
    getDetailTicket: build.query<TicketDetail, string>({
      query: (ticketid: string) => ({ url: `/ticket/${ticketid}` }),
      providesTags: (result, error, id) => [{ type: "TicketDetail", id }],
    }),
  }),
});
 
export const {
  useGetTodayTicketsQuery,
  useGetAllTicketsQuery,
  useGetDetailTicketQuery,
} = userApi;
export const ticketApi = createApi({
  reducerPath: "ticketApi",
  baseQuery: customFetchBaseQuery,
  tagTypes: ["Ticket", "MyTicket", "TodayTicket", "TicketDetail"],
  endpoints: (build) => ({
    getTodayTickets: build.query<TicketSales[], void>({
      //TODO : invalidate when 00:00
      query: () => "/ticket/today",
      providesTags: ["TodayTicket"],
    }),
    getAllTickets: build.query<TicketSales[], void>({
      query: () => "/ticket",
      providesTags: ["Ticket"],
    }),
    getDetailTicket: build.query<TicketDetail, string>({
      query: (ticketid: string) => ({ url: `/ticket/${ticketid}` }),
      providesTags: (result, error, id) => [{ type: "TicketDetail", id }],
    }),
  }),
});
 
export const {
  useGetTodayTicketsQuery,
  useGetAllTicketsQuery,
  useGetDetailTicketQuery,
} = userApi;

컴포넌트에서 사용하기(Query)

const Tickets = () => {
  const { data: allTickets, isLoading } = useGetAllTicketsQuery(undefined, {
    pollingInterval: 3000,
    refetchOnMountOrArgChange: true,
  });
  return <></>;
};
const Tickets = () => {
  const { data: allTickets, isLoading } = useGetAllTicketsQuery(undefined, {
    pollingInterval: 3000,
    refetchOnMountOrArgChange: true,
  });
  return <></>;
};

기본적인 useQuery Hook 이외에도, 여러 상황에서 사용이 가능한 Hook들이 존재합니다 => Query와 관련된 Hook 살펴보기

여러 가지 Hook들을 사용해, PrefetchRefetchLazy Fetch 등, Data Fetching 시점을 선택하고, Data Fetching 시점을 Caching 해 놓을 수 있습니다.

기본적인 useQuery의 경우, React Query에서 제공하는 useQuery와 거의 같은 반환값을 제공하며, useQuery의 두번째 인자에 pollingIntervalrefechOnFocus 와 같은 옵션을 통해, 특정 시점에 해당 Query를 다시 Refetch 하도록 만들 수 있습니다. -> useQuery의 반환값과 옵션 살펴보기


🌟 Mutation 사용하기

Query가 서버의 상태를 가져오는데 사용 되었다면, Mutation의 경우, 서버의 상태를 변화 시킬 때 사용합니다.

export const ticketApi = createApi({
  reducerPath: "ticketApi",
  baseQuery: customFetchBaseQuery,
  tagTypes: ["MyTicket"],
  endpoints: (build) => ({
    getMyTicket: build.query<MyTicket, string>({
      query: () => "/ticket/my",
      providesTags: ["MyTicket"],
    }),
 
    ticketing: build.mutation({
      query: (ticketId: string) => {
        return {
          url: "/ticket/user",
          method: "POST",
          body: { ticketId },
        };
      },
      invalidatesTags: (result, error, ticketId) => {
        if (result?.status >= 400 || error) return [];
        else return ["MyTicket"];
      },
    }),
  }),
});
export const ticketApi = createApi({
  reducerPath: "ticketApi",
  baseQuery: customFetchBaseQuery,
  tagTypes: ["MyTicket"],
  endpoints: (build) => ({
    getMyTicket: build.query<MyTicket, string>({
      query: () => "/ticket/my",
      providesTags: ["MyTicket"],
    }),
 
    ticketing: build.mutation({
      query: (ticketId: string) => {
        return {
          url: "/ticket/user",
          method: "POST",
          body: { ticketId },
        };
      },
      invalidatesTags: (result, error, ticketId) => {
        if (result?.status >= 400 || error) return [];
        else return ["MyTicket"];
      },
    }),
  }),
});

위 ticket API에서 ticketing mutation은 티켓팅을 하는 경우 사용될 mutation입니다.

getMyTicket Query는 내가 티켓팅 한 티켓의 목록을 가져오는 Query입니다.

사용자가 티켓팅을 성공한다면, 내가 티켓팅한 티켓의 목록을 업데이트 시켜줘야 합니다.

invalidateTags 에 들어가는 메서드는 resulterrorparams 라는 3개의 매게변수를 받습니다.

result는 실행 결과에 대한 서버의 응답을 받고, error는 실행 중 에러가 발생했을 때 에러를 반환하며, params에는 mutation의 매게변수로 전달한 데이터가 반환됩니다.

따라서, 티켓팅이 성공 했을 때만, 내가 티켓팅 한 티켓의 목록을 업데이트(invalidate) 시켜주도록 만들어, 효율적으로 API 요청을 관리 할 수 있었습니다.


🌟 Conditional Fetching

상황에 따라서, 원하는 경우에만 Query를 통한 Data Fetching을 하고 싶을 수도 있습니다.

FanUP 프로젝트에서는, 내가 티켓팅한 티켓의 목록을 가져오는 경우가 이에 해당했습니다,.

로그인을 한 사용자에 대해서만, 내가 티켓팅한 티켓의 목록을 가져와야 했습니다.

이때는 useQuery의 옵션 중 skip을 이용해 해결 할 수 있었습니다.

const { data: userData } = useGetUserQuery();
const { data: myTickets } = useGetMyTicketsQuery(undefined, {
  skip: userData ? false : true,
});
const { data: userData } = useGetUserQuery();
const { data: myTickets } = useGetMyTicketsQuery(undefined, {
  skip: userData ? false : true,
});

skip의 값에 따라, Query의 실행 여부를 결정 할 수 있었습니다.

사용자의 데이터가 있다면(로그인을 한 경우), 해당 Query를 수행하고(skip : false), 데이터가 없다면 해당 Query를 수행하지 않도록 만들 수 있었습니다.


🌟Cache Invalidate

앞서, mutation에 대해서 설명을 할 때, mutation내에서, invalidateTags 를 통해서, Query를 Refetch하도록 만들었습니다.

하지만, mutation을 통해, 서버의 상태를 변화시키는 경우가 아닐 때에도, Cache를 invalidate 시켜야 하는 경우도 있었습니다.

FanUP 서비스에서는, 로그아웃을 하는 경우가 그 경우에 해당했습니다.

토큰을 사용해, 로그인을 진행하기 때문에, 로그아웃을 하는 경우, 서버에 특정 요청을 보내지 않고, 클라이언트 자체적으로 token을 날려주기로 했습니다.

추가로, Token을 날려주는 것 외에도, 사용자와 관련된 모든 API의 Cache를 날려주어야 했습니다

이때는, createApi의 util함수에서 제공하는 resetApiState 메서드를 사용했습니다.

resetApiState를 사용할 경우, 해당 도메인에 묶여 있는 모든 Query가 Reset 되게 됩니다.

export const userApi = createApi({
  reducerPath: "userApi",
  baseQuery: customFetchBaseQuery,
  tagTypes: ["User", "SubScribedArtist", "MyTicket"],
  endpoints: (build) => ({
    getUser: build.query<IUser, void>({
      query: () => "/auth/me",
      providesTags: ["User"],
    }),
    getMyTickets: build.query<MyTicket[], void>({
      query: () => "/ticket/my",
      providesTags: ["MyTicket"],
    }),
  }),
});
 
export const { useGetUserQuery, useGetMyTicketsQuery } = userApi;
 
export const { resetApiState: resetUserService } = userApi.util;
export const userApi = createApi({
  reducerPath: "userApi",
  baseQuery: customFetchBaseQuery,
  tagTypes: ["User", "SubScribedArtist", "MyTicket"],
  endpoints: (build) => ({
    getUser: build.query<IUser, void>({
      query: () => "/auth/me",
      providesTags: ["User"],
    }),
    getMyTickets: build.query<MyTicket[], void>({
      query: () => "/ticket/my",
      providesTags: ["MyTicket"],
    }),
  }),
});
 
export const { useGetUserQuery, useGetMyTicketsQuery } = userApi;
 
export const { resetApiState: resetUserService } = userApi.util;
const logout = useCallback(() => {
  if (!window.confirm("로그아웃 하시겠어요?")) return;
  localStorage.removeItem("token");
  dispatch(setToken(null));
  dispatch(resetUserService());
}, []);
const logout = useCallback(() => {
  if (!window.confirm("로그아웃 하시겠어요?")) return;
  localStorage.removeItem("token");
  dispatch(setToken(null));
  dispatch(resetUserService());
}, []);

resetApi를 이용해, 사용자와 관련된 모든 API의 Cache를 invalidate 시킬 수 있었습니다.


🌟Query결과 가공

Query를 통해 서버에서 가져온 데이터를 가공해야 하는 경우에는 transformResponse를 사용했습니다.

아래 예제는 아티스트의 스케쥴을 가져온 뒤, 해당 스케쥴을 달력에 들어갈 수 있는 형태의 데이터로 가공하는 과정입니다.

getSchedules: build.query<any[], CalendarData>({
    query: ({ calendarMonth, calendarYear }) =>
        `/ticket/artist/calendar?year=${calendarYear}&month=${calendarMonth}`,
    providesTags: ['Schedules'],
    transformResponse: (response: any[]) => {
        const temp: any[] = Array.from({ length: 31 }, () => []);
        response?.forEach((data) => {
            const startDate = new Date(data.startTime);
            const date = startDate.getDate();
            const [diff] = dateDiff(startDate, new Date());
            temp[date].push({ data, isPast: diff < 0 ? true : false });
        });
 
        return temp;
    },
}),
 
getSchedules: build.query<any[], CalendarData>({
    query: ({ calendarMonth, calendarYear }) =>
        `/ticket/artist/calendar?year=${calendarYear}&month=${calendarMonth}`,
    providesTags: ['Schedules'],
    transformResponse: (response: any[]) => {
        const temp: any[] = Array.from({ length: 31 }, () => []);
        response?.forEach((data) => {
            const startDate = new Date(data.startTime);
            const date = startDate.getDate();
            const [diff] = dateDiff(startDate, new Date());
            temp[date].push({ data, isPast: diff < 0 ? true : false });
        });
 
        return temp;
    },
}),
 

Component에서는 해당 Query의 결과로 가공된 데이터를 받아, 바로 사용 할 수 있습니다.

const {
  isLoading,
  data: schedules,
  isFetching,
} = useGetSchedulesQuery({
  calendarYear,
  calendarMonth,
});
const {
  isLoading,
  data: schedules,
  isFetching,
} = useGetSchedulesQuery({
  calendarYear,
  calendarMonth,
});

🌟 FetchBaseQuery Header

RTK Query에서는 axiosfetch를 사용해 서버와 통신을 하지 않고, RTK에 내장 되어 있는 fetchBaseQuery를 이용해, 서버와 통신을 하게 됩니다.

FanUP에서는 로그인 후 서버에게 받은 token을 헤더에 넣어, 통신을 진행해야 했습니다.

이를 위해, customFetchBaseQuery를 만들어, 사용했습니다.

export const customFetchBaseQuery = fetchBaseQuery({
  baseUrl: process.env.REACT_APP_SERVER_URL,
  prepareHeaders: (headers, { getState }) => {
    const token = (getState() as any).userSlice.token;
    if (token) headers.set("Authorization", `Bearer ${token}`);
    return headers;
  },
});
 
export const userApi = createApi({
  reducerPath: "userApi",
  baseQuery: customFetchBaseQuery,
  tagTypes: ["User", "SubScribedArtist", "MyTicket"],
  endpoints: (build) => ({
    getUser: build.query<IUser, void>({
      query: () => "/auth/me",
      providesTags: ["User"],
    }),
  }),
});
export const customFetchBaseQuery = fetchBaseQuery({
  baseUrl: process.env.REACT_APP_SERVER_URL,
  prepareHeaders: (headers, { getState }) => {
    const token = (getState() as any).userSlice.token;
    if (token) headers.set("Authorization", `Bearer ${token}`);
    return headers;
  },
});
 
export const userApi = createApi({
  reducerPath: "userApi",
  baseQuery: customFetchBaseQuery,
  tagTypes: ["User", "SubScribedArtist", "MyTicket"],
  endpoints: (build) => ({
    getUser: build.query<IUser, void>({
      query: () => "/auth/me",
      providesTags: ["User"],
    }),
  }),
});

prepareHeaders의 두번 째 매게변수를 통해, 클라이언트 전역 스토어에 접근 할 수 있었고, 이를 통해 header에 token을 넣은 baseQuery를 만들 수 있었습니다.


🌟 Loading state 없이 사용하기

특정 Query에 대해서는 loading 상태 없이 바로 데이터를 사용해야 하는 경우가 존재했습니다. (ex, 사용자 정보)

이럴 경우, HOC 패턴을 이용해서, Query정보를 가져온 후, 컴포넌트를 렌더링 시켜 컴포넌트 내부에서 loading state가 발생하지 않도록 만들 수 있었습니다.

function withGetUser<P extends object>(Component: ComponentType<P>) {
  return function WihLoadingComponent({ ...props }) {
    const { isLoading } = useGetUserQuery();
 
    if (isLoading) return <></>;
    return <Component {...(props as P)} />;
  };
}
export default withGetUser;
function withGetUser<P extends object>(Component: ComponentType<P>) {
  return function WihLoadingComponent({ ...props }) {
    const { isLoading } = useGetUserQuery();
 
    if (isLoading) return <></>;
    return <Component {...(props as P)} />;
  };
}
export default withGetUser;
const Home = () => {
  const { data: userData } = useGetUserQuery();
  //isLoading 없이 바로 사용 가능
  return (
    <>
      <Header />
      <BannerWrapper>
        <SubLogo />
        <h1>No Fan, No Artist</h1>
      </BannerWrapper>
      <UserContentsWrapper>
        {userData ? <FanFeatureBox /> : <div></div>}
        <ArtistsBox />
      </UserContentsWrapper>
    </>
  );
};
 
export default withGetUser(Home);
const Home = () => {
  const { data: userData } = useGetUserQuery();
  //isLoading 없이 바로 사용 가능
  return (
    <>
      <Header />
      <BannerWrapper>
        <SubLogo />
        <h1>No Fan, No Artist</h1>
      </BannerWrapper>
      <UserContentsWrapper>
        {userData ? <FanFeatureBox /> : <div></div>}
        <ArtistsBox />
      </UserContentsWrapper>
    </>
  );
};
 
export default withGetUser(Home);

🌟 사용 후기

기존에 React Query를 즐겨 사용 했었고, 큰 불편함을 느끼지 못했기 때문에, 이번 프로젝트에 RTK Query를 적용하기 이전에는, RTK Query에 대한 거부감이 있었습니다.

하지만, 실제로 사용해보니, React Query의 기능을 거의 대부분 제공 했습니다. (물론 제공하지 않는 기능도 있었습니다)

오히려, RTK Query를 사용하면서, 좋았던 점도 있었습니다.

먼저, 서버와 통신하는 API들을 특정 도메인 단위로 관리 할 수 있다는 점이, 좋았습니다.

그 다음, 클라이언트 스토어와 연동해서 사용할 수 있다는 점도 상당히 마음에 들었습니다.

앞으로도, 도메인 별로 API를 관리하는 상황이 많으며, 클라이언트 상태 관리를 해야 하는 경우도 많다면, RTK Query를 사용 할 것 같습니다.