LAB

Suspense


오늘의 목표

Suspense 기능 구현하기
  • 프론트엔드 측에서는 단순히 정적으로 데이터를 가져와서 화면에 그려주는 것 뿐만아니라, 외부 API를 호출하여 동적으로 데이터를 가져와서 화면에 그려주는 기능이 필요합니다.
  • 하지만 외부 API를 호출하는 작업은 네트워크 상태에 따라 지연이 발생할 수 있기 때문에, 이러한 비동기 작업을 처리하는 방법이 필요합니다.
  • 리액트에서는 이러한 비동기 작업을 처리하기 위해Suspense라는 기능을 제공합니다.
  • 이번 섹션에서는 Suspense를 직접 구현해보며, 비동기 작업을 처리해보겠습니다.

Suspense

평소대로 API 호출후, 화면에 그려보기

  • 평소대로 api 호출후 받은 응답값을 화면에 그려주는 코드를 먼저 작성하고, 이를 app.tsx 모듈에서 가져와 img 태그의 src속성에 할당해보겠습니다.
api.ts
1 |
2 | export const getResource = () => {
3 | const random = Math.random();
4 | const photoURL = `https://picsum.photos/seed/${encodeURIComponent(
5 | random
6 | )}/200`;
7 |
8 | return new Promise((resolve, reject) => {
9 | setTimeout(() => resolve(photoURL), 1500);
10 | });
11 | };
12 |
app.tsx
1 | import React from "../core/react.js";
2 | import { getResource } from "./api.js";
3 |
4 | export const App = () => {
5 | ...
6 | const photo = getResource();
7 | return (
8 | ...
9 | <h4>
10 | 오늘 기분은 어떠신가요? 좋은 사진 보고가세요, 오늘도 화이팅입니다!!
11 | </h4>
12 | <img src={photo} alt="랜덤 이미지" style={{ width: "100%" }} />
13 | );
14 | };
15 |
image5
  • 위의 이미지를 보시면,렌더링이 완료 되었지만 화면에 이미지가 반영되지는 않았습니다.
core/suspense.js
1 | const resourceCache = {};
2 |
3 | export const createResource = (asyncTask, key) => {
4 | if (resourceCache[key]) return resourceCache[key];
5 | // 먼저 키가 캐시되어 있는지 확인하고 캐시되어있을시 캐시된값을 반환
6 |
7 | // 캐싱 되어있지 않을때
8 | const promise = asyncTask();
9 | throw { promise, key };
10 | };
11 |
  • 비동기함수에 대한 캐싱을 작성을 했습니다.
  • 캐싱이 되어있지 않을때는 인자로 받은 비동기 함수를 실행시키고, 그 promise 객체를 전파하게끔 했습니다.
  • 이제 인자로 비동기 함수를 받게 되었으니, 애플리케이션에서 사용해보겠습니다.
