글쓰기 프리뷰
    React Suspense와 Concurrent 렌더링 이해하기

    React Suspense와 Concurrent 렌더링 이해하기

    (수정: 2025년 12월 29일 오후 01:33)

    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; // 데이터 반환 } } }; }

    이 패턴의 동작 흐름:

    1. 컴포넌트가 렌더링되면서 resource.read() 호출
    2. 데이터가 아직 없으면 (pending) Promise를 throw
    3. React가 throw된 Promise를 catch하고 fallback UI 표시
    4. Promise가 resolve되면 React가 컴포넌트 다시 렌더링
    5. 이번에는 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의 반환값

    반환값타입설명
    isPendingbooleantransition이 진행 중인지 여부
    startTransitionfunction상태 업데이트를 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 │ │ │ │ ); │ │ │ └─────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘
    특성useTransitionuseDeferredValue
    감싸는 대상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 값만 있을 때 사용
    usePromise/Context 읽기조건부 사용 가능, React 19+

    Concurrent 렌더링의 핵심은 우선순위입니다. 사용자 입력 같은 급한 업데이트와 목록 필터링 같은 덜 급한 업데이트를 구분하여, 항상 반응성 있는 UI를 유지하는 것이 목표입니다.


    참고 자료

    Dunde's Portfolio

    © 2026 Dunde. All rights reserved.

    Built with React, TypeScript, and Vite. Deployed on GitHub Pages.