기본 콘텐츠로 건너뛰기

useEffect 로딩 상태와 Suspense는 무엇이 다르고 언제 써야 할까

useEffect 로딩 상태와 Suspense는 무엇이 다르고 언제 써야 할까

빠른 답

  • useEffect 방식은 요청 시작, 성공, 실패, 로딩 종료를 컴포넌트 안의 state로 직접 관리한다.
  • Suspense는 자식 트리가 아직 렌더링 준비를 끝내지 못했을 때 가장 가까운 fallback을 보여주는 경계 모델이다.
  • 일반 fetchuseEffect 안에서 호출하는 것만으로는 Suspense가 동작하지 않는다.
  • Suspense를 쓰려면 fallback 위치, Error Boundary, 데이터 캐시나 프레임워크 지원 여부를 함께 봐야 한다.

한눈에 비교

상태 소유권
useEffect 방식은 isLoading , data , error 를 컴포넌트가 직접 가진다. Suspense 방식은 대기 UI 표시를 경계가 맡고, 데이터 준비 여부는 Suspense를 지원하는 데이터 소스가 담당한다.
데이터 흐름
useEffect 는 첫 렌더 이후 Effect에서 요청을 시작하고 응답을 state로 반영한다. Suspense 는 렌더링 중 값을 읽을 때 아직 준비되지 않았음을 React가 감지하고 경계로 빠진다.
리렌더링
useEffect 는 setState 가 일어나며 로딩 화면, 성공 화면, 실패 화면으로 다시 렌더링된다. Suspense 는 자식 트리 렌더링을 보류했다가 데이터가 준비되면 다시 렌더링을 시도한다.
에러 처리
useEffect 는 보통 error state와 조건부 렌더링으로 처리한다. Suspense 와 함께 쓰는 Promise 실패는 Error Boundary 또는 대체 값 설계가 필요하다.
적용 조건
useEffect 는 브라우저 API, 네트워크 요청, 외부 위젯처럼 컴포넌트 밖 시스템과 동기화할 때 쓸 수 있다. Suspense 는 lazy , use , Relay, Next.js, TanStack Query 같은 Suspense 지원 흐름이 있어야 데이터 로딩과 연결된다.
설계 초점
useEffect 는 요청 생명주기를 어떻게 관리할지가 중심이고, Suspense 는 어느 UI 범위를 함께 기다리고 어디부터 먼저 보여줄지가 중심이다.

시간 흐름으로 이해하기

초기 렌더
useEffect 는 비어 있는 state로 먼저 렌더링한 뒤 Effect 실행을 예약한다. Suspense 는 렌더링 중 자식이 필요한 값을 읽는 시점부터 대기 가능성이 생긴다.
요청 시작
useEffect 는 마운트 이후 Effect 안에서 요청을 시작한다. Suspense 는 캐시, 라우터, 서버 컴포넌트, use 같은 Suspense 지원 데이터 소스에서 요청 흐름이 만들어진다.
대기 UI 표시
useEffect 는 status === "loading" 같은 조건문으로 로딩 UI를 반환한다. Suspense 는 가장 가까운 fallback 을 보여준다.
데이터 준비
useEffect 는 응답을 받은 뒤 state를 업데이트한다. Suspense 는 준비된 값을 다시 읽으며 보류했던 자식 렌더링을 재시도한다.
실패 처리
useEffect 는 컴포넌트 내부 분기로 실패 화면을 보여준다. Suspense 와 연동된 실패는 Error Boundary가 받거나 Promise의 대체 값을 통해 처리한다.

현재 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>
  );
}

이 경우 fallbackUserListLoader의 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

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...