Learn React 02: 왜 Suspense가 도입되었을까?

Learn React 02: 왜 Suspense가 도입되었을까?

왜 데이터 패칭에 비동기 함수가 사용되는지와 기존 로딩 처리 전략이 존재함에도 Suspense가 도입되었는지 알아봅니다.

1. 비동기가 필수인 이유

웹 등에서 서버로부터 데이터를 받아오는 fetch 로직은 기본적으로 비동기 함수입니다. 이 문장은 당연해 보이지만, "왜 반드시 비동기 함수여야 할까?"라는 질문을 한 번쯤 던져 보는 것도 좋습니다.

웹·앱을 구현하다 보면 개발자가 컨트롤할 수 있는 부분과 그렇지 못한 부분이 나뉩니다.

예를 들어 버튼을 눌러 alert 창을 띄우는 일은 개발자가 마음대로 제어할 수 있지만, 네트워크 요청에 문제가 생겨 데이터 도착이 지연되거나 실패하는 부분은 개발자가 컨트롤할 수 없습니다.

동기 vs 비동기 처리 비교
버튼을 클릭하여 동기와 비동기 처리의 차이를 체험해보세요

동기적 처리 (나쁜 예)

3초간 UI가 완전히 멈춥니다

버튼이 멈추고 다른 상호작용 불가

비동기적 처리 (좋은 예)

3초간 로딩 표시만 보여집니다

UI는 반응하며 다른 상호작용 가능

테스트 카운터: 0

동기 처리와 비동기 처리시 아래 toast 버튼 클릭시 다른점을 확인해보세요

이런 컨트롤 불가능한 부분이 만약 동기적으로 작동한다고 가정해 봅시다.

개발자와 사용자 모두 데이터 패칭 동안 웹·앱이 멈추는 것을 원하지 않습니다. 설령 멈춘다 하더라도 사용자가 체감하지 못할 정도(보통 100~200ms 이하)의 짧은 시간이어야 할 것입니다.

하지만 동기적으로 작성하면 이러한 부분의 제어가 개발자 손을 떠납니다.

사람마다 웹·앱에 접속하는 환경과 디바이스가 다르고, 어떤 곳에서는 네트워크가 불안정해 속도가 매우 느릴 수 있습니다. 이 경우 웹·앱은 상당 시간 먹통이 될 것입니다.

왜냐하면 싱글 스레드 JS에서 네트워크 I/O가 동기라면 메인 스레드가 콜 스택을 비우지 못해 이벤트 루프가 돌지 않기 때문입니다.

따라서 이러한 문제를 우회하려면 비동기로 데이터 패칭을 진행해야 합니다. 즉, 데이터 패칭 로직이 다른 로직을 블로킹해서는 안 됩니다.


2. async/await로 동기 흐름처럼 다루기

비동기 로직만으로는 UI가 복잡해지기 때문에, JS는 콜백 → .then()async/await 순으로 고수준 문법을 제공해 왔습니다.

EX

const data = await 비동기함수();

이렇게 비동기로 블로킹 문제를 피하면서 async/await를 통해 마치 동기 로직처럼 작성할 수 있습니다.


3. 로딩 상태 관리의 전통적 방법

이제 고려할 부분은 데이터 패칭 로직에서 데이터를 기다리는, 즉 await되는 동안 UI를 어떻게 처리할지입니다.

전통적인 로딩 상태 관리
useState를 사용한 로딩 상태 관리 예시
const [loading, setLoading] = useState(false)
const [data, setData] = useState(null)

// 데이터 로드
setLoading(true)
fetchData()
  .then(setData)
  .finally(() => setLoading(false))

매번 로딩 상태를 수동으로 관리해야 함

에러 처리도 별도로 구현 필요

복잡한 UI에서는 여러 로딩 상태가 얽힘

React 자체에 범용 데이터 Suspense API가 없어서, 다음과 같은 방법을 사용했습니다

// 전통적인 방법
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);

useEffect(() => {
  setLoading(true);
  fetchData()
    .then(setData)
    .finally(() => setLoading(false));
}, []);

if (loading) return <Spinner />;
return <div>{data}</div>;

참고: React 17부터 코드 분할용 <Suspense>는 존재했지만, 데이터 페칭을 위한 Suspense는 프레임워크 레벨에서만 지원되었습니다.


4. Suspense 도입 배경과 React 철학

그러나 React 팀은 **Suspense**라는 개념을 도입했고(초기에는 사용 범위가 좁아 lazy 로드 등에만 쓰였음) 이를 권장합니다.

Suspense를 사용한 로딩 상태 관리
선언적인 방식으로 로딩 상태를 처리하는 예시
<Suspense fallback={<LoadingFallback />}>
  <UserProfile />
