
if (loading) return 'Loading...' 이제 그만 쓰고 싶다 (Suspense & ErrorBoundary)
컴포넌트마다 지저분하게 널려있는 로딩/에러 처리를 우아하게 삭제하는 법. 선언적 UI가 가져다주는 평화.

컴포넌트마다 지저분하게 널려있는 로딩/에러 처리를 우아하게 삭제하는 법. 선언적 UI가 가져다주는 평화.
로버트 C. 마틴(Uncle Bob)이 제안한 클린 아키텍처의 핵심은 무엇일까요? 양파 껍질 같은 계층 구조와 의존성 규칙(Dependency Rule)을 통해 프레임워크와 UI로부터 독립적인 소프트웨어를 만드는 방법을 정리합니다.

습관적으로 모든 변수에 `useMemo`를 감싸고 있나요? 그게 오히려 성능을 망치고 있습니다. 메모이제이션 비용과 올바른 최적화 타이밍.

React나 Vue 프로젝트를 빌드해서 배포했는데, 홈 화면은 잘 나오지만 새로고침만 하면 'Page Not Found'가 뜨나요? CSR의 원리와 서버 설정(Nginx, Apache, S3)을 통해 이를 해결하는 완벽 가이드.

진짜 집을 부수고 다시 짓는 건 비쌉니다. 설계도(가상돔)에서 미리 그려보고 바뀐 부분만 공사하는 '똑똑한 리모델링'의 기술.

