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하여 사용 가능
- 필요한 상태만 구독하여 사용할 수 있어 리렌더링을 최소화
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(사용자 경험) 개선 : 페이지 이동이나 새로고침 시 이전에 불러온 데이터가 유지되어, 처음부터 다시 데이터를 불러오는 불편함을 없앰
'프로젝트 > Next+TypeScript' 카테고리의 다른 글
[맛길] 이미지 최적화 (LCP 최적화) (0) | 2025.03.06 |
---|---|
[맛길] 접근성, SEO 개선 (1) | 2025.02.28 |
[맛길] 안드로이드(AOS) 위치 정보(Geolocation API) 지연 문제 해결 (1) | 2025.02.26 |
[맛길] Naver Directions API를 이용한 길 찾기 기능 개발 (0) | 2025.02.21 |
[Coworkers] 접근 권한 관련 이슈 해결 (0) | 2025.02.15 |