사용자 경험 개선을 위한 Concurrent UI Pattern

JJ·2023년 4월 25일
0
post-thumbnail

서론

프론트엔드에서 실무경험을 하며 사용자 경험(UX)가 얼마나 중요한 지 깨닫게 되고있다. 데이터를 페칭해오는 시간 동안 UX를 개선하기 위해 개발자들은 많은 고민을 하고있다.

그것에 대해 공부하던 도중 React Query와 함께 Concurrent UI Pattern을 도입하는 방법에 대한 카카오페이의 기술블로그를 보게되었다. 많은 공부가 되었고 공부한 내용을 블로그에 정리해보려고 한다.

Concurrent UI Pattern이란?

React의 ConCurrent UI Mode는 앱의 성능을 개선하기 위한 새로운 모드이며 아직 실험적인 단계라고 설명하고 있다. 이 모드에서는 UI 렌더링을 일시적으로 중단하고, 우선순위에 따라 더 중요한 작업을 먼저 처리할 수 있다. 이를 통해 앱이 더 반응성있게 동작하며, 더 빠른 퍼포먼스 및 UX를 제공할 수 있다.

Concurrent Mode의 동작 원리

React의 Concurrent Mode는 React가 컴포넌트의 일부를 처리하는 동안 다른 작업을 수행할 수 있도록 한다. 예전에는 한 컴포넌트에서 한 작업만 수행할 수 있어서 대규모 서비스에서는 느리게 동작했었다.

Concurrent Mode에서 React는 컴포넌트를 여러 단계별로 나누어 처리한다. 이 단계를 통해 브라우저 이벤트, 애니메이션, 사용자 입력 등 다른 비동기 작업에 대한 우선순위를 나누고 이 우선순위를 조절하며 작업을 수행할 수 있다. Concurrent Mode는 성능 향상을 위해 이러한 작업을 "분할"하여 처리한다. 컴포넌트를 먼저 부분적으로 렌더링하고, 브라우저에서 동시에 처리할 수있는 다른 작업을 처리한다. 이를 통해 React가 컴포넌트 렌더링을 완료하기 전에 다른 작업을 수행할 수 있으므로, 사용자 경험이 더욱 개선된다.

카카오페이 프론트엔드 팀이 Concurrent UI Pattern을 대하는 방식

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

카카오페이 프론트엔드 팀에서는 Concurrent UI Pattern을 사용하지 않는 컴포넌트를 “명령형 컴포넌트”로, Concurrent UI Pattern을 사용하는 컴포넌트를 “선언형 컴포넌트”로 표현하고 있다.

명령형 컴포넌트를 사용한 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;

이 컴포넌트는 fetch API에서 데이터를 불러오는 역할을 한다. 데이터를 불러오는 중 로딩 상태가 되면 <Spinner/> 컴포넌트를 보여주어 사용자에게 로딩상태임을 알려주고, 이 과정에서 에러가 발생할 경우 <ErrorMessage/> 컴포넌트를 통해 에러가 발생했다는것을 알려준다.

사용자에게 보여지는 모든 UI 구성요소는 코드를 통해 모두 “명령형”으로 작성되어있다. <Spinner/> 컴포넌트와<ErrorMessage/> 컴포넌트는 <ImperativeComponent/>의 state에 따라 화면에 보여지거나 보여지지 않는다. 다시 말해 <ImperativeComponent/>는 UI를 어떻게(HOW) 보여주느냐에 집중하고 있다.

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

선언형 컴포넌트를 사용한 컴포넌트는 컴포넌트의 state에 따라 어떻게 보여줄 지가 아니라 상황에 따라 적절한 화면을 보여주어야 한다.

이 말이 조금 어려웠는데 단일책임 원칙을 생각해보면 이해가 가는 내용이다. 컴포넌트에 단일 책임 원칙을 적용해보면. 데이터를 렌더링 하는 컴포넌트, 로딩상태임을 알려주는 컴포넌트, 에러가 발생했을 시 알려주는 컴포넌트, 각각 단일 책임 원칙을 따른다고 이해했다.

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;

<HugeComponent/>는 해당 컴포넌트가 렌더링 되기 전 까지 불필요한 요소이기 때문에 lazy를 이용해 비동기적으로 컴포넌트를 불러오게 구성했다. <ComponentWithSuspense/> 컴포넌트에서는 <HugeComponent/>를 비동기적으로 불러오기 위해 Suspense를 사용했다. <HugeComponent/>가 불러와지는 도중에는 Suspense의 fallback을 통해 <Spinner/> 컴포넌트가 렌더링된다.

특정 컴포넌트를 비동기적으로 불러와 화면에 보여주는데, 비동기 로딩이 진행중인 상황에서는 화면을 어떻게 보여줄지가 아니라 무엇을 보여줄 지 집중하고있다.

Suspense를 활용한 컴포넌트의 단일 책임 원칙

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;

UserProfile 컴포넌트에서는 Suspense를 지원하는 userProfileRepository 객체를 통해 데이터를 비동기적으로 불러오고 있다. userProfileRepository는 Promise를 return하는 일반적인 fetch함수가 아닌 Suspense를 지원하는 특별한 객체이다.

비동기 데이터를 불러올 때 Suspense를 통해 처리할 경우 화면을 어떻게 보여줄 지 코드를 작성하는 것이 아닌 무엇을 보여줄 지 작성할 수 있다. 위 예제 코드에서는 데이터를 불러오기가 완료된 후 화면은 전적으로 UserProfile 컴포넌트가 담당한다. UserProfile 컴포넌트는 이미 데이터를 불러와져 있는 상황을 전제적으로 작성되어있고, 로딩 상황, 또는 에러 상황일 때 어떻게 보여줄 지에 대해서는 일체 관심이 없다. 이는 UserProfile 컴포넌트가 유저의 데이터만 렌더링 하도록 하는 단일 책임 원칙을 따른다.

Error Boundary

ErrorBoundary는 React Component 내부에서 에러가 발생했을 경우 사용자에게 빈화면을 보여주는 것이 아닌 미리 정의해둔 Fallback UI를 화면에 보여주기 위한 컴포넌트이다.

ErrorBoundary를 잘 사용하면 애플리케이션 내부에서 에러가 발생한 경우 사용자에게 우아하게 화면을 보여줄 수 있다. 컴포넌트의 state에 따라 에러 상황을 어떻게 보여줄 지 고민하는게 아닌 에러가 발생한 상황에 Fallback UI로 무엇을 보여줄 지 집중할 수 있다.

ErrorBounday를 더 쉽게 쓰기 위한 react-error-boundary

카카오페이 프론트엔드 팀은 ErrorBoundary를 더 쉽게 쓰기 위한 react-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에 에러 리포팅을 수행하는 등의 기능을 편리하게 구현할 수 있다.

마무리

Suspense와 ErrorBoundary를 이용하여 애플리케이션을 더욱 선언적으로 구성할 수 있다. 이 UI 패턴은 컴포넌트를 어떻게 보여주는지 집중하는게 아닌 무엇을 보여줄 지 집중할 수 있어서 컴포넌트의 책임을 확실히 분리하여 관리할 수 있으며 이를 통해 유지보수가 쉬운 코드를 작성할 수 있다. 또한 사용자 경험을 개선할 수 있는 다양한 UI요소(로딩, 메시지, 스켈레톤, 에러)를 활용할 수 있다.

참고

토스ㅣSLASH 21 - 프론트엔드 웹 서비스에서 우아하게 비동기 처리하기

React Query와 함께 Concurrent UI Pattern을 도입하는 방법 | 카카오페이 기술 블로그

0개의 댓글