Skip to main content
Overview

URL 기반 필터 상태와 Suspensive 도입까지

2026년 1월 5일
11 min read

팀 프로젝트에서 세션·크루 목록 페이지의 필터 로직을 개선하면서 겪은 문제와 고민의 흐름을 정리했습니다.

  • 필터 상태를 URL로 옮기게 된 계기
  • 그 과정에서 발생한 에러
  • Next.js가 왜 CSR bail out을 선택했는지
  • Suspense가 필요해진 이유
  • 왜 Suspense가 아닌 Suspensive를 사용했는지

필터 상태 관리 방식의 한계

세션 데이터는 지역·날짜·시간·난이도·정렬 필터 파라미터를 가진다. 초기 구현에서는 이 필터들을 모두 React state로 관리했다.

const [region, setRegion] = useState<Record<string, string[]> | undefined>()
const [date, setDate] = useState<DateRange | undefined>()
const [time, setTime] = useState<[number, number] | undefined>()
const [level, setLevel] = useState<LevelValue | undefined>()
const [sort, setSort] = useState<SortValue>()

문제는 필터 값들이 하나의 기준 상태를 공유하지 못하고, 각기 다른 목적에 따라 사용되고 있었다는 점이다.

필터는 다음과 같은 위치에서 동시에 사용되고 있었다.

  • 페이지 상단 필터 버튼
  • 세부 필터 모달 내부 상태
  • 세션 목록 데이터를 요청하는 쿼리 파라미터

이 구조에서는 사용자가 필터를 설정한 뒤 새로고침이 발생하면 모든 필터 값이 초기화됐다.
또한 페이지에 표시된 필터 상태와 모달 내부 상태가 서로 어긋나거나,
실제로 요청된 쿼리 파라미터와 UI가 일치하지 않는 문제도 발생했다.
즉, 필터가 페이지 상태이면서도 단순 UI state로 취급되고 있었던 것이다.

UI 구조와 서버 쿼리 파라미터의 간극

이 문제는 타입 구조에서도 명확하게 드러났다.

UI에서는 사용자 인터랙션에 맞춰 다음과 같은 타입을 사용하고 있었다.

type region = Record<string, string[]>
type DateRange = { from?: Date; to?: Date }
type time = [number, number]

UI 기준에서의 필터 예시는 다음과 같다.

{
region: { "서울": ["강북구", "노원구"] },
date: { from: Date, to: Date },
time: [0, 720],
level: "BEGINNER"
}

이 구조는 화면 단에서는 직관적이지만 서버 요청 파라미터로는 그대로 사용할 수 없다.
서버가 요구하는 필터 타입은 문자열 기반의 평탄한 구조였고,

type SessionListFilters = PaginationQueryParams & {
city?: string[]
district?: string[]
dateFrom?: string
dateTo?: string
timeFrom?: string
timeTo?: string
level?: LevelValue
sort: SortValue
}

따라서 다음과 같은 문제를 해결해주어야 했다:

  • Date 객체를 서버 요청용 문자열로 변환
  • 트리 형태의 지역 구조를 배열 기반 파라미터로 분해
  • 명확히 분리되어 있는 UI 타입과 서버 타입을 중간에서 조율할 기준 수립

이 시점에서 필터 state는 UI 상태이자, 서버 요청 상태이자, 페이지 상태라는 모호한 역할을 동시에 떠안고 있었다.

이 문제를 해결하기 위해 필터의 **단일 기준(Single Source of Truth)**을 다시 정의할 필요가 있었고, 그 기준으로 선택한 것이 URL이었다.

URL을 필터의 단일 진실 공급원(SSOT)으로

필터를 URL로 옮긴다는 것은 단순히 “상태를 하나 더 저장하는 것”이 아니었다.
URL을 기준으로 삼는 순간, 필터는 다음과 같은 성격을 갖게 된다.

  • 새로고침 이후에도 유지되어야 하는 페이지 상태
  • 서버 요청과 1:1로 대응되는 쿼리 파라미터
  • UI는 이 상태를 읽고 쓰는 표현 계층

