5 분 소요

🔍 Suspense 동작 원리

Before React 18

주로 lazy와 함께 사용되며, 코드 스플리팅에 사용되었다.
나뉜 번들 파일을 다운로드하는 동안 Suspense의 fallback UI를 표시하도록 동작하였다.

After React 18

18 버전 이후로는 이러한 기능이 data fetching에도 확대 적용되었다.
API의 결과값을 기다리는 동안 fallback UI를 지정해서 보여주는 것이 가능해졌다.

이를 위해서는 Suspense가 비동기를 감지해야 하는데,
이는 하위 컴포넌트에서 Promise를 throw하는 것으로 구현된다.

코드를 통해 확인해보자.

// fetchData.js

function wrapPromise(promise) {
  let status = "pending";
  let response;

  const suspender = promise.then(
    (res) => {
      // 전달받은 Promise의 상태가 settled 되었을 때
      status = "success";
      response = res;
    },
    (err) => {
      // 전달받은 Promise의 상태가 reject 되었을 때
      status = "error";
      response = err;
    }
  );

  const read = () => {
    switch (status) {
      case "pending":
        // Promise의 상태가 settled 되지 않았을 때 그대로 throw Promise
        throw suspender;
      case "error":
        // Promise의 상태가 reject 되었을 때 throw response(error)
        throw response;
      default:
        // 성공 시 데이터 반환
        return response;
    }
  };

  return { read };
}

export default function fetchData(url) {
  const promise = fetch(url)
    .then((res) => {
      if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
      return res.json();
    })
    .then((res) => res)
    .catch((err) => {
      throw err;
    });

  return wrapPromise(promise);
}
// App.js

import fetchData from "./fetchData";

const resource = fetchData("https://jsonplaceholder.typicode.com/todos");

function DataComponent() {
  const todos = resource.read();

  console.log(todos);

  return <div>Data Component</div>;
}

export default function App() {
  return (
    <ErrorBoundary fallback={<h1>Error!</h1>}>
      <Suspense fallback={<h1>Loading...</h1>}>
        <DataComponent />
      </Suspense>
    </ErrorBoundary>
  );
}

내부 동작을 좀 더 이해하기 위해 Suspense를 직접 구현해보자.
ErrorBoundary와 마찬가지로 하위에서의 throw를 catch하기 위해 클래스 컴포넌트로의 구현이 필요하다.

// Suspense.js

import React from "react";

export default class Suspense extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      pending: false,
      error: undefined,
    };
  }

  // Catch Promise!
  componentDidCatch(catchedPromise) {
    if (catchedPromise && typeof catchedPromise.then === "function") {
      this.setState({ pending: true });

      catchedPromise
        .then(() => {
          this.setState({ pending: false });
        })
        .catch((err) => {
          this.setState({ error: err || new Error("Unexpected Error") });
        });
    } else {
      throw catchedPromise;
    }
  }

  componentDidUpdate() {
    if (this.state.pending && this.state.error) {
      throw this.state.error;
    }
  }

  render() {
    return this.state.pending ? this.props.fallback : this.props.children;
  }
}


정상적인 로딩 프로세스의 경우 다음과 같은 과정을 거친다.

Suspense:         pending: false                           pending: true ---------------------> pending: false
                      |                                        ^                                         |
                      v                                        |                                         v
DataComponent:      render --------------> read() ---> throw Promise                                 read() ----> data

즉, 실행 -> api 요청 -> 로딩(fallback) -> 재실행의 과정을 거친다.

이와 같은 방식을 render as you fetch, 직역하면 “가져오는 대로 렌더링” 이라는 뜻이다.
컴포넌트가 실행되고 fetch가 완료되는 대로 해당 데이터를 렌더링하는 이 과정을 이해하고 보니 의미가 더 와닿는다.


❗Suspense 사용 시 주의할 점

다시 주제로 돌아와서 Suspense의 사용이 항상 옳은지에 대해 검토해보자.

1. 리렌더링

Suspense와 데이터를 fetch하는 컴포넌트 사이의 모든 컴포넌트는 “재실행” 과정에서 모두 리렌더링된다.

return (
  <>
    <Suspense>
      <Wrapper1>
        <Wrapper2>
          <DataComponent />
        </Wrapper2>
      </Wrapper1>
    </Suspense>
  </>
);

function Wrapper1({ children }) {
  console.log("hi, I'm Wrapper1");
  return <>{children}</>;
}

function Wrapper2({ children }) {
  console.log("hi, I'm Wrapper2");
  return <>{children}</>;
}

위 코드는 콘솔에 다음과 같이 찍힌다.

hi, I'm Wrapper1
hi, I'm Wrapper2

# api 호출 완료 후 재실행

hi, I'm Wrapper1
hi, I'm Wrapper2

api 요청이 완료되고 Suspense의 render() 가 다시 호출되면서 Suspense 하위의 모든 컴포넌트는 리렌더링되는 것을 알 수 있다.

// Suspense.js

render() {
  return this.state.pending ? this.props.fallback : this.props.children;
}

이 과정에서 데이터와 무관한 컴포넌트도 리렌더링될 수 있다는 점이 문제이다.
이를 방지하려면 Suspense와 DataComponent 간의 계층을 최대한 좁혀야 한다.

2. waterfall

하나의 컴포넌트에서 두 개의 api 호출을 하는 경우를 가정해보자.

import fetchData from "./fetchData";

const todoResource = fetchData("https://jsonplaceholder.typicode.com/todos2");
const postResource = fetchData("https://jsonplaceholder.typicode.com/todos2");

export default function DataComponent() {
  const todos = todoResource.read();
  const posts = postResource.read();

  return <div>Data Component</div>;
}

