좋습니다. 이번 페이지는 React에서 “아직 준비되지 않은 UI를 어떻게 다룰 것인가”를 배우는 페이지입니다.
<Suspense>를 단순히 Loading...을 보여주는 컴포넌트로 보면 반만 이해한 겁니다. 더 정확히는:
Suspense는 React에게 “이 경계 안의 UI는 준비가 끝날 때까지 대체 UI를 보여주고, 준비된 시점에 한꺼번에 공개해라”라고 알려주는 경계(boundary)입니다. (React)
1. 가장 기본적인 형태
import { Suspense } from 'react';
function App() {
return (
<Suspense fallback={<Loading />}>
<Albums />
</Suspense>
);
}
여기서:
<Albums />
가 렌더링 도중 suspend되면 React는 대신:
<Loading />
을 보여줍니다.
그리고 Albums가 준비되면 fallback을 없애고 실제 UI를 보여줍니다. (React)
흐름은 이렇게 생각하면 됩니다.
Albums 렌더링 시도
↓
아직 준비 안 됨
↓
suspend
↓
가장 가까운 Suspense 찾음
↓
fallback 표시
↓
준비 완료
↓
다시 렌더링
↓
Albums 표시
2. 그런데 “suspend된다”는 게 정확히 무슨 뜻일까?
여기가 Suspense를 이해하는 핵심입니다.
Suspense는 아무 비동기 작업이나 감지하는 게 아닙니다.
예를 들어 이건 Suspense와 연결되지 않습니다.
function Albums() {
const [albums, setAlbums] = useState(null);
useEffect(() => {
fetch('/api/albums')
.then(res => res.json())
.then(setAlbums);
}, []);
if (!albums) {
return <p>Loading...</p>;
}
return <AlbumList albums={albums} />;
}
이런 useEffect 기반 데이터 fetching은 <Suspense>를 활성화하지 않습니다. 공식 문서도 Effect나 이벤트 핸들러에서 시작한 데이터 요청을 Suspense가 감지하지 않는다고 명시합니다. (React)
즉:
<Suspense fallback={<Loading />}>
<Albums />
</Suspense>
라고 감싼다고 해서 기존 fetch + useEffect 코드가 자동으로 Suspense 기반으로 바뀌는 게 아닙니다.
3. 그러면 무엇이 Suspense를 활성화하는가?
현재 공식 문서에서는 대표적으로 다음 경우들을 설명합니다.
가장 중요한 것은:
lazy
use(Promise)
Suspense를 지원하는 framework의 데이터 fetching
입니다.
예를 들어 코드 splitting:
const SomeComponent = lazy(() => import('./SomeComponent'));
그리고:
<Suspense fallback={<Loading />}>
<SomeComponent />
</Suspense>
SomeComponent의 JS 코드가 아직 다운로드되지 않았다면 suspend되고 fallback이 나타납니다.
또 React의 use로 Promise를 읽는 경우도 Suspense와 연결됩니다. 현재 React는 그 외에도 stylesheet 로딩, streaming SSR 같은 상황과 Suspense를 통합하고 있습니다. (React)
보충 설명
Suspense에서 가장 중요한 사고방식은:
"로딩 상태를 내가 직접 if문으로 관리한다"
에서:
"렌더링에 필요한 것이 아직 준비 안 됐으면
React에게 이 렌더링을 보류하라고 알린다"
로 바뀐다는 것입니다.
4. Suspense는 “로딩 여부”보다 “공개 시점”을 제어한다
예제를 보겠습니다.
<Suspense fallback={<Loading />}>
<Biography />
<Panel>
<Albums />
</Panel>
</Suspense>
Biography는 1초 만에 준비되고 Albums는 3초가 걸린다고 해봅시다.
그렇다고:
1초 → Biography 표시
3초 → Albums 표시
가 되지 않습니다.
같은 Suspense boundary에 들어 있기 때문에 하나의 단위로 취급됩니다.
0초
Loading...
1초
Biography 준비됨
하지만 Albums가 아직 안 됨
→ 계속 Loading...
3초
Biography + Albums 모두 준비됨
→ 둘을 함께 표시
공식 문서는 같은 Suspense boundary 내부의 콘텐츠가 함께 공개된다고 설명합니다. (React)
따라서 Suspense는 단순한 spinner API라기보다:
UI reveal을 조율하는 API
라고 보는 게 좋습니다.
5. Suspense boundary의 위치가 UX를 결정한다
이번에는 이렇게 바꿔봅시다.
<Suspense fallback={<BigSpinner />}>
<Biography />
<Suspense fallback={<AlbumsSkeleton />}>
<Panel>
<Albums />
</Panel>
</Suspense>
</Suspense>
구조는:
Outer Suspense
├── Biography
└── Inner Suspense
└── Albums
이제 실행 순서는 달라집니다.
처음
→ BigSpinner
Biography 준비
→ Biography 표시
→ Albums 자리는 AlbumsSkeleton
Albums 준비
→ AlbumsSkeleton을 Albums로 교체
즉:
한꺼번에 공개
vs
점진적으로 공개
를 Suspense boundary의 위치로 설계할 수 있습니다. (React)
6. 그래서 아무 컴포넌트마다 Suspense를 넣으면 안 된다
예를 들어:
<Suspense fallback={...}>
<Header />
</Suspense>
<Suspense fallback={...}>
<Title />
</Suspense>
<Suspense fallback={...}>
<Avatar />
</Suspense>
<Suspense fallback={...}>
<Button />
</Suspense>
식으로 모든 컴포넌트를 독립 boundary로 만들면 로딩 UI가 지나치게 파편화될 수 있습니다.
공식 문서가 권장하는 기준은:
사용자가 어떤 콘텐츠 묶음을 함께 보게 할 것인지
입니다. Suspense boundary의 세밀함은 원하는 loading sequence보다 더 세밀할 필요가 없습니다. (React)
보충 설명
Suspense 경계는 기술적 분리 기준이라기보다 UX 설계 기준에 가깝습니다.
디자인에서:
[프로필 전체 Skeleton]
Biography
[앨범 Skeleton]
Biography
Albums
같은 로딩 흐름이 결정되어 있다면 그 단위대로 Suspense를 배치하면 됩니다.
7. 이미 보인 화면이 다시 fallback으로 사라질 수도 있다
여기서 중요한 문제가 생깁니다.
처음:
SearchResults("a")
가 이미 표시되어 있다고 합시다.
사용자가 검색어를:
a → ab
로 바꿨습니다.
새 검색 결과가 suspend하면 기본적으로 가장 가까운 Suspense는 다시 fallback을 보여줄 수 있습니다.
기존 결과
Apple
Amazon
↓ query 변경
Loading...
↓ 새 데이터 도착
Apple Music
Ableton
즉 기존 UI가 갑자기 사라집니다.
공식 문서에서는 이미 공개한 콘텐츠가 다시 suspend하면 기본적으로 fallback으로 돌아갈 수 있다고 설명합니다. (React)
8. useDeferredValue와 함께 사용하기
이전에 배운 useDeferredValue가 여기서 아주 자연스럽게 연결됩니다.
function App() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
return (
<>
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
<Suspense fallback={<Loading />}>
<SearchResults query={deferredQuery} />
</Suspense>
</>
);
}
여기서 중요한 것은:
query
는 즉시 바뀝니다.
그래서 input은 사용자 입력에 바로 반응합니다.
하지만:
deferredQuery
는 새 결과가 준비될 때까지 이전 값을 유지할 수 있습니다. (React)
그래서:
input : "ab"
SearchResults: 아직 "a" 결과
상태가 잠깐 생깁니다.
이게 오히려 UX 면에서는 좋을 수 있습니다.
9. 오래된 결과라는 것을 사용자에게 보여주기
공식 문서는 이런 패턴도 보여줍니다.
const isStale = query !== deferredQuery;
<Suspense fallback={<Loading />}>
<div style={{ opacity: isStale ? 0.5 : 1 }}>
<SearchResults query={deferredQuery} />
</div>
</Suspense>
그러면:
새 데이터 기다림
Loading...
대신:
이전 결과를 반투명하게 유지
할 수 있습니다. (React)
즉:
fallback을 보여줄 것인가?
vs
stale UI를 유지할 것인가?
도 UX 선택입니다.
10. startTransition과 Suspense
Suspense와 Transition의 연결도 매우 중요합니다.
예를 들어 현재 페이지:
Home
에서:
Artist Page
로 이동했는데 새 페이지가 suspend한다고 합시다.
루트에:
<Suspense fallback={<BigSpinner />}>
<Router />
</Suspense>
만 있다면:
Home
↓ navigation
BigSpinner
↓
Artist Page
처럼 기존 전체 UI가 spinner로 교체될 수 있습니다.
이건 꽤 거슬립니다.
11. navigation을 Transition으로 표시하면
function navigate(url) {
startTransition(() => {
setPage(url);
});
}
React에게:
"이 업데이트는 급한 것이 아니다."
라고 알려줍니다.
그러면 React는 새 화면이 suspend했을 때 이미 공개된 UI를 급하게 숨기지 않고 이전 화면을 유지하려고 합니다. (React)
흐름이:
Home
↓ 클릭
Home 유지
↓ 새 화면 준비
Artist Page
처럼 바뀔 수 있습니다.
이것이 Suspense + Transition 조합의 핵심입니다.
12. 하지만 모든 fallback을 막는 것은 아니다
중요한 세부 사항이 있습니다.
Transition은 이미 보인 UI가 갑자기 사라지는 것을 방지하려는 것이지:
새로운 화면 내부의 모든 Suspense fallback을 없애는 기능
은 아닙니다.
예를 들어:
새 ArtistPage
Biography
[Albums Skeleton]
처럼 새롭게 등장한 nested Suspense는 fallback을 보여줄 수 있습니다.
공식 문서도 Transition이 이미 공개된 콘텐츠를 숨기지 않도록 기다리는 것이지 모든 콘텐츠가 로드될 때까지 기다리는 것은 아니라고 설명합니다. (React)
13. useTransition으로 진행 상태 표시하기
startTransition만 사용하면 navigation이 진행되고 있다는 표시가 없을 수 있습니다.
그래서:
const [isPending, startTransition] = useTransition();
function navigate(url) {
startTransition(() => {
setPage(url);
});
}
로 만들고:
<Layout isPending={isPending}>
같이 사용할 수 있습니다. (React)
예를 들어:
<header style={{ opacity: isPending ? 0.5 : 1 }}>
처럼:
기존 화면 유지
+
"이동 중"이라는 작은 시각적 표시
를 만들 수 있습니다.
14. useDeferredValue와 Transition의 차이
Suspense와 둘 다 연결되기 때문에 구분이 중요합니다.
useDeferredValue
어떤 값을 늦춥니다.
const deferredQuery = useDeferredValue(query);
주로:
입력값은 바로 업데이트
결과 UI는 조금 늦게 따라옴
같은 상황입니다.
Transition
어떤 업데이트 전체를 non-urgent로 표시합니다.
startTransition(() => {
setPage(nextPage);
});
주로:
navigation
탭 전환
큰 UI 변경
같은 작업에서 사용됩니다.
공식 문서도 deferred values는 앱 코드에서 특정 UI를 뒤처지게 할 때, Transition은 전체 업데이트를 non-urgent로 처리하며 router 같은 곳에서 자주 사용된다고 설명합니다. (React)
15. navigation에서 Suspense boundary를 reset하기
Transition에서는 기존 화면을 유지할 수 있다고 했습니다.
그런데 상황에 따라 기존 화면을 유지하면 오히려 이상할 수도 있습니다.
예를 들어:
/user/1
에서:
/user/2
로 이동합니다.
두 URL 모두 같은:
<ProfilePage />
컴포넌트를 사용한다고 해봅시다.
React가 같은 화면의 업데이트라고 판단하면 기존 콘텐츠를 계속 유지하려 할 수 있습니다.
하지만 다른 사람의 프로필이라면:
완전히 새로운 콘텐츠라고 봐야 한다.
그래서:
<ProfilePage key={userId} />
처럼 key를 사용할 수 있습니다. 공식 문서는 navigation에서 다른 콘텐츠임을 나타내 Suspense boundary를 reset하려면 key를 사용할 수 있다고 설명합니다. (React)
우리가 이미 공부했던 Preserving and Resetting State의 key와 같은 원리입니다.
16. 처음부터 suspend한 컴포넌트의 state는 보존되지 않는다
공식 문서의 중요한 caveat입니다.
예를 들어:
<Suspense fallback={<Loading />}>
<Component />
</Suspense>
에서 Component가 처음 mount되기 전에 suspend했다고 합시다.
React는 그 중간 렌더의 state를 보존하지 않습니다.
데이터가 준비되면 React가 해당 tree를 처음부터 다시 렌더링합니다. (React)
개념적으로:
render attempt #1
↓
suspend
↓
이 렌더는 commit되지 않음
↓
준비 완료
↓
render attempt #2
↓
commit
입니다.
보충 설명
이건 React의 중요한 개념과 연결됩니다.
render했다고 해서 반드시 mount된 것은 아니다.
React는 render를 시도했다가 버릴 수 있습니다.
state와 Effect 같은 mount 이후의 의미는 commit과 연결해서 생각해야 합니다.
17. 이미 보인 Suspense 콘텐츠가 다시 숨겨지면 layout Effect도 정리된다
조금 고급 내용이지만 공식 문서에서 짚고 있으므로 알아둡시다.
이미 표시된 콘텐츠가:
Visible
이었다가 다시 suspend해서 fallback으로 숨겨지면 React는 그 트리의 layout Effect를 cleanup합니다.
다시 콘텐츠를 보여줄 때 layout Effect를 다시 실행합니다. (React)
왜냐하면:
useLayoutEffect(() => {
const rect = ref.current.getBoundingClientRect();
});
같은 Effect가 숨겨진 UI의 layout을 측정하면 잘못된 결과를 만들 수 있기 때문입니다.
18. Suspense와 Streaming SSR
Suspense는 클라이언트 loading UI만을 위한 기능도 아닙니다.
서버 렌더링에서는 매우 중요한 역할을 합니다.
예를 들어:
<html>
<body>
<h1>Alice</h1>
<Suspense fallback={<p>Loading posts...</p>}>
<Posts />
</Suspense>
</body>
</html>
Posts 데이터가 아직 준비 안 됐다면 서버는 전체 페이지를 기다리지 않고 먼저:
<h1>Alice</h1>
Loading posts...
부분을 보낼 수 있습니다.
나중에 Posts가 준비되면 해당 HTML을 추가로 stream해서 fallback을 실제 콘텐츠로 교체합니다. (React)
즉:
전부 준비될 때까지 기다렸다 HTML 전송
이 아니라:
준비된 부분 먼저 전송
↓
나머지는 점진적으로 stream
이 가능해집니다.
Suspense가 React의 서버 렌더링 아키텍처에서 중요한 이유입니다.
19. 서버에서 에러가 났을 때도 Suspense가 사용된다
streaming SSR 환경에서는 서버 렌더 도중 컴포넌트가 에러를 throw해도 React가 바로 전체 렌더를 포기하지 않을 수 있습니다.
가장 가까운 Suspense boundary의 fallback을 서버 HTML에 넣습니다.
<Suspense fallback={<Loading />}>
<Chat />
</Suspense>
서버:
Chat 렌더 실패
↓
Loading fallback 전송
그다음 브라우저에서는 Chat을 다시 렌더링해봅니다.
클라이언트에서는 성공한다면 최종 콘텐츠를 보여줄 수 있습니다. 클라이언트에서도 에러가 나면 그때는 가까운 Error Boundary가 처리합니다. (React)
따라서:
Suspense
→ loading / 준비되지 않은 UI
Error Boundary
→ 실제 클라이언트 렌더 에러
라는 구분을 기억하세요.
20. 브라우저에서만 렌더링해야 하는 콘텐츠
현재 공식 문서에는 browser-only UI에 대한 Suspense 활용도 있습니다.
예를 들어 서버에서는 사용할 수 없는:
localStorage
같은 API가 필요한 컴포넌트가 있다고 합시다.
React의 browser 관련 API와 use를 통해 서버에서는 fallback을 보내고, hydration 이후 실제 브라우저 콘텐츠로 교체하는 방식도 가능합니다. (React)
이 부분은 일반적인 클라이언트 React 앱보다 SSR/framework 환경에서 더 중요합니다.
21. stylesheet도 Suspense와 연결된다
현재 React에서는 이런 코드가 가능합니다.
<link
rel="stylesheet"
href={href}
precedence="default"
/>
Suspense boundary 내부에서 이 stylesheet가 로드되어야 한다면 React는 일정 시간 동안 콘텐츠 공개를 보류해 스타일 없는 UI가 먼저 보이는 현상을 막을 수 있습니다. (React)
즉:
HTML 먼저 나타남
↓
잠시 뒤 CSS 적용
대신:
fallback
↓
CSS 준비
↓
스타일 적용된 UI 공개
라는 UX를 만들 수 있습니다.
22. 이미지와 폰트는 항상 Suspense가 기다리는 건 아니다
여기서 오해하면 안 됩니다.
일반적으로:
<img src="..." />
라고 했다고 해서 Suspense가 이미지 로드를 자동으로 기다리는 것은 아닙니다.
폰트도 기본적으로 마찬가지입니다.
현재 공식 문서에서 이미지/폰트 로딩과 Suspense coordination은 특히 <ViewTransition>과 함께 사용하는 시나리오를 설명합니다. View Transition 중에는 React가 이미지나 폰트 로딩을 일정 시간 기다려 반쯤 로드된 상태에서 애니메이션이 시작되는 것을 피할 수 있습니다. (React)
이 부분은 지금 모두 외우기보다는:
Suspense가 React의 여러 resource loading 시스템과 점점 더 깊게 통합되어 있다.
정도로 잡아두면 됩니다.
23. 실무에서 가장 중요한 Suspense 모델
이 페이지에서 특히 기억했으면 하는 건 세 가지입니다.
첫째: 가장 가까운 boundary가 처리한다
<Suspense fallback={<Outer />}>
<A />
<Suspense fallback={<Inner />}>
<B />
</Suspense>
</Suspense>
B가 suspend하면:
Inner
가 처리합니다.
A가 suspend하면:
Outer
가 처리합니다.
즉 React는 가장 가까운 부모 Suspense를 찾습니다.
둘째: boundary는 reveal 단위다
<Suspense>
<A />
<B />
</Suspense>
이면 기본적으로:
A와 B를 하나의 reveal unit
으로 봅니다.
나눠서 공개하려면:
<Suspense>
<A />
<Suspense>
<B />
</Suspense>
</Suspense>
처럼 경계를 나눕니다. (React)
셋째: 기존 UI를 없앨지 유지할지는 Transition/deferred rendering과 연결된다
새 UI 기다리는 동안
fallback을 보여줄지
기존 UI를 유지할지
일부만 skeleton으로 바꿀지
가 Suspense UX 설계의 핵심입니다.
24. 흔한 실수
첫 번째:
<Suspense fallback={<Loading />}>
<ComponentThatFetchesInUseEffect />
</Suspense>
를 쓰고:
왜 fallback이 안 나오지?
라고 생각하는 것입니다.
useEffect fetching은 Suspense를 활성화하지 않습니다. (React)
두 번째:
Suspense 하나로 앱 전체를 감싸면 된다.
기술적으로 가능하지만 loading UI가 너무 큰 단위가 됩니다.
작은 콘텐츠 하나가 늦음
↓
전체 페이지 spinner
가 될 수 있습니다.
세 번째:
모든 컴포넌트를 Suspense로 감싸면 더 좋다.
아닙니다. boundary는 사용자가 보고 싶은 loading sequence에 맞춰 설계해야 합니다. (React)
네 번째:
Suspense = 데이터 fetching API
이것도 정확하지 않습니다.
Suspense는 비동기 resource를 가져오는 API 자체가 아니라, 준비되지 않은 렌더링을 어떻게 UI로 조율할지 결정하는 메커니즘입니다.
25. 우리가 이전에 배운 것들과 연결
Suspense는 지금까지 배운 여러 개념의 교차점입니다.
lazy
const Component = lazy(() => import('./Component'));
코드가 아직 준비되지 않으면 Suspense fallback이 나옵니다.
use
Promise를 render 과정에서 읽을 때 아직 resolve되지 않았다면 suspend할 수 있습니다.
useDeferredValue
새 콘텐츠가 준비되는 동안 이전 콘텐츠를 유지합니다.
startTransition / useTransition
이미 공개된 UI를 갑자기 fallback으로 교체하지 않도록 non-urgent update를 표현합니다.
key
navigation에서 서로 다른 콘텐츠임을 React에 알려 boundary/state를 reset할 수 있습니다.
Streaming SSR
Suspense boundary를 기준으로 서버 HTML을 점진적으로 전달할 수 있습니다.
이걸 한 그림으로 보면:
준비되지 않은 resource
│
▼
suspend
│
▼
가장 가까운 Suspense
/ \
fallback 표시 기존 UI 유지
Transition /
Deferred Value
│
▼
준비 완료
│
▼
실제 UI reveal
핵심 정리
<Suspense>를 한 문장으로 설명한다면:
Suspense는 하위 UI가 렌더링할 준비가 될 때까지 fallback을 표시하고, 어떤 콘텐츠를 어떤 순서로 공개할지 React가 조율할 수 있게 만드는 경계입니다.
특히 기억해야 할 것은 다음입니다.
fallback은 자식이 suspend했을 때 표시됩니다.useEffect나 event handler에서 fetch한다고 Suspense가 자동으로 작동하지 않습니다.lazy,use(Promise), Suspense-enabled framework 등의 작업이 boundary를 활성화할 수 있습니다. (React)- 같은 Suspense 안의 콘텐츠는 기본적으로 하나의 reveal 단위입니다.
- nested Suspense를 사용하면 점진적으로 UI를 공개할 수 있습니다. (React)
useDeferredValue는 기존 결과를 잠시 유지하는 데 유용합니다.- Transition은 이미 공개된 UI가 갑자기 fallback으로 사라지는 것을 방지하는 데 사용됩니다. (React)
- Suspense는 Streaming SSR과도 깊게 통합되어 있습니다. (React)
이해도 테스트
정답은 바로 알려주지 않겠습니다.
- 아래 코드는 왜
fetch하는 동안 Suspense의fallback을 자동으로 보여주지 않을까요?
function User() {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser().then(setUser);
}, []);
if (!user) return null;
return <Profile user={user} />;
}
<Suspense fallback={<Loading />}>
<User />
</Suspense>
- 다음 구조에서
Albums만 suspend한다면 어떤 fallback이 보일까요?
<Suspense fallback={<PageSpinner />}>
<Biography />
<Suspense fallback={<AlbumsSkeleton />}>
<Albums />
</Suspense>
</Suspense>
Biography와Albums가 반드시 동시에 화면에 나타나게 만들고 싶다면 Suspense boundary를 어떻게 구성해야 할까요?이미 검색 결과가 보이고 있는 상황에서 새로운 검색 결과를 기다리는 동안 기존 결과를 유지하고 싶다면
useDeferredValue가 어떤 역할을 할까요?Suspense와
startTransition을 함께 사용할 때 Transition이 해결하려는 UX 문제를 설명해보세요.
'리액트 > 레퍼런스 (react 19ver)' 카테고리의 다른 글
| cache (0) | 2026.09.15 |
|---|---|
| addTransitionType (0) | 2026.09.15 |
| act (0) | 2026.09.15 |
| <ViewTransition> (0) | 2026.09.15 |
| <Activity> (0) | 2026.09.14 |