리액트 서스펜스의 작동 원리로 보는 프로미스 던지기와 선언적 비동기 UI의 이해

0

여러분이 리액트로 개발하면서 가장 골치 아픈 순간은 언제인가요? 아마도 데이터를 불러오는 동안 사용자에게 보여줄 로딩 스피너와 에러 처리를 구현할 때일 것입니다. 컴포넌트마다 loading, error 상태를 관리하다 보면 코드가 복잡해지고, 유지보수는 점점 어려워집니다.

하지만 리액트 서스펜스는 이런 고민을 완전히 새로운 방식으로 해결합니다. 놀랍게도 프로미스를 던지는(throwing promises) 방식으로 말이죠. 이번에는 이 혁신적인 메커니즘의 내부 작동 원리를 깊이 살펴보겠습니다.

리액트에서 비동기 UI가 어려운 이유

전통적인 리액트 개발에서 비동기 데이터를 처리하는 과정을 생각해보세요. 사용자 정보를 가져오는 컴포넌트를 만든다면, 대략 이런 모습일 것입니다:

function UserProfile() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetchUser()
      .then(setUser)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  return <div>안녕하세요, {user.name}님!</div>;
}

이 코드의 문제점은 명확합니다. 실제 비즈니스 로직은 마지막 한 줄뿐인데, 나머지는 모두 상태 관리 코드입니다. 컴포넌트가 늘어날수록 이런 보일러플레이트 코드는 기하급수적으로 증가하죠.

서스펜스: 선언적 비동기 UI의 혁명

리액트 서스펜스는 이 문제를 근본적으로 다른 접근법으로 해결합니다. 프로미스를 예외로 던지는 자바스크립트의 특성을 활용하는 것입니다.

핵심 작동 원리: 프로미스 던지기

서스펜스의 마법은 다음과 같이 작동합니다:

  • 컴포넌트가 아직 준비되지 않은 데이터에 접근하려 시도
  • 해당 컴포넌트가 프로미스를 throw
  • 리액트가 이 프로미스를 catch하여 렌더링 일시정지
  • 상위 <Suspense>의 fallback UI 표시
  • 프로미스가 resolve되면 다시 렌더링 시도

이것이 가능한 이유는 자바스크립트에서 throw를 통해 함수 실행을 동기적으로 중단할 수 있기 때문입니다. 리액트는 이 특성을 영리하게 활용하여 데이터가 준비될 때까지 렌더링을 “일시정지”하는 것입니다.

React 19의 use 훅: 서스펜스의 핵심

React 19에서 도입된 use 훅은 이 패턴의 핵심입니다. 기존의 await 대신 프로미스를 use에 전달하면, 상황에 따라 다르게 작동합니다:

function PhoneDetails() {
  const details = use(phoneDetailsPromise);
  // 이 시점에서 details는 이미 준비되어 있습니다!
  return <div>{details.model}</div>;
}

프로미스 생성과 관리의 중요성

여기서 중요한 포인트는 phoneDetailsPromise를 어디서 생성하느냐입니다. 렌더링 함수 내부에서 생성하면 매번 새로운 요청이 발생하므로, 반드시 외부에서 생성해야 합니다:

// 이벤트 핸들러나 적절한 위치에서 실행
const phoneDetailsPromise = fetch('/api/phone-details').then(res => res.json());

use 훅의 동작 방식:

  • 프로미스가 아직 resolve되지 않았다면 → 프로미스를 throw하여 서스펜스 트리거
  • 프로미스가 resolve되었다면 → 값을 그대로 반환

에러 처리: 서스펜스와 에러 바운더리의 조합

서스펜스의 또 다른 장점은 에러 처리의 선언적 접근입니다. 프로미스가 reject되면, 리액트는 자동으로 에러 바운더리를 찾아 해당 fallback UI를 렌더링합니다:

import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

function App() {
  return (
    <ErrorBoundary fallback={<div>문제가 발생했습니다.</div>}>
      <Suspense fallback={<div>휴대전화 정보를 불러오는 중...</div>}>
        <PhoneDetails />
      </Suspense>
    </ErrorBoundary>
  );
}

이 구조의 아름다움은 PhoneDetails 컴포넌트가 로딩이나 에러 상태를 전혀 신경 쓰지 않아도 된다는 점입니다. 순수하게 데이터가 있다는 가정 하에 UI 로직에만 집중할 수 있습니다.

