← posts

Next.js에서 useSearchParams를 쓰는데 왜 Suspense가 필요할까?

2026-07-23 · 회고

이번 과제에서는 쇼핑몰 홈의 상품 목록 페이지를 구현했다.

검색·카테고리·정렬 조건을 URL로 관리한 상품 목록 화면

검색어, 카테고리, 정렬, 페이지를 URL에 남기면 같은 조건을 다른 사람에게 공유할 수 있고, 뒤로 가기와 앞으로 가기에서도 이전 상태를 복원할 수 있다. 그래서 이 값들을 컴포넌트 상태 대신 URL로 관리하기로 했다.

/products 페이지에 'use client'를 선언하고, nuqs로 URL 상태를 읽어 useQuery의 조회 조건으로 넘겼다. 아래는 핵심만 남긴 코드다.

'use client';
 
function ProductList() {
  const [{ q, category, sort, page }] = useProductListUrlState(); // nuqs 활용
 
  const { data, isPending, isError, error } = useQuery(
    productQueries.list({
      q,
      category,
      sort,
      page,
      pageSize: PRODUCT_PAGE_SIZE,
    }),
  );
 
  // ...
 
  return (
    <div className="week05-grid">
      {data.products.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

그런데 Suspense 없이 빌드하자 다음 에러가 발생했다.

useSearchParams를 Suspense 경계로 감싸야 한다는 빌드 에러

useSearchParams() should be wrapped in a suspense boundary at page "/products".

URL을 읽는데 왜 Suspense가 필요하지?

에러를 처음 봤을 때는 “빌드 중인 서버는 아직 사용자의 URL을 모르기 때문인가?”라고 생각했다.

정적 빌드는 실제 요청이 오기 전에 HTML을 미리 만들어두는 과정이다. 이때는 사용자가 /products?q=의자로 들어올지, 어떤 카테고리와 정렬을 선택할지 알 수 없다. 검색 파라미터의 조합을 모두 미리 만들 수도 없다.

서버가 URL을 모른다는 이유는 납득했지만, 그렇다고 왜 Suspense가 필요한지는 이해하지 못했다.

Suspense로 감싸면 빌드가 그 부분을 건너뛰는 걸까?

찾아보니 “스킵한다”기보다 “지금 완성할 수 없는 영역의 렌더링을 나중으로 미룬다”에 가까웠다. 프리렌더링할 수 있는 바깥쪽은 그대로 두고, 아직 완성할 수 없는 안쪽만 fallback으로 남길 수 있었다.

<Header />
 
<Suspense fallback={<FilterSkeleton />}>
  <ProductFilters />
</Suspense>
 
<Footer />

그렇다면 Suspense는 언제 fallback을 보여줄까? 하위 컴포넌트가 렌더링 도중 대기 중인 Promise를 use()처럼 Suspense가 인식할 수 있는 방식으로 읽어 “아직 준비되지 않았다”는 상태를 React에 알려야 했다.

Suspense가 fallback을 보여주는 흐름

기존의 isLoading 분기는 컴포넌트가 직접 로딩 상태와 UI를 연결한다.

if (isLoading) return <Loading />;
return <ProductList />;

반면 Suspense를 사용하면 상위에서는 기다리는 동안 보여줄 UI만 경계에 선언한다.

<Suspense fallback={<Loading />}>
  <ProductList />
</Suspense>

이제 확인할 것은 useSearchParams()의 내부 동작이었다.

그렇다면 URL 문자열을 읽는 useSearchParams() 안에서는 어떤 Promise를 읽고 있는 걸까?

nuqs부터 Next.js 내부까지 따라가 봤다

nuqs의 Next.js 어댑터까지 내려가니 useSearchParams()를 사용하고 있었다.

const [optimisticSearchParams, setOptimisticSearchParams] = useOptimistic(
  useSearchParams() ?? new URLSearchParams(),
);

그다음 Next.js의 구현을 따라갔다. 브라우저가 아닐 때만 서버용 처리를 불러오는 코드가 먼저 보였다.

const useDynamicSearchParams =
  typeof window === 'undefined'
    ? require('../../server/app-render/dynamic-rendering')
        .useDynamicSearchParams
    : undefined;
 
function useSearchParams() {
  useDynamicSearchParams?.('useSearchParams()');
  // URLSearchParams 반환
}

흐름을 정리하면 이랬다.

URL 상태 훅의 호출 경로

서버 프리렌더링 처리 안에는 렌더링 방식에 따른 분기가 있었다.

case 'prerender-client':
  React.use(makeHangingPromise(...));
  break;
 
case 'prerender-legacy':
case 'prerender-ppr':
  throw new BailoutToCSRError(expression);

두 분기 중 처음 눈에 들어온 것은 prerender-client였다. 실제 에러가 어느 분기에서 발생했는지는 뒤에서 프로젝트 설정과 빌드 결과를 맞춰 확인했다. 이 분기에서 Next.js는 makeHangingPromise()로 Promise를 만든 뒤 곧바로 React.use()로 읽고 있었다.

이 Promise는 검색 파라미터를 가져오는 비동기 작업이 아니었다. 프리렌더링이 진행되는 동안 계속 대기 상태를 유지하면서 “이 영역은 지금 완성할 수 없다”는 사실을 React에 전달하는 제어용 Promise였다.

핵심만 추리면 다음과 같았다.

function useSearchParams() {
  if (프리렌더링_&& prerenderClient) {
    const controlPromise = makeHangingPromise();
 
    React.use(controlPromise); // 여기서 suspend
  }
 
  const params = useContext(SearchParamsContext);
  return new ReadonlyURLSearchParams(params);
}

React.use()는 Promise를 어떻게 읽을까?

다음으로 use()가 Promise와 Suspense를 어떻게 연결하는지 확인했다.

use()가 대기 중인 Promise를 읽으면 React에서는 어떤 일이 일어날까?

use는 Promise나 Context 같은 리소스의 값을 렌더링 중에 읽는 API다.

const value = use(promise);

대기 중인 Promise를 읽으면 현재 렌더링이 중단되고, 가장 가까운 Suspense가 fallback을 보여준다. Promise가 완료되면 React는 중단했던 영역의 렌더링을 다시 시도하고 값을 반환한다. use가 비동기 작업을 만드는 것은 아니며, Promise의 대기 상태를 React의 렌더링과 연결한다.

일반적으로 렌더링 중 use()에 전달하는 Promise는 같은 인스턴스를 다시 사용할 수 있도록 캐시되어 있어야 한다. 다시 렌더링할 때마다 새 Promise를 만들면 매번 대기 상태로 돌아가 fallback이 반복될 수 있기 때문이다.

Next.js의 제어용 Promise는 일반적인 데이터 요청과 목적이 달랐다. 프리렌더링 중에는 의도적으로 끝나지 않으면서 렌더링을 미루라는 신호를 만들었다. 브라우저에서는 현재 URL을 알고 있으므로 이 서버용 처리를 거치지 않고 실제 ReadonlyURLSearchParams를 반환한다.

이를 바탕으로 useSearchParams()가 Suspense 경계를 요구하는 이유를 정리할 수 있었다. Client Component도 초기 HTML을 만들기 위해 프리렌더링될 수 있지만, 빌드 시점에는 실제 검색 파라미터를 알 수 없다. Next.js는 값을 임의로 채우는 대신 해당 영역의 렌더링을 미루기 위해 Suspense 경계를 요구하고 있었다.

실제 에러는 CSR bailout 경로에서 발생했다

hanging Promise 분기를 이해한 뒤 소스를 더 내려가 보니, Promise를 사용하지 않는 분기도 있었다. prerender-legacyprerender-ppr에서는 BailoutToCSRError를 던졌다.

프로젝트 설정과 빌드 결과를 다시 맞춰보니, 내가 실제로 만난 에러는 이 CSR bailout 경로에서 발생했다. Suspense 경계가 있었다면 bailout이 가장 가까운 경계에서 처리됐겠지만, 경계가 없어 페이지까지 빠져나오면서 missing-suspense-with-csr-bailout 에러가 발생한 것이었다.

결국 Next.js는 Client Component 전체를 빌드에서 제외하려던 것이 아니었다. useSearchParams()에 의존하는 영역만 fallback으로 남기고 나머지 셸을 프리렌더링하기 위해 Suspense 경계를 요구한 것이었다. 그래서 검색 폼과 필터, 목록을 하나의 Suspense로 감쌌다.

<Suspense fallback={<LoadingStatus />}>
  <ProductSearchForm />
  <ProductListFilters />
  <ProductList />
</Suspense>

변경 후 빌드는 통과했다. 생성된 HTML에는 Suspense 바깥의 셸과 fallback이 들어 있었고, URL 상태를 읽는 영역은 들어 있지 않았다. 브라우저가 실제 URL을 읽은 뒤 검색 폼과 필터, 상품 목록이 완성됐다.

직접 진입과 Link 이동의 네트워크 요청 비교

/products에 직접 진입할 때와 홈에서 Link로 이동할 때의 요청도 Chrome DevTools Network에서 비교해봤다.

진입 방식네트워크에서 확인한 요청브라우저에서의 처리
/products 직접 진입/products Document, JavaScript 청크HTML을 먼저 표시하고 RSC Payload로 트리를 맞춘 뒤 Client Component를 hydrate한다.
홈에서 Link로 이동?_rsc=... 요청, 필요한 JavaScript 청크새 문서를 받지 않고 RSC Payload를 기존 앱 트리에 반영한다.

직접 진입에서는 /products에 대한 Document 요청이 발생했다. Next.js는 초기 진입에 사용할 HTML과 RSC Payload를 만들고, 브라우저는 HTML로 화면을 먼저 표시한다. 이후 RSC Payload로 Server Component와 Client Component 트리를 맞추고 JavaScript로 Client Component를 hydrate한다.

Link로 이동할 때는 새로운 Document 요청이 없었다. 이미 실행 중인 앱이 ?_rsc=... 요청으로 다음 경로의 RSC Payload를 받고, 공용 Layout을 유지한 채 새 경로를 반영했다.

이 차이를 확인하면서 useSearchParams()가 브라우저에서 항상 suspend하는 것은 아니라는 점도 정리할 수 있었다. 직접 진입을 위한 프리렌더링에서는 실제 URL을 알 수 없어 Suspense 경계가 필요하지만, 클라이언트 이동에서는 Router가 현재 URL을 알고 있으므로 브라우저에서 실제 검색 파라미터를 읽는다.

프로덕션에서는 Link가 화면에 보일 때 경로를 미리 가져올 수 있다. 이 경우 ?_rsc=... 요청이 클릭 전에 나타날 수 있으므로, 네트워크를 볼 때는 prefetch 요청과 클릭 이후 요청을 구분해야 한다.

정리

실제 수정은 Suspense를 추가하는 것이었다. 이번 글에서 다룬 키워드는 지난해부터 개발하면서 여러 번 마주쳤지만, 그때마다 필요한 부분만 찾아보고 넘어갔다. 이번에는 에러가 발생한 지점부터 관련 개념을 차례로 확인했다.

App Router에서 URL로 직접 진입할 때와 Link로 이동할 때 렌더링 과정이 어떻게 다른지도 살펴봤다. useSearchParams()의 내부 처리와 사용 시 주의점, Suspense가 fallback을 표시하는 조건, use()의 역할, RSC Payload가 사용되는 방식도 함께 정리했다.

각 개념을 따로 볼 때보다 서로 어떤 관계가 있는지 이해하기 쉬워졌다.

앞으로 비슷한 에러를 만나면 해결 방법만 적용하는 데서 그치지 않고, 왜 이렇게 동작하는지 확인한 뒤 직접 설명해보려고 한다.

참고