본문으로 바로 가기
로고
개발

[React] React Suspense를 파헤쳐보자

읽는 시간 20분
React Suspense를 파헤쳐보자 글의 썸네일"

#들어가며

#React Suspense란?

SuspenseReact의 강력한 기능 중 하나로, 비동기 작업의 처리와 UI 렌더링을 보다 세련되게 관리할 수 있게 해준다. 이 기능은 데이터 로딩, 이미지 또는 스크립트의 지연 로딩과 같은 비동기 작업을 처리할 때 특히 유용하다. Suspense를 사용하면 개발자는 로딩 상태를 더 세밀하게 제어할 수 있고, 결과적으로 사용자 경험을 크게 향상시킬 수 있다.

React 16.6v에서 처음 소개된 Suspense는 초기에 실험적인 기능으로 등장했다. 이 기능의 도입은 React 애플리케이션에서의 비동기 처리에 새로운 패러다임을 제시했다(로드맵으로). 특히, 이 버전에서 Suspense는 주로 코드 스플릿팅과 같은 상황에서 유용하게 사용되었다. 하지만, 그 당시 Suspense는 서버 사이드 렌더링(SSR)을 지원하지 않는 한계가 있었다. 이로 인해 SSR을 사용하는 대규모 애플리케이션에서는 Suspense의 적용에 제한이 있었다. 이러한 초기의 한계에도 불구하고, SuspenseReact 생태계에서 중요한 발전을 이루었으며, 이후 버전에서 점진적으로 개선되고 확장되어 왔다.

#코드 스플릿팅과 Suspense

Suspense의 초기 도입 목적 중 하나는 코드 스플릿팅의 간소화와 개선이었다. 코드 스플릿팅은 애플리케이션의 번들 크기를 줄이고, 필요한 부분만 사용자에게 전달하는 기술이다. 이는 웹 애플리케이션의 초기 로딩 시간을 단축시키는 데 매우 효과적이다.

Suspense를 사용한 코드 스플릿팅의 경우, 개발자는 React.lazy와 함께 Suspense를 사용하여 컴포넌트를 동적으로 불러올 수 있다. 예를 들어, 특정 컴포넌트가 필요할 때까지 로딩을 지연시키고, 해당 컴포넌트가 로딩되는 동안 대체(fallback) 컴포넌트(로딩 인디케이터 등)를 표시할 수 있다.

jsx
import React, { Suspense } from 'react';
 
const LazyComponent = React.lazy(() => import('./LazyComponent'));
 
function App() {
  return (
    <div>
      <Suspense fallback={<div>로딩 중...</div>}>
        <LazyComponent />
      </Suspense>
    </div>
  );
}
jsx
import React, { Suspense } from 'react';
 
const LazyComponent = React.lazy(() => import('./LazyComponent'));
 
function App() {
  return (
    <div>
      <Suspense fallback={<div>로딩 중...</div>}>
        <LazyComponent />
      </Suspense>
    </div>
  );
}

이 코드에서 LazyComponent는 필요할 때만 불러와지고, 그 전까지는 fallback으로 지정된 컴포넌트가 표시된다.

#코드 스플릿팅의 장점

Suspense를 사용한 코드 스플릿팅의 주요 장점은 다음과 같다.

  1. 성능 향상: 사용자가 실제로 필요로 하는 코드만 로딩함으로써 애플리케이션의 초기 로드 시간이 단축
  2. 리소스 최적화: 불필요한 코드 로딩을 방지하여 네트워크 및 메모리 리소스를 절약
  3. 유연한 사용자 경험: 필요한 컴포넌트가 로딩되는 동안 사용자에게 로딩 인디케이터나 기타 플레이스홀더를 보여줄 수 있어, 더 나은 사용자 경험을 제공

#React v18에서의 Suspense와 데이터 페칭

React v18에서 Suspense는 주로 데이터 페칭과 관련하여 크게 발전했다. 이전 버전에서는 주로 코드 스플릿팅과 지연 로딩에 초점을 맞췄던 Suspensev18에서는 데이터 로딩 시나리오에 더욱 효과적으로 적용될 수 있도록 개선되었다. 이 변경을 통해 개발자들은 비동기 데이터 페칭과 관련된 사용자 경험을 더욱 세밀하게 제어할 수 있게 되었다.

#Fetch on Render 방식

