home

왜 useSearchParams는 Suspense로 감싸야 할까

Next.js App Router에서 useSearchParams가 Suspense 경계를 요구하는 이유를 정적·동적 렌더링 관점에서 정리한다.

App Router에서 페이지네이션이나 필터를 URL 검색 파라미터로 동기화하려고 useSearchParams를 쓰다 보면 빌드 단계에서 다음과 비슷한 에러를 만난다.

useSearchParams() should be wrapped in a suspense boundary at page "/".
Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout

해결책은 알려진 대로 호출부를 <Suspense>로 감싸는 것이다. 다만 왜 굳이 그래야 하는지, 안 감싸면 무슨 일이 생기는지는 한 번 정리해두지 않으면 매번 헷갈린다.

정적 렌더링이라는 전제

App Router의 모든 페이지는 기본적으로 빌드 시점에 정적으로 렌더된다. 한 번 만들어진 HTML이 모든 방문자에게 동일한 응답으로 나간다.

이 모델이 깨지는 순간은 페이지가 요청 시점의 정보(쿠키, 헤더, 검색 파라미터)에 의존할 때다. useSearchParams가 정확히 그 경우다. ?page=1?page=2는 다른 결과를 내야 하므로, 결과를 빌드 시점에 미리 정해둘 수 없다.

useSearchParams의 본질

useSearchParams는 클라이언트 훅이다. 내부적으로 window.location.search를 읽어 현재 URL의 검색 파라미터를 반환한다. 서버에는 window가 없으므로 서버 렌더링 단계에서는 이 값을 얻을 수 없다.

따라서 서버에서 페이지를 렌더할 때 Next.js는 두 가지 선택지를 가진다.

  1. 페이지 전체를 동적 렌더링으로 전환해 매 요청마다 서버에서 다시 그린다.
  2. useSearchParams가 호출되는 부분만 분리해 클라이언트 hydration 시점에 채워 넣고, 나머지는 정적으로 유지한다.

<Suspense>는 두 번째 선택을 명시적으로 선언하는 도구다.

Suspense가 하는 일

Suspense는 React에서 "여기는 데이터가 아직 준비 안 됐을 수 있다"는 지점을 표시하는 경계다. 데이터가 준비되기 전까지는 fallback UI를 렌더하고, 준비되면 진짜 자식 컴포넌트로 교체한다.

useSearchParams의 경우 "데이터가 준비된 시점"은 클라이언트에서 hydration이 끝났을 때다. 그래서 Suspense 경계 안의 컴포넌트는 다음 흐름으로 렌더된다.

  • 서버 렌더링 단계: Suspense가 자식을 렌더하려다 검색 파라미터 부재로 suspend → fallback 출력
  • 클라이언트 hydration: window.location.search가 확보되며 진짜 자식이 렌더 → fallback과 교체

경계 바깥의 트리는 영향을 받지 않는다. 정적 렌더링의 이점을 유지하면서 동적 부분만 따로 처리하는 셈이다.

경계가 없을 때

useSearchParams를 사용하는 컴포넌트가 어떤 Suspense 경계 안에도 들어 있지 않다면, Next.js는 페이지 전체를 정적으로 만들 수 없다. 빌드 도중 이 사실이 감지되면 위와 같은 에러를 띄우며 빌드가 실패한다.

이 에러는 단순한 까다로움이 아니라 의도된 검증이다. 명시적인 Suspense 경계 없이 useSearchParams를 추가하면 개발자는 그 페이지가 여전히 정적으로 캐시될 거라 기대하지만 실제로는 전체 라우트가 동적으로 강등되어 응답 속도와 캐시 효율이 떨어진다. 빌드 단계에서 명시적인 결정을 강제하는 쪽이 안전하다.

어디에 경계를 두어야 하나

원칙은 경계를 최대한 좁게 두는 것이다. useSearchParams를 호출하는 컴포넌트를 직접 감싸는 게 이상적이다. 경계 바깥은 정적으로 유지되므로, 좁힐수록 정적 캐싱의 혜택을 더 많이 받는다.

App Router에서 자주 보는 배치 패턴은 두 가지다.

  • Server Component에서 자식 Client Component를 렌더할 때 그 호출부만 감싼다.
  • 또는 loading.tsx를 두어 라우트 세그먼트 단위로 자동 Suspense 경계를 만든다.

페이지 전체를 한 번에 감싸도 동작은 하지만, 그러면 사실상 전체 페이지가 동적 렌더링으로 전환되는 것과 다르지 않다. 그 정도라면 차라리 export const dynamic = 'force-dynamic'을 명시하는 편이 의도가 더 명확하다.

운영 관점에서 보면 이 검증 덕분에 무심코 추가한 검색 파라미터 하나로 페이지 전체가 동적으로 강등되어 응답 시간이 떨어지는 일을 빌드 단계에서 잡아낼 수 있다. 에러로 빌드가 멈추는 게 결국 도움이 된다.

댓글

아직 댓글이 없습니다.