[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.