개요
Next.js App Router를 사용하다 보면 로딩 UX를 어떻게 구성해야 할지 한 번쯤 고민하게 된다.
처음에는 loading.tsx만 사용해도 충분할 것처럼 보인다.
하지만 실제로 적용해 보면 예상과 다른 동작들이 존재하고,
특히 검색 페이지나 데이터 변경이 잦은 화면에서는 한계가 느껴진다.
이번 글에서는
- 스트리밍(Streaming)이 왜 필요한지
- loading.tsx와 Suspense의 차이
- Skeleton UI는 언제 쓰는 게 좋은지
공부하면서 느낀 관점으로 정리해 보려고 한다.
내용
#1. 스트리밍(Streaming)이 필요한 이유
Next.js는 기본적으로 서버에서 모든 컴포넌트를 렌더링한 뒤 완성된 HTML을 브라우저로 전달한다.
이 구조 자체는 SEO나 초기 렌더링 측면에서는 장점이 있지만,
문제는 여기서 특정 컴포넌트 하나라도 느려지면 전체 페이지가 늦어진다는 점이다.
예를 들어 아래처럼 렌더링 시간이 걸린다고 가정해보자.
#1 루트 레이아웃(10ms)
#2 검색 폼 레이아웃(12ms)
#3 검색 폼 컴포넌트(13ms)
#4 페이지 컴포넌트(3300ms) ← 지연 발생
이미 위쪽 UI는 거의 준비가 됐는데도 마지막 컴포넌트 때문에 아무것도 보여주지 못하는 상황이 된다.
이 문제를 해결하기 위해 등장한 개념이 스트리밍이다.
스트리밍은 HTML을 한 번에 보내는 게 아니라 준비된 부분부터 청크(chunk) 단위로 점진적으로 응답하는 방식이다.
참고로 스트리밍은 Dynamic 페이지에서만 동작한다.
정적 페이지에서는 스트리밍이 의미가 없다.
이미 빌드 타임에 완성된 HTML이 있기 때문이다.
강제로 Dynamic 페이지로 만들고 싶다면 아래 설정을 사용할 수 있다.
페이지 컴포넌트 위에 "dynamic" 라우트 세그먼트 컨픽 작성
export const dynamic = "force-dynamic";
#2. loading.tsx — 가장 간단한 스트리밍 방법
폴더 아래에 loading.tsx 파일을 하나 생성하면
해당 라우트 하위 페이지에서 자동으로 스트리밍이 동작한다.
로딩 UI(loading.tsx) 예시
export default function Loading() {
return <div>검색 결과를 불러오는 중입니다...</div>
}
처음에는 정말 편하지만 실제 사용해 보면 두 가지 제약이 존재한다.
[ 1. 페이지 컴포넌트만 스트리밍된다. ]
페이지 내부에 비동기 컴포넌트가 여러 개 있어도 loading.tsx는 page.tsx 단위로만 동작한다.
즉, 일부 컴포넌트만 늦어도 전체 페이지에 영향이 발생하며, 세밀한 컴포넌트 단위의 스트리밍은 불가능하다.
[ 2. 쿼리 스트링 변경 시 다시 스트리밍되지 않는다. ]
검색 페이지에서 특히 당황했던 부분이다.
쿼리 스트링 예시
?q=react
?q=nextjs
URL은 바뀌지만 동일한 라우트이기 때문에 loading.tsx는 최초 1회만 동작한다.
검색어가 바뀌어도 로딩 UI가 다시 나오지 않는다.
#3. Suspense — 실제로 많이 쓰게 되는 스트리밍 방식
위 문제들을 해결하려고 결국 사용하게 되는 게 (오늘의 주인공) Suspense다.
Suspense는 특정 컴포넌트 단위로 fallback UI를 만들고 준비되는 순서대로 스트리밍을 가능하게 한다.
기본 예제
import { Suspense } from 'react'
import { PostFeed, Weather } from './Components'
export default function Posts() {
return (
<section>
<Suspense fallback={<p>Loading feed...</p>}>
<PostFeed />
</Suspense>
<Suspense fallback={<p>Loading weather...</p>}>
<Weather />
</Suspense>
</section>
)
}
이 구조가 좋은 이유는 명확하다.
페이지 전체가 아니라 느린 부분만 따로 스트리밍할 수 있었고,
특히 검색 결과 영역만 로딩 처리하는 게 가능해져서 UX가 훨씬 자연스러워졌다.
#4. Suspense에서 key를 쓰는 이유 (중요)
독특..한 점이라면 Suspense도 별도의 처리를 하지 않으면 loading.tsx처럼
쿼리 스트링 변경으로는 스트리밍이 재발생 되지 않는다는 점이다.
검색 페이지처럼 쿼리 스트링이 바뀌는 경우에는 재검색 중인 모습을 사용자에게 보여줘야 하므로
스트리밍이 돌아서 fallback을 보여줘야 한다.
이를 위해서는 Suspense에 key를 지정해줘야 한다.
스트리밍 재발생 방법 - Suspense 컴포넌트 key 지정
export default async function Page({
searchParams
}: {
searchParams: Promise<{q?: string}>
}) {
const { q } = await searchParams;
return (
<Suspense
key={q || ""}
fallback={<div>검색 결과를 불러오는 중입니다...</div>}
>
<SearchResult q={q || ""} />
</Suspense>
);
}

