네트워크 요청 캐싱이 필요해졌다.
저희의 서비스는 기본적으로 모든 페이지가 사용자가 인증을 받고 인가가 가능해져야 페이지를 이용할 수 있게 끔 기능이 제한이 되어 있습니다. 그렇다 보니 모든 페이지에서 사용자가 인증을 받은 사용자인지 확인을 할 필요가 있었고 저희는 이러한 인증을 API 에 요청을 보냈을 경우 해당 요청에 Cookie 값이 포함되어 있는 지 아닌 지를 기준으로 인증을 진행하였습니다.
하지만 이러한 작업들을 캐싱하기 이전에는 위와 같이 사용자의 상태가 변화되지 않은 상태임에도 불구하고 계속해서 같은 작업을 반복해야 하는 작업을 가져야 했습니다.
매 페이지 전환마다 새로운 인증 요청이 발생하면서, 사용자의 인증 상태가 변경되지 않았음에도 평균적으로 300ms 정도의 추가 로딩 시간이 발생했습니다. 베팅 서비스의 특성상 사용자들이 빠르게 여러 베팅룸을 오가며 실시간 정보를 확인해야 하는데, 이러한 지연은 사용자 경험을 저해하는 요소였습니다.
그래서 저희는 이 부분을 어떻게 해결할 수 있을 지에 대해서 고민하였습니다. 그래서 저희는 인증 상태가 갖는 특징에 대해서 생각 하였고 인증 상태는 보통 세션이 유지되는 동안 변경되지 않는 정보라고 판단 했습니다.
그렇기 때문에 저희는
tanstack query
를 활용하여 캐싱을 진행해야 겠다고 판단 했습니다. 사용자의 상태가 변화되지 않았을 경우 새로운 네트워크 요청은 불필요하기 때문에 를 활용하여 캐싱을 진행하는 것이 좋을 것이라고 판단하였습니다. 또한 불필요한 인증 요청을 줄이면서도 필요할 때 (예: 로그아웃, 세션 만료 등) 자동으로 캐시를 무효화하는 방법을 이용하여 성능을 개선 시키고자 하였습니다.잠깐 여기서 localStorage나 sessionStorage를 활용하면 안될까?
저희의 이번 프로젝트의 목적은 기본에 충실하기 였습니다. 그래서 최소한으로 패키지를 사용하는 것을 목적으로 하였기 때문에 단순히 요청에 대한 캐싱을 진행하기 위해서는 localStorage나 sessionStorage를 활용하면 안되는 지에 대해서 잠깐 고민해보았습니다.
하지만 이러한 부분에 대해서는 다음 글(링크) 에 localStorage를 sessionStorage 활용하는 것의 장단점에 대해서 다루었으니 참고해주시면 감사하겠습니다!
결과적으로 캐싱을 성공했다.
tanstack query의 어떤 것을 활용하여 캐싱을 진행하였는지에 대해서 자세하게 다루기 이전에 캐싱을 진행한 결과는 확실 했습니다.
사용자의 상태가 이전 상태와 변화가 없을 경우에는 추가적인 네트워크 요청을 요청하지 않았고 사용자의 상태의 변화가 필요할 경우에는 캐싱되어 있는 데이터를 무효화한 후 다시 데이터를 업데이트하고 캐싱하는 과정을 거쳐서 성능을 개선하는데 성공 했습니다. 이제부터 tanstack query 가 어떤 작업을 해주었기 때문에 문제를 해결할 수 있었는지 살펴보겠습니다.
tanstack query
- Caching... (possibly the hardest thing to do in programming)
- Deduping multiple requests for the same data into a single request
- Updating "out of date" data in the background
- Knowing when data is "out of date"
- Reflecting updates to data as quickly as possible
- Performance optimizations like pagination and lazy loading data
- Managing memory and garbage collection of server state
- Memoizing query results with structural sharing
위의 글은 tanstack query 에서 이야기하는 장점에 대한 이야기 입니다. 저희는 이 중 1번, 5번, 6번, 8번의 문제를 해결하지 못했고 이러한 문제를 해결하기 위하여 tanstack query 를 사용하였습니다.
const result = await context.queryClient.ensureQueryData(authQueries);
const parsedResult = AuthStatusTypeSchema.safeParse(result);
if (!parsedResult.success) {
return {
isAuthenticated: false,
userInfo: {
message: "로그인이 필요합니다.",
role: "guest",
nickname: "",
duck: 0,
},
};
}
이번 글이 tanstack query 의 사용법에 대해서 이야기하는 글이 아니기 때문에 어떤 방법을 사용하였고 이 방법이 어떻게 문제를 해결하는지에 대한 것을 중심으로 이야기 하겠습니다. 저희는 우선 가장 먼저 패키지를 실행하여 root 파일이 실행될 경우 해당 쿼리문을 실행 시켜주었습니다.
component: () => (
<QueryClientProvider client={queryClient}>
{" "}
<LayoutProvider>
{" "}
<RootLayout>
<RootHeader />
<RootSideBar />
<Outlet /> {" "}
</RootLayout>
{" "}
</LayoutProvider>
{" "}
</QueryClientProvider>
);
Tanstack Query는
QueryClient
라는 중앙 관리자가 존재합니다. 해당 중앙 관리자는 QueryCache
라는 특별한 저장소를 생성하여 데이터를 관리합니다.위의 간단한 그림과 코드를 보면 알 수 있듯이 tanstack query에서 핵심이 되는 것은
QueryClient
입니다. 쿼리문 내에서의 모든 작업들은 QueryClient
를 중심으로 작업이 이루어지고 있다는 것을 알 수 있습니다.캐싱 방법의 종류에 대해서 파악하기
선언적 캐싱
선언적 캐싱의 경우 쿼리문을 이용하여 데이터를 사용할 경우 초기에 정적인 데이터를 설정하고 해당 데이터를 기준으로 캐시 동작을 제어하는 방법은 선언적 캐싱이라고 합니다.
function TodoList() {
const { data } = useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
// 선언적 캐싱 설정
gcTime: 5 * 60 * 1000, // 5분
staleTime: 30 * 1000, // 30초
retry: 3, // 실패시 3번 재시도
refetchOnMount: true, // 컴포넌트 마운트시 재요청
refetchOnWindowFocus: true, // 윈도우 포커스시 재요청
});
}
이러한 방식의 한계점은 런타임에 동적으로 캐시 전략을 변경하기 어렵다는 것입니다. 예를 들어, 네트워크 상태나 사용자의 행동 패턴에 따라 실시간으로 캐시 전략을 최적화하고 싶다면, 이러한 선언적 방식만으로는 충분하지 않을 수 있습니다. 하지만 이러한
정적인
특성은 오히려 코드의 예측 가능성과 유지보수성을 높여주는 장점이 되기도 합니다.명령적 캐싱
명령적 캐싱은 선언적 캐싱과 달리 동적으로 캐싱 데이터를 초기화 시키거나 동적으로 데이터를 불러오고 해당 데이터의 캐싱을 설정하는 방법입니다. 이에 대한 예시를 아래의 간단한 코드로 살펴 보겠습니다.
const prefetchNextPage = async (currentPage) => {
const nextPage = currentPage + 1;
// 다음 페이지 데이터를 미리 가져와서 캐시에 저장
await queryClient.prefetchQuery({
queryKey: ["items", nextPage],
queryFn: () => fetchItems(nextPage),
staleTime: 30 * 1000, // 30초 동안 신선한 상태 유지
});
};
위의 코드는 페이지네이션 시 페이지를 로드하기 이전에
prefetchQuery
를 이용하여 데이터를 미리 불러오고 prefetching 시 queryKey 를 이용하여 해당 데이터를 캐싱할 수 있습니다.// 특정 조건에 따른 캐시 무효화
const invalidateMatchingQueries = async (category) => {
await queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === "items" && query.queryKey[1].category === category,
});
};
또한 위와 같이 함수를 이용하여 기존에 캐싱되어 있던 데이터를 무효화하고 새로운 데이터를 불러오는 로직을 활용하기도 합니다.
위와 같이 명령적 캐싱은 필요에 따라 데이터를 캐싱을 진행하거나 무효화하는 방법을 사용합니다. 이러한 방법이 유용한 경우는
낙관적 업데이트
가 유용한 경우입니다. 낙관적 업데이트는 무엇일까요?낙관적 업데이트
낙관적 업데이트는 사용자 경험을 향상 시키기 위한 약간의 트릭입니다. 낙관적 업데이트를 이야기할 경우에는 주로 인스타그램의 좋아요 버튼을 주된 예제로 사용합니다. 이러한 이유는
TTL(Time To Live) 캐시
// tanstack query
const { data } = useSuspenseQuery({
queryKey: bettingRoomQueryKey(roomId),
queryFn: () => getBettingRoomInfo(roomId),
staleTime: 1000 * 60 * 5, // ttl 시간을 설정
});
// swr
const { data, error } = useSWR("/api/data", fetcher, {
refreshInterval: 3000, // ttl 시간을 설정
});
각 캐시 항목에 유효 기간을 설정하는 방법입니다. 식품에 유통기한을 두고 해당 기간이 지났을 경우 폐기하는 것과 같은 맥락입니다. 예를 들어 이미지에 대한 유효기간이 2시간일 경우 2시간이 지났을 경우 캐싱되어 있는 이미지를 제거하고 새롭게 이미지를 다운로드 받아야만 합니다. 이러한 만료가 된 캐싱 데이터를 삭제하는 방법에도 종류가 있습니다.
적극적 삭제(Active deletion)
이 방식은 주기적으로 만료된 항목들을 검사하여 즉시 제거하는 방법입니다. 이 방식은 메모리 관리가 깔끔하고 캐시 상태가 항상 최신 데이터로 유지가 된다는 장점이 있습니다. 하지만 주기적으로 검사를 하기 때문에 시스템 리소스를 소비하며 특히 캐시된 데이터의 크기가 큰 경우 오버헤드가 상당할 수 있습니다.
지연 삭제(Lazy deletion)
이 방식은 데이터에 접근할 때만 만료 여부를 확인합니다. 이 방식의 장점은 적극적 삭제와 달리 필요할 경우에만 삭제 작업이 이루어지므로 삭제 작업에 따른 시스템 부하를 분산시킬 수 있다는 장점이 있습니다. 하지만 이 방법의 단점은 만료된 데이터가 즉시 삭제가 되지 않고 불필요하게 메모리를 차지하고 있을 수 있다는 단점이 존재합니다.
TTL 캐시의 장점은 구현이 단순하고 예측 가능하다는 것입니다. 데이터가 언제 만료될지 정확히 알 수 있어서 캐시 관리가 용이합니다. 하지만 TTL을 얼마로 설정할지는 까다로운 문제입니다. 너무 짧게 설정하면 캐시의 이점을 충분히 활용하지 못하고, 너무 길게 설정하면 오래된 데이터를 계속 사용하게 될 수 있습니다. 다음은 어떻게 TTL 시간을 적절하게 설정할 수 있을 지에 대해서 다루어 보겠습니다.
TTL 값 설정은 어떻게 설정해야 할까?
TTL 캐시를 사용할 경우에는 데이터의 특성을 잘 파악해야 합니다. 캐싱이 필요한 데이터가 장기간 유지가 될 필요가 있는 데이터일지 아니면 일시적으로 유지되어도 이상이 없는 데이터인지 데이터를 사용하는 서비스의 특징과 함께 고려하여 해당 시간을 설정해야 합니다. TTL 설정 시 고려해야하는 보편적인 특징에 대해서 아래의 리스트로 나열 해보겠습니다.
- 데이터 변경 빈도 분석
- 자주 변경되는 데이터 (예: 주식 가격): 수초~수분
- 중간 정도로 변경되는 데이터 (예: 상품 재고): 수분~수시간
- 거의 변경되지 않는 데이터 (예: 제품 기본 정보): 수시간~수일
- 시스템 부하 고려
- 원본 데이터 생성/조회 비용이 높을수록 TTL을 길게 설정
- 캐시 저장소의 용량이 제한적이라면 TTL을 짧게 설정
- 피크 시간대에는 더 긴 TTL을 사용하여 원본 시스템 부하 감소
- 데이터 일관성 요구사항
- 강한 일관성이 필요한 경우: 매우 짧은 TTL 또는 캐시 사용 지양
- 최종 일관성으로 충분한 경우: 비즈니스 요구사항에 따라 적절히 설정
- 사용자 경험 최적화
- 페이지 로딩 시간이 중요한 경우: 더 긴 TTL 설정
- 실시간 데이터가 중요한 경우: 더 짧은 TTL 설정
실제 구현 시에는 이러한 요소들을 종합적으로 고려하여 TTL을 설정하고, 모니터링을 통해 캐시 적중률(hit ratio)과 시스템 성능을 관찰하면서 점진적으로 최적화하는 것이 좋습니다.