즉, 필터 상태의 책임이 명확히 분리된다.

  • URL: 상태의 기준
  • UI state: URL을 표현하기 위한 임시 상태
  • 서버 요청: URL을 그대로 사용하는 결과물

이 구조를 통해 기존에 흩어져 있던 기준들을 하나로 모을 수 있었고, 이후 구현에도 직접적인 영향을 미쳤다.

  • 필터 상태 복원 로직이 단순해졌고
  • 모달과 페이지 간 상태 불일치 문제가 사라졌으며
  • 서버 요청 파라미터와 UI가 항상 일치하게 됐다

useSearchParams, 그리고 내가 놓친 것들

필터의 단일 기준을 URL로 정한 이후, 가장 먼저 한 일은 URL에서 필터 값을 읽어오는 것이었다.
Next.js App Router 환경에서는 useSearchParams() 훅을 사용해 현재 URL의 쿼리 문자열을 읽을 수 있다.

const searchParams = useSearchParams()
const region = searchParams.getAll('region')
const level = searchParams.get('level')

로컬 환경에서는 아무 문제 없이 동작했기 때문에, 이 에러는 전혀 예상하지 못한 지점에서 드러났다. CI 스크립트가 실행되며 빌드 단계에서 다음과 같은 에러가 발생했다.

Build Error

에러 메시지에는 공식 문서(Missing Suspense boundary with useSearchParams) 링크와 함께 해결 방법이 안내되어 있었다. 안내에 따라 useSearchParams 훅을 사용하는 컴포넌트를 Suspense로 감싸자 에러는 바로 해결됐다.

useSearchParams() should be wrapped in a suspense boundary at page "/crews".라는 에러 메시지를 보고, 나는 ‘useSearchParams 훅은 항상 Suspense로 감싸야 하는구나’라고 단순하게 받아들였다. 하지만 멘토링 시간에 멘토님께서 Suspense를 사용하게 된 계기와, 다른 곳에도 같은 방식으로 적용할 것인지 질문하셔서 당황했다.

나는 에러를 해결하는 데만 집중해 useSearchParams 훅과 Suspense를 항상 함께 써야 한다고 생각했다. 그러나 멘토님 설명을 듣고 보니, Suspense의 원리는 훨씬 다양한 목적에 활용될 수 있다는 점을 알게 되었다.

따라서 다른 페이지에도 이 패턴을 적용하려면, 단순히 에러 해결이 아니라 Suspense의 사용 목적을 명확히 이해해야겠다고 느꼈다. 우선 왜 Suspense를 사용하면 빌드 에러가 해결되는지, 그 원인부터 살펴봤다.

왜 빌드 에러가 발생했을까

useSearchParams()가 다루는 값은 브라우저 URL의 현재 상태다.
중요한 점은 브라우저 URL이 빌드 시점에 확정되지 않고 요청 시점, 즉 hydration 단계에서야 결정된다는 것이다.
서버는 HTML을 생성하는 시점에 브라우저의 URL 상태를 알 수 없다. 이 상황에서 Next.js는 다음과 같이 판단한다.

“이 컴포넌트는 서버에서 확정 가능한 값에 의존하지 않는다.” → “서버 렌더링이 불가능하다!”

그 결과, 현재 컴포넌트가 CSR로 강제 전환(bail out) 되고있는 것이었다.

CSR bail out이 의미하는 실제 동작

CSR로 전환된다는 것은, 렌더링 책임이 다음과 같이 이동했음을 의미한다.

  • 서버는 해당 컴포넌트의 렌더링을 스킵하고
  • HTML에는 해당 영역이 비어있는 상태로 내려오며
  • 클라이언트에서 JS가 로드된 이후에야 렌더링이 시작된다

즉, 사용자는 일정 시간 동안 빈 화면을 보게 된다.

useSearchParams 사용으로 인해 발생한 빌드 에러에서 Suspense 사용을 권장하는 이유가 바로 여기에 있다. Suspense는 이 “비는 구간”을 명시적으로 다루기 위한 장치이기 때문이다.