앞서 Suspense 내부 동작에 따라 위 컴포넌트의 동작을 유추해보면
먼저 todo에 대한 api 요청이 진행되고, Promise를 throw한다.
해당 작업이 완료되면, 다시 post에 대한 Promise를 throw한다.

때문에 api 요청이 병렬로 진행되지 않고, waterfall이 발생한다.

🤔 How to Solve?

  1. 하나의 컴포넌트에서 하나의 api 요청이 일어나도록 분리한다.

    return (
      <>
        <Suspense>
          <TodoComponent />
        </Suspense>
    
        <Suspense>
          <PostComponent />
        </Suspense>
      </>
    );
    
  2. Promise.all 혹은 useSuspenseQueries를 사용한다.

    // Promise.all 사용
    
    export default function DataComponent() {
      const { data } = useSuspenseQuery({
        queryKey: ["todos", "posts"],
        queryFn: async () => {
          const [{ data: todos }, { data: posts }] = await Promise.all([
            getTodos(),
            getPosts(),
          ]);
    
          return { todos, posts };
        },
      });
    
      return <div>Data Component</div>;
    }
    
    // useSuspenseQueries 사용
    
    export default function DataComponent() {
      const [{ data: todos }, { data: posts }] = useSuspenseQueries({
        queries: [
          {
            queryKey: ["todos"],
            queryFn: () => getTodos(),
          },
          {
            queryKey: ["posts"],
            queryFn: () => getPosts(),
          },
        ],
      });
    
      return <div>Data Component</div>;
    }
    

3. 순서 보장 x

컴포넌트에서 api 요청이 각각 이뤄질 경우 로드되는 순서가 보장되지 않는다.

return (
  <>
    <Suspense fallback={<h1>Loading user details</h1>}>
      <UserProfile />
    </Suspense>

    <Suspense fallback={<h1>Loading Todos</h1>}>
      <TodoComponent />
    </Suspense>
  </>
);

아래 이미지의 경우 유저에 대한 데이터보다 todo에 대한 데이터가 먼저 나온다.
이는 사용자가 어색함을 느낄 수 있는 요인이 된다.

🤔 How to Solve?

  1. 순차적으로 로딩

    return (
      <>
        <Suspense fallback={<h1>Loading user details</h1>}>
          <UserProfile />
    
          <Suspense fallback={<h1>Loading Todos</h1>}>
            <TodoComponent />
          </Suspense>
        </Suspense>
      </>
    );
    
  2. 하나의 Suspense로 관리

    return (
      <Suspense fallback={<h1>Loading...</h1>}>
        <UserProfile />
        <TodoComponent />
      </Suspense>
    );
    

4. 깜빡임

로딩을 UI로 알리는 경우, 네트워크 응답 속도가 너무 빨라 생기는 깜빡임 현상이 존재한다.

alt text

🤔 How to Solve?

state 업데이트의 우선순위를 낮추는 useDefferedValue / startTransition을 통해 이를 해결할 수 있다.

useDefferedValue
// App.jsx

export default function App() {
  const [keyword, setKeyword] = useState("");

  const handleChange = (e) => {
    setKeyword(e.target.value);
  };

  return (
    <>
      <input type="text" value={keyword} onChange={handleChange} />

      <Suspense fallback={<h1>Loading...</h1>}>
        <DataComponent keyword={keyword} />
      </Suspense>
    </>
  );
}
// DataComponent.jsx

export default function DataComponent({ keyword }) {
  const deferredKeyword = useDeferredValue(keyword);
  const { data } = useSuspenseQuery({
    queryKey: ["data", deferredKeyword],
    queryFn: () => getProductList(deferredKeyword),
  });

  if (data?.list.length === 0) return <span>데이터가 없습니다.</span>;

  return (
    <ul>
      {data?.list.map((each) => (
        <li key={each.id}>{each.name}</li>
      ))}
    </ul>
  );
}

위 코드에서 input의 value는 즉각적으로 업데이트되지만,
DataComponent 내부에서는 비동기 작업이 완료되기 전까지 Promise를 throw하지 않는다.

alt text


결론: 리액트에서의 로딩 처리

Suspense의 내부 동작 원리를 이해함으로써 발생하는 문제들을 해결할 수 있었다.
상태를 하나라도 줄이고 책임을 분리하는 측면에서 단점보다는 장점이 더 많다고 느껴졌다.
“항상 옳은가?”라는 질문에 대한 답은, “여건이 된다면 그렇다” 라고 결론지었다.


Suspense를 공부하면서 리액트에서의 로딩 처리에 대해 고민해볼 수 있는 시간이었고, 사용해 본 모든 로딩 처리와 문제점을 정리해봤다.

로딩 처리 문제점
아무런 처리를 하지 않는 경우 CLS 발생
Spinner 깜빡임 문제
스켈레톤 깜빡임 문제, 비용
이전 데이터를 보여주는 방식 응답이 너무 느리면 상호작용이 아예 안된다고 판단될 수 있음

정리해보자면, 현재까지의 경험으로 가장 완벽한 로딩 처리 방식은 다음과 같다:

  • 스켈레톤
  • 사용자의 상호작용에 의한 로딩 시 이전 데이터를 보여주는 방식 사용 (useDeferredValueplaceholderData: keepPreviousData)
    • 만약 응답 시간이 너무 길다면, 스켈레톤으로 대체

참고자료

  • https://velog.io/@seungchan__y/React-18-Concurrent-%EB%A7%9B%EB%B3%B4%EA%B8%B0
  • https://blog.logrocket.com/data-fetching-react-suspense/
  • https://happysisyphe.tistory.com/54

카테고리:

업데이트:

댓글남기기