기존의 useEffect를 사용한 데이터 페칭 방법, 즉 Fetch on Render 방식에서는 컴포넌트가 렌더링된 후 데이터를 페칭한다. 이 방식은 간단하고 직관적이지만, 여러 컴포넌트가 서로 의존하는 데이터를 로드할 때 "waterfall" 문제가 발생할 수 있다. Waterfall 문제란 하나의 데이터 페칭이 완료된 후에야 다음 데이터 페칭이 시작되어, 전체적인 로딩 시간이 길어지는 현상을 말한다.

Fetch-on-Render
Fetch-on-Render
Waterfall 문제
Waterfall 문제
jsx
import React, { useState, useEffect } from 'react';
 
// 데이터를 가져오는 가상의 함수
const fetchData = (endpoint) => {
  return new Promise((resolve) => {
    setTimeout(() => resolve(`Data from ${endpoint}`), 1000);
  });
};
 
// 부모 컴포넌트
const ParentComponent = () => {
  const [parentData, setParentData] = useState(null);
 
  useEffect(() => {
    fetchData('parentEndpoint').then(data => setParentData(data));
  }, []);
 
  return (
    <div>
      <h1>부모 컴포넌트</h1>
      {parentData ? <ChildComponent /> : <p>데이터 로딩 중...</p>}
    </div>
  );
};
 
// 자식 컴포넌트
const ChildComponent = () => {
  const [childData, setChildData] = useState(null);
 
  useEffect(() => {
    fetchData('childEndpoint').then(data => setChildData(data));
  }, []);
 
  return (
    <div>
      <h2>자식 컴포넌트</h2>
      {childData ? <p>{childData}</p> : <p>데이터 로딩 중...</p>}
    </div>
  );
};
 
export default ParentComponent;
jsx
import React, { useState, useEffect } from 'react';
 
// 데이터를 가져오는 가상의 함수
const fetchData = (endpoint) => {
  return new Promise((resolve) => {
    setTimeout(() => resolve(`Data from ${endpoint}`), 1000);
  });
};
 
// 부모 컴포넌트
const ParentComponent = () => {
  const [parentData, setParentData] = useState(null);
 
  useEffect(() => {
    fetchData('parentEndpoint').then(data => setParentData(data));
  }, []);
 
  return (
    <div>
      <h1>부모 컴포넌트</h1>
      {parentData ? <ChildComponent /> : <p>데이터 로딩 중...</p>}
    </div>
  );
};
 
// 자식 컴포넌트
const ChildComponent = () => {
  const [childData, setChildData] = useState(null);
 
  useEffect(() => {
    fetchData('childEndpoint').then(data => setChildData(data));
  }, []);
 
  return (
    <div>
      <h2>자식 컴포넌트</h2>
      {childData ? <p>{childData}</p> : <p>데이터 로딩 중...</p>}
    </div>
  );
};
 
export default ParentComponent;

이 코드에서 ParentComponent는 데이터를 불러오고, 그 후에 ChildComponent가 렌더링된다. ChildComponent는 자신의 데이터를 가져오는 동안 "데이터 로딩 중..." 메시지를 표시한다. 이러한 방식은 각 컴포넌트가 순차적으로 데이터를 가져오기(Waterfall) 때문에 전체적인 데이터 로딩 시간이 길어지는 문제가 발생한다.

#Fetch then Render 방식

Fetch then Render 방식에서는 데이터를 먼저 페칭하고, 데이터가 준비되면 그에 따라 컴포넌트를 렌더링한다. 이 방식은 데이터 페칭과 렌더링을 더 명확히 분리하지만, 페칭이 얼마나 걸리냐에 따라 초기 로딩 시간에 영향을 줄 수 있다.

Fetch-then-Render
Fetch-then-Render
jsx
import React, { useEffect, useState } from "react";
 
function fetchCountries() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve([{ name: "South Korea" }, { name: "Japan" }]); // 예시 데이터
    }, 1000);
  });
}
 
function fetchTime() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ datetime: new Date().toLocaleString() }); // 현재 시간
    }, 2000);
  });
}
 
async function fetchAllData() {
  const countries = await fetchCountries();
  const time = await fetchTime();
  return [countries, time];
}
 
const allData = fetchAllData(); // 컴포넌트에 들어가기전 미리 fetch 실행
 