Suspense 도입 과정

useSearchParams의 사용 위치

여기서 또 하나의 제약이 등장한다.

useSearchParams()page.tsx에서 직접 사용하면 빌드 에러가 발생한다.
page.tsx는 기본적으로 Pre-render 대상인데, Next.js는 페이지 전체를 CSR로 전환하는 것을 허용하지 않기 때문이다. (페이지 범위가 너무 크고, 정적 셸의 장점을 포기하게 되기 때문)

그래서 Next.js는 다음 조건을 강제한다.

To keep the route statically generated, wrap the smallest subtree that calls useSearchParams() in Suspense, for example you may move its usage into a child Client Component and render that component wrapped with Suspense. This preserves the static shell and avoids a full CSR bailout.

즉, useSearchParams()는 반드시 Suspense로 감싼 하위 Client 컴포넌트에서만 사용해야 한다.
이 제약을 만족시키기 위해 세션·크루 목록 페이지의 컴포넌트 구조를 다음과 같이 변경하였다.

page.tsx (Server Component)
└─ <Suspense>
└─ SessionPageContent / CrewPageContent (Client Component)
└─ useSessionFilters / useCrewFilters ← useSearchParams 사용

핵심은 세 가지다.

  1. 페이지는 Server Component로 유지한다
  2. URL을 읽는 로직은 Client Component로 격리한다
  3. CSR bail out이 발생하는 지점을 Suspense로 감싼다

Suspense가 필요해진 지점

정리하면, 문제는 다음과 같았다.

  • URL을 필터의 기준으로 삼기 위해 useSearchParams가 필요했고
  • 이 훅은 서버에서 확정할 수 없는 값에 의존하며
  • Next.js는 이를 감지하면 CSR bail out을 발생시킨다

Suspense는 이 상황에서

  • “지금은 렌더링할 수 없다”는 상태를 명시적으로 표현하고
  • fallback UI를 통해 사용자 경험을 보호하는 역할을 한다

이 구조는 선택이라기보다 Next.js가 의도한 패턴에 가깝다. Suspense가 해결하는 문제는 기존의 스피너 중심의 로딩 처리와 동일한 역할처럼 보였다. 그렇다면 왜 Next.js는 Suspense를 사용해 처리하는 것을 권장하는걸까?

기존 로딩 처리의 한계와 Suspense의 역할

다른 컴포넌트에서는 로딩 상태를 스피너로 처리하고 있었다. 예를 들면, React Query를 사용해 데이터 요청을 보냈을 경우, 데이터를 불러오고 있는 상황 즉 isLoading일 경우 스피너를 렌더링한다.

export default function SessionDetail({ sessionId }: SessionDetailProps) {
// 로딩, 에러 상태 관리
const {
data: session,
isLoading,
error,
} = useQuery(sessionQueries.detail(sessionId));
if (isLoading) return <Spinner />;
if (error) return <ErrorFallback error={error} />;
// 로직과 UI를 모두 처리
return <div>{/* 세션 상세 정보 렌더링 */}</div>;
}

컴포넌트는 ’데이터 상태에 따라 무엇을 보여줄지’를 직접 판단하고 있다. 이 구조에서는

  • 조건부 렌더링 분기가 늘어나고
  • UI 수정 시 로딩/에러 로직까지 함께 건드리게 되며
  • 컴포넌트가 커질수록 이해하기 어려워진다.

스피너 중심의 로딩을 Suspense 기반 구조로 전환하면 다음과 같은 한계를 극복할 수 있다.

  • 기존 스피너 방식은 단순히 “기다리고 있다”는 신호만 전달할 뿐, 어떤 컴포넌트가 아직 렌더링되지 않았는지, 어떤 영역이 먼저 렌더링될 수 있는지와 같은 정보는 구조적으로 표현하지 못한다.
  • Suspense는 렌더링할 수 없는 컴포넌트를 명시적으로 경계(Suspense boundary)로 분리하고, 그 경계 안에서 대기 중인 동안 보여줄 fallback UI를 정의할 수 있다. 이를 통해 “어디서, 왜 렌더링이 멈췄는지”를 코드 구조로 드러낸다.

