useSuspenseQuery Next.js SSR 환경에서의 문제

JT·2026년 1월 6일
post-thumbnail

useSuspenseQuery와 Next.js SSR 환경에서의 문제

Next.js App Router에서 React Query를 사용할 때, useSuspenseQuery는 직관적이고 깔끔한 데이터를 패칭 방식으로 많은 사랑을 받고 있습니다. 하지만 SSR(서버사이드 렌더링) 또는 정적 페이지 생성(SSG/ISR)을 활용하고자 할 때는 주의가 필요합니다.

이번 글에서는 useSuspenseQuery의 개념부터 SSR에서 발생하는 문제, 그리고 이를 해결하는 방법까지 자세히 소개하겠습니다.

📌 useSuspenseQuery란?

useSuspenseQuery는 @tanstack/react-query에서 제공하는 훅으로, React의 Suspense 기능을 기반으로 데이터를 가져옵니다. 데이터 로딩 상태를 관리하기 위해 별도의 isLoading, isFetching, isError등의 상태를 다루지 않고, Suspense fallback을 통해 처리할 수 있어 코드가 훨씬 간결해집니다.

import { Suspense } from 'react';
import UserInfo from './UserInfo';
import ErrorBoundary from './ErrorBoundary';

export default function UserPage() {
  return (
    <div>
      <h1>유저 정보</h1>
      <ErrorBoundary fallback={<p>에러가 발생했습니다. 잠시 후 다시 시도해주세요.</p>}>
        <Suspense fallback={<p>유저 정보를 불러오는 중...</p>}>
          <UserInfo />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}
'use client';

import { useSuspenseQuery } from '@tanstack/react-query';

// 예시 API 함수
async function fetchUser(): Promise<{ name: string }> {
  const res = await fetch('/api/user', {
    credentials: 'include',
  });

  if (!res.ok) {
    throw new Error('유저 정보를 불러오지 못했습니다.');
  }

  return res.json();
}

export default function UserInfo() {
  const { data } = useSuspenseQuery({
    queryKey: ['user'],
    queryFn: fetchUser,
  });

  return <p>안녕하세요, {data.name}님!</p>;
}

데이터가 준비되기 전까지는 Suspense의 fallback UI가 표시되고,
데이터가 성공적으로 로드되면 해당 컴포넌트(UserInfo)가 자동으로 렌더링됩니다.

만약 데이터 패칭 도중 에러가 발생하면, ErrorBoundary의 fallback UI가 대신 렌더링되어
사용자에게 오류 메시지를 안전하게 전달할 수 있습니다.

❗ SSR 환경에서 문제가 되는 이유

useSuspenseQuery는 클라이언트 컴포넌트 내에서 호출되더라도, Next.js의 Streaming SSR 과정에서 서버가 HTML을 미리 생성하기 위해 queryFn을 서버 메모리상에서 직접 실행합니다.

useSuspenseQuery는 데이터가 없으면 렌더링을 중단(Suspend)하고 데이터를 기다립니다. Next.js 서버는 HTML을 생성할 때 이 중단된 컴포넌트를 완성하기 위해 서버에서 queryFn을 실행하여 데이터를 가져오려고 시도합니다.

이때 서버는 브라우저가 아니기 때문에 window, localStorage, document 같은 객체에 접근할 수 없습니다. 만약 queryFn 내부에서 이러한 API를 직접 사용하고 있다면, 서버 렌더링 도중 참조 에러(ReferenceError)가 발생하거나, 인증 정보(Token)를 찾지 못해 데이터 패칭에 실패하게 됩니다.

🎯 서버에서 queryFn이 실행되는 경우

다음과 같은 경우, queryFn이 서버에서 실행될 수 있습니다.

  • 페이지가 서버에서 렌더링될 때 (SSR 또는 prefetch)
  • Suspense 기반의 데이터 패칭이 감지될 때
  • React Query가 prefetch 또는 dehydration 용도로 queryFn을 자동 실행할 때

이때 문제가 되는 것은 바로 queryFn 내에 브라우저 전용 API가 포함되어 있을 경우입니다.

❌ 예: localStorage, document.cookie 사용

const { data } = useSuspenseQuery({
  queryKey: ["user"],
  queryFn: () => {
    const token = localStorage.getItem("accessToken"); // ❌ 서버에선 localStorage가 없음
    return fetchUserData(token);
  }
});

이 코드는 클라이언트 컴포넌트 내에서만 사용된다고 하더라도, Next.js가 prefetch 등을 수행하는 과정에서 queryFn을 서버에서 실행하게 되면 localStorage는 존재하지 않기 때문에 런타임 에러 또는 401 인증 오류가 발생할 수 있습니다.

✅ 해결 방법

이 문제는 데이터 페칭 시점이 서버가 아닌 클라이언트(브라우저)가 되도록 제어해야 해결할 수 있습니다.

1. dynamic import + ssr: false

해당 컴포넌트를 동적 임포트로 불러오며 ssr: false 옵션을 주어 서버 렌더링 단계에서 아예 제외하는 방법입니다.

'use client'

import { Suspense } from 'react';
import ErrorBoundary from './ErrorBoundary';
import dynamic from "next/dynamic";

const UserInfo = dynamic(() => import("./UserInfo"), {
  ssr: false,
  loading: ()=> <p>유저 정보를 불러오는 중...</p>,
});

export default function UserPage() {
  return (
    <div>
      <h1>유저 정보</h1>
      <ErrorBoundary fallback={<p>에러가 발생했습니다. 잠시 후 다시 시도해주세요.</p>}>
        <Suspense fallback={<p>유저 정보를 불러오는 중...</p>}>
          <UserInfo />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

이렇게 설정하면 해당 컴포넌트는 서버 렌더링 단계에서 무시되고, 오직 클라이언트에서만 렌더링되므로 queryFn도 클라이언트에서만 실행됩니다.

2. CustomSuspense

마운트 여부를 확인하는 커스텀 Suspense를 만들어, 브라우저 환경에서만 Suspense가 동작하도록 강제하는 방법입니다.

import { useEffect, useState, Suspense } from 'react';

function useMounted() {
  const [isMounted, setIsMounted] = useState(false);

  useEffect(() => {
    setIsMounted(true);
  }, []);

  return isMounted;
}

export default function CustomSuspense({ fallback, children }: {
  fallback: React.ReactNode;
  children: React.ReactNode;
}) {
  const isMounted = useMounted();

  if (isMounted) {
    return <Suspense fallback={fallback}>{children}</Suspense>;
  }

  return <>{fallback}</>;
}
import { Suspense } from 'react';
import UserInfo from './UserInfo';
import ErrorBoundary from './ErrorBoundary';
import CustomSuspense from './CustomSuspense';

export default function UserPage() {
  return (
    <div>
      <h1>유저 정보</h1>
      <ErrorBoundary fallback={<p>에러가 발생했습니다. 잠시 후 다시 시도해주세요.</p>}>
        <CustomSuspense fallback={<p>유저 정보를 불러오는 중...</p>}>
          <UserInfo />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

useMounted() 훅을 통해 클라이언트 환경에서만 Suspense를 렌더링하도록 제어합니다. SSR 중에는 isMounted === false이므로, Suspense 자체를 렌더링하지 않고 fallback만 출력합니다.
클라이언트에서 마운트된 이후에만 Suspense를 활성화하므로, queryFn이 브라우저 환경에서만 실행됩니다.

3. useQuery로 전환

useSuspenseQuery 대신 일반적인 useQuery를 사용하면, Suspense 메커니즘을 사용하지 않으므로 Next.js가 서버 렌더링 시점에 데이터 페칭을 강제로 실행하지 않습니다. 브라우저 전용 API가 필요한 경우에 안정적인 대안입니다.

'use client';

import { useQuery } from '@tanstack/react-query';

// fetch 함수 정의
async function fetchUser(): Promise<{ name: string }> {
  const res = await fetch('/api/user', {
    credentials: 'include',
  });

  if (!res.ok) {
    throw new Error('유저 정보를 불러오지 못했습니다.');
  }

  return res.json();
}

export default function UserInfo() {
  const { data, isLoading, isError } = useQuery({
    queryKey: ['user'],
    queryFn: fetchUser,
  });
  
  if (isLoading) return <p>loading...</p>;
  
  if (isError) return <p>유저 정보를 불러올 수 없습니다.</p>;

  return <p>안녕하세요, {data?.name}님!</p>;
}

useQuery를 사용할 경우, isLoading, isError 등의 상태를 직접 분기 처리해줘야 합니다.

마무리

useSuspenseQuery는 React Query와 Suspense의 강력한 조합으로, 컴포넌트를 깔끔하게 만들 수 있는 매력적인 도구입니다.
하지만 Next.js App Router와 함께 사용할 때는, 서버에서 queryFn이 실행될 수 있다는 점을 반드시 인지하고 설계해야 합니다.

특히 localStorage, document.cookie, 쿠키 기반 인증 등 브라우저 전용 API에 의존하는 경우, 다음과 같은 방식으로 안전하게 처리할 수 있습니다:

방법설명
dynamic(..., { ssr: false })서버 렌더링 자체를 차단하여 CSR 전용으로 렌더링
CustomSuspense클라이언트에서만 Suspense 활성화
useQuery로 전환Suspense 사용 없이 안전하게 데이터 패칭

useSuspenseQuery`는 무조건 서버에서 queryFn이 실행된다는 의미는 아니지만, 조건에 따라 실행될 수 있으므로 항상 실행 환경을 명확히 제어해야 합니다.

profile
함께 개선하는 프론트엔드 개발자

0개의 댓글