// 국가 목록을 표시하는 컴포넌트
function CountryList({ data }) {
  return (
    <ul>
      {data.map((country, index) => (
        <li key={index}>{country.name}</li>
      ))}
    </ul>
  );
}
 
// 시간을 표시하는 컴포넌트
function Time({ data }) {
  return <p>Current Time: {data.datetime}</p>;
}
 
// 메인 컴포넌트
function Countries() {
  const [countries, setCountries] = useState([]);
  const [time, setTime] = useState({});
  const [isLoading, setIsLoading] = useState(true);
 
  useEffect(() => {
    async function fetchData() {
      const [fetchedCountries, fetchedTime] = await allData;
 
      setCountries(fetchedCountries);
      setTime(fetchedTime);
 
      setIsLoading(false);
    }
 
    fetchData();
  }, []);
 
  if (isLoading) {
    return <div>Loading Countries and Time...</div>;
  }
 
  return (
    <>
      <h2>All Countries with the Current Time - Data Fetched and then Rendered</h2>
      <Time data={time} />
      <CountryList data={countries} />
    </>
  );
}
 
export default Countries;
jsx
import React, { useEffect, useState } from "react";
 
function fetchCountries() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve([{ name: "South Korea" }, { name: "Japan" }]); // 예시 데이터
    }, 1000);
  });
}
 
function fetchTime() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ datetime: new Date().toLocaleString() }); // 현재 시간
    }, 2000);
  });
}
 
async function fetchAllData() {
  const countries = await fetchCountries();
  const time = await fetchTime();
  return [countries, time];
}
 
const allData = fetchAllData(); // 컴포넌트에 들어가기전 미리 fetch 실행
 
// 국가 목록을 표시하는 컴포넌트
function CountryList({ data }) {
  return (
    <ul>
      {data.map((country, index) => (
        <li key={index}>{country.name}</li>
      ))}
    </ul>
  );
}
 
// 시간을 표시하는 컴포넌트
function Time({ data }) {
  return <p>Current Time: {data.datetime}</p>;
}
 
// 메인 컴포넌트
function Countries() {
  const [countries, setCountries] = useState([]);
  const [time, setTime] = useState({});
  const [isLoading, setIsLoading] = useState(true);
 
  useEffect(() => {
    async function fetchData() {
      const [fetchedCountries, fetchedTime] = await allData;
 
      setCountries(fetchedCountries);
      setTime(fetchedTime);
 
      setIsLoading(false);
    }
 
    fetchData();
  }, []);
 
  if (isLoading) {
    return <div>Loading Countries and Time...</div>;
  }
 
  return (
    <>
      <h2>All Countries with the Current Time - Data Fetched and then Rendered</h2>
      <Time data={time} />
      <CountryList data={countries} />
    </>
  );
}
 
export default Countries;

#Suspense를 사용한 Render as You Fetch

Suspense를 사용한 Render as You Fetch 또는 Render while Fetch 라고 불리는 패턴은 Fetch on RenderFetch then Render의 장점을 결합했다. 이 패턴에서는 컴포넌트가 렌더링되면서 동시에 데이터를 페칭한다. Suspense는 데이터가 준비되지 않은 경우 대체 UI를 보여주며, 데이터가 준비되면 주 UI로 전환된다. 이 방식은 waterfall 문제를 해결하고 사용자 경험을 향상시킬 수 있다.

Render as You Fetch
Render as You Fetch

#Suspense와 Error Boundary로 우아하게 비동기 다루기

Suspense and ErrorBoundary
Suspense and ErrorBoundary

#기존의 useEffect 사용

jsx
import React, { useEffect, useState } from 'react';
 
function DataFetchingComponent() {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
 
  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch('https://api.example.com/data');
        const result = await response.json();
        setData(result);
      } catch (err) {
        setError(err);
      }
    };
 
    fetchData();
  }, []);
 
  if (error) {
    return <div>에러가 발생했습니다: {error.message}</div>;
  }
 
  if (!data) {
    return <div>로딩 중...</div>;
  }
 
  return <div>{JSON.stringify(data)}</div>;
}
jsx
import React, { useEffect, useState } from 'react';
 
function DataFetchingComponent() {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
 
  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch('https://api.example.com/data');
        const result = await response.json();
        setData(result);
      } catch (err) {
        setError(err);
      }
    };
 
    fetchData();
  }, []);
 
  if (error) {
    return <div>에러가 발생했습니다: {error.message}</div>;
  }
 
  if (!data) {
    return <div>로딩 중...</div>;
  }
 
  return <div>{JSON.stringify(data)}</div>;
}