</Suspense>

// UserProfile 컴포넌트 내부
const userData = use(fetchUserData())

테스트2

test2@example.com

프론트엔드 개발자

로딩 상태를 선언적으로 처리

컴포넌트가 데이터에만 집중 가능

에러 바운더리와 함께 사용하여 에러 처리도 선언적으로

저는 처음 Suspense를 접했을 때 "이미 로딩 상태를 제어할 수 있는데 왜 또 다른 개념이 필요할까?"라는 의문이 들었습니다. 그래서 Suspense 도입 배경을 설명하는 RFC 문서를 찾아보았습니다.

Suspense의 핵심은 React의 철학과 연결됩니다. React는 "우리는 선언만 하고 내부 제어는 React가 수행한다"는 철학을 가지고 있습니다.

하지만 로딩 플래그를 직접 정의해 "이 부분에서는 로딩이면 이걸 보여줘야 하고…" 같은 패턴은 React의 핵심 개념과는 다소 어긋납니다.

React 철학에 따르면 "이 컴포넌트에서 로딩이 발생하면 이 fallback UI를 보여줘"라고 선언하는 편이 더 적합합니다. 이를 만족시키는 개념이 바로 Suspense입니다.

// Suspense를 사용한 선언적 방법
<Suspense fallback={<Spinner />}>
  <DataComponent />
</Suspense>;

// React 19의 use() API 사용 예시
function DataComponent() {
  const data = use(fetchData()); // Promise를 직접 읽기
  return <div>{data}</div>;
}

Suspense 사용 시 제약사항

React 공식 문서에 따르면, Suspense-enabled data source만 지원됩니다


5. Suspense의 추가 이점

React Suspense 공식 문서에 따르면, Suspense에는 다음과 같은 장점이 있습니다.

커밋된 컴포넌트만 상태 유지

불완전한 트리가 커밋되지 않으므로 일관성이 보장됩니다.

startTransition과 통합

이전 UI를 유지한 채 새 UI를 준비할 수 있습니다.

Suspense + startTransition 조합
탭을 전환할 때 이전 컨텐츠를 유지하면서 새 컨텐츠를 로드합니다

프로젝트 개요

이 프로젝트는 React 19의 새로운 기능들을 활용한 모던 웹 애플리케이션입니다. Suspense와 startTransition을 통해 더 나은 사용자 경험을 제공합니다.

startTransition의 장점:

  • • 탭 전환 시 이전 컨텐츠가 즉시 사라지지 않음
  • • 로딩 중에도 UI가 반응성을 유지
  • • 사용자가 빠르게 탭을 전환해도 안정적
  • 대기 중...
팁: 처음 방문하는 탭은 로딩이 표시되고, 이미 방문한 탭은 캐시된 데이터가 즉시 표시됩니다.

서버 컴포넌트와 자연스러운 통합

스트리밍 SSR 등 서버 사이드 렌더링 시 이점을 제공합니다.


6. 성능 비교: 전통적 방법 vs Suspense

실제로 Suspense가 성능면에서 어떤 이점이 있는지 시각적으로 비교해보겠습니다.

성능 비교: 전통적 방법 vs Suspense
동일한 데이터를 로드할 때 두 방식의 렌더링 차이를 비교합니다

전통적 방법(useState)

Suspense 방법

이 데모는 단순화된 예시입니다. 실제 애플리케이션에서는 React의 Concurrent 기능, 메모이제이션, 서버 컴포넌트 등이 성능에 더 큰 영향을 미칩니다.

주요 성능 차이점

  1. 렌더링 최적화

    • 전통적 방법: 로딩 상태 변경 시마다 전체 컴포넌트 리렌더링
    • Suspense: React가 렌더링을 지연시켜 불필요한 중간 상태 렌더링 방지
  2. 코드 복잡도 감소

    • 전통적 방법: 각 컴포넌트마다 로딩 상태 관리 필요
    • Suspense: 로딩 플래그 state 분산을 줄여 코드 단순화
  3. 코드 스플리팅

    • Suspense는 lazy와 자연스럽게 통합되어 번들 크기 최적화

그래서 결국 왜 Suspense 사용을 권장하는지?

위에서 알아본 여러 이유가 존재하지만 사실 제가 느낀 핵심은 리액트답게 사용하기 위함이 가장 의미깊게 다가왔습니다.
결국 리액트 핵심은 "너는(개발자) 선언만 해 나머지는 내가(리액트) 처리할게"입니다.
로딩도 내가 직접 모든걸 컨트롤하는게 아니라 "로딩이 존재할 부분에 로딩 상태면 이 Fallback UI를 보여줘"라고 선언만 하면 끝나는 부분에서 Suspense 개념 확장은 혁신적이라고 생각합니다.