core/suspense.js
1 | export const App = () => {
2 |
3 | const photo = createResource(getResource, "test");
4 |
5 | return (
6 | <div>
7 | <h2>안녕하세요! 저는 유선향 입니다.</h2>
8 | <h4>
9 | 오늘 기분은 어떠신가요? 좋은 사진 보고가세요, 오늘도 화이팅입니다!!
10 | </h4>
11 | <img src={photo} alt="랜덤 이미지" style={{ width: "100%" }} />
12 | </div>
13 | );
14 | };
15 |
image1image1
  • 렌더링 프로세스 자체가 멈춰버리고, 그결과 아무것도 렌더링 되지 않았습니다.

  • 가상돔 생성시 Promise 처리하기

    • 이전에 작성한 createElement를 이용해서,tag의 타입이 함수인 경우에는 로딩중... 이라는 h2태그를 리턴하게 했습니다.
    core/react.js
    1 | const React = {
    2 | createElement: (tag, props, ...children) => {
    3 | if (typeof tag === "function") {
    4 | try {
    5 | return tag(props, ...children);
    6 | } catch ({ promise, key }) {
    7 | return { tag: "h2", props: null, children: ["로딩중..."] };
    8 | }
    9 | }
    10 | const el = {
    11 | tag,
    12 | props,
    13 | children,
    14 | };
    15 | return el;
    16 | },
    image1
    • 예상대로 대체 UI인 "로딩중..." 이라는 문구가 화면에 표시되는것을 확인할 수 있습니다.
    • 또한 더이상 콘솔에서 전파된 promise에 대한 에러메시지가 출력되지 않았습니다.

    promise 완료 처리하기

    • 그런데 지금은 promise가 완료 되었을때, 다시 렌더링이 실행되거나 하지는 않기 때문에, 영원히 "로딩중..." 상태에 머물러 있게 됩니다.
    • then 메소드로 promise가 완료 되었을때, 캐시객체에 등록하고, 리렌더링이 실행되도록 코드를 수정해보겠습니다.
    core/react.js
    1 | const React = {
    2 | createElement: (tag, props, ...children) => {
    3 | if (typeof tag === "function") {
    4 | try {
    5 | return tag(props, ...children);
    6 | } catch ({ promise, key }) {
    7 | promise.then((value) => {
    8 | resourceCache[key] = value;
    9 | rerender();
    10 | });
    11 | return { tag: "h2", props: null, children: ["로딩중..."] };
    12 | }
    13 | ...
    14 | },
    image1
    • 목표한대로 잘 되고 있습니다!

    동시성 렌더링

    • 위에서 우리는 간단하게 Suspense 기능을 구현해보았습니다.
    • 그런데, suspense 두가지를 동시에 사용하게 되면 어떻게 될까요?
    • 바로 해보겠습니다.
    core/react.js
    1 | ...
    2 | export const App = () => {
    3 | const photo1 = createResource(getResource, "photo-url");
    4 | const photo2 = createResource(getResource, "photo-url2");
    5 |
    6 | return (
    7 | <div>
    8 | ...
    9 | <img src={photo1} alt="랜덤 이미지" style={{ width: "100%" }} />
    10 | <img src={photo2} alt="랜덤 이미지" style={{ width: "100%" }} />
    11 | </div>
    12 | );
    13 | };
    14 | render(<App />, document.getElementById("app"));
    15 |
    image1
    • 두개의 Suspense가 동시에 작동하는 것이 아니라, 첫번째 Suspense가 완료된 이후에야 두번째 Suspense가 작동하는것을 확인할 수 있습니다.
    • 이전에 한개의 Suspense만 사용했을때의 모습과 비교해 보시죠
    image1image1

    동시성 문제 해결하기

    app.tsx
    1 | export const App = () => {
    2 | const photo1 = resourceCache["photo-url-1"];
    3 | const photo2 = resourceCache["photo-url-2"];
    4 |
    5 | return (
    6 | ...
    7 | <Suspense
    8 | fallback={<div>photo-url 로딩중...</div>}
    9 | key="photo-url-1"
    10 | asyncFn={getResource}
    11 | >
    12 | <img src={photo1} alt="랜덤 이미지1" style={{ width: "100%" }} />
    13 | </Suspense>
    14 | <Suspense
    15 | fallback={<div>photo-url2 로딩중...</div>}
    16 | key="photo-url-2"
    17 | asyncFn={getResource}
    18 | >
    19 | <img src={photo2} alt="랜덤 이미지2" style={{ width: "100%" }} />
    20 | </Suspense>
    21 | );
    22 | };
    23 |
    /core/suspense.js
    1 | export const createResource = (asyncFn, key, fallback) => {
    2 | // 먼저 키가 캐시되어 있는지 확인하고 리턴
    3 | if (resourceCache[key]) {
    4 | return;
    5 | }
    6 | const promise = asyncFn();
    7 | throw { promise, key, fallback };//fallback도 전파
    8 | };
    9 |
    10 | export const Suspense = (props, children) => {
    11 | const { asyncFn, key, fallback } = props;
    12 | createResource(asyncFn, key, fallback);
    13 | return children;
    14 | };
    /core/react.js
    1 | const React = {
    2 | createElement: (tag, props, ...children) => {
    3 | if (typeof tag === "function") {
    4 | try {
    5 | return tag(props, ...children);
    6 | } catch ({ promise, key, fallback }) {
    7 | promise.then((value) => {
    8 | resourceCache[key] = value;
    9 | rerender();
    10 | });
    11 | return fallback;
    12 | }
    13 | }
    14 | const el = {
    15 | tag,
    16 | props,
    17 | children,
    18 | };
    19 | return el;
    20 | },
    • 여러 코드를 수정했는데요, 우선 Suspense 컴포넌트가 추가되었습니다.
    • Suspense 컴포넌트는 비동기 함수와 키, 그리고 대체 UI를 props로 받습니다.
    • 그리고 createResource 함수를 호출하여, 비동기 함수를 실행시키고, promise 객체를 전파합니다.
    • React의 createElement 함수에서는 전파된 fallback도 함께 받아서, 대체 UI를 렌더링하도록 수정했습니다.

    목표 완수!

    • 이렇게 해서, Suspense 훅을 아주 간단한 형태로 구현해보았습니다.
    • Suspense 기능을 구현해보면서, 리액트에서 비동기 작업을 처리하는 방법에 대해 조금 더 이해할 수 있었습니다.
    아래는 위의 과정을 거쳐 완성된 index.html 파일입니다!
    <iframe/>을 이용해서 삽입 되어있습니다,
    이미 모든 비동기 작업이 완료되어서 아래에는 두개의 이미지가 보이실텐데요, 브라우져 새로고침으로 다시 한번 확인해 보세요!