여기서 ‘경계를 분리한다’는 것은 “로딩 중인가?”를 묻는 게 아니라 “여기서부터는 지금 렌더링할 수 없다”를 선언하는 것을 의미한다. 그래서 경계를 어디에 두느냐가 곧 렌더링 책임의 범위를 구분하는 것과 같고, fallback은 그 경계가 막혔을 때 대체될 UI를 의미한다.

<Suspense fallback={<Skeleton />}>
<ErrorBoundary fallback={<ErrorFallback />}>
<SessionDetailContent sessionId={sessionId} />
</ErrorBoundary>
</Suspense>
Note

이 fallback UI를 무엇으로 구성할지는 구조의 문제라기보다 UX 선택의 영역이다.
이번 페이지에서는 스피너 대신 스켈레톤 UI를 사용했다. 스켈레톤을 사용함으로써 사용자는 로딩 중에도 페이지의 전체 구조를 인지할 수 있고, 콘텐츠가 점진적으로 채워지는 흐름을 자연스럽게 받아들일 수 있었다.

  • Suspense는 데이터 로딩 책임을,
  • ErrorBoundary는 에러 처리 책임을 갖고,
  • 컴포넌트는 “정상 상태의 UI만 렌더링”한다.

이로써 컴포넌트의 역할이 명확해졌다.
컴포넌트는 무엇을 보여줄지 판단하는 책임이 제거되어, 데이터가 있다는 전제 하에 UI만 담당하면 된다.
또한, 로딩/에러에 대한 조건문이 사라지면서 컴포넌트는 더 작고 읽기 쉬워졌다.

function SessionDetailContent({ sessionId }: { sessionId: number }) {
const { data: session } = useSuspenseQuery(
sessionQueries.detail(sessionId)
);
return <div>{/* 세션 상세 정보 렌더링 */}</div>;
}

Fine-grained Suspense로 “부분 로딩”

한 페이지 안에서 여러 데이터를 불러오고 있었고,
이때 먼저 불러온 데이터를 보여주거나
비동기 로직과 관계없는 요소(헤더, 제목 등 레이아웃)를 먼저 보여주는 것이
사용자 경험 측면에서 더 낫다고 판단했다.

나와 같은 작업 방식이 이미 하나의 패턴으로 정리되어 있었다!
바로 Fine-grained Suspense다.

Fine-grained Suspense란

페이지 전체를 하나의 Suspense 경계로 감싸는 방식이 아니라,
데이터 페칭 단위별로 Suspense 경계를 나누는 패턴이다.

<h2>Section Title</h2> {/* 즉시 렌더링 */}
<Suspense fallback={<SectionSkeleton />}>
<SectionContent /> {/* 독립적으로 로딩 */}
</Suspense>
  • 정적 요소(제목, 탭, 레이아웃)는 즉시 렌더링
  • 각 섹션은 자신이 필요한 데이터만 로딩하며 서로의 영역에 영향을 주지 않는다

Fine-grained Suspense 패턴이 필요했던 이유는,
Suspense로 감싼 컴포넌트에 렌더링 시점에 비동기 값을 필요로 하는 훅(useSearchParams, useSuspenseQuery)이 있기 때문에
Suspense 경계를 세분화하지 않을 경우 페이지 전체가 불필요하게 대기 상태에 들어갈 수 있기 때문이다.

Problem

하나의 Suspense로 감싸는 경우 (coarse-grained Suspense)
A, B 중 하나라도 렌더링 불가능하면 전체가 fallback으로 대체되면서
가장 느린 요청이 전체 렌더링을 지연시키는 구조였다.

<Suspense fallback={<Spinner />}>
<ErrorBoundary fallback={<ErrorFallback />}>
<A />
<B />
</ErrorBoundary>
</Suspense>

Solution

각각을 Suspense boundary로 분리할 경우 (fine-grained Suspense)
A와 B는 서로 독립적으로 렌더링된다.

