[React] React Suspense를 파헤쳐보자
#들어가며
#React Suspense란?
Suspense는 React의 강력한 기능 중 하나로, 비동기 작업의 처리와 UI 렌더링을 보다 세련되게 관리할 수 있게 해준다. 이 기능은 데이터 로딩, 이미지 또는 스크립트의 지연 로딩과 같은 비동기 작업을 처리할 때 특히 유용하다. Suspense를 사용하면 개발자는 로딩 상태를 더 세밀하게 제어할 수 있고, 결과적으로 사용자 경험을 크게 향상시킬 수 있다.
React 16.6v에서 처음 소개된 Suspense는 초기에 실험적인 기능으로 등장했다. 이 기능의 도입은 React 애플리케이션에서의 비동기 처리에 새로운 패러다임을 제시했다(로드맵으로). 특히, 이 버전에서 Suspense는 주로 코드 스플릿팅과 같은 상황에서 유용하게 사용되었다. 하지만, 그 당시 Suspense는 서버 사이드 렌더링(SSR)을 지원하지 않는 한계가 있었다. 이로 인해 SSR을 사용하는 대규모 애플리케이션에서는 Suspense의 적용에 제한이 있었다. 이러한 초기의 한계에도 불구하고, Suspense는 React 생태계에서 중요한 발전을 이루었으며, 이후 버전에서 점진적으로 개선되고 확장되어 왔다.
#코드 스플릿팅과 Suspense
Suspense의 초기 도입 목적 중 하나는 코드 스플릿팅의 간소화와 개선이었다. 코드 스플릿팅은 애플리케이션의 번들 크기를 줄이고, 필요한 부분만 사용자에게 전달하는 기술이다. 이는 웹 애플리케이션의 초기 로딩 시간을 단축시키는 데 매우 효과적이다.
Suspense를 사용한 코드 스플릿팅의 경우, 개발자는 React.lazy와 함께 Suspense를 사용하여 컴포넌트를 동적으로 불러올 수 있다. 예를 들어, 특정 컴포넌트가 필요할 때까지 로딩을 지연시키고, 해당 컴포넌트가 로딩되는 동안 대체(fallback) 컴포넌트(로딩 인디케이터 등)를 표시할 수 있다.
import React, { Suspense } from 'react';
const LazyComponent = React.lazy(() => import('./LazyComponent'));
function App() {
return (
<div>
<Suspense fallback={<div>로딩 중...</div>}>
<LazyComponent />
</Suspense>
</div>
);
}import React, { Suspense } from 'react';
const LazyComponent = React.lazy(() => import('./LazyComponent'));
function App() {
return (
<div>
<Suspense fallback={<div>로딩 중...</div>}>
<LazyComponent />
</Suspense>
</div>
);
}이 코드에서 LazyComponent는 필요할 때만 불러와지고, 그 전까지는 fallback으로 지정된 컴포넌트가 표시된다.
#코드 스플릿팅의 장점
Suspense를 사용한 코드 스플릿팅의 주요 장점은 다음과 같다.
- 성능 향상: 사용자가 실제로 필요로 하는 코드만 로딩함으로써 애플리케이션의 초기 로드 시간이 단축
- 리소스 최적화: 불필요한 코드 로딩을 방지하여 네트워크 및 메모리 리소스를 절약
- 유연한 사용자 경험: 필요한 컴포넌트가 로딩되는 동안 사용자에게 로딩 인디케이터나 기타 플레이스홀더를 보여줄 수 있어, 더 나은 사용자 경험을 제공
#React v18에서의 Suspense와 데이터 페칭
React v18에서 Suspense는 주로 데이터 페칭과 관련하여 크게 발전했다. 이전 버전에서는 주로 코드 스플릿팅과 지연 로딩에 초점을 맞췄던 Suspense가 v18에서는 데이터 로딩 시나리오에 더욱 효과적으로 적용될 수 있도록 개선되었다. 이 변경을 통해 개발자들은 비동기 데이터 페칭과 관련된 사용자 경험을 더욱 세밀하게 제어할 수 있게 되었다.
#Fetch on Render 방식
기존의 useEffect를 사용한 데이터 페칭 방법, 즉 Fetch on Render 방식에서는 컴포넌트가 렌더링된 후 데이터를 페칭한다. 이 방식은 간단하고 직관적이지만, 여러 컴포넌트가 서로 의존하는 데이터를 로드할 때 "waterfall" 문제가 발생할 수 있다. Waterfall 문제란 하나의 데이터 페칭이 완료된 후에야 다음 데이터 페칭이 시작되어, 전체적인 로딩 시간이 길어지는 현상을 말한다.


