Hengxi's 개발 블로그

[React] Concurrent UI Pattern 이란 (UI 개발 패턴) 본문

개발/React

[React] Concurrent UI Pattern 이란 (UI 개발 패턴)

HENGXI 2022. 11. 6. 22:21

프론트엔드 개발자로 실무에서 일을 시작하고 생각보다 프론트엔드 개발자로서 개발에 있어 '사용자 경험'이 매우 중요하다는 것을 깨닫고 자연스럽게 사용자 경험에 대해서 고민을 하게 되었다.

 

Concurrent UI Pattern에 대해서 알게 되었고 React Query와 함께 Concurrent UI Pattern을 도입하는 방법이라는 카카오페이 Tech Log의 글을 보게 되었다. 많은 공부가 되었다고 생각하여 개인적으로 정리를 해보고자 했다.

 

Concurrent UI Pattern 이란

리액트 공식 문서 - Concurrent 모드에 대해

 

리액트 공식 문서를 보면 Concurrent 모드에 대한 도입이 실험적 단계로 진행되고 있다고 한다. 그렇다면 이 Concurrent 모드는 무엇이고 왜 등장한 것일까?
Concurrent는 우리말로 '동시의'라는 뜻을 가지고 있다. 자바스크립트는 다중 코어를 이용해서 병렬적으로 실행시키는 언어가 아니다. 이벤트 루프라는 방식을 이용해서 이 한계점을 최대한 극복할 수 있는 구조로 되어있으며, 자바스크립트의 라이브러리인 React 또한 마찬가지이다. Concurrent 모드는 이런 환경 속에서 최대한 동시성을 추구할 수 있는 방법을 도입하겠다는 의미를 담고 있다고 한다.
우리가 짠 코드들이 동시적으로 잘 작동할 수 있지만, 꼭 그렇지만은 않다. DOM 트리가 복잡해질 수록 쟁크 현상이나, 반응성 저하 문제 등이 React에서 발생할 수 있다. 따라서 많은 프론트엔드 개발자들은 항상 사용자 경험을 높이는데에 온 힘을 쏟고 있고, 렌더링 최적화는 필수적인 요소가 되었다.
Concurrent 모드를 사용하면 앱이 빠른 반응속도를 유지하도록하고 사용자 기기의 성능과 네트워크 속도에 맞추어 동작할 수 있게끔 만들 수 있다고 한다. 이를 위해 "우선순위에 따른 화면 렌더", "컴포넌트의 지연 렌더" 그리고 "로딩 화면의 유연한 구성" 등을 쉽게 구성할 수 있도록 특성화된 기능들을 제공하고 있다. 이러한 기능들을 사용한 UI 개발 패턴을 React 팀에서는 "Concurrent UI Pattern"이라고 부르고 있다.

명령형 컴포넌트와 선언형 컴포넌트

갑자기 명령형 컴포넌트와 선언형 컴포넌트?

Concurrent UI Pattern을 도입한 컴포넌트 작성 방식이 개발론에서의 '선언적 프로그래밍'을 설명하는 방식과 비슷하기 때문이라고 한다.

 

Concurrent UI Pattern을 사용하지 않은 컴포넌트가 "어떻게" 애플리케이션에 상태에 따라 화면을 보여줄지에 집중한다면, Concurrent UI Pattern을 사용한 컴포넌트는 사용자 경험의 향상을 위해 "무엇을" 애플리케이션에서 보여줄지에 집중하기 때문이다.

 

명령형 컴포넌트를 사용한 React Component

명령형 컴포넌트를 사용한 React Component를 보자

import { useState, useEffect } from 'react';
const ImperativeComponent = () => {
  const [ isLoading, setIsLoading ] = useState(false);
  const [ data, setData ] = useState();
  const [ error, setError ] = useState();
  useEffect(() => {
    !async () => {
      try {
        setIsLoading(true);
        const { json } = await fetch(URL);
        setData(json());
        setError(undefined);
        setIsLoading(false);
      } catch(e) {
        setData(undefined);
        setError(e);
        setIsLoading(false);
      }
    }();
  }, []);
  if (isLoading) {
    return <Spinner/>
  }
  if (error) {
    return <ErrorMessage error={error}/>
  }
  return <DataView data={data}/>;
}
export default ImperativeComponent;

이 컴포넌트는 API에서 데이터를 비동기적으로 불러와서 사용자에게 보여주는 역할을 한다. 이 과정에서 비동기 데이터 불러오는 중임을 <Spinner/> 컴포넌트를 통해 사용자에게 알리고, 데이터 불러오기 과정에서 에러가 발생할 경우 <ErrorMessage/> 컴포넌트를 통해 에서 상황임을 알려주고 있다.