간단한 서스펜스 데이터 페처

실제로 서스펜스를 활용한 데이터 페처를 구현해보겠습니다:

let userPromise;

function fetchUser() {
  // 캐시된 프로미스가 있으면 재사용, 없으면 새로 생성
  userPromise = userPromise ?? fetch('/api/user').then(res => res.json());
  return userPromise;
}

function UserInfo() {
  const user = use(fetchUser());
  return <div>안녕하세요, {user.name}님!</div>;
}

function App() {
  return (
    <Suspense fallback={<div>사용자 정보를 불러오는 중...</div>}>
      <UserInfo />
    </Suspense>
  );
}

고급 패턴: 매개변수가 있는 데이터 페처

실제 프로젝트에서는 더 복잡한 케이스가 필요합니다. 매개변수가 있는 데이터 페처를 구현해보겠습니다:

const promiseCache = new Map();

function fetchUserProfile(userId) {
  if (!promiseCache.has(userId)) {
    const promise = fetch(/api/users/${userId})
      .then(res => res.json());
    promiseCache.set(userId, promise);
  }
  return promiseCache.get(userId);
}

function UserProfile({ userId }) {
  const profile = use(fetchUserProfile(userId));
  return (
    <div>
      <h2>{profile.name}</h2>
      <p>{profile.bio}</p>
    </div>
  );
}

서스펜스 활용의 실제 사례

대시보드 애플리케이션

한 스타트업에서 관리자 대시보드를 개발할 때, 여러 API에서 데이터를 병렬로 가져와야 했습니다. 기존 방식이라면 각 컴포넌트마다 로딩 상태를 관리해야 했지만, 서스펜스를 활용하면서 코드 복잡도가 70% 이상 감소했습니다. 특히 중첩된 컴포넌트에서 각각 다른 API를 호출하는 상황에서 서스펜스의 장점이 극명하게 드러났습니다.

전자상거래 상품 상세 페이지

한 전자상거래 회사는 상품 상세 페이지에서 상품 정보, 리뷰, 재고 상태 등을 각각 다른 API에서 가져와야 했습니다. 서스펜스를 도입한 후, 개발자들은 각 섹션의 비즈니스 로직에만 집중할 수 있게 되었고, 로딩 상태 관리로 인한 버그가 현저히 줄어들었습니다.

서스펜스의 장점과 한계

주요 장점

  • 선언적 접근: 더 이상 loading, error 상태를 수동으로 관리할 필요가 없습니다. 데이터가 있다는 가정 하에 컴포넌트를 작성하면 됩니다.
  • 조합 가능성: 데이터뿐만 아니라 이미지, 코드 분할 등 모든 비동기 리소스와 함께 사용할 수 있습니다.
  • 확장성: 서스펜스 경계를 중첩하여 세밀한 제어가 가능합니다. 전체 페이지 로딩과 개별 섹션 로딩을 별도로 관리할 수 있습니다.
  • 미래 지향성: 이것이 리액트에서 비동기 UI를 처리하는 “표준” 방식이 될 것입니다.

고려해야 할 한계

서스펜스도 만능은 아닙니다. 아직 서버 사이드 렌더링에서의 지원이 완전하지 않고, 기존 코드베이스에 도입할 때는 신중한 마이그레이션 전략이 필요합니다.

비동기 UI의 새로운 패러다임

리액트 서스펜스는 단순한 기능 추가가 아닌, 비동기 UI 처리에 대한 패러다임 전환을 의미합니다. 프로미스를 던지고 받는 독특한 메커니즘을 통해, 우리는 더 깔끔하고 선언적인 컴포넌트를 작성할 수 있게 되었습니다.

여러분의 다음 프로젝트에서 서스펜스를 도입해보세요. 처음에는 낯설 수 있지만, 일단 익숙해지면 이전 방식으로 돌아가기 어려울 것입니다. 복잡한 상태 관리 로직 대신 비즈니스 로직에 집중할 수 있는 개발 경험을 직접 체험해보시기 바랍니다.

그리고 이것은 시작일 뿐입니다. 리액트 팀은 서스펜스를 더욱 발전시켜 나갈 계획이며, 앞으로 더 많은 혁신적인 기능들이 우리를 기다리고 있습니다.

참고 자료: Kent C. Dodds, “How React Suspense Works Under the Hood: Throwing Promises and Declarative Async UI”

답글 남기기