이건 React 렌더링 방식 때문인데, 컴포넌트가 동일하다고 판단하면 재생성하지 않기 때문이다.
그래서 key를 변경해서 React가 새로운 컴포넌트로 인식하게 만들고 스트리밍을 다시 시작하게 만들어야 한다.
#5. 스켈레톤(Skeleton) UI는 왜 같이 쓰는가
텍스트 로딩 메시지는 기능적으로는 충분하지만 실제 서비스에서는
검색 결과를 불러오는 중입니다...
이 문구 하나로는 UX가 너무 빈약하다.
스켈레톤은 최종 UI의 형태를 미리 보여주는 방식이라서
- 카드 위치
- 리스트 형태
- 이미지 영역
- etc
이런 것들을 미리 보여주기 때문에 사용자는 이미 로딩이 진행 중이라는 느낌을 받는다.
보통은 Suspense의 fallback으로 넣어서 사용한다.
Suspense와 스켈레톤
export default async function Page({
searchParams
}: {
searchParams: Promise<{q?: string}>
}) {
const { q } = await searchParams;
return (
<Suspense
fallback={new Array(3).fill(0).map((_, idx) => (
<BookItemSkeleton key={`reco-book-skeleton-${idx}`} /> // 스켈레톤
))}
>
<SearchResult q={q || ""} />
</Suspense>
);
}


정리
처음에는 loading.tsx 하나로 충분할 줄 알았다.
하지만 실제로 검색이나 필터처럼 상태가 자주 바뀌는 화면에서는
Suspense를 중심으로 구조를 잡는 게 훨씬 편해 보인다..
지금 기준으로 정리하면,
- loading.tsx는 라우트 진입 시 로딩 처리
- Suspense는 컴포넌트 단위 스트리밍
- Skeleton은 UX를 위한 보완 요소
이 세 가지를 역할에 맞게 나눠서 사용하는 게 가장 안정적이라 생각한다.
특히 검색 페이지나 데이터 변경이 잦은 화면이라면 loading.tsx보다 Suspense 중심으로 설계하는 게 훨씬 유연하다.
'Web' 카테고리의 다른 글
| [Next.js] App Router에서 error.tsx를 쓰다가 try/catch로 돌아온 이유 (0) | 2026.02.16 |
|---|---|
| [Next.js] App Router 데이터 fetching 구조와 에러 핸들링 (0) | 2026.02.16 |
| [React] useRef()로 DOM 참조하기 (0) | 2026.02.13 |