<Spinner/> 컴포넌트와 <ErroMessage/> 컴포넌트는 <ImperativeComponent/>의 state에 따라 화면에 보이거나 보이지 않는다. 즉, <ImperativeComponent/>는 UI를 어떻게(HOW) 보여줄 것이냐 에 집중하고 있는 것이다.

선언형 컴포넌트를 사용한 React Component

선언형 컴포넌트를 사용한 컴포넌트는 '무엇을(WHAT)' 보여줄 것이냐'에 집중한다.

즉, 선언형 컴포넌트를 사용한 컴포넌트는 state에 따라 UI를 화면에 그리는 것이 아니라 상황에 따라 적절한 UI를 화면에 보여주어야 할 것이다. 

React에서는 컴포넌트를 선언적으로 구성하는데 유용하게 사용할 수 있는 두 가지 구성요소를 제공한다.

Suspense

Suspense를 사용하면 컴포넌트가 렌더링하기 전에 다른 작업이 먼저 이루어지도록 "대기"한다.

Suspense는 React Component 내부에서 비동기적으로 다른 요소를 불러올 때 해당 요소가 불러와질 때까지 Component의 렌더링을 잠시 멈추는 용도로 사용할 수 있는 컴포넌트이다.

import { Suspense, lazy } from 'react';
const HugeComponent = lazy(() => import('./HugeComponent'));
const ComponentWithSuspense = () => {
  return (
    <Suspense fallback={<Spinner />}>
      <HugeComponent />
    </Suspense>
  );
};
export default ComponentWithSuspense;

lazy를 사용해서 <HugeConponent/>를 비동기적으로 불러오게 구성했다. <ComponentWithSuspense/> 컴포넌트에서는 <HugeConponent/>를 Suspense를 사용해서 컴포넌트 내부에 비동기적으로 불러오고, <HugeConponent/>가 불러와지는 중에는 Suspense의 fallback Prop을 통해 <Spinner/>를 화면에 보여준다.

 

특정 컴포넌트를 비동기적으로 불러와서 화면에 보여주는데, 비동기 로딩이 진행 중인 상태에는 그에 맞추어 스피너를 화면에 노출하는 이 상황, 화면을 어떻게(HOW) 그릴지 집중하는 것이 아니라 무엇을(WHAT) 보여줄 것인지 집중하였다고 보기에 충분하다.

데이터 불러오기를 위한 Suspense

Suspense를 사용해서 비동기 데이터도 선언적으로 처리도 가능하다면 화면을 어떻게(HOW) 그릴지 고민하지 않고 API 로딩 중인 경우와 비동기 데이터가 불러와진 경우에 따라 무엇을(WHAT) 사용자에게 보여줄지를 바탕으로 컴포넌트를 구성할 수 있을 것이다.

import { Suspense } from 'react';
const User = () => {
  return (
    // UserProfile에서 비동기 데이터를 로딩하고 있는 경우
    // Suspense의 fallback을 통해 Spinner를 보여줌
    <Suspense fallback={<Spinner />}>
      <UserProfile />
    </Suspense>
  );
};
const UserProfile = () => {
  // userProfileRepository는 Suspense를 지원하는 "특별한 객체"
  const { data } = userProfileRepository();
  return (
    // 마치 데이터가 "이미 존재하는 것처럼" 사용함
    <span> {data.name} / {data.birthDay} </span>
  );
};
export default User;

<User/> 컴포넌트에서는 Suspense를 통해 <UserProfile/> 컴포넌트를 불러오고 있다. <UserProfile/>는 lazy를 통해 불로오고 있지 않기 때문에, 위에서 말했던 Suspense에서 동적 컴포넌트 불러오기와는 관련 없는 컴포넌트이다.

 

대신 <UserProfile/> 컴포넌트 내부에서는 Suspense를 지원하는 userProfileRepository 객체를 통해 데이터를 비동기적으로 불러오고 있다. 이 userProfileRepository는 Promise를 반환하는 일반적인 fetch 함수가 아니다. Suspense를 지원하는 특별한 객체로 비동기 데이터 불러오기도 Suspense를 통해 처리할 수 있다.

 

위 예시 코드에서 데이터 불러오기가 완료된 후 화면은 전적으로 <UserProfile/> 컴포넌트에서 담당한다. <UserProfile/> 컴포넌트는 이미 데이터가 불러와져 있음을 전제로 작성되어 있고, 비동기 요청이 진행 중인 상태에서 사용자에게 보일 화면에 대해서는 일체 관심이 없다. 대신 <UserProfile/> 컴포넌트를 불러오는 <User/> 컴포넌트가 비동기 요청 상태에 따라 어떤(WHAT) 화면을 보여줄지를 관리한다. 만약 데이터를 불러오는 중이라면  <UserProfile/> 컴포넌트를 렌더하지 않고 Suspense의 fallback으로 지정된 <Spinner/> 컴포넌트를 화면에 보여줄 것이다.

