
— 비동기 때문에 흔들리던 UI를 React가 직접 통제하기 시작한 순간
프론트엔드 개발을 하다 보면 자연스럽게 하나의 고민이 생깁니다.
“왜 로딩 UI가 이렇게 많지?”
“왜 페이지 이동할 때 깜빡이지?”
“데이터 패칭 실패하면 화면이 바로 죽는 이유가 뭘까?”
“컴포넌트마다 isLoading, error를 계속 만들어야 하는 게 맞는 걸까?”
React는 분명 “UI를 선언적으로 그리는 라이브러리”인데,
정작 데이터 패칭·로딩·에러·코드 스플리팅처럼 실제 애플리케이션에 반드시 필요한 비동기 흐름은 React가 직접 관리하지 않았습니다.
그 결과 애플리케이션 구조는 점점 복잡해졌습니다.
React 팀은 어느 순간 이렇게 결론을 내립니다.
“비동기는 UI의 일부이므로, UI 프레임워크인 React가 이걸 직접 통제해야 한다.”
그 결론의 첫 번째 산물이 바로 Suspense입니다.
그리고 나머지 기술들은 이 Suspense라는 개념을 중심으로 확장된 애들입니다.
이 글에서는 기술 이름만 나열하지 않고,
이 네 가지 축으로 길게 정리해보겠습니다.
React는 초기에 “상태 → UI”라는 동기적 렌더링 모델을 기반으로 설계됐습니다.
즉, 렌더링 시점에는 모든 데이터가 이미 존재한다고 가정한 거죠.
하지만 실제 애플리케이션은 다음처럼 비동기 투성이입니다.
이 비동기 흐름을 React는 “외부 상태”나 “사용자 정의 로직”으로 해결하게 만들었습니다.
그 때문에 애플리케이션 구조는 자연스럽게 이렇게 변해갔습니다.
isLoading, error를 도배이건 React의 “버그”라기보다는,
애초에 React가 비동기를 렌더링 모델 안에 포함시키지 않았기 때문입니다.
그러니 React는 이런 질문을 하게 됩니다.
“비동기를 UI 렌더링의 일부로 만들 수 없을까?”
Suspense는 바로 그 질문에 대한 첫 번째 대답입니다.
프론트엔드 앱이 커지면서 JS 번들은 점점 커졌고,
SPA 특성상 안 쓰는 페이지 JS까지 처음에 다 들고 오는 문제가 생겼습니다.
그래서 Webpack / Vite 같은 도구가 코드 스플리팅을 제공하기 시작했죠.
하지만 이 상태에서는 이런 문제가 남아 있었습니다.
“번들은 쪼개지는데, 그게 로딩 중인지 아닌지 React는 모른 척한다.”
그래서 예전에는 이런 코드가 많았습니다.
// (예전에 흔히 보던 패턴 – 직접 상태 관리)
function LazySettingsWrapper() {
const [Settings, setSettings] = React.useState<React.ComponentType | null>(null);
React.useEffect(() => {
import('./Settings').then(mod => {
setSettings(() => mod.default);
});
}, []);
if (!Settings) {
return <div>설정 화면 불러오는 중...</div>;
}
return <Settings />;
}
React는 이걸 “React가 직접 지원해야 하는 영역”이라고 보고,
React.lazy를 도입합니다.
const Settings = React.lazy(() => import('./Settings'));
이 한 줄은 단순히 “동적 import”가 아닙니다.
내부적으로는 렌더링 중에 Promise를 던지는 컴포넌트가 됩니다.
즉, 렌더링 중에 이 컴포넌트를 만나면 흐름이 이렇게 바뀝니다.
렌더링 → Promise throw → “대기 필요”
근데 아직 React는 이 Promise를 받을 줄 모릅니다.
이 Promise를 받아서, “잠깐 이 UI 대신 다른 걸 보여주자”를 처리해주는 애가 바로 Suspense입니다.
import React, { Suspense } from 'react';
const SettingsPage = React.lazy(() => import('./SettingsPage'));
export function App() {
return (
<Suspense fallback={<div>설정 화면 불러오는 중...</div>}>
<SettingsPage />
</Suspense>
);
}
SettingsPage는 아직 안 받아온 상태일 수 있고fallback을 렌더링해줍니다.여기서 이미 “비동기 로딩을 UI 렌더링의 일부로 넣는다”는 개념이 시작됩니다.
Suspense를 잘 모르면 흔히 이렇게 생각하기 쉽습니다.
“아, 로딩 스피너 보여주는 컴포넌트지?”
절반은 맞고, 절반은 틀렸습니다.
Suspense의 본질은 “렌더링 중 발생한 비동기를 React가 직접 처리하는 것”입니다.
중요한 포인트는 이겁니다.
Suspense는 DOM을 직접 조작하지 않고,
“렌더링 과정을 제어”합니다.
대략적인 흐름은 이렇습니다.
Promise를 던짐React.lazy, React Query suspense: true, 커스텀 리소스 래퍼 등Suspense Boundary를 찾음fallback UI를 대신 렌더링Promise가 resolve되면fallback DOM은 사라지고, 실제 UI로 자연스럽게 교체즉, “fallback → 실제 UI”로 부드럽게 전환되는 전체 과정을
React 렌더링 엔진이 직접 관리하게 됩니다.
이 방식 덕분에:
예를 들어 이런 식입니다.
// 대시보드 전체를 하나의 Suspense boundary로 감싸는 예시
function Dashboard() {
return (
<Suspense fallback={<div>대시보드 로딩 중...</div>}>
<UserSummary /> {/* 내부에서 데이터 패칭 */}
<ActivityChart /> {/* 내부에서 데이터 패칭 */}
<NotificationList /> {/* 내부에서 데이터 패칭 */}
</Suspense>
);
}
각 컴포넌트가 알아서 비동기를 던지고,
Suspense가 그 전체를 하나의 “로딩 화면”으로 묶어줍니다.
좀 더 세분화하고 싶다면, 이런 식도 가능합니다.
function Dashboard() {
return (
<div>
<Suspense fallback={<div>프로필 불러오는 중...</div>}>
<UserSummary />
</Suspense>
<Suspense fallback={<div>활동 차트 로딩 중...</div>}>
<ActivityChart />
</Suspense>
</div>
);
}
Boundary를 어떻게 나눌지에 따라
“사용자가 어디까지를 하나의 화면으로 인식할지”를 설계할 수 있습니다.
여기까지 보면 Suspense는 “대기(loading)”만 해결합니다.
그러면 “실패(error)”는 누가 처리할까요?
대답: Suspense가 아니라, ErrorBoundary입니다.
React는 렌더링 중 에러가 발생하면 아래와 같이 동작합니다.
throw됨<ErrorBoundary>를 찾음fallback UI를 렌더링하여 안전하게 복구Suspense = “기다림” 담당
ErrorBoundary = “실패” 담당
둘이 세트라고 보면 됩니다.
// 클래스형 컴포넌트로만 공식 지원
class RootErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ hasError: boolean }
> {
constructor(props: any) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: any, info: any) {
console.error('[ErrorBoundary]', error, info);
}
render() {
if (this.state.hasError) {
return <div>문제가 발생했습니다. 잠시 후 다시 시도해 주세요.</div>;
}
return this.props.children;
}
}
// Suspense와 함께 사용
function App() {
return (
<RootErrorBoundary>
<Suspense fallback={<div>전체 앱 로딩 중...</div>}>
<MainRouter />
</Suspense>
</RootErrorBoundary>
);
}
이렇게 되면:
React Query를 쓸 때 가장 익숙한 패턴은 이거일 겁니다.
const { data, isLoading, isError } = useQuery({
queryKey: ['user', userId],
queryFn: fetchUser,
});
if (isLoading) return <div>로딩 중...</div>;
if (isError) return <div>에러…</div>;
이 방식의 단점은:
React 18 이후, React Query는 suspense: true를 지원하면서 완전히 다른 그림이 나옵니다.
// React Query + Suspense 사용
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: fetchUser,
suspense: true, // 핵심
});
이제 내부적으로는 이렇게 동작합니다.
Promise를 던짐 → Suspense에서 fallback 렌더예시를 한 번에 정리하면:
function UserProfile() {
const { data: user } = useQuery({
queryKey: ['user', 1],
queryFn: fetchUser,
suspense: true,
});
return <div>{user.name}님 안녕하세요.</div>;
}
function App() {
return (
<RootErrorBoundary>
<Suspense fallback={<div>사용자 정보를 불러오는 중입니다...</div>}>
<UserProfile />
</Suspense>
</RootErrorBoundary>
);
}
여기서 포인트는:
UserProfile 내부에는 isLoading, isError가 사라지고실제 프로젝트가 커지면, 이 구조가 생각보다 엄청 시원해집니다.
Next.js 13 이후 도입된 Server Components / Streaming도 사실 Suspense 위에 서 있는 기능입니다.
아이디어는 단순합니다.
“서버에서 준비된 부분만 먼저 보내고,
아직 안 된 부분은 Suspense fallback으로 대체해서 보낸 다음,
준비되면 해당 부분만 교체하자.”
// app/page.tsx (Next.js 13+ 예시 느낌)
import { Suspense } from 'react';
import UserFeed from './UserFeed';
export default function Page() {
return (
<><h1>대시보드</h1>
<Suspense fallback={<div>피드 로딩 중...</div>}>
{/* 서버에서 데이터 패칭 후 스트리밍 */}
<UserFeed />
</Suspense>
</>
);
}
실제로는 서버가:
<h1>대시보드</h1>와 fallback을 먼저 내려보내고UserFeed 데이터가 준비되면이 구조 덕분에:
이 모든 게 가능한 이유가 바로 “부분적으로 UI를 대체하고 다시 복구한다”는 Suspense 모델이 존재하기 때문입니다.
정리해보면:
| 기술 | 해결하려는 문제 | Suspense와의 관계 |
|---|---|---|
| React.lazy | 코드 스플리팅 시 “로딩 중” 상태 처리 | Promise를 던져서 Suspense가 받도록 함 |
| Suspense | 비동기 렌더링 전체를 React가 직접 제어 | 모든 비동기 흐름의 중심 엔진 |
| ErrorBoundary | 비동기/동기 에러를 안전하게 복구 | Suspense가 못 잡는 “실패”를 담당 |
| React Query + Suspense | 데이터 패칭 로딩/에러를 렌더링 모델로 통합 | Promise/Error를 던져 Suspense/EB에 위임 |
| Server Components/Streaming | SSR에서 부분 렌더링/스트리밍 | Suspense를 기반으로 부분적인 UI 교체 |
즉, 이 기술들은 따로 떨어진 기능 목록이 아니라
“React 렌더링 모델의 진화 과정에서 등장한 한 계보”에 가깝습니다.
공통된 철학은 항상 하나입니다.
“UI와 비동기를, React가 직접 통제하겠다.”
Suspense는 강력하지만, “그냥 아무 데나 막 꽂아 넣으면 좋은 기능”은 아닙니다.
개인적으로는 아래 같은 상황에서 특히 잘 맞는다고 느꼈습니다.
이럴 때 “Suspense + ErrorBoundary + (React Query or lazy)” 조합이 진짜 빛을 발합니다.
Suspense는 “로딩 스피너 보여주는 컴포넌트”가 아닙니다.
Suspense는 React가 비동기 UI 전체를 스스로 통제하기 시작한 첫 번째 기술입니다.
비동기 → 대기 → 복구 → 렌더링
이 전체 사이클을 React 내부 엔진이 책임지는 구조로 바꾸면서,
이런 것들이 한 줄로 이어지기 시작했습니다.
개인적으로는,
“Suspense를 이해하는 순간
‘React 생태계 전체가 왜 이런 방향으로 진화하고 있는지’가
훨씬 명확하게 보인다”
라는 느낌이 들었습니다.