프로젝트/Next+TypeScript

[맛길] Zustand + sessionStorage로 맛집 리스트 캐싱하기

dev-hpk 2025. 3. 7. 20:58

Zustand와 sessionStorage로 데이터를 캐싱하게 된 계기

맛길을 배포 후 지인들에게 피드백을 요청했습니다. 가장 많이 받은 피드백이 페이지를 이동할 때마다 이전 데이터가 사라져 불편하다는 내용이었습니다.

피드백을 바탕으로 생각해보니 아래와 같은 문제가 있을 수 있을 것 같습니다.

  • 페이지 전환이나 새로고침 시 기존 데이터를 다시 불러오기 위해 불필요한 API 요청이 발생
  • API 응답이 길어질 경우, UX(사용자 경험) 저하
  • 길 찾기 기능에서 prop drilling 발생

이 문제를 해결하기 위해 Zustand를 도입해 맛집 리스트와 위치 정보를 전역으로 관리하고, 맛집 리스트를 sessionStorage에 저장해 불필요한 API 호출을 줄이기로 했습니다.

Zustand를 선택한 이유 : 상태 관리 라이브러리 비교

전역 상태 관리 방법으로는 Context API를 이용하거나 외부 상태 관리 라이브러리를 이용하는 방법이 있습니다.

 

Context API

  • 내장 기능으로 외부 라이브러리에 의존하지 않고 간단하게 사용 가능
  • 상태가 변경될 때마다 해당 컨텍스트를 구독하고 있는 모든 컴포넌트가 리렌더링 되기 때문에 리렌더링 최적화 어려움

Redux

  • 강력한 상태관리 기능과 미들웨어라는 기능을 제공하여 비동기 작업을 효율적으로 관리해 줌
  • 보일러 플레이트 코드가 많음 → 단순한 기능인데도 여러 개의 파일을 만들고, action → reducer → store → component로 연결하는 과정이 필요해 코드량이 많아지고 유지보수가 어려움
  • Provider를 최상위에 두고, Redux의 상태가 변경되면 해당 상태를 구독하는 모든 컴포넌트가 리렌더링 되기 때문에 리렌더링 최적화 어려움

Recoil

  • Github 확인 결과 2025년 1월 2일부터 GitHub에서 읽기 전용 상태로 전환되어 배재했습니다.

React-Query

  • 데이터 페칭과 캐싱을 위한 강력한 기능을 제공
  • 주로 서버 상태 관리에 중점을 두고 있어, 클라이언트 측 상태 관리에는 적합하지 않을 수 있음

Zustand

  • 보일러 플레이트 코드가 적고 사용법이 간단
  • 별도의 Provider 없이 상태와 상태를 변경하는 액션을 훅으로 정의하고, 어느 컴포넌트에서나 import하여 사용 가능
  • 필요한 상태만 구독하여 사용할 수 있어 리렌더링을 최소화

npm trends로 비교한 상태관리 라이브러리

Zustand를 선택한 이유는 다음과 같습니다.

  • 작은 번들 사이즈
  • 적은 보일러플레이트 코드와 간단한 사용법
  • Provider 없이 원하는 상태만 구독이 가능해 리렌더링을 최소화 

sessionStorage를 선택한 이유 : 클라이언트 캐싱 방법 비교

localStorage

  • 데이터를 쉽게 저장하고 불러올 수 있으며, 브라우저를 종료해도 데이터가 유지됨(영구 저장)

indexedDB

  • 많은 양의 구조화된 데이터를 클라이언트에 저장 가능

sessionStorage

  • 데이터를 쉽게 저장하고 불러올 수 있으며, 세션 동안 데이터를 유지하고 세션이 종료되면 데이터가 삭제됨(임시 저장)

맛집 리스트 데이터는 대량의 데이터가 아니기 때문에 indexedDB를 사용하는 것은 과한 것 같습니다.

처음 취지에 맞는 캐싱 방법은 localStorage와 sessionStorage인데, 세션 동안만 데이터를 유지하고 브라우저를 닫으면 데이터를 삭제하는 것이 자연스럽다고 생각해 sessionStorage를 선택했습니다.

Zustand + sessionStorage 적용

import { create } from 'zustand';
import { YoutubeData } from '@/app/types/youtube';

interface ListsStore {
  lists: Record<string, YoutubeData[]>;
  cursors: Record<string, number | null>;
  hasNexts: Record<string, boolean>;

