YKSS
리액트 서스펜스의 내부 작동 원리: 프로미스를 던지기와 선언적 비동기 UI
Suspense 는 "프로미스를 throw" 하는 기묘한 메커니즘이다. React 19 의 use 훅과 ErrorBoundary 까지 묶어, 비동기 UI 를 선언적으로 처리하는 표준 흐름을 정리한다.
핵심 요약
React Suspense 는 비동기 UI 의 로딩/에러 상태를 선언적으로 처리한다. 동작 원리: 컴포넌트가 아직 준비 안 된 데이터에 접근하면 프로미스를 throw, 리액트가 catch 해 가장 가까운 <Suspense> fallback 을 렌더, 프로미스가 resolve 되면 다시 렌더. React 19 의 use(promise) 훅은 이 패턴의 공식 API다. 프로미스가 reject 되면 가장 가까운 ErrorBoundary 가 잡는다. 선언적·조합 가능·확장 가능·미래 지향적이라는 네 가지 장점이 있고, 캐시 도입 후엔 진입할 때마다 새 fetch 가 일어나지 않게 만든다.
리액트가 "렌더 도중 프로미스를 throw 하면 가장 가까운 <Suspense> 가 잡아서 fallback 을 그린다"는 한 문장이 핵심. 동기 코드가 비동기 데이터를 "기다리는 척" 할 수 있는 트릭이며, try/catch 가 함수 호출을 "즉시 중단"하는 자바스크립트 본연의 동작을 활용한 것이다.
면접에서 "Suspense 어떻게 동작하나요?" 답변에 "fallback 보여준다" 이상을 말할 수 있어야 한다. throw 메커니즘 + use 훅 + ErrorBoundary 의 조합이 React 19 비동기 UI의 표준이며, RSC·서스펜스 가드·렌더-애즈-유-페치 같은 후속 개념이 모두 이 위에 올라간다.
학습 포인트
면접 답변으로 연결할 학습 포인트입니다.
왜 "프로미스를 throw" 하는가
자바스크립트 throw 는 함수 호출을 동기적으로 중단시킨다. 리액트는 렌더 함수에서 throw 된 값이 프로미스이면 "이 컴포넌트는 아직 준비 안 됨"이라 해석하고 가장 가까운 <Suspense> 의 fallback 을 그린다.
function PhoneDetails() {
const details = use(phoneDetailsPromise);
// 여기 도달했다는 건 details 가 준비됐다는 뜻
}
"Suspense 는 로딩 컴포넌트일 뿐"이라는 단순화. 진짜는 throw/catch 메커니즘으로 렌더 흐름을 제어하는 트릭이다.
프로미스 위치는 "렌더 함수 바깥"
// 잘못: 매 렌더마다 새 fetch
function Bad() {
const p = fetch('/api').then(r => r.json());
return <Inner promise={p} />;
}
// 올바름: 모듈/이벤트 스코프
const phoneDetailsPromise = fetch('/api/phone-details').then(r => r.json());
프로미스가 렌더 함수 안에서 만들어지면 매 렌더마다 새 요청·새 참조가 되어 무한 루프 같은 현상.
컴포넌트 본문에서 fetch 호출. 캐시 없이 use 에 넣으면 무한 fallback.
`Suspense` + `ErrorBoundary` 조합
로딩과 에러는 다른 메커니즘이지만 같은 자리에서 묶는다.
<ErrorBoundary fallback={<div>문제가 발생했습니다.</div>}>
<Suspense fallback={<div>로딩 중...</div>}>
<PhoneDetails />
</Suspense>
</ErrorBoundary>
resolve → 정상 렌더, pending → fallback, reject → ErrorBoundary 셋이 모두 자동.
에러 처리 안 하고 Suspense 만 두기. reject 된 프로미스는 가장 가까운 ErrorBoundary 가 없으면 앱 전체로 전파.
캐시가 있어야 실용적
let userPromise;
function fetchUser() {
userPromise = userPromise ?? fetch('/api/user').then(r => r.json());
return userPromise;
}
React Query/TanStack Query/SWR/react-cache 같은 도구가 "같은 키 → 같은 프로미스" 를 보장해 fallback 깜빡임과 중복 요청을 막는다.
직접 모듈 변수에 캐시 두면 사용자별 데이터에 부적절. 키 기반 캐시가 안전하다.
읽는 순서
- 1이론
리액트의 렌더 사이클, throw 메커니즘,
Suspense/ErrorBoundary의 책임 분담을 정리. - 2구현
캐시 없는
use(promise)가 어떻게 무한 fallback 을 일으키는지 직접 만들어 본 뒤, 모듈 변수 캐시 → React Query 키 캐시로 단계 개선. - 3실무
기존
useEffect+isLoading패턴 1개 화면을Suspense+use로 마이그레이션. fallback/에러/depth 별Suspense경계 설계. - 4설명
"throw 가 어떻게 비동기 UI 를 가능하게 하는가" 를 도식 한 장으로 설명할 수 있게 정리.
면접 연결 질문
[감점 답변] "로딩 fallback 을 보여준다" 만. [좋은 답변] (1) 컴포넌트가 준비 안 된 데이터에 접근하면 프로미스를 throw, (2) 리액트가 catch 해 가장 가까운 <Suspense> fallback 렌더, (3) 프로미스 resolve 시 다시 렌더, reject 시 ErrorBoundary. 자바스크립트 throw 가 동기 함수 흐름을 즉시 중단 한다는 사실을 활용한 트릭이라는 점이 핵심.
[감점 답변] "코드가 짧다". [좋은 답변] (1) 선언적: 로딩·에러 상태를 컴포넌트 안에서 직접 관리하지 않음. (2) 조합 가능: Suspense/ErrorBoundary 와 자연스럽게 결합. (3) 렌더-애즈-유-페치 호환: 라우트 로더에서 시작한 프로미스를 그대로 받기. (4) 조건부 호출 가능: hooks 규칙의 예외로 조건부 호출이 허용된다.
[감점 답변] "빨리 끝나면 된다". [좋은 답변] (1) 요청 중복 제거: 같은 키 → 같은 프로미스 (React Query 등). (2) useTransition 으로 전환을 "낮은 우선순위"로 표시해 이전 UI 유지. (3) unstable_Activity 로 첫 렌더 결과 보관. (4) 라우트 prefetch + 캐시. "trickle 깜빡임"의 원인을 분리해서 답하면 깊이가 보인다.
자기 점검
"렌더마다 새 프로미스라도 결과는 같다"는 오해. 참조가 다르면 use 가 매번 throw 한다.
둘이 같은 일을 한다고 생각. 실제로는 "비동기 대기" vs "실패 복구"라는 다른 도메인.