import React, { useState, useEffect } from 'react';
// 데이터를 가져오는 가상의 함수
const fetchData = (endpoint) => {
return new Promise((resolve) => {
setTimeout(() => resolve(`Data from ${endpoint}`), 1000);
});
};
// 부모 컴포넌트
const ParentComponent = () => {
const [parentData, setParentData] = useState(null);
useEffect(() => {
fetchData('parentEndpoint').then(data => setParentData(data));
}, []);
return (
<div>
<h1>부모 컴포넌트</h1>
{parentData ? <ChildComponent /> : <p>데이터 로딩 중...</p>}
</div>
);
};
// 자식 컴포넌트
const ChildComponent = () => {
const [childData, setChildData] = useState(null);
useEffect(() => {
fetchData('childEndpoint').then(data => setChildData(data));
}, []);
return (
<div>
<h2>자식 컴포넌트</h2>
{childData ? <p>{childData}</p> : <p>데이터 로딩 중...</p>}
</div>
);
};
export default ParentComponent;import React, { useState, useEffect } from 'react';
// 데이터를 가져오는 가상의 함수
const fetchData = (endpoint) => {
return new Promise((resolve) => {
setTimeout(() => resolve(`Data from ${endpoint}`), 1000);
});
};
// 부모 컴포넌트
const ParentComponent = () => {
const [parentData, setParentData] = useState(null);
useEffect(() => {
fetchData('parentEndpoint').then(data => setParentData(data));
}, []);
return (
<div>
<h1>부모 컴포넌트</h1>
{parentData ? <ChildComponent /> : <p>데이터 로딩 중...</p>}
</div>
);
};
// 자식 컴포넌트
const ChildComponent = () => {
const [childData, setChildData] = useState(null);
useEffect(() => {
fetchData('childEndpoint').then(data => setChildData(data));
}, []);
return (
<div>
<h2>자식 컴포넌트</h2>
{childData ? <p>{childData}</p> : <p>데이터 로딩 중...</p>}
</div>
);
};
export default ParentComponent;이 코드에서 ParentComponent는 데이터를 불러오고, 그 후에 ChildComponent가 렌더링된다. ChildComponent는 자신의 데이터를 가져오는 동안 "데이터 로딩 중..." 메시지를 표시한다. 이러한 방식은 각 컴포넌트가 순차적으로 데이터를 가져오기(Waterfall) 때문에 전체적인 데이터 로딩 시간이 길어지는 문제가 발생한다.
#Fetch then Render 방식
Fetch then Render 방식에서는 데이터를 먼저 페칭하고, 데이터가 준비되면 그에 따라 컴포넌트를 렌더링한다. 이 방식은 데이터 페칭과 렌더링을 더 명확히 분리하지만, 페칭이 얼마나 걸리냐에 따라 초기 로딩 시간에 영향을 줄 수 있다.

