useSuspenseQuery 필터 깜빡임, startTransition으로 해결하기
Suspense fallback이 페이지를 덮는 문제와 startTransition
들어가며
React 18 + TanStack Query 기반 백오피스를 개발하면서 겪은 문제입니다. 목록 페이지에서 필터를 변경하고 검색 버튼을 누르면 화면 전체가 로딩 화면으로 바뀌었다가 다시 나타나는 깜빡임 현상이 발생했습니다. 입력 필드의 포커스도 사라졌습니다.
원인을 추적해보니 useSuspenseQuery와 Suspense의 동작 방식에 대한 이해가 부족했던 것이 문제였습니다. 이 글에서는 문제의 원인과 해결 과정을 정리합니다.
1. useQuery vs useSuspenseQuery
TanStack Query는 서버 데이터를 가져오는 두 가지 훅을 제공합니다. 같은 일을 하지만, 로딩과 에러를 누가 처리하는가가 근본적으로 다릅니다.
useQuery — 컴포넌트가 직접 처리
const { data, isLoading, isError, error } = useQuery({
queryKey: ['transactions', params],
queryFn: () => fetchTransactions(params),
})
if (isLoading) return <Loading />
if (isError) return <Error message={error.message} />
// data는 T | undefined — 위 분기를 통과해야 안전하게 사용 가능
return <Table data={data} />
useQuery는 데이터가 없는 상태(로딩 중, 에러)를 컴포넌트 내부에서 분기문으로 처리합니다. data의 타입은 T | undefined이므로, 항상 존재 여부를 확인해야 합니다.
useSuspenseQuery — React에게 위임
const { data } = useSuspenseQuery({
queryKey: ['transactions', params],
queryFn: () => fetchTransactions(params),
})
// data는 항상 T — 로딩/에러 분기가 필요 없음
return <Table data={data} />
useSuspenseQuery는 데이터가 준비되지 않으면 컴포넌트 렌더링 자체를 중단(suspend) 합니다. 로딩과 에러는 상위의 Suspense와 ErrorBoundary가 대신 처리합니다.
// 라우트 레벨에서 한 번만 선언하면 모든 하위 컴포넌트에 적용
<ErrorBoundary fallback={<ErrorPage />}>
<Suspense fallback={<PageLoading />}>
<TransactionsPage /> {/* useSuspenseQuery 사용 */}
</Suspense>
</ErrorBoundary>
핵심 차이 요약
| useQuery | useSuspenseQuery | |
|---|---|---|
data 타입 |
T | undefined |
T (항상 존재) |
| 로딩 처리 | 컴포넌트 내부 if (isLoading) |
상위 Suspense fallback |
| 에러 처리 | 컴포넌트 내부 if (isError) |
상위 ErrorBoundary |
| 컴포넌트 코드 | 분기문 보일러플레이트 필요 | 비즈니스 로직에만 집중 |
useSuspenseQuery를 사용하면 컴포넌트가 "데이터가 있을 때의 UI"만 선언하면 됩니다. 로딩과 에러라는 횡단 관심사를 상위로 분리할 수 있어서 코드가 간결해집니다.
2. 그런데 왜 검색할 때마다 화면이 깜빡일까?
문제의 구조를 단순화하면 이렇습니다:
// 라우트 설정 — 모든 페이지를 Suspense로 감쌈
const RouteElement = ({ route }) => (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Suspense fallback={<PageLoading />}>
<Component />
</Suspense>
</ErrorBoundary>
)
// 거래내역 페이지
const TransactionsPage = () => {
const filter = useTransactionsFilter() // URL 쿼리 파라미터 기반
// filter.params가 바뀌면 queryKey가 바뀐다
const { data } = useSuspenseQuery({
queryKey: ['transactions', filter.params],
queryFn: () => fetchTransactions(filter.params),
})
return (
<>
<TransactionsFilter onSearch={filter.set} />
<TransactionsTable data={data} />
</>
)
}
깜빡임이 발생하는 과정
사용자가 검색어를 입력하고 엔터를 누르면 다음이 순서대로 일어납니다:
- 엔터 →
filter.set({ keyword: '홍길동', page: 1 }) - URL 쿼리 파라미터 변경:
?keyword=홍길동&page=1 filter.params변경 → queryKey 변경:['transactions', { keyword: '홍길동', ... }]- 새 queryKey에 대한 캐시 없음 →
useSuspenseQuery가 컴포넌트를 suspend - React가 가장 가까운 Suspense 경계를 찾음 → 라우트 레벨의
<Suspense> - TransactionsPage 전체가 언마운트되고
<PageLoading />이 표시됨 - 데이터 도착 → TransactionsPage 다시 마운트
- 입력 필드 포커스 유실 + 화면 깜빡임
Note:
useSuspenseQuery는 queryKey가 바뀔 때 캐시에 데이터가 없으면 항상 suspend 합니다. 페이지 최초 진입이든, 필터 변경이든 구분하지 않습니다.
그렇다면 왜 일부 페이지에서만 문제가 보일까?
같은 프로젝트에서 본사계정관리 페이지는 문제가 없었습니다. 확인해보니 이 페이지는 useQuery를 사용하고 있었습니다:
// 본사계정관리 — useQuery 사용
const { data } = useQuery({
queryKey: ['hqAdmins', params],
queryFn: () => fetchHqAdmins(params),
placeholderData: keepPreviousData, // 이전 데이터 유지
})
useQuery + keepPreviousData는 새 데이터를 로딩하는 동안 이전 데이터를 그대로 보여줍니다. 컴포넌트가 suspend되지 않으므로 깜빡임이 없습니다.
3. 해결: useQuery로 바꿀까, startTransition을 쓸까?
방법 A: useQuery + keepPreviousData로 교체
const { data, isLoading, isError } = useQuery({
queryKey: ['transactions', params],
queryFn: () => fetchTransactions(params),
placeholderData: keepPreviousData,
})
if (isLoading) return <Loading />
if (isError) return <Error />
return <Table data={data} />
문제를 해결할 수 있지만:
- 모든 목록 쿼리 훅을
useQuery로 변경해야 함 - 각 페이지 컴포넌트에 로딩/에러 분기를 추가해야 함
- 라우트 레벨 Suspense/ErrorBoundary 구조와 맞지 않게 됨
data가T | undefined로 바뀌면서 타입 분기도 필요
방법 B: useSuspenseQuery 유지 + startTransition
React 18의 startTransition은 상태 업데이트의 우선순위를 낮추는 API입니다.
import { useTransition } from 'react'
const [isPending, startTransition] = useTransition()
const handleSearch = (values) => {
startTransition(() => {
filter.set(values) // 이 업데이트는 "긴급하지 않음"으로 표시됨
})
}
startTransition으로 감싼 상태 업데이트가 Suspense를 트리거하면, React는 fallback을 보여주는 대신 이전 UI를 유지합니다.
Point: startTransition 없이 필터 변경 → suspend → PageLoading 표시 → 데이터 도착 → 페이지 다시 렌더링
startTransition 사용 필터 변경 → suspend → 이전 화면 유지 (isPending=true) → 데이터 도착 → 새 데이터로 교체
이것이 가능한 이유는 React 18의 Concurrent Rendering 때문입니다. React는 두 가지 버전의 UI를 동시에 준비할 수 있습니다. startTransition은 "새 버전이 준비될 때까지 이전 버전을 보여줘라"라고 React에게 알려주는 것입니다.
두 방법 비교
| 항목 | useQuery + keepPreviousData | useSuspenseQuery + startTransition |
|---|---|---|
| 로딩 UX | 이전 데이터 유지 + isFetching으로 직접 처리 |
이전 UI 유지 + isPending으로 처리 |
| 초기 로딩 | if (!data) 분기 직접 작성 |
Suspense fallback 자동 처리 |
| 에러 처리 | isError 분기 직접 작성 |
ErrorBoundary 자동 캐치 |
| 컴포넌트 코드 | 로딩/에러 보일러플레이트 필요 | data가 항상 존재, 분기 없음 |
| 타입 안전성 | data가 T | undefined |
data가 항상 T |
| 변경 범위 | 각 쿼리 훅 + 페이지 컴포넌트 수정 | 공통 필터 훅 한 곳만 수정 |
| 기존 설계 | 라우트 Suspense/ErrorBoundary와 안 맞음 | 현재 아키텍처와 일관됨 |
최종 선택: 방법 B
이 프로젝트에서는 방법 B를 선택했습니다. 모든 필터가 공통 훅(useFilter)을 사용하고 있었기 때문에, 이 한 곳에만 startTransition을 적용하면 모든 목록 페이지에 일괄 적용됩니다:
export function useFilter<T extends Record<string, string | number>>(defaults: T) {
const [state, setState] = useQueryStates(parsers, { history: 'push' })
const [isPending, startTransition] = useTransition()
const set = (next: Partial<T>) => {
startTransition(() => {
setState(next)
})
}
return { state, params, set, reset, isPending }
}
isPending을 반환하므로, 필요한 페이지에서 로딩 중 시각적 피드백을 줄 수도 있습니다:
<div className={cn(isPending && 'opacity-50 pointer-events-none')}>
<TransactionsTable data={data} />
</div>
4. 왜 라우트 레벨 Suspense를 선택했는가
여기서 한 걸음 더 들어가면 근본적인 질문이 생깁니다. 꼭 라우트 레벨에서 Suspense + ErrorBoundary를 써야 하나? 그냥 각 컴포넌트에서 useQuery로 처리하면 startTransition 같은 추가 개념 없이도 keepPreviousData로 간단하게 해결되는 거 아닌가?
맞습니다. 둘 다 유효한 선택입니다. 핵심은 보일러플레이트 vs 명시적 제어의 트레이드오프입니다.
컴포넌트 레벨 useQuery 방식
// 모든 목록 페이지마다 이 패턴이 반복된다
const { data, isLoading, isError } = useQuery({
queryKey: ['transactions', params],
queryFn: () => fetchTransactions(params),
placeholderData: keepPreviousData,
})
if (isLoading) return <Loading />
if (isError) return <Error />
return <Table data={data} />
- 로딩/에러 처리가 직관적이고 세밀한 제어 가능 (
keepPreviousData,enabled등) - 하지만 모든 페이지에 로딩/에러 보일러플레이트가 반복됨
- 페이지마다 로딩 UI가 달라야 하는 프로젝트에 적합
라우트 레벨 Suspense 방식 (이 프로젝트)
// 라우트에서 한 번만 선언
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Suspense fallback={<PageLoading />}>
<Component />
</Suspense>
</ErrorBoundary>
// 페이지 컴포넌트는 비즈니스 로직에만 집중
const { data } = useSuspenseQuery({ ... })
return <Table data={data} />
- 로딩/에러를 한 곳에서 선언적으로 처리, 페이지 컴포넌트가 간결해짐
- 하지만 필터 변경 같은 세밀한 제어가 필요하면
startTransition등 추가 개념이 필요 - 페이지가 많고 로딩/에러 UI가 통일된 백오피스에 적합
이 프로젝트에서의 판단
이 프로젝트는 수십 개의 목록 페이지가 있는 백오피스입니다. 모든 페이지의 초기 로딩과 에러 UI가 동일합니다. 이런 상황에서 각 페이지마다 isLoading/isError 분기를 반복하는 건 불필요한 비용입니다.
라우트 레벨 Suspense로 보일러플레이트를 제거하고, 필터 변경 시의 UX 문제는 공통 훅(useFilter) 한 곳에 startTransition을 적용해서 해결했습니다. 새 프로젝트를 시작한다면 팀의 선호와 요구사항에 따라 달라질 수 있지만, 이 구조에서는 Suspense 기반을 유지하는 것이 일관성 있는 선택이었습니다.
5. 적용 범위와 성과
| 지표 | 수치 |
|---|---|
| 적용 대상 | useSuspenseQuery를 사용하는 모든 목록 페이지 (주문내역, 거래내역, 정산내역, 회원목록 등) |
| 수정 파일 수 | 공통 필터 훅 1곳만 (startTransition 추가) |
| 각 페이지 변경량 | 0줄 (페이지 코드 수정 불필요) |
| 로딩/에러 보일러플레이트 | useSuspenseQuery 유지로 각 페이지 if(isLoading)/if(isError) 분기문 0개 |
| 깜빡임 현상 | 완전 해결 (isPending으로 이전 UI 유지 + opacity 처리) |
6. 정리: 초기 로딩과 재검색은 다르다
같은 "데이터를 기다리는 상황"이지만, 사용자 경험 관점에서 초기 로딩과 재검색은 완전히 다릅니다.
| 초기 로딩 (페이지 진입) | 재검색 (필터 변경) | |
|---|---|---|
| 이전 데이터 | 없음 | 있음 |
| 기대하는 UX | 로딩 스켈레톤/스피너 | 이전 데이터 유지 + 미세 로딩 표시 |
| 적절한 처리 | Suspense fallback | startTransition |
useSuspenseQuery는 이 둘을 구분하지 않습니다. 캐시에 데이터가 없으면 항상 suspend합니다. startTransition은 React에게 “이 업데이트로 인한 suspend에서는 fallback 대신 이전 UI를 유지해라” 라고 알려주는 역할입니다.
결과적으로 Suspense + useSuspenseQuery + startTransition의 조합은:
- 초기 로딩: Suspense fallback이 정상 작동
- 필터 변경: 이전 UI를 유지하며 백그라운드에서 데이터 로딩
- 에러: ErrorBoundary가 자동 캐치
세 가지를 모두 선언적으로 처리하는 완성된 패턴이 됩니다.