페이지 로딩 UX
페이지 로딩 UX — loading.tsx · Suspense · 페이드인의 자리
빠른 페이지에 스켈레톤이 깜빡이면 불쾌합니다. 느린 페이지에 빈 화면이 길면 불안합니다. 둘 다 같은 root cause — fallback 의 자리가 라우트 단위로만 있어서입니다.
1. 두 단위의 fallback
Next.js App Router 는 fallback 을 두 가지 단위로 받습니다.
app/<segment>/loading.tsx— 디렉터리 segment 의 page 가 RSC 페이로드를 받는 동안 보여줄 fallback. 라우트 단위로 자동 wiring.<Suspense fallback={...}>— page 내부 한 zone 에만 적용되는 fallback. server component 가 await 하는 부분을 감싸 그 부분만 streaming.
같은 page 가 둘 다 가질 수 있습니다. 외곽은 loading.tsx, 안쪽은 Suspense — 느린 영역만 부분 fallback.
2. loading.tsx
app/(group)/page.tsx ← async function Page() { await db.fetch(...) }
app/(group)/loading.tsx ← export default function Loading() { ... }
라우터가 새 segment 로 이동을 시작하면 즉시 loading.tsx 를 마운트하고, page 의 RSC payload 가 도착하면 loading 을 unmount + page 를 mount. 사용자 입장에서 "클릭 → fallback → 본문" 의 자연스러운 흐름.
함정: 그룹 segment 에 loading.tsx 를 두면 그 그룹의 모든 라우트에 fallback 이 적용됩니다. 정적 페이지 (/help · /privacy) 나 client useEffect 페이지에까지 fallback 이 깜빡여, 빠른 라우트에서는 오히려 불쾌해집니다.
3. Suspense
export default async function Page() {
return (
<>
<Header />
<Suspense fallback={<TableSkeleton />}>
<SlowList />
</Suspense>
</>
);
}
page 자체는 즉시 렌더되고 Header 부터 보여줍니다. <SlowList> 가 await 하는 동안만 그 자리에 TableSkeleton. RSC payload 가 도착하면 zone 만 streaming swap.
장점은 사용자 시각에 맞는 progressive disclosure. 단점은 page.tsx 가 동기적으로 끝나야 fallback 이 보이므로 데이터 fetch 를 async 자식 component 로 분리해야 합니다.
4. 페이드인은 어디에서?
라우트 전환 자체에 짧은 페이드인을 주면 fallback ↔ 본문 전환의 jank 가 줄어듭니다. 가장 KISS 한 자리는 레이아웃의 children wrapper 에 key={pathname} + CSS 애니메이션:
"use client";
import { usePathname } from "next/navigation";
export default function Layout({ children }) {
const pathname = usePathname();
return (
<main>
<div key={pathname} className="animate-fade-in">{children}</div>
</main>
);
}
라우트가 바뀌면 wrapper 가 unmount/mount → .animate-fade-in (예: 0.2s ease-out) 이 매번 재실행. globals.css 에서 prefers-reduced-motion: reduce 처리하면 접근성 자동 보장.
5. 라이브러리·View Transitions API
framer-motion · motion · next-view-transitions 같은 패키지가 더 화려한 전환을 지원합니다. 그러나 페이지 단위 fade-in 만 필요하면 CSS keyframe 한 줄이 충분하고 bundle 부담이 있습니다. 화려한 전환 (슬라이드·스케일·페이지 간 shared element) 이 필요한 자리에만 도입합니다.
View Transitions API 는 브라우저 네이티브 API 입니다. Chrome 111+ 지원, Safari/Firefox 미지원 부분 있음. 프로덕션에서 폴백을 잘 설계하지 않으면 일부 사용자가 jank 를 봅니다. 시기상조.
6. 외부 spinner 한 곳
전역 layout 한 곳에 spinner 를 두고 라우터의 useLinkStatus (Next 16) 또는 router.events 로 토글합니다. 단순하지만 fallback 이 원거리에 있으므로 layout shift 와 위치감각이 어색합니다. 정밀한 zone fallback 을 못 줍니다.
7. 라우트 분류
세 갈래로 나누면 사고가 단순해집니다.
| 유형 | 데이터 의존 | 둘 곳 |
|---|---|---|
| server await | page.tsx 가 async + await getDb()/fetch() | 라우트 단위 loading.tsx 또는 page 내부 <Suspense> |
| client useEffect | "use client" + useEffect 안에서 fetch | page-level loading.tsx 두지 말 것. 폼/네비/레이아웃은 즉시 + 데이터 영역만 view 내부 loading |
| 정적 | 데이터 fetch 없음 (정책 문서·폼·redirect 등) | loading.tsx 절대 금지 |
원칙: 데이터 의존 없는 페이지에 fallback 두면 사용자 경험이 깎입니다. fallback 은 비용이 있는 곳에만.
8. 한 사례 — 그룹 단위 loading.tsx 의 함정
한 프로젝트 의 web-app 은 한때 app/(route)/loading.tsx 한 파일이 그룹의 모든 라우트 (48 개 페이지) 에 fallback 이었습니다. 도트 스피너가 풀스크린으로 깜빡였습니다. 정적 페이지 11 개 (/help · /privacy · /terms · /auth/signup 등) 와 client useEffect 페이지 24 개 (/wishlist · /feed · /mypage 등) 까지 이 스피너를 한 번씩 거쳤습니다 — 데이터 의존이 전혀 없는데도.
해결은 두 줄입니다.
① (route)/loading.tsx 삭제
→ 그룹 단위 fallback 자체를 없앰
② 데이터 의존이 있는 13개 중 자체 Suspense 없는 10개에만 라우트별 loading.tsx
→ 도메인 모양에 맞는 skeleton (테이블·카드 grid·디테일·프로필)
→ Suspense 가 이미 잘 박혀 있는 페이지는 그대로
부수 효과로 라우트 전환 페이드인을 layout wrapper 에 추가하면 — 빠른 정적 페이지는 spinner 없이 곧장 페이드인, 느린 server await 페이지는 정밀 skeleton 후 페이드인. 두 흐름이 같은 0.2s 시각 언어를 공유하면서 어색함이 사라집니다.
핵심 교훈: fallback 의 위치가 사용자 경험을 결정합니다. 라우트 그룹 단위로 일률적으로 두는 건 편하지만 빠른 라우트에 부담을 강요합니다. 라우트별로 — 또는 더 좋게 page 내부 zone 별로 — fallback 을 두는 편이 KISS 와 사용자 친화 둘 다를 만족합니다.
9. 자주 걸리는 자리
<Suspense> 가 streaming 하지 않을 때 — page.tsx 자체가 async 면 outer page 가 끝날 때까지 Suspense 가 보이지 않습니다. async 자식 component 로 데이터 fetch 를 분리해야 zone fallback 이 의미를 가집니다.
client component 에 <Suspense> — 클라이언트 트리에서는 lazy import 외에 거의 의미가 없습니다. server component 트리에서만 streaming 효과.
key={pathname} 누락 — layout 자체가 persistent 라 wrapper 가 reuse 되면 같은 element 위에서 children 만 swap 됩니다 — animation 재실행 안 됨. key 가 바뀌어야 React 가 unmount/mount 로 처리합니다.
prefetch 와 loading.tsx 의 상호작용 — <Link prefetch> 는 RSC payload 를 미리 받아 fallback 이 거의 안 보입니다 (좋은 일). 반대로 prefetch={false} 인 자리는 클릭 후 fallback 이 보장됩니다. 의도된 노출 자리.
Lighthouse 의 LCP 측정 — fallback 안의 큰 placeholder 가 LCP 가 될 수 있습니다. skeleton 의 높이를 본문 첫 화면과 비슷하게 맞추면 layout shift 가 줄고 LCP 도 안정됩니다.
하고픈 말
로딩 UX 는 fallback 의 위치가 거의 모든 것을 결정합니다. 라우트 단위로 일률 적용하면 빠른 페이지에 부담을 강요하고, fallback 을 안 두면 느린 페이지가 빈 화면으로 보입니다. 라우트별·zone 별로 fallback 위치를 정하는 흐름이 KISS 와 사용자 경험 둘 다를 만족합니다.
Next
- (frontend 끝)
Next.js loading UI · React Suspense · View Transitions API · WCAG prefers-reduced-motion 을 참고합니다.