Notice
Recent Posts
Recent Comments
Link
«   2026/01   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

likeornament 님의 블로그

[Next.js] 서버 컴포넌트에서 prefetchQuery가 실패하는 이유와 useQuery가 만들어낸 착각 본문

공부/next.js

[Next.js] 서버 컴포넌트에서 prefetchQuery가 실패하는 이유와 useQuery가 만들어낸 착각

likeornament 2025. 4. 15. 15:28

프로젝트를 진행하면서 토큰 만료 대응을 위해 다음과 같은 작업을 하다가 문제를 발견했다.

 

// axios.ts
import axios from 'axios';

// 인증이 필요한 요청을 위한 인스턴스
const authApi = axios.create({
  baseURL: process.env.NEXT_PUBLIC_BACKEND_URL
});

// 인증 인스턴스 추가
authApi.interceptors.request.use((config) => {
  const token = localStorage.getItem('accessToken');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

export { authApi };

이는 본인이 사용하고 있는 authApi 코드이고

 

// 응답 인터셉터 추가 (토큰 만료 처리)
authApi.interceptors.response.use(
  (response) => {
    return response;
  },
  (error) => {
    if (error.response) {
      // 토큰 만료 오류 처리
      if (error.response.status === 401 || 
          error.response.status === 403 ||
          error.response.status === 302
        ) {
        alert("토큰이 만료되었습니다. 다시 로그인해주세요.");
        localStorage.removeItem('accessToken');
        
        // 인터셉터 내부에서는 router 사용이 불가능하므로 window.location 사용
        window.location.replace('/');
      }
      else if(error.response.status === 500) {
        alert("서버에 문제가 발생했습니다. 잠시 후 다시 시도해주세요.");
      }
    }
    
    return Promise.reject(error);
  }
);

만료된 토큰으로 인증을 보냈을 때 서버로부터 401, 403이 아닌 302 Found가 날아왔다.

 

이유는 로그인되지 않은 사용자(토큰 만료)가 접근할 경우

백엔드에서 401 대신 302 리디렉션으로 로그인 페이지로 보내는 정책이기 때문에 발생한 것으로 보인다.

 

그래서 302 상태도 토큰 만료 처리 대상에 포함시켰다.

그런데 302 응답이 분명히 날아오는데도 인터셉터가 동작하지 않는 문제가 발생했다...

 

// home/page.tsx
import BottomNavigation from "../_component/BottomNavigation";
import Header from "./_component/Header";
import MainFeatures from "./_component/MainFeatures";
import WeeklyActivity from "./_component/WeeklyActivity";
import { HydrationBoundary, QueryClient, dehydrate } from '@tanstack/react-query';

export default async function Home() {
  const isDevelopment = process.env.NODE_ENV === 'development';
  
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery({queryKey: ['todayTasks'], queryFn: getTodayTasks});
  const dehydratedState = dehydrate(queryClient);
  

  return (
    <HydrationBoundary state={dehydratedState}>
      <MainFeatures/>
    </HydrationBoundary>
  );
}

이는 page.tsx에 prefetchQuery를 사용해서 서버에서 데이터를 미리 가져오고, 클라이언트에서 재수화(hydration) 하는 방식으로 UX를 개선하려는 구조다.

 

// home/_lib/getTodayTasks.ts
import { QueryFunction } from "@tanstack/react-query";
import { Tasks } from "@/model/Tasks";
import { authApi } from "@/app/_lib/axios";
import axios from "axios";

export const getTodayTasks: QueryFunction<Tasks, [_1: string]>
 = async () => {
  const useMock = process.env.NEXT_PUBLIC_USE_MSW_HOME === 'true';
  let response;

  if(useMock) {
    response = await axios.get(`${process.env.NEXT_PUBLIC_BASE_URL}/api/studies/tasks`, { 
      headers: {
        'Cache-Control': 'no-store',
      },
    });
  } else {
    response = await authApi.get(`/api/studies/tasks`, {
      headers: {
        'Cache-Control': 'no-store',
      },
    });
  }
  
  return response.data.result;
 }

 

 

그래서 queryFn에 적힌 getTodayTasks 함수에 에러를 잡지 못하는 이유가 있나? 하고 보다가 다른 문제를 발견했다.

 

내가 만든 authApilocalStorageaccessToken을 사용한다.

그리고 위의 함수는 prefetchQuery의 queryFn에 적힌 함수이기 때문에 서버에서 실행된다.


그런데 서버에서는 브라우저의 localStorage접근할 수가 없다는 것이었다.

그렇다면 accessToken 없이 요청이 가니 prefetch는 실패해야 정상이다.

prefetch가 실패했다면 위의 사진처럼 데이터가 없으니 skeleton이 화면에 그려져야 하는데...

문제는 화면에는 이렇게 정상적으로 데이터가 뜨고 있다는 것이다.
왜일까?


 

🔄 이유: prefetchQuery는 실패했지만 useQuery가 실행됐다.

// home/_component/MainFeatures.tsx
"use client";

import { Tasks } from '@/model/Tasks';
import { useQuery } from '@tanstack/react-query';
import { BookOpen, Lightbulb, GitPullRequest, MessageSquare } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { getTodayTasks } from '../_lib/getTodayTasks';
import MainFeaturesSkeleton from './skeleton/MainFeaturesSkeleton';

export default function MainFeatures() {
  const router = useRouter();

  const {data: tasks, isLoading} = useQuery<Tasks, object, Tasks, [_1: string]>({
    queryKey: ['todayTasks'],
    queryFn: getTodayTasks,
    staleTime: 0,
    refetchOnMount: 'always',
  });

  //로딩 중이거나 데이터가 없다면 Skeleton을 보여줌
  if(isLoading || !tasks) {
    return (
      <MainFeaturesSkeleton/>
    )
  }

  return (
	...
  )
}

이게 /home/page.tsx의 할 일 들을 보여주는 컴포넌트인 MainFetures의 코드다.

서버에서 prefetchQuery는 실패했지만, 클라이언트 컴포넌트가 마운트 될 때 useQuery가 실행된다.

 

useQuery는 클라이언트에서 실행되므로 localStorage에 저장된 accessToken을 읽을 수 있고, 따라서 authApi를 통해 데이터를 정상적으로 불러왔던 것이다.

 

실행 흐름 정리:

1. 서버에서 prefetchQuery 시도 => 실패(localStorage 접근 불가)

2. 초기 HTML에는 데이터가 없음 => skeleton이 보여져야 정상

3. 클라이언트 컴포넌트 마운트 => useQuery 실행

4. 클라이언트에서는 localStorage 접근 가능 => 데이터 정상 fetch

5. 화면에는 데이터가 정상적으로 표시됨

 

이 흐름 때문에 prefetchQuery잘 동작한다고 착각했던 것이었다.

 


🔧 해결: prefetchQuery 제거

// /home/page.tsx
import BottomNavigation from "../_component/BottomNavigation";
import Header from "./_component/Header";
import MainFeatures from "./_component/MainFeatures";
import WeeklyActivity from "./_component/WeeklyActivity";

export default function Home() {
  const isDevelopment = process.env.NODE_ENV === 'development';

  return (
    <MainFeatures/>
  );
}

이렇게 서버 컴포넌트에서 prefetchQuery 관련 코드를 모두 제거했다.

 


😟 우려되는 점

"prefetch를 못 하면 UX가 나빠지는 거 아닌가?" 라는 걱정이 생겼다.

이 서비스는 로그인 후에 사용할 수 있는 기능들이 대부분이기 때문에
대부분의 api가 accessToken을 요구한다.
이 말은 곧, prefetchQuery를 전혀 사용할 수 없다는 뜻이다.


accessToken을 쿠키에 저장하고 SSR 시 쿠키에서 읽는 방식도 있긴 하지만, 인증 처리 로직이 복잡해지고 보안 이슈도 고려해야 하기 때문에 현재 서비스 규모에서는 클라이언트 fetch 방식이 더 적합하다고 판단했다.

그렇다면 UX는 포기해야 할까?

 


✅ 결론: useQuery도 충분히 빠르다.

직접 체감해보니 useQuery도 매우 빠르다.

실제로도 prefetch가 된 줄 착각했을 정도니 말이다.

 

그래서 결론은 이 서비스에서 모든 prefetchQuery를 제거하고 useQuery만 사용하는 방식으로 리팩토링했다.

 

이 경험을 통해 서버에서는 localStorage에 접근할 수 없다는 점을 알게 되었고

prefetchQuery가 동작하는 줄 알고 있었다는 점에서 useQuery로도 충분히 괜찮은 UX를 챙겨갈 수 있다는 점도 알게 되었다.

 



📎 추가로...


왜 302 Found 응답을 인터셉트에서 잡지 못 했는지는 아래 글에서 확인 가능하다.

https://likeornament.tistory.com/18