[서론]
1. [Suspense의 등장]
Suspense는 비동기 데이터를 다루는 보일러플레이트를 해결하기 위해 나왔다. 데이터를 받아오는 동안 로딩을 띄우고, 에러가 나오면 그에 맞는 UI를 띄우는 작업을 말이다. 아래 예시를 보자.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetchUser(userId)
.then(data => { setUser(data); setLoading(false); })
.catch(err => { setError(err); setLoading(false); });
}, [userId]);
if (loading) return <Spinner />; // 핵심로직
if (error) return <ErrorCard error={error} />; // 핵심로직
return <UserCard user={user} />; // 핵심로직
}
마지막 핵심로직에 비해 그 위에 loading/error 관리 코드가 컴포넌트를 덮고 있다. 컴포넌트가 10개면 이 패턴을 10개를 만들어야 한다는 의미이다. Suspense는 이 상태관리의 책임을 외부로 위임하고 컴포넌트는 핵심로직에 집중할 수 있도록 하기 위해 만들어졌다.
2. [Suspense의 사용]
아래는 React 19 버전식 Suspense 예제이다. Suspense는 Children에 필요한 코드와 데이터를 로딩할 때까지 Fallback을 보여주게 된다. Children이 지연되면 가장 가까운 Boundery의 Suspense가 활성화된다. use()에 대해 궁금할 텐데 일단 여기선 비동기 작업(Promise)의 결과를 마치 동기 코드처럼 처리해주고 있다고 이해하라.
import { use } from "react";
function UserProfile({ userId }) {
const user = use(fetchUser(userId));
return <UserCard user={user} />; // 핵심로직만!
}
function App() {
return (
<Suspense fallback={<Spinner />}>
<ErrorBoundary fallback={<ErrorCard error={error} />}>
<UserProfile userId={1} />
</ErrorBoundary>
</Suspense>
);
}
의문이 생긴다. 어떻게 Suspense는 비동기 상태를 읽어들이는 것일까? 아무 비동기 상태나 되는 것인가?
그렇지 않다. React 공식문서에 따르면 “Suspense가 가능한 데이터만이 Suspense 컴포넌트를 활성화합니다.”라고 되어있다.
따라서 React 개발팀에서 정해둔 기준이 있는 것이다. (코드는 마법이 아니다.) 그 기준을 소개해보겠다.
[본론]
1. [동작원리에 대하여]
1.1. [Suspense는 Wakeable을 throw하는 것으로 통신한다]
Suspense는 하나의 통신에 기반한다. React 내부에서 Suspense가 비동기 요청과 통신하는 방법은 Wakeable을 throw하는 것이다.
컴포넌트가 아직 준비되지 않았을 때 Wakeable을 throw하면, React가 가로채서 가장 가까운 Suspense 경계의 fallback을 보여준다. 그리고 Wakeable이 resolve되면 children을 다시 렌더링한다.
무슨 말인지 모르겠을 테지만 일단 쭉 읽어보아라. 아무 비동기 요청이나 처리하는 마법은 없으니 Suspense가 처리할 요청을 React팀에서 정의한 것이다. 단순한 비동기 요청과는 차이를 둔 Wakeable 개념을 소개해보겠다.
[Thenable, Promise 그리고 Wakeable]
각 개념은 서로 계층적인 관계가 있다.
1. Thenable
Thenable은 JS에서 가장 넓은(loose한) 개념인데, `then()` 메서드를 가진 객체라고 생각하면 다.
{ then(onFulfill, onReject) { ... } } 구조를 가졌다면 모두 Thenable이다. 상태의 정의는 개발자의 몫이나, Promise와 호환을 보장하는 레벨이라고 이해하자.
2. Promise
Promise는 Thenable의 특수한 경우라 할 수 있겠다. Promise/A+ 스펙을 준수하는 객체이다. 상태(pending → fulfilled/rejected)가 한 번 정해지면 불변이고, then() 콜백은 항상 microtask 큐로 비동기 실행되는 엄격한 규칙을 따른다는 것이다.
3. Wakeable
Wakeable은 React가 Suspense 상태를 제어하기 위해 만든 Promise의 최소 인터페이스이다.
네이티브 Promise와는 달리, React가 비동기 요청의 주체에게 "지금 준비된 거야?"라고 질문(ping)할 수 있어야 한다.
Thenable과 Promise는 "언제 완료될지" React가 모르나, Wakeable은 React가 주도권을 가질 수 있도록 설계한 것이다.
Wakeable은 React에 전달만 되면 되기 때문에 가벼운 인터페이스를 유지한다. 이로 얻는 장점이 있는데:
- 성능: 네이티브 Promise는 스펙상 콜백을 항상 마이크로태스크 큐에 넣는다.
반면 React Flight 같이 성능이 중요한 영역에서 Wakeable(가짜 Promise)를 이용해서 비용을 줄일 수 있다. - 유연성: 네이티브 Promise는 생성 시점에서 실행자가 즉시 실행된다. 반면 Wakeable은 커스텀하여 제어할 수 있다. React에게 주도권을 쥐어주기 위한 용도이니 훨씬 React에 유용하다.
- 가벼움: Wakeable은 ping을 할 수 있는 최소 인터페이스이다. reconciler가 throw된 객체에서 `then(onFulfill, onReject)` 콜백만 연결하면 된다. 나머지 Promise의 메서드는 신경 쓰지 않아도 된다.
이 설명을 들으면 "난 Suspense를 쓸 때 Wakeable한 Promise를 호출해서 쓰지 않았는데"라는 생각이 들 수도 있는데, React가 당신이 만든 Promise를 Wakeable 객체로 취급(duck typing)했기 때문이다. 이 과정에 대해 알아보자.
1.2. [React는 throw된 값이 Wakeable인지 판별한다]
React는 컴포넌트를 렌더링할 때 fiber 트리를 위에서 아래로 내려가며 하나씩 처리한다. 이 내려가는 과정을 begin phase라 부른다. Suspense를 만나면 그 안으로 들어가 children을 렌더링하기 시작한다. 이 상태에서 컴포넌트가 무언가를 throw하면 workloop의 try...catch가 이것을 잡는다. 그리고 이것이 Suspense를 위한 Wakeable인지, 진짜 에러인지 조사한다. throw는 원래 에러를 던지기 위해 만든 것이기 때문이다.
실제 코드를 보면 알 수 있다. React는 instanceof Promise로 판단하지 않는다. then이 붙어있으면 Wakeable로 취급한다.
반대로 then이 없는 것이 throw되면 에러로 취급해 ErrorBoundary 경로로 흘러간다.
// ReactFiberThrow.js
function throwException(root, returnFiber, sourceFiber, value, rootRenderLanes) {
sourceFiber.flags |= Incomplete;
if (
value !== null &&
typeof value === 'object' &&
typeof value.then === 'function' // ← 이 조건 하나가 전부
) {
// Wakeable로 취급 → Suspense 처리 경로
const wakeable = value;
// ...
} else {
// 일반 에러 → ErrorBoundary 경로
}
}
참고로 Duck Typing은 "오리처럼 걷고, 오리처럼 꽥꽥 소리를 낸다면 오리다"라는 의미이다. 개발자들은 귀여운 거 좋아한다.
Wakeable로 판별됐다. 지금 React는 Suspense 안쪽 깊은 곳, throw가 발생한 지점에 있다. 이제 이 throw를 처리할 Suspense를 찾아야 한다.
1.3. [가장 가까운 Suspense Boundary를 찾아 ShouldCapture를 세운다]
getNearestSuspenseBoundaryToCapture()가 throw가 발생한 fiber의 return 포인터를 타고 조상을 거슬러 올라가며 가장 가까운 Suspense fiber를 직접 찾아낸다. 이로 인해 여러 Suspense가 중첩되어 있어도 가장 안쪽 경계가 먼저 잡힌다. (여기서 fiber라는 개념이 생소할 수 있는데 React는 모든 것을 fiber로 표현한다고 생각하라. 당신이 만든 Component도 모두 Fiber화 되어있다. 부모-자식 컴포넌트는 fiber의 return 포인터로 연결되어 있다.)
Suspense fiber를 찾으면 그 fiber에 직접 ShouldCapture 플래그를 세운다. "이 Suspense가 이번 suspend를 처리해야 한다"는 접수 신호다. 바로 fallback을 렌더링하지 않는 이유가 있다. 지금은 아직 begin phase 도중이라 자식 평가가 끝나지 않은 상태다. 이 시점에 fallback 렌더링을 확정하면 불완전한 상태에서 진행하게 된다. 실제 확정은 이후 complete phase에서 이루어진다.
const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber);
if (suspenseBoundary !== null) {
markSuspenseBoundaryShouldCapture(
suspenseBoundary, returnFiber, sourceFiber, root, rootRenderLanes
);
}
1.4. [Wakeable이 끝나면 알려줄 리스너를 등록한다]
Suspense가 처리할 요청이 예약됐다. 이제 언제 그 요청이 끝날지 알아야 한다. React가 주기적으로 상태를 확인하는 것이 아니라, Promise 측에서 상태를 알려주는 구조다. 옵저버 패턴이다.
이를 위해 두 개의 리스너를 등록한다. ping은 "스케줄링할 작업이 생겼다는 신호"를 보내는 것이고, retry는 "이 특정한 Suspense가 대상이라는 정보"다. React가 <언제를 결정하는 스케줄링 레이어>와 <어디를 결정하는 렌더링 타겟 레이어>를 명확히 나누었기 때문이다.
스케줄링 레이어는 우선순위에 따라 렌더링 순서를 조정할 뿐이다. 대상이 작업 순서에 오면 그때 타겟 레이어를 확인하여 무엇을 해야 할지 정보를 확인한다.
if (suspenseBoundary.mode & ConcurrentMode) {
attachPingListener(root, wakeable, rootRenderLanes); // 언제
}
attachRetryListener(suspenseBoundary, root, wakeable); // 어디
[ping]
attachPingListener()의 역할은 React 스케줄러에게 "이 root에서 대기 중이던 Suspense 작업이 끝났으니, 해당 lanes 우선순위로 리렌더링을 스케줄링해라"라는 신호를 보낸다.
코드를 보면 알 수 있는 한 가지 사실은 Cache가 있다. 한번 Listen한 Wakeable를 중복 등록하지 않게 WeakMap을 만들어놨다.
function attachPingListener(root, wakeable, lanes) {
let pingCache = root.pingCache;
// ... (WeakMap으로 중복 방지)
const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes);
wakeable.then(ping, ping); // resolve든 reject든 ping 호출
}
여기서 주목할 점은 resolve callback과 reject callback 모두 ping으로 설정해둔 점이다. Suspense의 관점에서는 성공했건 실패했건 중요하지 않다. "Promise가 끝나면 다시 렌더링을 시도해야 한다"는 것이다. 성공인지 실패인지는 다음 렌더링의 역할이다.
[retry]
ping이 "언제 다시 시작할지"라면, retry는 "어디서부터 다시 시작할지"다. 1.3에서 ShouldCapture를 세운 Suspense fiber를 retry가 기억해둔다. Promise가 resolve된 이후 실제로 동작하는데, 자세한 동작은 1.6에서 다루겠다.
Wakeable 판별, Boundary 탐색, ShouldCapture 세팅, 리스너 등록이 모두 끝났다. 지금까지가 전부 begin phase 도중 일어난 일이다. React가 children을 렌더링하러 내려가다가 throw가 터졌고, 그 자리에서 처리할 수 있는 것들을 해뒀다. 이제 begin phase에서 할 수 없었던 것, 즉 경로 정리와 fallback 렌더링 확정을 위해 complete phase로 넘어가야 한다.
1.5. [ShouldCapture를 DidCapture로 전환하고 fallback을 렌더링한다]
begin phase에서 내려가던 렌더링이 throw로 중단됐다. 이제 complete phase가 시작된다. React는 begin phase에서 내려간 경로를 올라오며 마무리하는데, throw가 발생했으니 정상적인 완료가 아니다. workloop는 completeUnitOfWork를 호출하고, 이것이 Stack Unwinding의 시작이다.
1. [Stack Unwinding]
completeUnitOfWork는 throw가 발생한 fiber부터 위로 올라가며 경로상의 모든 fiber에 Incomplete를 마킹한다. "이 작업은 중단되었음. 자식이 다 못 그려졌기 때문."이라는 표시다. 이 과정에서 미완성 노드들이 실제 DOM에 반영되는 것을 막을 수 있다.
올라오다가 1.3에서 ShouldCapture를 세워둔 Suspense fiber에 도달한다. 이것이 Suspense의 complete phase다. 이 순간 ShouldCapture를 DidCapture로 전환하고 멈춘다.
ShouldCapture가 "throw 잡았어, 내가 처리할게"라는 접수 신호였다면, DidCapture는 "경로 정리까지 다 끝났어, 이제 fallback 렌더링해도 돼"라는 수습 완료 신호다. ShouldCapture는 begin phase 도중 세워진 것이라 자식 평가가 끝나지 않은 불완전한 상태였다. DidCapture는 complete phase에서 경로를 전부 정리한 뒤 세워지는 것이라 이제 안전하게 fallback 렌더링을 확정할 수 있다.
2. [fallback 렌더링]
workloop는 반환된 Suspense부터 다시 begin phase를 시작한다. DidCapture 플래그를 확인하고, children 대신 fallback 브랜치로 진입한다.
const didSuspend = (workInProgress.flags & DidCapture) !== 0;
if (didSuspend) {
return mountSuspenseFallbackChildren(...); // fallback 렌더링
} else {
return mountSuspensePrimaryChildren(...); // children 렌더링
}
DidCapture는 일회용이다. fallback을 렌더링하고 나면 제거된다. 이후 retry로 재렌더링이 트리거될 때 DidCapture가 없으므로 children 브랜치로 정상 진입한다.
[children은 fiber 트리에서 사라지지 않는다]
fallback이 화면에 나타나는 동안 children은 DOM에서 제거되지만 fiber 트리에서는 살아있다. Suspense는 내부적으로 children을 Offscreen 컴포넌트로 감싸서 숨긴 채로 유지한다. 마치 `display: none`처럼 화면에서만 안 보이는 것이지, 존재 자체가 사라진 게 아니다.
1.6. [Promise가 resolve되면 retry가 children을 다시 렌더링한다]
이제 fallback이 화면에 나타났다. Promise가 resolve되면 ping이 스케줄러를 깨우고, 스케줄러가 적절한 시점에 렌더링을 다시 시작한다. 이때 1.4에서 등록해둔 retry가 "어디서부터 다시 시작할지"를 알려준다. React는 root 전체를 처음부터 다시 렌더링하지 않는다. retry가 가리키는 Suspense fiber부터 reconciling을 재시작한다.
이 재시도에서 children 컴포넌트가 다시 실행된다. 이번엔 캐시에 데이터가 있으므로 throw 없이 정상적으로 값을 반환한다. DidCapture가 없으므로 children 브랜치로 정상 진입하고, DOM에서 fallback이 제거되고 children이 나타난다.
자 여기까지 Suspense의 동작원리에 대해 알아보았다. 이제 서론에서 간단히 설명했던 Suspense의 사용에 동작원리를 더해보자.
2.1. [서론의 코드가 동작하는 이유]
서론에서 이런 코드를 소개하였다.
import { use } from "react";
function UserProfile({ userId }) {
const user = use(fetchUser(userId));
return <UserCard user={user} />; // 핵심로직만!
}
function App() {
return (
<Suspense fallback={<Spinner />}>
<ErrorBoundary fallback={<ErrorCard error={error} />}>
<UserProfile userId={1} />
</ErrorBoundary>
</Suspense>
);
}
use()에 대해 자세히 소개해보겠다.
앞서 Wakeable은 React가 비동기 요청의 주도권을 갖기 위해 설계한 인터페이스라고 했다. 그런데 개발자 입장에서 Wakeable을 직접 다루려면 pending인지 확인하고, pending이면 throw하고, resolve됐으면 값을 꺼내는 분기 처리를 매번 직접 작성해야 한다.
그 주도권을 개발자가 편하게 쓸 수 있도록 React 19에서 공식 API로 노출한 것이 use()다.
use()는 Promise를 받아서 아직 pending이면 throw하고, resolve됐으면 값을 반환한다.
function use(promise) {
if (promise.status === 'pending') throw promise; // → Suspense로
if (promise.status === 'rejected') throw promise.reason; // → ErrorBoundary로
return promise.value; // → 정상 반환
}
자 이제 이걸 알았으면, 동작을 전개해 보자.
fetchUser(userId)가 pending Promise를 반환하면 use()가 그것을 throw한다. 1.2번에서 봤듯이 React는 throw된 값에 then이 붙어있으면 Wakeable로 판별한다. Promise는 then을 가지고 있으니 당연히 Wakeable로 취급된다. React는 가장 가까운 Suspense 경계의 fallback을 보여준다.
fetchUser가 resolve되면 ping과 retry가 React를 깨운다. 컴포넌트가 다시 실행되고 이번엔 use()가 resolved된 값, 즉 유저 데이터를 반환한다. user 변수에 담기고, 비로소 <UserCard user={user} />가 화면에 나타난다.
서론의 코드가 동작한 이유는 use()가 pending/resolved/rejected 분기를 처리하고 Wakeable를 throw했기에 가능한 일이다.
예제 코드에는 사실 한 가지 문제가 있다. fetchUser(userId)가 컴포넌트 안에서 호출되고 있으니, 렌더링 때마다 Promise가 생긴다.
무한루프가 발생하는 것이다. 이것을 해결하고 넘어가 보자.
2.2. [무한루프를 막는 방법]
해결책은 단순하다. 렌더링마다 새 Promise를 만들지 않으면 된다. 같은 요청에 대해서는 항상 같은 Promise 객체를 반환해야 한다.
가장 간단한 방법은 Promise를 컴포넌트 바깥에서 만드는 것이다.
const userPromise = fetchUser(1); // 컴포넌트 바깥에서 한 번만 생성
function UserProfile() {
const user = use(userPromise); // 항상 같은 Promise
return <UserCard user={user} />;
}
그러나 이 방식은 userId가 바뀔 때 대응이 안 된다. 임시방편으로 useMemo로 개선할 수 있다.
function UserProfile({ userId }) {
const userPromise = useMemo(() => fetchUser(userId), [userId]);
const user = use(userPromise);
return <UserCard user={user} />;
}
userId가 같으면 같은 Promise를 유지하고, 바뀌면 새로 만든다. 무한루프는 막힌다.
여기서 useMemo를 임시방편이라고 소개한 두 가지 이유가 있는데,
1. React는 캐싱의 영속을 보장하지 않는다.
React useMemo 공식 문서에 "You may rely on useMemo as a performance optimization, not as a semantic guarantee."라고 적혀있다. 즉, React는 메모리 확보를 위해 언제든 useMemo에 저장된 값을 삭제할 수 있도록 설계되어 있다. React 설계 원칙상 useMemo는 값의 유지를 보장(Guarantee)하지 않으므로, 비동기 데이터의 무결성을 담보하기에는 구조적으로 위험하다.
2. useMemo가 컴포넌트 생명주기에 완전히 종속되어 있음. 확장의 어려움.
그러나 useMemo는 컴포넌트 인스턴스에 묶여있다. 컴포넌트가 언마운트되면 캐시가 사라지고, 다른 컴포넌트에서 같은 userId로 요청하면 새로 fetch한다. 거기에 "오래된 데이터를 다시 가져오고 싶다"는 요구사항이 생기는 순간 직접 구현해야 할 것들이 폭발적으로 늘어난다. stale 처리, 탭 포커스시 재검증, 네트워크 재연결시 갱신... 결국 직접 구현하다 보면 TanStack Query를 다시 만들게 된다.
그래서 실무에서는 useSuspenseQuery를 쓴다. queryKey를 기반으로 Promise를 컴포넌트 외부의 QueryClient에 캐싱하고, stale 처리와 재검증까지 알아서 해준다.
import { useSuspenseQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data: user } = useSuspenseQuery({
queryKey: ['user', userId], // 이 키로 Promise를 캐싱
queryFn: () => fetchUser(userId),
});
return <UserCard user={user} />;
}
data가 항상 정의되어 있음이 보장된다. 로딩 상태는 Suspense가, 에러 상태는 ErrorBoundary가 처리하기 때문이다. 서론에서 보여준 보일러플레이트 문제를 라이브러리 수준에서 완전히 해결한 것이다.
2.3. [Suspense를 트리거하는 다른 방법들]
지금까지는 use()로 Promise를 throw해서 Suspense를 트리거했다. 서론에서 "Suspense는 Children에 필요한 코드와 데이터를 로딩할 때까지 Fallback을 보여주게 된다."라고 말했는데 데이터를 기다리는 방법은 알아봤으니, 이제 코드를 기다리는 방법이다.
React가 처음부터 공식 지원한 방식, React.lazy다.
import { lazy, Suspense } from 'react';
// 이 컴포넌트의 코드는 실제로 렌더링될 때까지 로드되지 않음
const HeavyEditor = lazy(() => import('./HeavyEditor'));
function App() {
return (
<Suspense fallback={<div>에디터 로딩 중...</div>}>
<HeavyEditor />
</Suspense>
);
}
React.lazy의 동작은 use()와 동일한 개념이다. 그전에 import()에 대해 짚고 넘어가자.
import()는 JavaScript 언어 표준(ES2020)에 추가된 문법인데, 평소에 쓰는 static import와 달리 함수처럼 호출하고 Promise를 반환한다. 번들러(Webpack, Vite)는 import() 구문을 보고 해당 파일을 별도 청크로 분리한다.
static import → 빌드 타임에 하나의 번들로 합쳐짐
import() → 런타임에 필요할 때 별도 파일로 요청
React.lazy는 이 import()가 반환하는 Promise를 받아서 Wakeable로 throw하는 래퍼다. 컴포넌트 코드가 아직 로드되지 않았으면 Promise를 throw한다. React가 이것을 Wakeable로 판별하고, 가장 가까운 Suspense 경계의 fallback을 보여준다. 다운로드가 완료되면 ping과 retry가 React를 깨우고 컴포넌트를 렌더링한다.
여기서도 알 수 있듯이 Suspense는 하나의 통신에 기반하고, 방식은 Wakeable을 throw하는 것이다.
[결론]
어떻게 Suspense는 비동기 상태를 읽어 들이는 것일까? 아무 비동기 상태나 되는 것인가? 아무 비동기 상태나 되는 것이 아니다. Suspense는 Wakeable을 throw하는 것 하나에 기반한다. use()든, React.lazy든, TanStack Query의 useSuspenseQuery든 결국 내부에서 하는 일은 같다. Wakeable을 throw하고, React가 그것을 잡아 fallback을 보여주고, resolve되면 다시 렌더링한다.
'개발 공부' 카테고리의 다른 글
| OpenAPI만 주면 API 요청 함수를 생성해준다고? (0) | 2026.06.07 |
|---|---|
| JS 배열을 순회하는 함수들의 내부구조를 뜯어보자 (0) | 2026.04.29 |
| TSC는 Type Narrowing을 어떻게 처리하는 걸까 (1) | 2026.04.28 |