
React Suspense와 Concurrent 렌더링 이해하기
React Suspense와 Concurrent 렌더링 이해하기
React 18에서 도입된 Concurrent 렌더링은 React의 근본적인 렌더링 방식을 바꾸는 패러다임 전환입니다. 이 글에서는 Suspense의 내부 동작 원리부터 useTransition, useDeferredValue, 그리고 React 19의 use 훅까지 깊이 있게 살펴봅니다.
Suspense란?
Suspense는 컴포넌트가 렌더링되기 전에 "무언가를 기다릴 수 있게" 해주는 React의 기능입니다. 가장 쉽게 말하면, 데이터나 코드가 준비될 때까지 로딩 UI를 보여주는 선언적인 방법입니다.
<Suspense fallback={<Loading />}> <SomeComponent /> </Suspense>
하지만 Suspense의 진정한 가치를 이해하려면, 내부에서 어떻게 동작하는지 알아야 합니다.
Suspense의 동작 원리: Promise를 throw하다
Suspense의 핵심 메커니즘은 Promise를 throw하는 것입니다. JavaScript에서 throw는 보통 에러를 던질 때 사용하지만, 사실 어떤 객체든 던질 수 있습니다.
┌─────────────────────────────────────────────────────────────┐ │ Suspense Boundary │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ try { render } │ │ │ │ │ │ │ │ │ ┌──────────────────────▼──────────────────────┐ │ │ │ │ │ Child Component │ │ │ │ │ │ │ │ │ │ │ │ const data = resource.read(); │ │ │ │ │ │ │ │ │ │ │ │ │ ▼ │ │ │ │ │ │ ┌─────────────────────────┐ │ │ │ │ │ │ │ Promise pending? │ │ │ │ │ │ │ │ YES → throw promise │──────────┼──┼────┼──▶ catch │ │ │ │ NO → return data │ │ │ │ │ │ │ └─────────────────────────┘ │ │ │ │ │ └─────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────┐ │ │ │ Show fallback UI │ │ │ │ Wait for Promise │ │ │ │ Re-render when done │ │ │ └─────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘
wrapPromise 패턴
Suspense와 함께 데이터를 가져오려면, Promise의 상태를 추적하고 적절한 시점에 throw하는 래퍼가 필요합니다:
function wrapPromise<T>(promise: Promise<T>) { let status: 'pending' | 'success' | 'error' = 'pending'; let result: T; let error: Error; const suspender = promise.then( (data) => { status = 'success'; result = data; }, (err) => { status = 'error'; error = err; } ); return { read(): T { switch (status) { case 'pending': throw suspender; // Promise를 throw! case 'error': throw error; // Error를 throw (ErrorBoundary가 처리) case 'success': return result; // 데이터 반환 } } }; }
이 패턴의 동작 흐름:
- 컴포넌트가 렌더링되면서
resource.read()호출 - 데이터가 아직 없으면 (
pending) Promise를 throw - React가 throw된 Promise를 catch하고 fallback UI 표시
- Promise가 resolve되면 React가 컴포넌트 다시 렌더링
- 이번에는
success상태이므로 데이터 반환
대수적 효과(Algebraic Effects)
React 팀의 Dan Abramov는 Suspense가 대수적 효과를 토대로 만들어졌다고 설명합니다. 대수적 효과의 핵심 아이디어는 "효과(effect)를 발생시키는 코드"와 "효과를 처리하는 코드"를 분리하는 것입니다.
┌────────────────────────────────────────────────────────────┐ │ Traditional Approach │ │ │ │ Component: "I need data, let me handle loading myself" │ │ ┌────────────────────────────────────────┐ │ │ │ if (loading) return <Spinner /> │ │ │ │ if (error) return <Error /> │ │ │ │ return <Content data={data} /> │ │ │ └────────────────────────────────────────┘ │ └────────────────────────────────────────────────────────────┘ VS ┌────────────────────────────────────────────────────────────┐ │ Algebraic Effects (Suspense) │ │ │ │ Component: "I need data" (throws) │ │ │ │ │ ▼ │ │ Parent (Suspense): "I'll handle the waiting part" │ │ ┌────────────────────────────────────────┐ │ │ │ <Suspense fallback={<Spinner />}> │ │ │ │ <Component /> ← just renders data │ │ │ │ </Suspense> │ │ │ └────────────────────────────────────────┘ │ └────────────────────────────────────────────────────────────┘
이렇게 관심사가 분리되면서, 컴포넌트는 데이터가 "있다고 가정하고" 렌더링 로직만 작성하면 됩니다.
Concurrent 렌더링의 핵심 개념
React 18 이전에는 렌더링이 시작되면 중단할 수 없었습니다. 하지만 Concurrent 렌더링에서는:
- 렌더링을 중단하고 나중에 재개할 수 있음
- 여러 버전의 UI를 동시에 준비할 수 있음
- 급한 업데이트와 덜 급한 업데이트를 구분할 수 있음
┌────────────────────────────────────────────────────────────┐ │ Synchronous Rendering │ │ │ │ User types → [====== Render heavy list ======] → Update │ │ (UI frozen) │ └────────────────────────────────────────────────────────────┘ VS ┌────────────────────────────────────────────────────────────┐ │ Concurrent Rendering │ │ │ │ User types → Update input immediately │ │ │ │ │ └──▶ [== Render list ==] ← interrupted! │ │ User types again → Update input │ │ │ │ │ └──▶ [====== Render list ======] → Update │ │ (UI stays responsive) │ └────────────────────────────────────────────────────────────┘
useTransition: 긴급하지 않은 업데이트 표시하기
useTransition은 상태 업데이트를 "긴급하지 않음"으로 표시하여, 더 급한 업데이트가 먼저 처리되도록 합니다.
import { useState, useTransition } from 'react'; function TabContainer() { const [tab, setTab] = useState('about'); const [isPending, startTransition] = useTransition(); function selectTab(nextTab: string) { startTransition(() => { setTab(nextTab); // 이 업데이트는 낮은 우선순위 }); } return ( <> <TabButton isActive={tab === 'about'} onClick={() => selectTab('about')} > About </TabButton> <TabButton isActive={tab === 'posts'} onClick={() => selectTab('posts')} isPending={isPending && tab !== 'posts'} > Posts (slow) </TabButton> <div style={{ opacity: isPending ? 0.7 : 1 }}> {tab === 'about' && <AboutTab />} {tab === 'posts' && <PostsTab />} {/* 500개의 아이템 렌더링 */} </div> </> ); }
useTransition의 반환값
| 반환값 | 타입 | 설명 |
|---|---|---|
isPending | boolean | transition이 진행 중인지 여부 |
startTransition | function | 상태 업데이트를 transition으로 감싸는 함수 |
startTransition (독립 함수)
컴포넌트 외부에서 transition을 사용해야 할 때는 독립적인 startTransition 함수를 사용합니다:
import { startTransition } from 'react'; // 라이브러리 코드나 이벤트 핸들러 외부에서 startTransition(() => { setState(newState); });
차이점:
useTransition:isPending상태 제공, 컴포넌트 내부에서만 사용startTransition:isPending없음, 어디서든 사용 가능
useDeferredValue: 값 자체를 지연시키기
useDeferredValue는 상태 업데이트 코드에 접근할 수 없을 때 사용합니다. 값 자체를 "지연된 버전"으로 만들어줍니다.
import { useState, useDeferredValue, Suspense } from 'react'; function SearchPage() { const [query, setQuery] = useState(''); const deferredQuery = useDeferredValue(query); // query !== deferredQuery면 아직 새 결과를 계산 중 const isStale = query !== deferredQuery; return ( <> <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." /> <Suspense fallback={<Loading />}> <div style={{ opacity: isStale ? 0.7 : 1 }}> <SearchResults query={deferredQuery} /> </div> </Suspense> </> ); }
useTransition vs useDeferredValue
┌─────────────────────────────────────────────────────────────┐ │ useTransition │ │ │ │ "I control the state update" │ │ │ │ ┌─────────────────────────────────────┐ │ │ │ startTransition(() => { │ │ │ │ setState(newValue); ◄── wrap │ │ │ │ }); │ │ │ └─────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────┐ │ useDeferredValue │ │ │ │ "I only have access to the value" │ │ │ │ ┌─────────────────────────────────────┐ │ │ │ const deferred = useDeferredValue( │ │ │ │ props.value ◄── wrap the value │ │ │ │ ); │ │ │ └─────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘
| 특성 | useTransition | useDeferredValue |
|---|---|---|
| 감싸는 대상 | setState 호출 | 값(value) |
| 사용 시점 | 상태 업데이트 코드 접근 가능 | props로 받은 값만 있을 때 |
| 반환값 | [isPending, startTransition] | deferredValue |
| 주 용도 | 탭 전환, 폼 제출 등 | 검색 필터링, 목록 업데이트 |
React 19의 use 훅
React 19에서 도입된 use는 Promise나 Context를 읽을 수 있는 새로운 API입니다. 기존 훅들과 달리 조건문이나 반복문 안에서도 사용할 수 있습니다.
Promise와 함께 사용
import { use, Suspense } from 'react'; function Message({ messagePromise }: { messagePromise: Promise<string> }) { // Promise가 resolve될 때까지 자동으로 suspend const message = use(messagePromise); return <p>{message}</p>; } function App() { const [messagePromise, setMessagePromise] = useState<Promise<string> | null>(null); const fetchMessage = () => { setMessagePromise( new Promise((resolve) => setTimeout(() => resolve('Hello from server!'), 1000) ) ); }; return ( <> <button onClick={fetchMessage}>Fetch Message</button> {messagePromise && ( <Suspense fallback={<p>Loading...</p>}> <Message messagePromise={messagePromise} /> </Suspense> )} </> ); }
Context와 함께 사용
use는 조건부로 Context를 읽을 수 있게 해줍니다:
import { use, createContext } from 'react'; const ThemeContext = createContext('light'); function ThemedButton({ showTheme }: { showTheme: boolean }) { // 조건부로 Context 읽기 - useContext로는 불가능! if (showTheme) { const theme = use(ThemeContext); return <button className={theme}>Themed Button</button>; } return <button>Regular Button</button>; }
use vs useContext vs await
┌────────────────────────────────────────────────────────────┐ │ Feature Comparison │ ├────────────────────────────────────────────────────────────┤ │ │ │ useContext: │ │ - Context only │ │ - Cannot be conditional │ │ - Must be at top level │ │ │ │ use: │ │ - Context OR Promise │ │ - CAN be conditional │ │ - Can be in loops/conditions │ │ - Re-renders component when Promise resolves │ │ │ │ async/await (Server Components): │ │ - Promise only │ │ - Continues from where await was │ │ - Preferred in Server Components │ │ │ └────────────────────────────────────────────────────────────┘
Suspense와 Transition의 조합
Suspense와 Transition을 함께 사용하면 더 세밀한 로딩 UX를 구현할 수 있습니다:
import { Suspense, useState, useTransition } from 'react'; function ProfilePage() { const [tab, setTab] = useState('posts'); const [isPending, startTransition] = useTransition(); return ( <> <TabBar tabs={['posts', 'photos', 'videos']} currentTab={tab} onSelect={(nextTab) => { startTransition(() => { setTab(nextTab); }); }} /> {/* Transition 중에는 이전 콘텐츠를 유지하고 opacity만 조절 */} <div style={{ opacity: isPending ? 0.7 : 1 }}> <Suspense fallback={<TabSkeleton />}> {tab === 'posts' && <PostsTab />} {tab === 'photos' && <PhotosTab />} {tab === 'videos' && <VideosTab />} </Suspense> </div> </> ); }
이 패턴의 장점:
- 탭 전환 시 즉시 로딩 스피너가 뜨지 않음
- 이전 콘텐츠를 보여주면서 새 콘텐츠 준비
isPending으로 "준비 중" 상태를 시각적으로 표시
실전 예제: 검색 자동완성
모든 개념을 조합한 실전 예제입니다:
import { Suspense, useState, useDeferredValue, useTransition, use } from 'react'; // API 호출을 캐시하는 간단한 구현 const cache = new Map<string, Promise<string[]>>(); function fetchSuggestions(query: string): Promise<string[]> { if (!cache.has(query)) { cache.set(query, fetch(`/api/suggestions?q=${query}`) .then(res => res.json()) ); } return cache.get(query)!; } function Suggestions({ query }: { query: string }) { const suggestions = use(fetchSuggestions(query)); if (suggestions.length === 0) { return <p>No results found</p>; } return ( <ul> {suggestions.map((item, i) => ( <li key={i}>{item}</li> ))} </ul> ); } function SearchBox() { const [input, setInput] = useState(''); const deferredInput = useDeferredValue(input); const isStale = input !== deferredInput; return ( <div> <input value={input} onChange={(e) => setInput(e.target.value)} placeholder="Type to search..." /> {deferredInput && ( <div style={{ opacity: isStale ? 0.5 : 1, transition: 'opacity 0.2s' }}> <Suspense fallback={<div>Searching...</div>}> <Suggestions query={deferredInput} /> </Suspense> </div> )} </div> ); }
주의사항과 모범 사례
1. 모든 곳에 Transition을 사용하지 말 것
// Bad - 불필요한 transition startTransition(() => { setUsername(e.target.value); // 입력 필드는 즉시 업데이트되어야 함 }); // Good - 무거운 업데이트만 transition으로 setUsername(e.target.value); // 즉시 업데이트 startTransition(() => { setSearchResults(filterResults(e.target.value)); // 무거운 작업 });
2. Suspense는 데이터 페칭 라이브러리와 함께
직접 wrapPromise를 구현하기보다 React Query, SWR 등의 라이브러리 사용을 권장합니다:
// React Query와 Suspense const { data } = useSuspenseQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), });
3. Server Components에서는 async/await 선호
// Server Component - async/await 사용 async function UserProfile({ userId }: { userId: string }) { const user = await fetchUser(userId); // use 대신 await return <div>{user.name}</div>; } // Client Component - use 훅 사용 'use client'; function UserProfile({ userPromise }: { userPromise: Promise<User> }) { const user = use(userPromise); return <div>{user.name}</div>; }
정리
| 기능 | 용도 | 핵심 포인트 |
|---|---|---|
| Suspense | 비동기 렌더링 경계 | Promise throw 패턴, fallback UI |
| useTransition | 낮은 우선순위 업데이트 | isPending으로 진행 상태 표시 |
| useDeferredValue | 값의 지연 버전 | props 값만 있을 때 사용 |
| use | Promise/Context 읽기 | 조건부 사용 가능, React 19+ |
Concurrent 렌더링의 핵심은 우선순위입니다. 사용자 입력 같은 급한 업데이트와 목록 필터링 같은 덜 급한 업데이트를 구분하여, 항상 반응성 있는 UI를 유지하는 것이 목표입니다.