이 방식에서는 데이터 로딩, 에러 처리, 그리고 실제 데이터 렌더링이 같은 컴포넌트 안에서 이루어진다. 관리하는 데이터가 많아 질수록 이 코드의 가독성과 재사용성에 영향을 미칠 수 있다.

#Suspense와 ErrorBoundary를 사용

React에서 SuspenseErrorBoundary의 결합은 비동기 작업을 처리하는 데 있어 매우 우아하고 효율적인 방법을 제공한다. 이 두 기능을 함께 사용하면 에러와 로딩 상태를 컴포넌트 외부에서 효과적으로 처리할 수 있으며, 컴포넌트 내부는 성공한 데이터 처리에만 집중할 수 있다.

SuspenseErrorBoundary의 결합 Suspense는 데이터 로딩을 처리하기 위한 React의 구성 요소다. 만약 데이터가 아직 준비되지 않았다면, Suspense는 대체 UI(로딩 인디케이터 등)를 렌더링한다. 이를 통해 개발자는 데이터 로딩 상태를 세련되게 관리할 수 있다.

ErrorBoundary는 자식 컴포넌트에서 발생하는 JavaScript 에러를 캐치하고, 이를 대체 UI로 처리한다. 이를 통해 애플리케이션의 나머지 부분이 정상적으로 작동할 수 있도록 보장한다.

이 두 구성 요소를 함께 사용하면, 데이터 로딩 및 에러 처리 로직을 컴포넌트 외부로 추출할 수 있으며, 개발자는 데이터를 처리하고 UI를 렌더링하는 데 집중할 수 있다.

jsx
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
// 에러 바운더리는 Class 컴포넌트로 구현할 수 있지만 라이브러리를 사용했다.
 
import CountryList from "./CountryList";
import Time from "./Time";
 
const Countries = () => {
  return (
    <>
      <h2>Countries with Time - Suspense & Error Boundaries</h2>
 
      <Suspense fallback={<p>Loading time...</p>}>
        <Time />
      </Suspense>
      <ErrorBoundary
        fallback={<p>Something went wrong in fetching countries...</p>}
      >
        <Suspense fallback={<p>Loading countries...</p>}>
          <CountryList />
        </Suspense>
      </ErrorBoundary>
    </>
  );
};
 
export default Countries;
 
// Time.jsx
const resource = fetchData("time url"); // Promise 가 아님!!
 
const Time = () => {
  const time = resource.read();
  // return ...
};
 
// CountryList.jsx
const resource = fetchData("countryList url"); // Promise 가 아님!!
 
const CountryList = () => {
  const countries = resource.read();
  // return ...
};
jsx
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
// 에러 바운더리는 Class 컴포넌트로 구현할 수 있지만 라이브러리를 사용했다.
 
import CountryList from "./CountryList";
import Time from "./Time";
 
const Countries = () => {
  return (
    <>
      <h2>Countries with Time - Suspense & Error Boundaries</h2>
 
      <Suspense fallback={<p>Loading time...</p>}>
        <Time />
      </Suspense>
      <ErrorBoundary
        fallback={<p>Something went wrong in fetching countries...</p>}
      >
        <Suspense fallback={<p>Loading countries...</p>}>
          <CountryList />
        </Suspense>
      </ErrorBoundary>
    </>
  );
};
 
export default Countries;
 
// Time.jsx
const resource = fetchData("time url"); // Promise 가 아님!!
 
const Time = () => {
  const time = resource.read();
  // return ...
};
 
// CountryList.jsx
const resource = fetchData("countryList url"); // Promise 가 아님!!
 
const CountryList = () => {
  const countries = resource.read();
  // return ...
};

Fetch then Render 방식처럼 컴포넌트를 렌더링하기 전에 네트워크 요청을 하고 있다. (fetchData()) 그리고 각각 Suspense 컴포넌트로 래핑해주었다. 처음 Countries 컴포넌트가 마운트되면 TimeCountryList를 실행하고 이는 resource.read() 를 실행하게 된다. 이때 요청이 아직 resolve 되지 않으면 Suspensefallback을 렌더링 하게 된다. 만약 에러가 발생하면 가장 가까운 ErrorBoundaryfallback을 렌더링 한다.

