FEInterview Prep

YKSS

리액트 서스펜스의 내부 작동 원리: 프로미스를 던지기와 선언적 비동기 UI

Suspense 는 "프로미스를 throw" 하는 기묘한 메커니즘이다. React 19 의 use 훅과 ErrorBoundary 까지 묶어, 비동기 UI 를 선언적으로 처리하는 표준 흐름을 정리한다.

2025-08-01·5분 읽기
React
원문 보기 ↗

핵심 요약

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 가 준비됐다는 뜻
}
throw promiseSuspense fallbackuse hook
자주 하는 오해

"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());

프로미스가 렌더 함수 안에서 만들어지면 매 렌더마다 새 요청·새 참조가 되어 무한 루프 같은 현상.

promise identitystable referencerequest cache
자주 하는 오해

컴포넌트 본문에서 fetch 호출. 캐시 없이 use 에 넣으면 무한 fallback.

`Suspense` + `ErrorBoundary` 조합

로딩과 에러는 다른 메커니즘이지만 같은 자리에서 묶는다.

<ErrorBoundary fallback={<div>문제가 발생했습니다.</div>}>
  <Suspense fallback={<div>로딩 중...</div>}>
    <PhoneDetails />
  </Suspense>
</ErrorBoundary>

resolve → 정상 렌더, pending → fallback, reject → ErrorBoundary 셋이 모두 자동.

ErrorBoundaryreact-error-boundarydeclarative async UI
자주 하는 오해

에러 처리 안 하고 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 깜빡임과 중복 요청을 막는다.

request deduplicationreact-cachestable key
자주 하는 오해

직접 모듈 변수에 캐시 두면 사용자별 데이터에 부적절. 키 기반 캐시가 안전하다.

읽는 순서

  1. 1이론

    리액트의 렌더 사이클, throw 메커니즘, Suspense/ErrorBoundary 의 책임 분담을 정리.

  2. 2구현

    캐시 없는 use(promise) 가 어떻게 무한 fallback 을 일으키는지 직접 만들어 본 뒤, 모듈 변수 캐시 → React Query 키 캐시로 단계 개선.

  3. 3실무

    기존 useEffect+isLoading 패턴 1개 화면을 Suspense + use 로 마이그레이션. fallback/에러/depth 별 Suspense 경계 설계.

  4. 4설명

    "throw 가 어떻게 비동기 UI 를 가능하게 하는가" 를 도식 한 장으로 설명할 수 있게 정리.

면접 연결 질문

medium`Suspense` 가 동작하는 원리를 "throw" 키워드까지 사용해 설명해주세요.
힌트

[감점 답변] "로딩 fallback 을 보여준다" 만. [좋은 답변] (1) 컴포넌트가 준비 안 된 데이터에 접근하면 프로미스를 throw, (2) 리액트가 catch 해 가장 가까운 <Suspense> fallback 렌더, (3) 프로미스 resolve 시 다시 렌더, reject 시 ErrorBoundary. 자바스크립트 throw 가 동기 함수 흐름을 즉시 중단 한다는 사실을 활용한 트릭이라는 점이 핵심.

medium`use` 훅이 기존 `useEffect + useState` 패턴 대비 갖는 장점은?
힌트

[감점 답변] "코드가 짧다". [좋은 답변] (1) 선언적: 로딩·에러 상태를 컴포넌트 안에서 직접 관리하지 않음. (2) 조합 가능: Suspense/ErrorBoundary 와 자연스럽게 결합. (3) 렌더-애즈-유-페치 호환: 라우트 로더에서 시작한 프로미스를 그대로 받기. (4) 조건부 호출 가능: hooks 규칙의 예외로 조건부 호출이 허용된다.

hard`Suspense` fallback 이 매번 깜빡이는 문제를 어떻게 해결하나요?
힌트

[감점 답변] "빨리 끝나면 된다". [좋은 답변] (1) 요청 중복 제거: 같은 키 → 같은 프로미스 (React Query 등). (2) useTransition 으로 전환을 "낮은 우선순위"로 표시해 이전 UI 유지. (3) unstable_Activity 로 첫 렌더 결과 보관. (4) 라우트 prefetch + 캐시. "trickle 깜빡임"의 원인을 분리해서 답하면 깊이가 보인다.

자기 점검

프로미스를 컴포넌트 본문에서 만들면 왜 안 되는지 한 줄로 답해보세요.
매 렌더 새 참조무한 fallback캐시 없음
자주 하는 오해

"렌더마다 새 프로미스라도 결과는 같다"는 오해. 참조가 다르면 use 가 매번 throw 한다.

`Suspense` 와 `ErrorBoundary` 가 서로 다른 컴포넌트인 이유는?
로딩과 에러는 다른 의미fallback 분리선언적 분리
자주 하는 오해

둘이 같은 일을 한다고 생각. 실제로는 "비동기 대기" vs "실패 복구"라는 다른 도메인.