ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [React] Concurrency(동시성) - #1. Suspense와 React.lazy
    Front-End(Web)/React - 프레임워크(React, Next) 2022. 10. 24. 02:53
    반응형

    🧐 서론

    전 회사분들과 진행하는 스터디에서 나는 Suspense와 React.lazy를 통한 코드 스플리팅 부분을 맡게 되었다.

    Suspense에 대해서만 공부하려고 했다가, Error Boundary나 Transition API 등 관련된 기능들을 함께 공부하면서 Concurrent Rendering(동시성 렌더링)까지 공부하게 되었다.

     

    첫 번째 주제는 선언적 Loading UI와 Code Splitting(코드 분할)을 위한 Suspense와 React.lazy부터 시작된다.

    기존의 비동기 렌더링 분기를 어떻게 사용했는지 알아보고 이것이 동시성 모드에서 어떻게 변화하려고 하는지 로 시리즈를 전개하려고 한다.

     


     

    💙 Code Splitting(코드 분할)

    Suspense와 React.lazy를 바로 정리하기 앞서, Code Splitting(코드 분할)의 개념과 의의를 먼저 짚고 넘어가는게 좋다고 생각했다.

    코드 분할은 React 프로젝트를 빌드하면서 하나의 파일로 병합하는 번들링에서 요구되는 주요 컨셉이다.

     

    - Bundling(번들링)

    번들링은 JS 파일들의 연관성(import/export)을 따라가며 하나의 패키지 파일로 병합하는 과정이다. (빌드 결과물, 번들 파일)

    우리가 React로 개발한 웹사이트에서 페이지를 요청하면, 이 번들 파일을 불러와서 화면에 그려지게끔 하는 것이다.

    대표적으로, Webpack(CRA, Gatsby), Rollup(Vite), Browserify 등의 번들링 툴이 있다.

    // 번들링 전
    
    // app.js
    import { add } from './math.js';
    
    console.log(add(16, 26)); // 42
    
    // math.js
    export function add(a, b) {
      return a + b;
    }
    // 번들링 후
    
    function add(a, b) {
      return a + b;
    }
    
    console.log(add(16, 26)); // 42

    위와 정확히 일치하진 않지만, 번들링이 어떠한 흐름으로 진행되는지 감만 잡고 Code Splitting에 대해 좀 더 알아보자!

     

     

    - Code Splitting(코드 분할)이 필요한 이유

    번들링된 앱은 최적화되어 있지만, 프로젝트가 커지면 그만큼 번들 사이즈도 커지게 된다.

    이렇게 거대한 파일을 불러오거나, 브라우저가 이를 파싱하면서 퍼포먼스 저하가 발생할 수 밖에 없다.

    그렇기에, 번들을 나누어야 할 필요가 있는 것이다.

     

    코드 분할은 런타임에 여러 번들을 동적으로 만들고, 이 번들들을 우선순위에 따라 로드하거나 병렬적으로 로드하는 기능으로 Webpack과 같은 번들러는 이를 지원한다.

    코드 분할은 앱을 Lazy Loading(지연 로딩) 을 할 수 있게끔 하여, 필요한 코드만 불러와 앱의 초기화 로딩 성능을 향상시킨다.

     

     

    * dynamic import

     

    ES module 시스템에서, import/export 구문을 통해 각각의 JS 파일(모듈)을 공유할 수 있게 된다.

    여기서, 동적 import를 적용하게 되면 Webpack이 이 구문을 확인하는 시점에서 앱의 코드를 분할하게 된다. (CRA에 설정되어 있음)

    // basic import
    import { add } from './math';
    
    console.log(add(16, 26));
    // dynamic import
    import("./math").then(math => {
      console.log(math.add(16, 26));
    });

     

     

     

    💙 React.lazy

    React에서 컴포넌트들 역시 JS모듈로 공유된다. 하지만 이를 동적으로 import 하면 에러가 발생한다.

    컴포넌트를 동적 import 하기 위한 문법이 바로 React.lazy 인 것이다.

    const SomeComponent = React.lazy(() => import('./SomeComponent'));

     

    React.lazy는 동적 import를 호출하는 함수를 인자로 받아 컴포넌트를 Promise 비동기로 반환한다.

    불러오는 컴포넌트 모듈은 1) React Component를 포함하고, 2) 이를 Default Export 해야 한다는 2가지 조건이 요구된다.

    * Named Export를 하려면 이를 default로 재정의한 중간 모듈이 필요. 이들 역시 tree shaking 됨

     

     

     

    React.lazy로 가져온 Loadable Component는 Suspense 아래에서 렌더링되어야 한다. 이 내용을 아래에서 자세히 보자.

     


     

    💙 Suspense

    Suspense는 React v16.6에서 실험적 기능으로 등장했다가, React 18에서 정식으로 추가된 기능이다.

    Concurrent 모드에서 사용이 가능하므로, App.jsx가 ReactDOM.createRoot().render 의 18버전 형태로 작성되야 Suspense 컴포넌트가 정상적으로 작동한다.

     

    Suspense는 컴포넌트의 렌더링을 특정 작업이 끝날 때까지 잠시 중단시키고, 다른 컴포넌트(fallback)를 먼저 렌더링하도록 도와주는 기능이다.

    즉, Data Fetching이나 Component Lazy Loading 등의 비동기를 처리할 때, 응답을 기다리는 동안 fallback UI(스피너 등)을 먼저 보여주고, 그 사이에 우선순위가 높은 다른 UI들이 먼저 렌더링되도록 하는 것이다.

     

     

    1. Suspense 문법

    function SuspenseExample() {
      return (
        <Suspense fallback={<FallbackComponent />}>
          <ContesntsComponent />
        </Suspense>
      )
    }

     

    기본적인 문법은, 로딩 처리할 컴포넌트를 <Suspense> 컴포넌트로 감싸고, 로딩 중에 보여줄 fallback 요소를 인자로 전달하면 된다.

    Suspense의 원리는 아래와 같이 정리한다.

    1. Child Component의 render() 메서드에서 캐시로부터 값(비동기)을 읽기를 시도한다.
    2. value가 캐시되어 있으면 정상적으로 렌더한다.
    3. value가 캐시되어 있지 않으면 캐시는 Promise를 throw 한다. 여기서 가장 가까운 Suspense의 Fallback이 노출된다.
    4. Promise가 resolve 되면, React는 Promise를 throws 한 곳부터 재시작한다. 여기서 Child Component를 다시 노출한다.

    React Core Team - Andrew Clark의 Suspense 트윗

     

     

    2. Suspense와 Data Fetching

    Suspense 컴포넌트가 가장 유용하게 사용되는 예시 중 하나인 데이터를 불러오는 경우이다. 

     

    * AS-IS : 명령형 비동기 처리

    function App() {
      const [userData, setUserData] = useState(null)
      const [isLoading, setIsLoading] = useState(false)
      const [errorMessage, setErrorMessage] = useState('')
    
      useEffect(() => {
        const fetchUserData = async() => {
          try {
            setIsLoading(true)
            const { data } = await apiClient.get('api/user');
            setUserData(process(data))
          } catch(e) {
            setUserData(null)
            setErrorMessage(e.message)
          } finally {
            setIsLoading(false)
          }
        }
        fetchUserData()
      }, [])
    
      if (isLoading) return <Spinner/>
      
      if (!userData) return <Error />
      
      return (
        <div>{userData.name}<div>
      )
    }

    기존에는 비동기 요청의 상태(로딩, 에러)에 따라 각각의 UI를 JSX 단에서 명시해주기에, 그만큼 템플릿 코드가 많아지고 복잡해진다.

    이는, 복수의 비동기 처리를 진행하면 더욱 분기가 많아질 것이다.

     

    무엇보다, 이 예시의 문제는 waterfall 이다. 컴포넌트가 마운트가 다 된 뒤, 데이터를 패치하기 시작하고 이 도중에 로더를 그려준다.

    즉, 여러 개의 요청 로직이 직렬적으로 수행된다는 단점이 있다.

     

     

    * TO-BE : 선언형 비동기 처리

     

    위 예시를, Suspense를 활용한 선언적 비동기 코드로 바꿔보자. (에러는 다음 섹션인 Error Boundary에서 깊게 다루겠다!)

    function App() {
      return (
        <Suspense fallback={<Spinner />}>
          <UserName />
        </Suspense>
      )
    }
    
    function UserName() {
      const [userData, setUserData] = useState(null)
    
      const fetchUserData = async () => {
        try {
          const { data } = await apiClient.get('api/user');
          setUserData(process(data))
        } catch(e) {
          console.error(e);
        }
      }
      
      useEffect(() => {  
        fetchUserData()
      }, [])
    
      return (
        <div>{userData.name}<div>
      )
    }

    <Suspense>는 내부의 비동기가 아직 resolve 되지 않은 Promise인 경우, 컴포넌트가 준비되지 않았다고 판단하여 Fallback UI를 노출한다.

    비동기가 resolve 되면 그 때 컴포넌트를 노출하며, 에러가 발생하면 이를 버블링해서 가까운 ErrorBoundary 에 위임한다.

     

    결론적으로, 기존에 하나의 컴포넌트에서 많은 분기를 가졌던 명령적 방법에서,

    Suspense는 로딩처리에 관한 부분만 맡으며 컴포넌트는 로딩에 성공한 UI만 담당하는 선언적 방법으로 개선된 것이다.

     

    이를 통해, 개발자는 UI와 비즈니스 로직에 집중할 수 있게 된 것이 Suspense가 만든 첫 번째 개선점인 것이다.

     

     

    3. Suspense와 React.lazy

    SPA(Single Page Application)의 단점은 한 번에 모든 리소스를 불러오기에, 그만큼 시간이 오래 걸리는 것이다.

    React.lazy() 로 컴포넌트를 dynamic import 하면 위의 문제를 보완할 수 있으며, 이 비동기 컴포넌트는 반드시 <Suspense> 하위에 있어야 한다.

    import React, { Suspense } from 'react';
    
    const OtherComponent = React.lazy(() => import('./OtherComponent'));
    
    function MyComponent() {
      return (
        <div>
          <Suspense fallback={<div>Loading...</div>}>
            <OtherComponent />
          </Suspense>
        </div>
      );
    }

    React.lazy는 컴포넌트가 렌더링되는 시점에서 비동기적으로 로딩을 시켜주며, 이를 통해 간단하게 코드 분할이 가능하다.

    또한, 기존이 fetch-on-render(직렬방식) 이라면, Suspense는 render-as-fetch(병렬방식) 으로 효율적인 렌더링을 제공한다.

    (문서링크)

    • fetch-on-render : fetch 요청 - Loader 노출 - fetch 응답 - 컴포넌트 렌더링
    • render-as-fetch : fetch 요청 - Suspense 동작(Loader 노출 + 컴포넌트 hidden 렌더링) - fetch 응답 - 컴포넌트 반영

     

     

    4. Route-based Code Splitting

    이렇게, Lazy Loading으로 코드분할을 하는 경우는 주로 아래의 3가지가 있다.

    1. Router Level : 각 라우트가 다른 컴포넌트를 관리하는 경우 페이지 컴포넌트 단위로 Lazy Loading
    2. Page Level : 페이지 안의 특정 컨텐츠의 로딩이 오래 걸리는 경우, 해당 컨텐츠만 Lazy Loading하여 다른 부분을 우선 노출
    3. Component Level : Input 등 유저 상호작용으로 나타내는 컴포넌트를 Lazy Loading. 상호작용 시에 노출.

     

    앱에서 코드 분할을 어디에 도입할지는 매우 까다로우나, 공식문서는 Route에서 시작할 것을 권장한다.

    페이지 전환에는 어느 정도 시간을 요구하며, 페이지가 보통 한 번에 렌더링되기 전까지 사용자가 다른 상호작용을 하지 않기 때문이다.

    import React, { Suspense, lazy } from 'react';
    import { Routes, Route } from 'react-router-dom';
    import Spinner from './items/Spinner'
    //import Login from './pages/Login';
    //import Main from './pages/Main';
    //import Search from './pages/Search';
    //import Setting from './pages/Setting';
    const Main = lazy(() => import('./pages/Main'));
    const Login = lazy(() => import('./pages/Login'));
    const Search = lazy(() => import('./pages/Search'));
    const Setting = lazy(() => import('./pages/Setting'));
    
    function App() {
    
      return (
        <div>
        	<Suspense fallback={<Spinner text='페이지를 불러오는'/>}>
              <Routes>
                <Route exact path='/' component={Main} />
                <Route path='/login' component={Login} />
                <Route path='/setting' component={Setting} />
                <Route path='/search/query=:word' component={Search} />
              </Routes>
    		</Suspense>
        </div>
      );
    }
    
    export default App;

     

     

    5. Suspense의 지향점

    React 공식문서에 따르면 현재 18버전의 Suspense는 React.lazy를 통한 비동기 컴포넌트를 지원한다고 설명한다.

    Data Fetching에도 적용이 가능하지만, 아직까지 본격적으로 권장하고 있지 않다.

     

    하지만, React Core Team은 궁극적으로 Data Fetching 뿐만 아니라 어떤 비동기 요청(이미지 등)에도 사용할 수 있도록 설계하는 것이 앞으로 나아갈 목표라고 한다.

     

     

     

    💙 SuspenseList

    React 17 히스토리 문서에서, 동시성 모드에 관련된 API들을 보다가 <SuspenseList> 를 볼 수 있었다. (문서링크)

     

    Concurrent Mode API Reference (Experimental) – React

    A JavaScript library for building user interfaces

    17.reactjs.org

    SuspenseList 는 여러 개의 Suspense를 사용할 때 노출되는 순서를 인위적으로 설정할 경우 사용한다.

    보통은 비동기가 resolve 되는대로 노출하겠지만, Suspense들을 위-아래 등 어떤 순서로 노출할지를 설정할 수 있다.

     

     

    - 문법

    <React.SuspenseList revealOrder="forwards">
      <React.Suspense fallback="Loading...">
        <ProfilePicture id={1} />
      </React.Suspense>
      <React.Suspense fallback="Loading...">
        <ProfilePicture id={2} />
      </React.Suspense>
      <React.Suspense fallback="Loading...">
        <ProfilePicture id={3} />
      </React.Suspense>
    </React.SuspenseList>

    이처럼, <SuspenseList> 로 적용할 <Suspense> 들을 랩핑만해주면 된다. 설정할 수 있는 props는 revealOrder, tail 2가지다.

     

    1) revealOrder : Suspense 컴포넌트가 그려질 순서

    • null : 비동기가 완료되는 대로 (default)
    • "forwards" : 위에서 아래로
    • "backwards" : 아래에서 위로
    • "together" : 모든 컴포넌트를 동시에

     

    2) tail : Suspense 컴포넌트들의 fallback 처리 방법

    • null : 전부 보여준다 (default)
    • "collapsed" : revealOrder 설정 순서대로 하나씩 노출
    • "hidden" : 모든 fallback 미노출

     

    revealOrder는 직계 컴포넌트에만 적용되지만, tail은 모든 하위 컴포넌트에 적용되므로 이를 피하려면 다시 SuspenseList로 맵핑해야한다.

     


     

    📎 출처

    - [Code Splitting] React 공식문서 : https://reactjs.org/docs/code-splitting.html

    - [Code Splitting(공식문서 번역)] xtring 님의 블로그 : https://xtring-dev.tistory.com/24

     

    - [동시성에서의 Suspense] React 17 히스토리 문서 : https://17.reactjs.org/docs/concurrent-mode-suspense.html

    - [React.lazy & Suspense] web.dev 기술 블로그 : https://web.dev/i18n/ko/code-splitting-suspense/

    - [Suspense] 콴다 기술 블로그 : https://blog.mathpresso.com/conceptual-model-of-react-suspense-a7454273f82e

    - [Suspense] bbaa3218 님의 블로그 : https://tecoble.techcourse.co.kr/post/2021-07-11-suspense/

    - [Suspense와 대수적 효과] maxkim 님의 블로그 : https://maxkim-j.github.io/posts/suspense-argibraic-effect/  

    - [SuspenseList] itchallenger 님의 블로그 : https://itchallenger.tistory.com/306

     

    - [동시성 모드 API] React 17 히스토리 문서 : https://17.reactjs.org/docs/concurrent-mode-reference.html

     

    반응형
Designed by Tistory.