  initializeState: () => void;
  setLists: (channel: string, newLists: YoutubeData[]) => void;
  setCursor: (channel: string, cursor: number | null) => void;
  setHasNext: (channel: string, hasNext: boolean) => void;
}

const useListsStore = create<ListsStore>((set) => ({
  lists: {},
  cursors: {},
  hasNexts: {},

  // sessionStorage에서 상태를 초기화
  initializeState: () => {
    const sessionStorageData = sessionStorage.getItem('youtubeData');
    if (sessionStorageData) {
      const { lists, cursors, hasNexts } = JSON.parse(sessionStorageData);
      set({ lists, cursors, hasNexts });
    }
  },
  // 채널의 목록을 설정하고 sessionStorage에 저장
  setLists: (channel, newLists) =>
    set((state) => {
      const updatedLists = { ...state.lists, [channel]: newLists };
      sessionStorage.setItem(
        'youtubeData',
        JSON.stringify({ ...state, lists: updatedLists }),
      );
      return { lists: updatedLists };
    }),
  // 채널의 커서를 설정하고 sessionStorage에 저장
  setCursor: (channel, cursor) =>
    set((state) => {
      const updatedCursors = { ...state.cursors, [channel]: cursor };
      sessionStorage.setItem(
        'youtubeData',
        JSON.stringify({ ...state, cursors: updatedCursors }),
      );
      return { cursors: updatedCursors };
    }),
  // 채널의 'hasNext' 값을 설정하고 sessionStorage에 저장
  setHasNext: (channel, hasNext) =>
    set((state) => {
      const updatedHasNexts = { ...state.hasNexts, [channel]: hasNext };
      sessionStorage.setItem(
        'youtubeData',
        JSON.stringify({ ...state, hasNexts: updatedHasNexts }),
      );
      return { hasNexts: updatedHasNexts };
    }),
}));

export default useListsStore;
import { useCallback, useEffect } from 'react';
import { ChannelResponse, YoutubeData } from '../types/youtube';
import axios from '@/app/lib/instance';
import useIntersectionObserver from '@/app/hooks/useIntersectionObserver';
import useListsStore from '@/app/stores/useListsStore';

const useFetchData = (
  channel: string,
  initialLists: YoutubeData[],
  initialHasNext: boolean,
  nextCursor: number | null,
) => {
  const {
    lists,
    cursors,
    hasNexts,
    setLists,
    setCursor,
    setHasNext,
    initializeState,
  } = useListsStore();

  // 컴포넌트 마운트될 때 store 상태 초기화
  useEffect(() => {
    initializeState();
  }, [initializeState]);
  // sessionStorage에 저장된 채널 데이터가 없는 경우 초기 데이터 설정
  useEffect(() => {
    const sessionStorageData = sessionStorage.getItem('youtubeData');
    const hasStoreData = sessionStorageData
      ? JSON.parse(sessionStorageData)
      : null;

    if (!hasStoreData || !hasStoreData.lists[channel]) {
      if (!lists[channel]) {
        setLists(channel, initialLists);
      }
      if (cursors[channel] === undefined) {
        setCursor(channel, nextCursor);
      }
      if (hasNexts[channel] === undefined) {
        setHasNext(channel, initialHasNext);
      }
    }
  }, [
    channel,
    initialLists,
    initialHasNext,
    nextCursor,
    lists,
    cursors,
    hasNexts,
    setLists,
    setCursor,
    setHasNext,
  ]);

  const fetchData = useCallback(async () => {
    if (!hasNexts[channel]) return;

    const { data } = await axios.get<ChannelResponse>(channel, {
      params: { limit: 12, cursor: cursors[channel] },
    });

    setLists(channel, [...(lists[channel] || []), ...data.lists]);
    setCursor(channel, data.nextCursor);
    setHasNext(channel, data.hasNext);
  }, [channel, lists, cursors, hasNexts, setLists, setCursor, setHasNext]);

  const endRef = useIntersectionObserver(fetchData, hasNexts[channel]);

  return {
    lists: lists[channel] || [],
    hasNext: hasNexts[channel] || false,
    endRef,
  };
};

export default useFetchData;

Zustand + sessionStorage 적용 결과

  • 불필요한 API 요청 감소 : 페이지 이동이나 새로고침 시 sessionStorage의 데이터를 사용해 불필요한 API 요청을 최소화
  • UX(사용자 경험) 개선 : 페이지 이동이나 새로고침 시 이전에 불러온 데이터가 유지되어, 처음부터 다시 데이터를 불러오는 불편함을 없앰
맛집 리스트 클라이언트 캐싱 결과