<Suspense fallback={<Spinner />}>
<ErrorBoundary fallback={<ErrorFallback />}>
<A />
</ErrorBoundary>
</Suspense>
<Suspense fallback={<Spinner />}>
<ErrorBoundary fallback={<ErrorFallback />}>
<B />
</ErrorBoundary>
</Suspense>
  • 레이아웃과 정적 요소는 즉시 렌더링하고 이미 준비된 데이터는 바로 표시한다
  • 아직 준비되지 않은 섹션만 스켈레톤으로 대체하여 데이터가 도착하는 순서대로 화면이 채워진다
  • 실제 로딩 시간을 줄이지 않더라도, 사용자의 체감 성능을 개선할 수 있다

Suspense 대신 Suspensive를 선택한 이유

Suspense와 ErrorBoundary를 조합해 사용하다 보니 반복되는 패턴이 눈에 띄기 시작했다.

<Suspense fallback={<Spinner />}>
<ErrorBoundary fallback={<ErrorFallback />}>
<SessionDetailContent sessionId={sessionId} />
</ErrorBoundary>
</Suspense>

이 구조는 모든 페이지, 모든 섹션에서 거의 동일하게 반복됐다.
문제는 단순히 코드가 길어진다는 것만이 아니었다.

  • Suspense와 ErrorBoundary를 항상 함께 사용해야 한다는 점
  • 중첩 순서를 실수로 바꾸면 의도와 다르게 동작할 수 있다는 점
  • fallback UI를 일관되게 관리하기 어렵다는 점

이런 반복을 줄이고, Suspense 사용을 더 안정적으로 만들기 위해 Suspensive 라이브러리를 도입했다.

Suspensive가 해결한 문제들

1. Suspense + ErrorBoundary 보일러플레이트 감소

Suspensive는 React의 SuspenseErrorBoundary를 래핑하여 추가 기능을 제공한다.

