상태 · 데이터
Suspense·ErrorBoundary·스켈레톤 — 로딩/에러 경계 전략
페이지마다 isLoading·error 분기를 컴포넌트 안에 흩뿌리고 있었다. 로딩 스피너 위치도, 에러 메시지
모양도 화면마다 제각각이었다. 이걸 경계(boundary)에서 한 번에 처리하는 규약으로 갈아엎었다.
기준은 "어떤 hook을 쓰느냐"가 아니었다. "이 컴포넌트는 데이터 전체가 있어야 렌더되는가" —
이 질문 하나로 전부 갈랐다.
isLoading 분기가 화면마다 흩어졌다
쿼리 하나당 컴포넌트 안에서 이런 코드가 반복됐다.
function SomeWidget() {
const { data, isLoading, error } = useGetSomethingQuery();
if (isLoading) return <SomeSkeleton />; // 스켈레톤 위치가 컴포넌트마다 다름
if (error) return <ErrorMessage />; // 에러 UI도 제각각
if (!data) return null; // 빈 가드도 매번
return <ActualContent data={data} />;
}문제는 세 가지였다.
- 로딩 UI가 컴포넌트마다 흩어져 스켈레톤 위치·에러 메시지 모양에 일관성이 없다
data가 있고 나서야 본문을 쓰므로if (!data) return null같은 빈 가드 보일러플레이트가 매번 붙는다- 본문 JSX가 항상
isLoading·error분기에 들여쓰기로 한 단계 밀려 가독성이 나쁘다
구조 — QueryErrorBoundary는 두 경계의 조합이다
해결의 출발점은 QueryErrorBoundary 하나였다. 이름은 하나지만 실제로는 ErrorBoundary와 Suspense를
포갠 조합이다.
QueryErrorBoundary
├── ErrorBoundary → children이 throw한 Error를 catch → ErrorFallback (Retry 버튼)
└── Suspense → children이 throw한 Promise를 catch → fallback (스켈레톤)
↑
useSuspenseQuery가 데이터 없을 때 Promise를 throw → Suspense가 그걸 catch여기서 헷갈렸던 건 "왜 throw냐"였다. React Query의 useSuspenseQuery는 캐시에 데이터가 없으면
Promise를 throw하고, 쿼리가 에러나면 Error를 throw한다. 그러면 바깥의 Suspense가 Promise를,
ErrorBoundary가 Error를 각각 받아 fallback을 띄운다. 즉 로딩·에러를 컴포넌트 안에서 if로 처리하는
대신, 위로 던져서 경계가 받게 하는 구조다.
각 조각의 역할과 트리거를 정리하면 이렇다.
| 개념 | 역할 | 트리거 |
|---|---|---|
useSuspenseQuery | 데이터 없으면 Promise throw → Suspense 작동 | 캐시 miss (첫 로드) |
useQuery | 에러·로딩을 상태값(isLoading, error)으로 반환 | 항상 즉시 렌더 |
Suspense | children이 throw한 Promise를 catch → fallback 표시 | useSuspenseQuery 사용 시 |
ErrorBoundary | children이 throw한 Error를 catch → fallback 표시 | query 에러 시 |
원칙 — 기본은 useSuspenseQuery
규약의 1순위는 기본을 useSuspenseQuery로 잡는 것이었다.
컴포넌트가 데이터 전체가 있어야 렌더 가능하면 useSuspenseQuery를 쓴다. 그러면 로딩은 page에
감싼 Suspense의 스켈레톤이 처리하고, 컴포넌트 내부에서 isLoading 분기 자체가 사라진다. 위의
보일러플레이트가 이렇게 줄어든다.
function SomeWidget() {
const { data } = useSuspenseGetSomethingQuery();
// isLoading 없음, error 분기 없음, !data 가드 없음 — data는 항상 존재
return <ActualContent data={data} />;
}// page.tsx — 로딩·에러 경계를 한 곳에 모음
export default function SomePage() {
return (
<QueryErrorBoundary fallback={<SomeSkeleton />}>
<SomeWidget />
</QueryErrorBoundary>
);
}data가 항상 존재하는 타입으로 좁혀지니 !data 가드도 없고, 본문이 분기에 밀려 들여쓰기되지도
않는다. 로딩 UI는 page 한 곳, 에러 UI도 ErrorBoundary 한 곳 — 흩어졌던 게 경계로 모인다.
예외 — useQuery를 유지하는 두 경우
다만 "전부 useSuspenseQuery"는 정답이 아니었다. useSuspenseQuery로 갈 수 없는 케이스가 두 개
있었고, 이게 이 규약에서 제일 중요한 부분이다.
예외 1 — 페이지네이션·필터 전환 (keepPreviousData)
이메일 목록처럼 페이지·필터를 바꾸는 화면에선, 전환할 때마다 스켈레톤이 번쩍이면 UX가 나쁘다.
이전 데이터를 흐리게(opacity) 보여주며 새 데이터로 바꾸는 게 자연스럽다. 이게 React Query의
keepPreviousData(placeholderData)다.
문제는 useSuspenseQuery가 placeholderData/isPlaceholderData를 미지원한다는 점이다.
useSuspenseQuery는 params가 바뀌면 캐시 miss로 다시 Promise를 throw → 매 전환마다 스켈레톤이 뜬다.
그래서 이 화면만 useQuery를 유지했다.
// 이메일 리스트: 페이지 전환 시 이전 데이터를 보여주며 opacity 처리
const { data, isLoading, isPlaceholderData } = useGetEmailsQuery(params, {
placeholderData: keepPreviousData,
});isLoading은 첫 로드에만 true → 그때만 스켈레톤. 이후 페이지 전환은 isPlaceholderData로 잡아
이전 목록에 opacity만 입힌다. 여기서 세 플래그의 차이를 똑바로 알아야 했다.
| 플래그 | 의미 | 사용 |
|---|---|---|
isLoading | 첫 fetch 진행 중 (캐시 없음) | useQuery 전용 |
isFetching | 어떤 fetch든 진행 중 (refetch 포함) | 둘 다 |
isPlaceholderData | keepPreviousData 적용 중 (이전 데이터 표시) | useQuery + placeholderData |
isLoading과 isFetching을 헷갈리면 안 됐다. refetch(백그라운드 갱신)에서도 isFetching은 true가
되는데, 그때마다 스켈레톤을 띄우면 화면이 계속 깜빡인다. 첫 로드는 isLoading, 전환 중은
isPlaceholderData — 이렇게 갈라야 의도대로 동작했다.
예외 2 — 카드 껍데기는 즉시, 이미지만 로딩
또 하나는 회사 로고·파비콘 카드였다. 카드의 타이틀과 업로드 버튼은 즉시 보여주고, 이미지 영역만
스켈레톤이면 충분한데, useSuspenseQuery로 묶으면 카드 전체가 suspend돼 껍데기조차 안 보인다.
// Company Logo/Favicon: 카드 타이틀+버튼은 즉시 렌더, 이미지 영역만 스켈레톤
const { data, isLoading } = useGetCompanyQuery();
// → ImageUploadField에 isLoading 전달 → 이미지 영역만 SkeletonuseQuery로 받아 isLoading을 하위 컴포넌트에 prop으로 내려 부분 스켈레톤을 만들었다.
"전체가 있어야 렌더되는가?"의 답이 아니오인 케이스 — 껍데기는 데이터 없이도 렌더되니까 — 라서
useQuery가 맞았다.
혼합 — 같은 쿼리 키를 두 hook이 같이 써도 fetch는 1회
흥미로웠던 건 한 페이지에서 useSuspenseQuery와 useQuery를 섞어 써도 문제가 없다는 점이다.
React Query는 쿼리 키로 캐시를 공유하므로, 같은 키면 어느 hook을 쓰든 fetch는 1회만 일어난다.
// page.tsx
<QueryErrorBoundary fallback={<AllCardSkeletons />}>
<CompanyLogoCard /> {/* useQuery — 즉시 렌더, 이미지만 isLoading */}
<CompanyFaviconCard /> {/* useQuery — 즉시 렌더, 이미지만 isLoading */}
<CompanyInfoCard /> {/* useSuspenseQuery — 데이터 필수, suspend */}
</QueryErrorBoundary>- 셋 다 같은
['company']쿼리 키 → React Query가 dedupe → API 호출 1회 CompanyInfoCard가 에러를 throw하면 바깥ErrorBoundary가 전체를 catch- Logo/Favicon은 에러 시 이니셜 placeholder로 graceful degradation (카드 자체는 살아 있음)
하나의 경계 안에서, 데이터 필수 카드는 suspend로 스켈레톤을, 껍데기 카드는 즉시 렌더 + 부분 로딩을 동시에 굴리는 구성이다.
로딩 UX를 시나리오로 못 박았다
규약이 흔들리지 않게, 대표 시나리오 셋을 글로 박아뒀다.
시나리오 1 — 첫 페이지 로드
사용자 → /email 접속
→ QueryErrorBoundary의 Suspense 발동 → 전체 스켈레톤
→ 계정 쿼리(useSuspenseQuery) 완료 → 헤더·액션바 즉시 렌더
→ 목록 쿼리(useQuery) isLoading → 리스트 영역만 스켈레톤
→ 데이터 도착 → 목록 렌더바깥은 Suspense 전체 스켈레톤, 안쪽 목록은 isLoading 부분 스켈레톤 — 두 단계로 켜진다.
시나리오 2 — 페이지네이션·필터 전환
사용자 → 2페이지 클릭 → 목록 쿼리 params 변경
→ isPlaceholderData: true → 이전 데이터 유지 + opacity 40%
→ 새 데이터 도착 → isPlaceholderData: false → 정상 렌더
(스켈레톤 안 뜸 — keepPreviousData 덕분)시나리오 3 — API 에러
API 에러 발생 → useSuspenseQuery가 Error throw
→ ErrorBoundary catch → ErrorFallback (Retry 버튼) 표시
→ Retry 클릭 → reset → 재시도에러도 컴포넌트마다 처리하지 않고 경계가 받아 Retry까지 한 곳에서 끝낸다.
구현 규약 — 어디에 두느냐까지 정했다
패턴이 사람마다 갈리지 않게, 배치 규칙도 같이 박았다.
1. QueryErrorBoundary는 page에서 감싼다. 로딩·에러 경계는 라우트 진입점 한 곳에 모은다.
위젯이 스스로를 감싸지 않는다.
2. Skeleton은 위젯에 co-locate한다. 스켈레톤은 그 위젯의 레이아웃을 그대로 본뜬 것이라, 같은 파일에 둔다.
widgets/settings/company-info-card/ui/
└── company-info-card.tsx # CompanyInfoCard + CompanyInfoCardSkeletonbarrel에서 둘 다 export하고, page의 fallback에서 스켈레톤들을 조합한다.
3. 카드 wrapper 스타일은 변수로 추출하되 export 금지. 실제 카드와 스켈레톤이 같은 껍데기
스타일을 쓰니 const cardStyle = '...'로 묶지만, 슬라이스 밖으로 새지 않게 export는 막았다
(아키텍처상 슬라이스 교차 import 방지).
배운 점
- 분기를 hook 종류로 외우면 매번 헷갈린다. "이 컴포넌트는 데이터 전체가 있어야 렌더되는가?" 한
질문으로 갈라야 일관됐다 — 예이면
useSuspenseQuery(경계가 로딩 처리), 아니오이면useQuery(부분 로딩) useSuspenseQuery의 진짜 이득은 "멋진 Suspense"가 아니라 컴포넌트 안isLoading·!data가드가 통째로 사라져 본문 한 줄이 분기에 안 밀리는 것이다- 그래서
useSuspenseQuery가 늘 정답은 아니다.placeholderData미지원이라 페이지네이션 전환만은useQuery로 남겨야 했다 — 전환마다 스켈레톤이 번쩍이는 걸 막으려면 isLoading·isFetching·isPlaceholderData를 구분 못 하면 refetch마다 화면이 깜빡인다. 첫 로드와 전환과 백그라운드 갱신은 서로 다른 플래그다