useEffect 로딩 상태와 Suspense는 무엇이 다르고 언제 써야 할까
빠른 답
useEffect방식은 요청 시작, 성공, 실패, 로딩 종료를 컴포넌트 안의 state로 직접 관리한다.Suspense는 자식 트리가 아직 렌더링 준비를 끝내지 못했을 때 가장 가까운fallback을 보여주는 경계 모델이다.- 일반
fetch를useEffect안에서 호출하는 것만으로는Suspense가 동작하지 않는다. Suspense를 쓰려면fallback위치, Error Boundary, 데이터 캐시나 프레임워크 지원 여부를 함께 봐야 한다.
목차
한눈에 비교
시간 흐름으로 이해하기
현재 React 기준에서 봐야 할 점
2026년 4월 기준 react.dev의 최신 문서 기준은 React 19.2 계열이다. 관련 기준은 React Versions, Suspense 공식 문서, use 공식 문서, useEffect 공식 문서, React 19.2 릴리스 글에서 확인할 수 있다.
오래된 설명에서는 “Suspense는 Promise를 기다린다”라고 단순히 말하는 경우가 많았다. 현재 문서 기준으로는 조금 더 좁게 봐야 한다. Suspense를 활성화하는 것은 아무 비동기 작업이 아니라 Suspense를 지원하는 데이터 소스다. React 문서도 Effect나 이벤트 핸들러 안에서 가져오는 데이터는 Suspense가 감지하지 않는다고 설명한다.
React 19의 use API는 Promise 값을 읽을 때 Suspense와 연동될 수 있다. 다만 클라이언트 컴포넌트 렌더링 중 매번 새 Promise를 만들면 렌더마다 다른 작업이 생기기 쉽다. 공식 문서는 서버 컴포넌트에서 만든 Promise를 클라이언트 컴포넌트로 전달하거나, Suspense를 지원하는 프레임워크와 캐시를 사용하는 흐름을 권한다.
useEffect 자체가 deprecated된 것은 아니다. 다만 초기 데이터 로딩을 모두 Effect에 넣으면 서버 렌더링, 캐싱, 요청 중복 제거, 워터폴 제어에서 한계가 생길 수 있다. 또한 React 19.2에는 useEffectEvent가 추가되어 Effect 안의 비반응형 로직을 분리하는 선택지도 생겼다. 로딩 상태 관리와 직접 같은 문제는 아니지만, Effect 의존성 배열을 억지로 비우거나 lint를 끄는 식의 오래된 패턴을 줄이는 데 도움이 된다.
데이터 패칭 라이브러리 쪽도 버전 차이가 있다. 예전 “React Query”라는 이름은 현재 “TanStack Query”로 바뀌었고, TanStack Query v5에서는 기존 suspense: true 플래그 중심 예제보다 useSuspenseQuery, useSuspenseInfiniteQuery, useSuspenseQueries 같은 전용 훅을 쓰는 흐름이 문서화되어 있다. 관련 내용은 TanStack Query Suspense 문서와 v5 마이그레이션 문서에서 확인할 수 있다.
useEffect 방식이 맞는 상황
useEffect와 로딩 state 방식은 요청의 시작과 끝을 해당 컴포넌트가 직접 알아야 할 때 읽기 쉽다. 작은 위젯 하나가 독립적으로 데이터를 가져오거나, 검색 버튼 이후 결과를 가져오거나, 실패했을 때 같은 영역 안에서 다시 시도 버튼을 보여주는 흐름에는 지역 state가 충분할 수 있다.
아래 코드는 userId가 바뀔 때마다 사용자를 다시 가져온다. 이전 요청이 늦게 끝나도 현재 화면을 덮어쓰지 않도록 ignore 플래그와 AbortController를 함께 둔다.
import { useEffect, useState } from "react";
export function UserPanel({ userId }) {
const [state, setState] = useState({
status: "idle",
user: null,
error: null,
});
useEffect(() => {
const controller = new AbortController();
let ignore = false;
setState({ status: "loading", user: null, error: null });
async function loadUser() {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error("사용자 조회에 실패했습니다.");
}
const user = await response.json();
if (!ignore) {
setState({ status: "success", user, error: null });
}
} catch (error) {
if (!ignore && error.name !== "AbortError") {
setState({ status: "error", user: null, error });
}
}
}
loadUser();
return () => {
ignore = true;
controller.abort();
};
}, [userId]);
if (state.status === "loading") return <UserSkeleton />;
if (state.status === "error") return <UserErrorView error={state.error} />;
if (!state.user) return null;
return <UserProfile user={state.user} />;
}
이 방식에서 로딩 상태의 주인은 UserPanel이다. 그래서 요청이 하나일 때는 흐름이 잘 보이지만, 목록, 상세, 권한, 추천 데이터처럼 요청이 늘어나면 컴포넌트 안에 isUserLoading, isPermissionLoading, isRecommendationLoading 같은 상태가 쌓이기 쉽다. 그때는 단순히 로딩 UI를 줄이는 문제가 아니라 데이터 소유권을 어느 계층에 둘지의 문제가 된다.
개발 모드의 Strict Mode에서는 Effect의 setup과 cleanup이 한 번 더 실행될 수 있다. 이 동작은 cleanup이 제대로 작성되었는지 확인하기 위한 것이므로, 네트워크 요청을 Effect에 둘 때는 취소와 무시 처리를 함께 두는 편이 안전하다.
useEffect에서 자주 생기는 안티패턴
로딩 state를 줄이려고 데이터의 모양만 보고 로딩 여부를 추론하는 경우가 있다. 예를 들어 배열이 비어 있으면 로딩이라고 보는 방식이다. 하지만 “아직 요청 중”과 “정말 결과가 없음”은 다른 상태다.
function BadUserList({ users }) {
const isLoading = users.length === 0;
if (isLoading) return <LoadingView />;
return <UserList users={users} />;
}
function GoodUserList({ result }) {
if (result.status === "loading") return <LoadingView />;
if (result.status === "error") return <ErrorView error={result.error} />;
if (result.users.length === 0) return <EmptyView />;
return <UserList users={result.users} />;
}
로딩, 실패, 빈 결과, 갱신 중 상태는 서로 다른 의미를 가진다. useEffect 방식이라면 idle, loading, success, error처럼 명시적인 상태를 두는 편이 해석하기 쉽다. 캐시 라이브러리를 쓴다면 라이브러리가 제공하는 isPending, isFetching, isError 같은 값을 그대로 활용할 수 있다.
Suspense를 쓴다고 이 구분이 사라지는 것도 아니다. 처음 준비되지 않은 상태는 fallback으로 보낼 수 있지만, 이미 보이던 데이터를 다시 가져오는 중인지, 실패했지만 오래된 데이터를 계속 보여줄지, 완전히 실패 화면으로 바꿀지는 별도의 UI 정책으로 남는다.
Suspense 방식이 맞는 상황
Suspense는 “이 영역은 데이터나 코드가 준비될 때까지 하나의 단위로 기다린다”는 구조를 표현할 때 잘 맞는다. 컴포넌트 내부의 isLoading 조건문을 줄이는 대신, 로딩 경계를 컴포넌트 트리 바깥에서 배치한다. 그래서 관심사는 요청 state를 어떻게 업데이트할지가 아니라, 어떤 화면 조각을 함께 드러낼지로 이동한다.
Suspense와 데이터 패칭을 함께 쓰려면 Suspense를 지원하는 데이터 소스가 필요하다. 아래 예시는 TanStack Query v5의 useSuspenseQuery를 사용하는 구성이다. 먼저 앱 상위에 QueryClientProvider를 둔다.
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
retry: 1,
},
},
});
export function AppProviders({ children }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
그다음 Suspense 경계와 Error Boundary를 함께 둔다. Suspense는 대기 UI를 맡고, 실패 UI는 Error Boundary 쪽에서 처리한다.
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { useSuspenseQuery } from "@tanstack/react-query";
async function fetchUser(userId) {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error("사용자 조회에 실패했습니다.");
}
return response.json();
}
function UserDetails({ userId }) {
const { data: user } = useSuspenseQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
});
return <UserProfile user={user} />;
}
export function UserPage({ userId }) {
return (
<ErrorBoundary fallback={<UserErrorView />}>
<Suspense fallback={<UserSkeleton />}>
<UserDetails userId={userId} />
</Suspense>
</ErrorBoundary>
);
}
이 코드에서 UserDetails는 로딩 분기를 직접 갖지 않는다. useSuspenseQuery가 아직 데이터를 준비하지 못하면 가장 가까운 Suspense 경계가 UserSkeleton을 보여준다. 데이터가 준비되면 UserDetails가 다시 렌더링되고, user는 정의된 값으로 다뤄진다.
반대로 아래와 같은 구조는 Suspense로 감싸도 데이터 요청 때문에 fallback이 자동으로 뜨지 않는다. 요청이 Effect 안에서 시작되기 때문이다.
import { Suspense, useEffect, useState } from "react";
function UserListLoader() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch("/api/users")
.then((response) => response.json())
.then(setUsers);
}, []);
return <UserList users={users} />;
}
export function UserListPage() {
return (
<Suspense fallback={<LoadingView />}>
<UserListLoader />
</Suspense>
);
}
이 경우 fallback은 UserListLoader의 Effect 요청과 연결되지 않는다. 경계 안의 다른 자식이 실제로 suspend하지 않는다면 LoadingView는 데이터 요청 때문에 표시되지 않는다. 이런 화면에서는 기존처럼 지역 로딩 state를 두거나, 요청을 Suspense 호환 데이터 계층으로 옮겨야 한다.
경계 배치와 리렌더링 흐름
Suspense의 체감 품질은 API보다 경계 배치에서 많이 갈린다. 너무 상위에 하나만 두면 작은 데이터 하나가 늦어졌을 때 화면 전체가 스켈레톤으로 바뀐다. 반대로 너무 잘게 나누면 여러 fallback이 제각각 나타나 화면이 흔들려 보일 수 있다.
export function UserRoute() {
return (
<AppFrame>
<ErrorBoundary fallback={<RouteErrorView />}>
<Suspense fallback={<MainAreaSkeleton />}>
<UserMainArea />
<Suspense fallback={<ActivitySkeleton />}>
<UserActivity />
</Suspense>
</Suspense>
</ErrorBoundary>
<Suspense fallback={<RecommendationSkeleton />}>
<Recommendations />
</Suspense>
</AppFrame>
);
}
이 구성에서는 UserMainArea가 준비되지 않으면 본문 전체가 MainAreaSkeleton으로 대체된다. 본문이 준비된 뒤 UserActivity만 늦으면 활동 영역만 ActivitySkeleton으로 남는다. Recommendations는 별도 경계를 가지므로 추천 데이터가 늦어도 본문 표시를 막지 않는다.
이미 표시된 UI가 새 요청 때문에 다시 fallback으로 숨는 상황도 볼 수 있다. React 문서는 이런 업데이트를 줄이기 위해 startTransition이나 useDeferredValue를 함께 사용할 수 있다고 설명한다. 사용자가 이미 보고 있는 결과를 유지하면서 새 결과를 준비할지, 아니면 전체 영역을 다시 스켈레톤으로 바꿀지는 로딩 state 문법보다 화면 전환 정책에 가깝다.
선택 기준
단일 컴포넌트가 독립적으로 한 번 요청하고, 실패와 재시도를 같은 영역에서 처리한다면 useEffect와 명시적인 상태가 읽기 쉽다. 요청 취소, 폼 제출 후 갱신, 토스트 표시처럼 사용자 행동과 밀접한 흐름도 지역 state로 다루기 편하다.
라우트 단위 데이터, 서버 렌더링, 캐시 재사용, 병렬 로딩, 점진적 표시가 중요해지면 Suspense 기반 구성을 검토할 만하다. 다만 Suspense 하나를 추가한다고 데이터 계층이 자동으로 정리되지는 않는다. 어떤 계층이 데이터를 소유하는지, 중복 요청을 누가 제거하는지, 실패를 어느 Error Boundary에서 받을지, 기존 화면을 갱신 중에도 유지할지를 함께 정해야 한다.
작은 독립 위젯은 useEffect와 지역 state가 단순할 수 있다. 여러 자식이 같은 시점에 보여야 하는 화면은 하나의 Suspense 경계로 reveal 시점을 묶을 수 있다. 일부만 늦게 보여도 되는 화면은 중첩 Suspense로 점진적 표시를 구성할 수 있다. Effect가 부모와 자식마다 이어져 요청 워터폴이 생기는 화면이라면, 라우터 로더, 서버 데이터 로딩, 클라이언트 캐시의 prefetch처럼 요청을 더 이른 계층으로 끌어올리는 접근이 도움이 된다.
원문 참고
https://www.maeil-mail.kr/question/82
댓글
댓글 쓰기