Error Boundary

Error Boundary는 React Component 내부에서 에러가 발생한 경우 사용자에게 잘못된 UI나 빈 화면을 보여주는 대신 미리 정의해 둔 Fallback UI를 화면에 보여주기 위한 컴포넌트이다.

 

Error Boundary를 잘 사용하면 애플리케이션 내부에서 "에러"가 발생한 상황을 사용자에게 우아하게 보여줄 수 있다. 컴포넌트 내부에서 state를 통해 에러 UI를 관리하고 사용자에게 보여주는 것이 아니라, 에러가 발생한 상황에 "어떤 화면을 Fallback으로 보여줄 것인지"를 고민할 수 있는 것이다.

Error Boundary를 쉽게 쓰기 위한 react-error-boundary

카카오페이 프론트엔드 팀에서는 Error Boundary를 더 쉽에 사용하기 위해 [react-error-boundary]라는 Componenet를 사용한다고 한다.

react-error-boundary는 getDerivedStateFromError나 componentDidCatch를 사용하여 직접 에러 UI 상태를 구현해야 하는 Error Boundary를 추상화하여 사용할 수 있게 정리한 컴포넌트라고 한다.

import { ErrorBoundary } from 'react-error-boundary';
import { sendErrorToErrorTracker } from '../utils';

const UserProfileFallback = ({ error, resetErrorBoundary }) => (
  <div>
    <p> 에러: {error.message} </p>
    <button onClick={() => resetErrorBoundary()}> 다시 시도 </button>
  </div>
);
const handleOnError = (error) => sendErrorToErrorTracker(error);
const User = () => (
  <ErrorBoundary
    FallbackComponent={UserProfileFallback}
    onError={handleOnError}
  >
    <UserProfile/>
  </ErrorBoundary>,
);
export default User;

react-error-boundary를 사용하면 컴포넌트에서 제고하는 FallbackComponent나 onError 같은 Props를 사용하여 사용자에게 Fallback UI를 편리하게 보여주고 AEM에 에러 리포팅을 수행하는 등의 기능을 편리하게 구현할 수 있다. 

 

더 나아가 resetErrorBoundary 함수를 FallbackComponent 컴포넌트의 Props로 제공하므로 "다시 시도" 등의 UI 요소도 쉽게 추가할 수 있어 매우 유용하다고 한다.

 

마치며

앞에서 살펴본 Suspense와 Error Boundary를 사용하면 선언형 컴포넌트를 구성할 수 있다. 기존의 어떻게(HOW) 화면을 보여줄 것이냐가 아니라 화면에 무엇을(WHAT) 보여줄 것이냐 를 고려하며 화면을 설계하는 쪽으로 패러다임이 바뀌게 되는 것이다.

그렇다면 어떻게 해야 실제로 우리가 Suspense와 Error Boundary를 사용하여 화면을 구성할 수 있을까? 카카오페이 프론트엔드 팀에서는 React Query와 함께 Suspense와 Error Boundary를 사용하여 비동기 데이터를 불러오기(API 요청) 시 로딩 중, 에러 발생, 성공에 대한 케이스에 대응하는 UI를 각각 구성하여 화면에 보여주겠다고 한다.

 

내가 정리하고 싶었던 부분은 Concurrent UI Pattern선언적으로 코드를 짜고 컴포넌트를 구성하는 부분이었기에 여기까지만 정리해보았다. 항상 선언적으로 코드를 짜려고 했지만 어떻게 해야 하는지 방법을 잘 몰랐던 것 같다. React Query와 함께 Concurrent UI Pattern을 도입하는 방법이라는 글이 많은 도움이 되었으며, React Query를 사용해서 선언형 컴포넌트를 구성하는 방법이 궁금하다면 링크를 통해 가서 글을 읽어 보면 될 것 같다.

 

Suspense와 ErrorBoundary를 사용하면 우리의 React Application의 UI를 선언적으로 구성할 수 있다는 것을 통해 선언적으로 UI를 구성할 경우 컴포넌트들의 관심을 확실하게 분리할 수 있고, 이를 통해 유지보수가 편리한 프로젝트 환경을 유지할 수 있으며 사용자 경험 향상을 위한 다양한 UI 요소를 활용할 수 있다는 것을 한번 더 깨닫게 되는 글이었다.

 

Comments