import React, { useEffect, useState } from "react";
function fetchCountries() {
return new Promise((resolve) => {
setTimeout(() => {
resolve([{ name: "South Korea" }, { name: "Japan" }]); // 예시 데이터
}, 1000);
});
}
function fetchTime() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ datetime: new Date().toLocaleString() }); // 현재 시간
}, 2000);
});
}
async function fetchAllData() {
const countries = await fetchCountries();
const time = await fetchTime();
return [countries, time];
}
const allData = fetchAllData(); // 컴포넌트에 들어가기전 미리 fetch 실행
// 국가 목록을 표시하는 컴포넌트
function CountryList({ data }) {
return (
<ul>
{data.map((country, index) => (
<li key={index}>{country.name}</li>
))}
</ul>
);
}
// 시간을 표시하는 컴포넌트
function Time({ data }) {
return <p>Current Time: {data.datetime}</p>;
}
// 메인 컴포넌트
function Countries() {
const [countries, setCountries] = useState([]);
const [time, setTime] = useState({});
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
async function fetchData() {
const [fetchedCountries, fetchedTime] = await allData;
setCountries(fetchedCountries);
setTime(fetchedTime);
setIsLoading(false);
}
fetchData();
}, []);
if (isLoading) {
return <div>Loading Countries and Time...</div>;
}
return (
<>
<h2>All Countries with the Current Time - Data Fetched and then Rendered</h2>
<Time data={time} />
<CountryList data={countries} />
</>
);
}
export default Countries;import React, { useEffect, useState } from "react";
function fetchCountries() {
return new Promise((resolve) => {
setTimeout(() => {
resolve([{ name: "South Korea" }, { name: "Japan" }]); // 예시 데이터
}, 1000);
});
}
function fetchTime() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ datetime: new Date().toLocaleString() }); // 현재 시간
}, 2000);
});
}
async function fetchAllData() {
const countries = await fetchCountries();
const time = await fetchTime();
return [countries, time];
}
const allData = fetchAllData(); // 컴포넌트에 들어가기전 미리 fetch 실행
// 국가 목록을 표시하는 컴포넌트
function CountryList({ data }) {
return (
<ul>
{data.map((country, index) => (
<li key={index}>{country.name}</li>
))}
</ul>
);
}
// 시간을 표시하는 컴포넌트
function Time({ data }) {
return <p>Current Time: {data.datetime}</p>;
}
// 메인 컴포넌트
function Countries() {
const [countries, setCountries] = useState([]);
const [time, setTime] = useState({});
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
async function fetchData() {
const [fetchedCountries, fetchedTime] = await allData;
setCountries(fetchedCountries);
setTime(fetchedTime);
setIsLoading(false);
}
fetchData();
}, []);
if (isLoading) {
return <div>Loading Countries and Time...</div>;
}
return (
<>
<h2>All Countries with the Current Time - Data Fetched and then Rendered</h2>
<Time data={time} />
<CountryList data={countries} />
</>
);
}
export default Countries;#Suspense를 사용한 Render as You Fetch
Suspense를 사용한 Render as You Fetch 또는 Render while Fetch 라고 불리는 패턴은 Fetch on Render와 Fetch then Render의 장점을 결합했다. 이 패턴에서는 컴포넌트가 렌더링되면서 동시에 데이터를 페칭한다. Suspense는 데이터가 준비되지 않은 경우 대체 UI를 보여주며, 데이터가 준비되면 주 UI로 전환된다. 이 방식은 waterfall 문제를 해결하고 사용자 경험을 향상시킬 수 있다.

🤔 어떻게 컴포넌트가 렌더링되면서 동시에 데이터를 페칭할 수 있죠?
Suspense 기능과 동시성(Concurrency)
동시성과 중단 가능한 렌더링 (Concurrency and Interruptible Rendering)
React v18은 동시성을 기반으로 한 새로운 렌더링 메커니즘을 도입했다. 이는 렌더링 프로세스를 중단 가능하게 만들어, 필요에 따라 렌더링 프로세스를 일시 중지하거나 나중에 재개할 수 있다.
이러한 변화를 통해 React는 백그라운드에서 새로운 화면을 준비할 수 있게 되며, 이는 메인 스레드를 차단하지 않고 사용자 상호작용에 즉시 반응할 수 있게 해준다.
Transitions: 18버전에서 Transition이라는 새로운 개념을 도입했다. 이것은 긴급 업데이트(예: 타이핑, 클릭 등)와 비긴급 업데이트(Transition)를 구분한다.
startTransition API를 사용하거나 useTransition 훅을 사용해서 비긴급 업데이트(예: 검색 결과 렌더링)를 표시할 수 있으며, 이러한 업데이트는 더 긴급한 업데이트에 의해 중단될 수 있다.
Suspense: Suspense는 컴포넌트 트리의 일부가 아직 표시 준비가 되지 않았을 때 로딩 상태를 선언적으로 지정하는 기능이다.
React 18에서는 서버 렌더링과 연계하여 Suspense의 기능이 확장되었습니다. 예를 들어, Transition 도중에 Suspense가 발생하면, React는 이미 보이는 컨텐츠를 대체 UI로 교체하지 않고, 충분한 데이터가 로드될 때까지 렌더링을 지연시킨다.
#Suspense와 Error Boundary로 우아하게 비동기 다루기

