codingstairs
노트에듀라이프연락
⌕검색⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

연락하기

로그인 없이도 보낼 수 있어요. 답변이 필요하면 이메일을 함께 적어 주세요.

  • 익명 폼으로 의견 남기기 →
  • ✉ warragon112@gmail.com
  • 카카오톡 오픈채팅 ↗

© 2026 codingstairs

  • 노트
  • 에듀
  • 검색
  • 라이프
  • 연락
  • 약관
  • RSS
  • GitHub
노트›frontend

페이지 로딩 UX

2026-04-28 게시· 2026-05-18 갱신·0회 조회

페이지 로딩 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 을 참고합니다.

frontend 카테고리의 다른 글

카테고리 전체 보기 →
  • 도메인 위젯의 통일성 — 4개 도메인에 3개 위젯만 두지 마라
  • 관리자 UI — ResourceTable SSOT 패턴
  • 네이티브 통합 — OS 기능들
  • OCR · STT · TTS
  • SQLite — 로컬 앱의 단일 파일 DB
  • Tauri 모바일과 AdMob