프론트엔드 애플리케이션이 점점 복잡해질수록, 정상적인 동작의 케이스보다 비정상적인 순간을 어떻게 다룰 것인가가 사용자 경험을 크게 좌우한다고 생각합니다. 특히 로딩 상태, 네트워크 오류, 예외 사항은 더 이상 예외가 아니라 항상 발생하는 기본 상태에 가깝다고 생각합니다. 이 글에서는 Suspense 와 Error Boundary 를 도입하게 된 배경과, 동작 방식, 기존 방식이 왜 한계에 부딪혔는 지를 정리해보고자 합니다.
suspense 도입 이전에는 보통 아래와 같은 패턴으로 로딩, 에러 상태를 관리했습니다.
const { data, isLoading, error } = useQuery(...)
if (isLoading) return <Loading />
if (error) return <Error />
return <Component data={data} />
컴포넌트마다 로딩 UI 구현이 제각각이고, 페이지 단위 / 섹션 단위 로딩을 통합하기 어렵다는 점.
여러 비동기 의존성이 얽히는 경우 조건문이 급격히 증가하다는 점 등 결과적으로 UI 구조와 비즈니스 로직이
강하게 결합되었고, 로딩 전략을 바꾸려면 많은 컴포넌트를 수정해야 했습니다.
또한 에러처리도 마찬가지로, API 에러, 렌더링 중 발생한 예외, ㅇ예상하지 못한 런타임 에러 등 이 모든 것을 각 컴포넌트에서 개별적으로 처리하다 보니, 동일한 에러 UI 가 여러 곳에서 중복 에러의 "범위"를 조절하기 어려운 문제 등 복잡도가 증가했습니다. 특히 렌더링 단계에서 발생한 에러는 try-catch 로 잡을 수 없기 때문에, 의도치 않게 전체 앱이 흰 화면이 되는 경우들도 있었을 것입니다.
Suspense 의 핵심 개념은 "이 컴포넌트는 준비될 떄까지 렌더링을 미룬다" 는 것입니다.
이를 통해 로딩을 더 이상 개별 컴포넌트의 상태로 관리하지 않고, UI 트리 상의 경계로 선언할 수 있게 됩니다.
import {Suspense} from "react";
<Suspense fallback={<Loading />}>
<Counter />
</Suspense>
이로 인해 로딩 UI 를 상위에서 일관되게 제어가 가능해졌고, 하위 컴포넌트는 데이터가 항상 있다는 전제 하에 구현이 가능하며
UI 구조가 훨씬 읽기 쉬워졌습니다. 특히 비동기 로직과 UI 렌더링 책임이 분리된다는 점이 가장 컸습니다.
즉, 언제 로딩이 시작되고 언제 끝나며 어떤 조건에서 보여줄 지를 모두 개발자가 컨트롤했던 기존 방식을
"이 영역은 준비되면 보여준다. 준비되지 않았으면 fallback 을 보여준다" 라는 선언적인 표현이 가능해졌습니다.
Suspense 는 어떤 상태값을 직접 관찰하지 않고, 렌더링 과정에서 Promise 가 throw 되었는 지를 봅니다.
// 내부 흐름 예시
function Component() {
if (dataNotReady) {
throw promise
}
return <UI />
}
React 는 렌더링 중 throw Promise 를 만나면 트리가 아직 준비되지 않았다고 판단해서,
가장 가까운 Suspense 를 찾고, 그 Suspense 의 fallback 을 렌더링합니다.
즉, isLoading === true 같은 상태 체크가 아니라, 렌더링을 중단시키는 Promise throw 가
로딩 상태를 나타내는 신호가 되는 것입니다. 그래서 Suspense 는 "상태"가 없다고 표현하는 것이 맞으며,
여기서 Suspense 는 로딩 사태를 모르고, 데이터가 뭔지도 모르고, 그냥 Promise 가 던져지면 fallback 으로 전환할 뿐입니다.
이것이 가능한 건, React Query, Relay 같은 라이브러들이 내부에서
if (!data) throw fetchPromise
를 해주기 때문에 Suspense 가 동작하는 것입니다.
만약 Promise 가 resolve 되면, React 는 해당 트리를 다시 렌더링하고. 이번에는 더 이상 Promise 가 throw 되지 않으면, 정상 UI 가 렌더링 되는 것입니다. (retry 는 React 가 자동으로 해줌.)
한 컴포넌트의 에러 때문에 전체 페이지가 꺠지면 안 되는 경우들이 대부분일 텐데,
<ErrorBoundary fallback={<ErrorFallback />}>
<Counter />
</ErrorBoundary>
이렇게 감싸는 것만으로도 특정 영역의 에러를 그 영역 안에서만 처리하고, 전체 앱 크래시를 방지하여
사용자에게 최소한의 피드백 제공이 가능해졌습니다. 뿐만 아니라, Error Boundary 를 사용하면 에러에 대해서
이 에러는 페이지 전체를 막아야 할 지, 아니면 특정 섹션만 대체 UI 로 보여줘야 할 지 등 에러를 상태가 아닌
설계의 문제로 다루게 되었습니다.
에러 처리 로직이 컴포넌트 내부에 흩어지지 않고, UI 트리 상에서 명확한 위치를 갖게 된 것도 큰 변화였습니다.
Error Boundary 역시 Suspense 와 동일하게 상태를 감시하지 않습니다. Suspense 와 거의 동일한 구조지만 대상만 다릅니다.
// 내부 흐름 예시
function Component() {
if (somethingWrong) {
throw new Error("Boom")
}
return <UI />
}
React 가 렌더링 / lifecycle / constructor 중 throw Error 를 감지하면,
가장 가까운 Error Boundary 를 찾아서 그 fallback UI 를 렌더링하고
하위 트리 렌더링을 중단합니다.
즉, hasError 같은 플래그 감시가 아니라, 실제 예외 발생을 기준으로 전환하는 것입니다. Error Boundary 의 fallback 은 "회복 상태" 를 의미하며,
state = {
hasError: true
}
내부적으로 위와 같은 상태를 가지는데, 하지만 이건 결과일 뿐 원인은 아닙니다. 트리거는 항상 throw 된 Error 입니다.
Error Boundary 는 위에서 정리한대로, 렌더링 / lifecycle / constructor 에서 발생된 에러를 잡고, 이벤트 핸들러(onClick), 비동기 콜백 (setTimeout, fetch.then), 서버 에러 자체는 잡지 못합니다. 다만, 렌더링 중 에러로 변환되면 잡힙니다.
이 글을 정리하며 Suspense + Error Boundary 두 기능에 대해 느낀 점은, 로딩과 에러가 예외적인 흐름이 아니라 기본 흐름이라는 점. 컴포넌트는 정상 상태에만 집중할 수 있게 되었다는 점을 다시 한 번 깨닫게 되었고
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Loading />}>
<Counter />
</Suspense>
</ErrorBoundary>
이 구조는 단순하지만, "이 영역에서 발생할 수 있는 모든 비정상 상태를 선언적으로 정의한다"는 점에서 매우 강력하다는 것을 느꼈습니다. 이는 다시 말해, Suspense 와 Error Boundary 는 단순한 React API 가 아니라, 프론트엔드에서 비동기와 실패를 바라보는 관점을 바꿔주는 도구라고 느꼈습니다.