#기존의 useEffect 사용
import React, { useEffect, useState } from 'react';
function DataFetchingComponent() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch('https://api.example.com/data');
const result = await response.json();
setData(result);
} catch (err) {
setError(err);
}
};
fetchData();
}, []);
if (error) {
return <div>에러가 발생했습니다: {error.message}</div>;
}
if (!data) {
return <div>로딩 중...</div>;
}
return <div>{JSON.stringify(data)}</div>;
}import React, { useEffect, useState } from 'react';
function DataFetchingComponent() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch('https://api.example.com/data');
const result = await response.json();
setData(result);
} catch (err) {
setError(err);
}
};
fetchData();
}, []);
if (error) {
return <div>에러가 발생했습니다: {error.message}</div>;
}
if (!data) {
return <div>로딩 중...</div>;
}
return <div>{JSON.stringify(data)}</div>;
}이 방식에서는 데이터 로딩, 에러 처리, 그리고 실제 데이터 렌더링이 같은 컴포넌트 안에서 이루어진다. 관리하는 데이터가 많아 질수록 이 코드의 가독성과 재사용성에 영향을 미칠 수 있다.
#Suspense와 ErrorBoundary를 사용
React에서 Suspense와 ErrorBoundary의 결합은 비동기 작업을 처리하는 데 있어 매우 우아하고 효율적인 방법을 제공한다. 이 두 기능을 함께 사용하면 에러와 로딩 상태를 컴포넌트 외부에서 효과적으로 처리할 수 있으며, 컴포넌트 내부는 성공한 데이터 처리에만 집중할 수 있다.
Suspense와 ErrorBoundary의 결합
Suspense는 데이터 로딩을 처리하기 위한 React의 구성 요소다. 만약 데이터가 아직 준비되지 않았다면, Suspense는 대체 UI(로딩 인디케이터 등)를 렌더링한다. 이를 통해 개발자는 데이터 로딩 상태를 세련되게 관리할 수 있다.
ErrorBoundary는 자식 컴포넌트에서 발생하는 JavaScript 에러를 캐치하고, 이를 대체 UI로 처리한다. 이를 통해 애플리케이션의 나머지 부분이 정상적으로 작동할 수 있도록 보장한다.
이 두 구성 요소를 함께 사용하면, 데이터 로딩 및 에러 처리 로직을 컴포넌트 외부로 추출할 수 있으며, 개발자는 데이터를 처리하고 UI를 렌더링하는 데 집중할 수 있다.
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
// 에러 바운더리는 Class 컴포넌트로 구현할 수 있지만 라이브러리를 사용했다.
import CountryList from "./CountryList";
import Time from "./Time";
const Countries = () => {
return (
<>
<h2>Countries with Time - Suspense & Error Boundaries</h2>
<Suspense fallback={<p>Loading time...</p>}>
<Time />
</Suspense>
<ErrorBoundary
fallback={<p>Something went wrong in fetching countries...</p>}
>
<Suspense fallback={<p>Loading countries...</p>}>
<CountryList />
</Suspense>
</ErrorBoundary>
</>
);
};
export default Countries;
// Time.jsx
const resource = fetchData("time url"); // Promise 가 아님!!
const Time = () => {
const time = resource.read();
// return ...
};
// CountryList.jsx
const resource = fetchData("countryList url"); // Promise 가 아님!!
const CountryList = () => {
const countries = resource.read();
// return ...
};import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
// 에러 바운더리는 Class 컴포넌트로 구현할 수 있지만 라이브러리를 사용했다.
import CountryList from "./CountryList";
import Time from "./Time";
const Countries = () => {
return (
<>
<h2>Countries with Time - Suspense & Error Boundaries</h2>
<Suspense fallback={<p>Loading time...</p>}>
<Time />
</Suspense>
<ErrorBoundary
fallback={<p>Something went wrong in fetching countries...</p>}
>
<Suspense fallback={<p>Loading countries...</p>}>
<CountryList />
</Suspense>
</ErrorBoundary>
</>
);
};
export default Countries;
// Time.jsx
const resource = fetchData("time url"); // Promise 가 아님!!
const Time = () => {
const time = resource.read();
// return ...
};
// CountryList.jsx
const resource = fetchData("countryList url"); // Promise 가 아님!!
const CountryList = () => {
const countries = resource.read();
// return ...
};Fetch then Render 방식처럼 컴포넌트를 렌더링하기 전에 네트워크 요청을 하고 있다. (fetchData()) 그리고 각각 Suspense 컴포넌트로 래핑해주었다.
처음 Countries 컴포넌트가 마운트되면 Time과 CountryList를 실행하고 이는 resource.read() 를 실행하게 된다.
이때 요청이 아직 resolve 되지 않으면 Suspense의 fallback을 렌더링 하게 된다. 만약 에러가 발생하면 가장 가까운 ErrorBoundary의 fallback을 렌더링 한다.
#fetchData (wrapPromise) 중요!
function wrapPromise(promise) {
let status = 'pending'; // 인수의 상태
let response; // Promise의 결과 저장
const suspender = promise.then(
res => {
status = 'success';
response = res;
},
err => {
status = 'error';
response = err;
},
);
const read = () => {
switch (status) {
case 'pending':
throw suspender;
case 'error':
throw response;
default:
return response;
}
};
return { read };
}function wrapPromise(promise) {
let status = 'pending'; // 인수의 상태
let response; // Promise의 결과 저장
const suspender = promise.then(
res => {
status = 'success';
response = res;
},
err => {
status = 'error';
response = err;
},
);
const read = () => {
switch (status) {
case 'pending':
throw suspender;
case 'error':
throw response;
default:
return response;
}
};
return { read };
}이 함수는 프로미스를 감싸서, Suspense가 비동기 데이터를 처리할 수 있도록 하는 역할을 한다.
프로미스의 상태(대기, 성공, 실패)에 따라 다르게 동작하며, 데이터가 준비되지 않았을 경우 Suspense에 의해 처리된다.
- 대기 -> promise throw
- 성공 -> resolve된 데이터 반환
- 실패 -> 에러 throw
fetchData 함수는 API에서 데이터를 가져오는 네트워크 요청을 수행하고, 이를 wrapPromise로 감싸서 Suspense가 처리할 수 있는 형태로 만드는 역할을 한다. 구현 방법은 다음과 같다.
import wrapPromise from './wrapPromise';
function fetchData(url) {
const promise = fetch(url)
.then(response => response.json())
.catch(error => {
throw error;
});
return wrapPromise(promise);
}
export default fetchData;import wrapPromise from './wrapPromise';
function fetchData(url) {
const promise = fetch(url)
.then(response => response.json())
.catch(error => {
throw error;
});
return wrapPromise(promise);
}
export default fetchData;-
네트워크 요청 수행:
fetchData함수는 주어진 URL로부터 데이터를 가져오는 네트워크 요청을 수행한다. 이를 위해fetch API또는axios와 같은 라이브러리를 사용할 수 있다. -
Promise 처리: 네트워크 요청은
Promise를 반환한다. 이Promise는 데이터의 로드가 완료되었을 때 결과를 반환하거나, 오류가 발생했을 때 오류를 반환한다. -
wrapPromise 함수 사용:
fetchData함수는 이Promise를wrapPromise함수에 전달한다.wrapPromise함수는 이Promise를 처리하여Suspense가 이해할 수 있는 형태로 변환한다. -
응답 객체 반환:
fetchData함수는 최종적으로wrapPromise함수에서 반환된 객체를 반환한다. 이 객체는read메서드를 통해 데이터를 동기적으로 읽을 수 있게 해준다.
이렇게 구현된 fetchData 함수는 React 컴포넌트에서 Suspense와 함께 사용될 수 있다. 데이터가 준비되지 않았을 때는 Suspense의 fallback이 표시되고, 에러가 발생했을 때는 가장 가까운 ErrorBoundary의 fallback이 표시된다.
#Note - 알아두기
아직 데이터 페칭에 Suspense를 도입하는 공식적인 방법은 지원되지 않는다. 데이터 소스를 Suspense와 통합하기 위한 React 공식 API는 미래에 출시할 계획이라고 한다.
공식문서에서도 Suspense가 지원하는 데이터 소스를 사용하는 것을 추천한다. 현재 Suspense는 다음과 같은 경우에 활성화된다.
-
Suspense를 지원하는 프레임워크나 라이브러리를 이용하자:
Relay나Next.js와 같은 프레임워크에서Suspense를 활용한 데이터 페칭을 지원한다.React Router Dom(v6)이나ReactQuery(v4)에서는 실험적 기능으로suspense: boolean옵션을 켜주면 사용할 수 있고 5버전에서는 그 옵션이 사라지고useSuspenseQuery등을 이용하면 된다. -
Suspense는Effect나 이벤트 핸들러 내부에서 페칭하는 경우를 감지하지 않는다.
#마치며
이전에는 Suspense를 단순히 로딩 처리를 위한 도구로만 인식했지만, 이제는 그 이상의 것으로 인식하게 되었다.
마치 JavaScript의 Promise가 복잡한 비동기 로직을 간결하고 선언적으로 다루도록 도와주는 것처럼,
Suspense는 React에서 데이터 로딩과 관련된 UI 표현을 보다 명확하고 효과적으로 관리할 수 있게 만들어 주는 것 같다.
Suspense와 ErrorBoundary를 통해 우리는 컴포넌트의 로딩 상태와 에러 상태를 더 직관적으로 처리하고, 코드를 더 간결하게 처리할 수 있었다.
#참고자료
Data Fetching using React Suspense and Error Boundary - React Data Fetching Patterns.