// Before: React 기본 Suspense + ErrorBoundary
import { Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
<Suspense fallback={<Skeleton />}>
<ErrorBoundary fallback={<ErrorFallback />}>
<SessionDetailContent sessionId={sessionId} />
</ErrorBoundary>
</Suspense>
// After: Suspensive 적용
import { Suspense, ErrorBoundary, ErrorBoundaryGroup } from '@suspensive/react'
<ErrorBoundaryGroup.Consumer>
{(group) => (
<AsyncBoundary
group={group}
pendingFallback={<Skeleton />}
rejectedFallback={<ErrorFallback />}
>
<SessionDetailContent sessionId={sessionId} />
</AsyncBoundary>
)}
</ErrorBoundaryGroup.Consumer>

Suspensive를 사용하면 다음과 같은 이점이 있다:

  • ErrorBoundaryGroup을 통해 여러 ErrorBoundary를 함께 제어 가능
  • clientOnly 옵션으로 SSR 환경에서 안전하게 처리
  • 일관된 패턴을 강제하여 실수 방지

2. SSR 환경에서의 안정적인 Suspense 처리

Next.js App Router 환경에서 Suspense를 사용할 때 가장 까다로운 부분은
서버와 클라이언트에서 Suspense가 다르게 동작한다는 점이었다.

  • 서버에서는 Suspense boundary가 해소될 때까지 기다리지만
  • 클라이언트에서는 fallback을 즉시 보여주고 비동기 작업을 처리한다

Suspensive의 clientOnly 옵션은 이 차이를 명시적으로 다룬다.

<Suspense clientOnly fallback={<Skeleton />}>
<SessionPageContent />
</Suspense>

clientOnly를 사용하면:

  • 서버에서는 Suspense가 비활성화되고 자식 컴포넌트가 바로 렌더링 시도
  • 클라이언트에서만 Suspense boundary가 활성화되어 fallback 처리

이를 통해 useSearchParams() 같은 클라이언트 전용 훅을 사용하는 컴포넌트를
서버 렌더링과 충돌 없이 안전하게 감쌀 수 있었다.

3. React Query와의 통합

Suspensive는 React Query를 위한 래퍼도 제공한다.

import { useSuspenseQuery } from '@suspensive/react-query'
function SessionDetailContent({ sessionId }: { sessionId: number }) {
const { data: session } = useSuspenseQuery(
sessionQueries.detail(sessionId)
)
return <div>{/* data는 항상 존재한다고 보장됨 */}</div>
}

기존 useQuerysuspense: true 옵션 대신 useSuspenseQuery(무한스크롤인 경우 useInfiniteSuspenseQuery)를 사용하면 데이터가 항상 존재한다는 것이 타입으로 보장된다.
그 결과, isLoading이나 조건부 렌더링 없이 data를 바로 사용할 수 있다.

4. ErrorBoundaryGroup으로 에러 일괄 초기화

여러 섹션에서 에러가 발생했을 때, 각각 다시 시도하는 것보다
한 번에 모든 에러를 초기화하고 싶은 경우가 있다.

<ErrorBoundaryGroup>
<ErrorBoundaryGroup.Consumer>
{(group) => (
<>
<button onClick={group.reset}>전체 다시 시도</button>
<Suspense clientOnly fallback={<SkeletonA />}>
<ErrorBoundary group={group} fallback={<ErrorFallback />}>
<SectionA />
</ErrorBoundary>
</Suspense>
<Suspense clientOnly fallback={<SkeletonB />}>
<ErrorBoundary group={group} fallback={<ErrorFallback />}>
<SectionB />
</ErrorBoundary>
</Suspense>
</>
)}
</ErrorBoundaryGroup.Consumer>
</ErrorBoundaryGroup>

ErrorBoundaryGroup을 사용하면 여러 ErrorBoundary를 묶어서 한 번에 제어할 수 있다.
페이지 상단에 “전체 다시 시도” 버튼을 두고, 모든 에러를 한 번에 초기화하는 식으로 사용했다.

결론: Suspense의 원칙을 지키면서 실용성을 더하다

Suspensive는 React의 Suspense를 대체하는 것이 아니라,
Suspense를 더 안전하고 편리하게 사용할 수 있도록 돕는 도구다.

  • Suspense와 ErrorBoundary의 조합 패턴을 표준화하고
  • SSR/CSR 환경 차이를 명시적으로 다루며
  • React Query와의 통합을 통해 타입 안정성을 높인다

Suspense의 개념을 이해한 뒤, 실제 프로젝트에서 적용할 때
Suspensive는 반복을 줄이고 실수를 방지하는 데 큰 도움이 됐다.

마무리하며 — 상태, 렌더링, 그리고 책임의 기준

이번 리팩토링은 단순히 “필터 상태를 URL로 옮겼다”거나 “Suspense를 도입했다”는 이야기로 끝나지 않는다.

문제의 핵심은 처음부터 끝까지 기준의 부재였다.

  • 필터는 UI 상태이기도 했고
  • 서버 요청 파라미터이기도 했으며
  • 페이지 상태이기도 했다

URL을 단일 기준으로 삼으면서 상태의 책임은 정리되었지만, 그 선택은 자연스럽게 렌더링 시점의 문제를 드러냈다.

useSearchParams는 이 문제를 가장 솔직하게 드러내는 훅이었고, Suspense는 그 문제를 렌더링 경계라는 구조로 표현하는 도구였다.

그리고 Suspensive는 이 구조를 실제 서비스 코드에서 지속 가능하게 유지하기 위한 선택이었다.

  • Suspense와 ErrorBoundary의 반복을 줄이고
  • 렌더링과 에러 처리의 책임을 명확히 하며
  • 서버와 클라이언트의 차이를 코드 레벨에서 의식하게 만든다

결과적으로 이번 작업은 특정 라이브러리를 도입한 경험이라기보다,
상태의 기준 → 렌더링의 기준 → 에러 처리의 기준을 하나씩 정리해 나간 과정에 가까웠다.

앞으로도 비슷한 문제가 다시 등장하겠지만,
이제는 “어떤 도구를 쓸지”보다 “무엇을 기준으로 나누어야 하는지”를 먼저 고민할 수 있게 되었다.