#fetchData (wrapPromise) 중요!

wrapPromise.js
js
function wrapPromise(promise) {
  let status = 'pending'; // 인수의 상태
  let response; // Promise의 결과 저장
 
  const suspender = promise.then(
    res => {
      status = 'success';
      response = res;
    },
    err => {
      status = 'error';
      response = err;
    },
  );
 
  const read = () => {
    switch (status) {
      case 'pending':
        throw suspender;
      case 'error':
        throw response;
      default:
        return response;
    }
  };
 
  return { read };
}
wrapPromise.js
js
function wrapPromise(promise) {
  let status = 'pending'; // 인수의 상태
  let response; // Promise의 결과 저장
 
  const suspender = promise.then(
    res => {
      status = 'success';
      response = res;
    },
    err => {
      status = 'error';
      response = err;
    },
  );
 
  const read = () => {
    switch (status) {
      case 'pending':
        throw suspender;
      case 'error':
        throw response;
      default:
        return response;
    }
  };
 
  return { read };
}

이 함수는 프로미스를 감싸서, Suspense가 비동기 데이터를 처리할 수 있도록 하는 역할을 한다. 프로미스의 상태(대기, 성공, 실패)에 따라 다르게 동작하며, 데이터가 준비되지 않았을 경우 Suspense에 의해 처리된다.

  • 대기 -> promise throw
  • 성공 -> resolve된 데이터 반환
  • 실패 -> 에러 throw


fetchData 함수는 API에서 데이터를 가져오는 네트워크 요청을 수행하고, 이를 wrapPromise로 감싸서 Suspense가 처리할 수 있는 형태로 만드는 역할을 한다. 구현 방법은 다음과 같다.

js
import wrapPromise from './wrapPromise';
 
function fetchData(url) {
  const promise = fetch(url)
    .then(response => response.json())
    .catch(error => {
      throw error;
    });
 
  return wrapPromise(promise);
}
 
export default fetchData;
js
import wrapPromise from './wrapPromise';
 
function fetchData(url) {
  const promise = fetch(url)
    .then(response => response.json())
    .catch(error => {
      throw error;
    });
 
  return wrapPromise(promise);
}
 
export default fetchData;
  1. 네트워크 요청 수행: fetchData 함수는 주어진 URL로부터 데이터를 가져오는 네트워크 요청을 수행한다. 이를 위해 fetch API 또는 axios와 같은 라이브러리를 사용할 수 있다.

  2. Promise 처리: 네트워크 요청은 Promise를 반환한다. 이 Promise는 데이터의 로드가 완료되었을 때 결과를 반환하거나, 오류가 발생했을 때 오류를 반환한다.

  3. wrapPromise 함수 사용: fetchData 함수는 이 PromisewrapPromise 함수에 전달한다. wrapPromise 함수는 이 Promise를 처리하여 Suspense가 이해할 수 있는 형태로 변환한다.

  4. 응답 객체 반환: fetchData 함수는 최종적으로 wrapPromise 함수에서 반환된 객체를 반환한다. 이 객체는 read 메서드를 통해 데이터를 동기적으로 읽을 수 있게 해준다.

이렇게 구현된 fetchData 함수는 React 컴포넌트에서 Suspense와 함께 사용될 수 있다. 데이터가 준비되지 않았을 때는 Suspensefallback이 표시되고, 에러가 발생했을 때는 가장 가까운 ErrorBoundaryfallback이 표시된다.

#Note - 알아두기

#마치며

이전에는 Suspense를 단순히 로딩 처리를 위한 도구로만 인식했지만, 이제는 그 이상의 것으로 인식하게 되었다. 마치 JavaScriptPromise가 복잡한 비동기 로직을 간결하고 선언적으로 다루도록 도와주는 것처럼, SuspenseReact에서 데이터 로딩과 관련된 UI 표현을 보다 명확하고 효과적으로 관리할 수 있게 만들어 주는 것 같다. SuspenseErrorBoundary를 통해 우리는 컴포넌트의 로딩 상태와 에러 상태를 더 직관적으로 처리하고, 코드를 더 간결하게 처리할 수 있었다.

#참고자료

Data Fetching using React Suspense and Error Boundary - React Data Fetching Patterns.

React Suspense와 비동기 통신 - Kasterra's Archive