loading 지옥이었다개발 초기, 제 코드는 항상 이런 식전 기도(?)로 시작했습니다.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetchUser(userId)
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
// ...드디어 본문 시작
}
문제는 이런 코드가 모든 컴포넌트에 복붙되어 있다는 거였죠.
TodoList에도, Sidebar에도, Settings에도.
앱이 커질수록 useState(true)와 if (loading)이 바이러스처럼 퍼졌습니다.
가장 큰 문제는 "사용자 경험(UX)"이 엉망이라는 점이었습니다. 데이터를 가져오는 순서에 따라 로딩 스피너가 여기저기서 깜빡거리는 'Waterfall' 현상이 발생했으니까요.
기존 방식은 명령형입니다.
React 팀이 제시한 Suspense와 ErrorBoundary는 선언형입니다.
비유하자면 이렇습니다.
Suspense를 쓰면 컴포넌트 내부에서 loading 상태를 지울 수 있습니다.
대신 부모 컴포넌트가 로딩 처리를 '대신' 해줍니다.
// 1. 컴포넌트는 데이터가 '있다'고 가정하고 작성
function UserProfile() {
const user = useUserQuery(); // 데이터 없으면 여기서 실행 중단 (Suspend)
return <div>{user.name}</div>;
}
// 2. 부모가 로딩을 책임짐
function App() {
return (
<Suspense fallback={<Spinner />}>
<UserProfile />
</Suspense>
);
}
이제 UserProfile은 지저분한 if (loading) 분기문에서 해방되었습니다.
데이터가 로딩 중이면 React가 알아서 렌더링을 멈추고(Suspend), 가장 가까운 Suspense의 fallback을 보여줍니다.
이게 왜 강력하냐고요? 로딩 단위를 내 맘대로 조절할 수 있기 때문입니다.
// 1. 각각 로딩하고 싶으면? (개별적으로 뜸)
<Suspense fallback={<Spinner />}><Header /></Suspense>
<Suspense fallback={<Spinner />}><Body /></Suspense>
// 2. 한 번에 로딩하고 싶으면? (둘 다 준비돼야 뜸)
<Suspense fallback={<BigSpinner />}>
<Header />
<Body />
</Suspense>
코드를 거의 안 고치고도 로딩 UX를 드라마틱하게 바꿀 수 있습니다.
에러 처리도 마찬가지입니다. try-catch로 도배하는 대신, 에러가 났을 때 보여줄 UI를 선언합니다.
// React 문서에 있는 표준 ErrorBoundary
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() { return { hasError: true }; }
render() {
if (this.state.hasError) return <ErrorPage />;
return this.props.children;
}
}
// 사용
<ErrorBoundary>
<Suspense fallback={<Spinner />}>
<UserProfile />
</Suspense>
</ErrorBoundary>
이제 UserProfile에서 API 에러가 터지든, 렌더링 에러가 터지든, ErrorBoundary가 낚아채서(Catch) ErrorPage를 보여줍니다.
마치 JavaScript의 try-catch 블록이 돔(DOM) 레벨로 올라온 느낌이죠.
꿀팁: react-error-boundary 라이브러리를 쓰면 훨씬 편합니다. 재시도(Retry) 기능도 쉽게 붙일 수 있어요.
<ErrorBoundary
FallbackComponent={ErrorFallback}
onReset={() => resetQuery()} // 다시 시도 버튼 누르면 실행
>
<UserProfile />
</ErrorBoundary>
Suspense는 단순히 문법적 설탕(Syntactic Sugar)이 아닙니다. 데이터 패칭의 패러다임을 바꿉니다.
React Query(TanStack Query)나 Relay 같은 라이브러리가 이 패턴을 완벽하게 지원합니다.
// React Query와 Suspense 조합
const { data } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
suspense: true // 요거 한 줄이면 끝!
});
Suspense와 ErrorBoundary를 도입하고 나서 제 코드는 절반으로 줄었습니다. 무엇보다 "로딩 상태 관리"라는 지루한 작업에서 벗어나, "어떤 데이터를 보여줄까"라는 본질에 집중하게 되었습니다.
아직도 if (loading) return ...을 치고 계신가요?
이제 그 짐을 React에게 내려놓으세요. 선언적 UI의 세계에 오신 것을 환영합니다.
loading HellEarly in my career, my components always started with this ritual prayer:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetchUser(userId)
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
// ...Actual content starts here
}
The problem was, this code was copy-pasted into every single component.
TodoList, Sidebar, Settings...
As the app grew, useState(true) and if (loading) spread like a virus.
Worst of all, the User Experience (UX) was terrible. Spinners flashed everywhere chaotically (the Waterfall effect) depending on when data arrived.
The old way is Imperative.
React Suspense and ErrorBoundary are Declarative.
Analogy:
With Suspense, you delete the loading state from your component.
The parent component handles loading 'on your behalf'.
// 1. Component assumes data exists
function UserProfile() {
const user = useUserQuery(); // If no data, execution SUSPENDS here
return <div>{user.name}</div>;
}
// 2. Parent takes responsibility
function App() {
return (
<Suspense fallback={<Spinner />}>
<UserProfile />
</Suspense>
);
}
Now UserProfile is freed from the messy if (loading) blocks.
When data is loading, React pauses rendering and shows the nearest Suspense's fallback.
Why is this powerful? You control the loading granularity.
// 1. Load separately (Popcorn effect)
<Suspense fallback={<Spinner />}><Header /></Suspense>
<Suspense fallback={<Spinner />}><Body /></Suspense>
// 2. Load together (All or Nothing)
<Suspense fallback={<BigSpinner />}>
<Header />
<Body />
</Suspense>
You can drastically change the Loading UX without touching the component logic.
Same for errors. Instead of try-catch everywhere, declare a UI fallback for crashes.
// Standard ErrorBoundary from React Docs
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() { return { hasError: true }; }
render() {
if (this.state.hasError) return <ErrorPage />;
return this.props.children;
}
}
// Usage
<ErrorBoundary>
<Suspense fallback={<Spinner />}>
<UserProfile />
</Suspense>
</ErrorBoundary>
Now, whether it's an API error in UserProfile or a render error, ErrorBoundary Catches it and resolves the UI to ErrorPage.
It's like a JavaScript try-catch block lifted up to the DOM level.
Pro Tip: Use react-error-boundary. It handles things like 'Retry' buttons easily.
<ErrorBoundary
FallbackComponent={ErrorFallback}
onReset={() => resetQuery()} // Runs when user clicks Retry
>
<UserProfile />
</ErrorBoundary>
Suspense isn't just syntactic sugar. It changes the data fetching paradigm.
Libraries like React Query (TanStack Query) or Relay support this perfectly.
// Combining React Query and Suspense
const { data } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
suspense: true // Just this one line!
});
use HookReact 19 introduces a new hook simply called use.
This allows you to unwrap promises directly inside your component, without needing a special library like React Query (though libraries still help with caching).
// React 19: No more useEffect!
import { use, Suspense } from 'react';
function UserProfile({ userPromise }) {
const user = use(userPromise); // Pauses here if promise is pending
return <div>{user.name}</div>;
}
function App() {
const userPromise = fetchUser(1); // Start fetching early
return (
<Suspense fallback={<Spinner />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
This brings the "Async/Await" mental model directly into the render phase. It's cleaner, safer, and native to the platform.
A very common mistake is expecting ErrorBoundary to catch errors in Event Handlers.
IT DOES NOT.
// ❌ ErrorBoundary won't catch this!
const handleClick = () => {
throw new Error("Button crashed!");
};
Why? Because Event Handlers run outside the render phase. If they crash, the UI is still intact. React doesn't need to unmount the component tree.
For Event Handlers, just use standard try-catch.
If you want to catch async errors (like inside fetch), you need to pass them to the nearest ErrorBoundary by updating state: setState(() => { throw err; }). (Or use a library that does this for you).
Suspense is not just for the client. It empowers Streaming Server-Side Rendering (SSR).
In Next.js App Router, wrapping a component in <Suspense> allows the server to send the initial HTML shell immediately, and then "stream" the slow data as it becomes available.
This leads to a new architecture: Partial Prerendering (PPR).
Before Suspense, the server had to wait for everything before sending anything. Now, thanks to Suspense boundaries, we can unblock the UI painting process. The declarative nature of Suspense makes this complex orchestration automatic. You don't need to manually configure streams or buffers; React handles the heavy lifting, ensuring your Time to First Byte (TTFB) is as low as possible while still delivering dynamic content.