React Query와 함께 Concurrent UI Pattern을 도입하는 방법

React Query와 함께 Concurrent UI Pattern을 도입하는 방법

시작하며

안녕하세요, 카카오페이 FE인프라TF 에릭입니다.

혹시 React의 Suspense, Error Boundary 그리고 Concurrent UI 패턴에 대해 들어보셨나요? 카카오페이 프론트엔드 개발자들은 React를 사용한 프로젝트에서 Suspense와 Error Boundary를 활용한 Concurrent UI 패턴을 적극적으로 사용하고 있습니다.
본 포스팅에서는 Suspense, Error Boundary, Concurrent UI 패턴에 대해 설명하고 React Query를 사용하여 손쉽게 선언형 컴포넌트를 프로젝트에 녹여낸 사례에 대해 이야기해보려 합니다.

Relay팀에서 공개한 Relay 예제 프로젝트 issue-tracker는 본문에서 다룰 "데이터 불러오기를 위한 Suspense"를 적극적으로 활용해 Concurrent UI Pattern으로 구성된 프로젝트입니다.

본문을 다 읽으시고 실제 프로젝트에서 Concurrent UI Pattern이 어떻게 사용되고 있는지 궁금하시다면 위 프로젝트를 참고하세요.

Concurrent UI Pattern이란

프론트엔드 개발자로서 프론트엔드 개발을 할 때 가장 중요하게 생각해야 하는 점이 무엇일까요? 저는 개인적으로 「사용자 경험에 대한 고민」이라고 생각합니다.

「사용자 경험에 대한 고민」이라는 문장은 굉장히 다양한 요소를 포함하고 있는 문장입니다. 간단하게는 사용자가 서비스를 정상적으로 사용할 수 있게끔 오류 없는 비즈니스 로직을 작성하는 것부터 시작해서, 크게는 서비스 전반에 발생할 수 있는 이슈를 선제적으로 파악하기 위한 Error Tracker를 추가하는 것까지 사용자 경험에 대한 고민의 일환이라고 생각할 수 있을 것 같습니다.
React 팀에서도 당연히 사용자 경험에 대한 다양한 고민을 하고 있을 텐데요, 사용자 인터페이스를 만들기 위한 Javascript 라이브러리인 React의 입장에서 어떻게 사용자 경험의 향상을 불러올 수 있을까요?

PC 기반의 웹 환경이 대부분이었던 과거와는 달리 요즈음에는 PC, 모바일 기기, 더 나아가 다양한 IoT 디바이스를 통해 웹 페이지를 열람하는 등 접속 환경이 다변화되고 있습니다. 접속 환경에 따라 복잡하고 무거운 UI 변경을 무리 없이 화면에 보여줄 수 있는 경우도 있지만 상대적으로 성능이 낮은 디바이스에서는 UI 변경이 쾌적하게 반영되지 못해 사용자에게 안 좋은 경험을 제공하는 경우가 생길 수 있습니다.
인터넷 속도 이야기도 사용자 경험에서 빼놓을 수 없는 부분입니다. 안정적이고 빠른 접속을 제공하는 인터넷 환경을 갖춘 사용자에게는 여러 API를 호출하여 구성하는 화면이 쾌적하게 노출되겠지만, 그렇지 못한 사용자들은 API 응답이 지연되거나 누락되어서 화면이 정상적으로 노출되지 않는 경우도 분명히 존재할 수 있습니다.

프론트엔드 개발자 입장에서 앞서 이야기한 비즈니스 로직이나 Error Tracking 같은 요소는 그리 어렵지 않게 구현하거나 추가할 수 있을 텐데, 다양한 환경의 사용자에게 쾌적한 사용자 경험을 제공하기 위한 작업은 임기응변식의 작업으로 진행되는 경우가 많습니다.
React 팀은 이런 "임기응변식"으로 대응되는 사용자 경험 향상 요소들을 라이브러리단에서 제공할 수 있게끔 React가 동작하는 새로운 방식인 「Concurrent Mode」의 제공을 준비하고 있습니다.

Concurrent Mode를 사용하면 우리는 앱이 빠른 반응속도를 유지하도록 하고 사용자 기기의 성능과 네트워크 속도에 맞추어 동작할 수 있게끔 만들 수 있습니다. 이를 위해 Concurrent Mode에서는 "우선순위에 따른 화면 렌더", "컴포넌트의 지연 렌더" 그리고 "로딩 화면의 유연한 구성" 등을 쉽게 구성할 수 있도록 특성화된 기능들을 제공하고 있습니다. 이러한 기능들을 사용한 UI 개발 패턴을 React 팀에서는 "Concurrent UI Pattern"이라고 부르고 있습니다.

너무 당연하게도 이러한 요소들은 React 18에서 릴리즈 예정인 "Concurrent Mode"를 통해 정식으로 사용할 수 있습니다. 카카오페이 프론트엔드 팀에서는 Concurrent Mode가 정식 릴리즈 되기 이전인 현시점에도 실험적으로 사용 가능한 몇 가지 기능들을 사용해 Concurrent UI Pattern을 도입하여 사용자 경험의 향상을 꾀하고 있습니다.

본 아티클은 "카카오페이에서 Concurrent UI Pattern을 도입하는 방법"에 대해 다루고 있기 때문에 Concurrent Mode와 Concurrent UI Pattern에 대해서 자세하게 다루고 있지 않습니다.

Concurrent Mode와 Concurrent UI Pattern에 대한 자세한 내용은 React 팀의 블로그 아티클을 참고하셔요. 🙇‍♂️

📘 Concurrent Mode 📘 Concurrent UI Pattern

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

카카오페이 프론트엔드 팀에서는 Concurrent UI Pattern을 조금 다른 방식으로 대하고 있습니다. 앞 단락에서 언급한 바와 같이 아직 React의 Concurrent Mode는 정식으로 릴리즈 되지 않았습니다.

대신 실험적으로 사용할 수 있는 일부 기능들을 사용해 Concurrent UI Pattern을 도입한 컴포넌트를 작성하여 사용하고 있는데요, 이 방식이 사뭇 개발론에서의 「선언적 프로그래밍」을 설명하는 방식과 비슷합니다.

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

본 아티클에서는 Concurrent UI Pattern을 사용하지 않은 컴포넌트를 「명령형 컴포넌트」로, Concurrent UI Pattern을 사용한 컴포넌트를 「선언형 컴포넌트」로 표현하고 있음을 인지하시고 읽어주세요.

명령형 컴포넌트 / 선언형 컴포넌트는 카카오페이 내부에서 사용하는 단어입니다. 🙇‍♂️

비슷한 용어인 「명령형 컴포넌트」와 「선언형 UI」는 통상 이 아티클에서 다루는 내용과는 완전히 다른 의미를 갖는 경우가 많습니다.

통상 「명령형 컴포넌트」는 DOM에 직접 접근하여 수정하는 경우를 뜻합니다. "어떻게" 화면을 보여줄지를 코드로 기술하기 때문입니다.

통상 「선언형 UI」는 DOM에 직접 접근하지 않고 화면에 "무엇을" 보여줄지를 코드로 기술하는 것을 뜻합니다. React의 JSX를 생각하시면 편하게 이해가 가능합니다.

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

앞에서 이야기했던 "명령형 프로그래밍은 어떻게(HOW) 에 주목"이라는 문장을 생각하며 위 코드의 구성을 볼까요?

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

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

명령형 컴포넌트를 사용한 컴포넌트는 「UI를 어떻게(HOW) 보여줄 것이냐」에 집중합니다.

선언형 컴포넌트를 사용한 React Component (설명을 들어가기에 앞서)

그렇다면 선언형 컴포넌트를 사용한 컴포넌트는 명령형 컴포넌트를 사용한 컴포넌트와 어떻게 다를까요? 지금까지 이야기한 명령형과 선언형의 차이로 미루어보아 선언형 컴포넌트를 사용한 컴포넌트는 다음과 같은 정의를 갖게 될 것입니다.

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

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

state에 맞추어 UI를 구성하는 건 너무나도 Common하고 친숙한데, 상황에 따라 적절한 UI를 화면에 보여주어야 한다? 어떻게 하면 좋을까요?

선언형 컴포넌트 구성을 위한 바탕

React에서는 컴포넌트를 선언적으로 구성하는데 유용하게 (라고 쓰고 필수적으로 라고 읽는) 사용할 수 있는 두 가지 구성요소를 제공합니다.

Suspense

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

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;

<ComponentWithSuspense/> 컴포넌트는 내부적으로 <HugeComponent/>를 화면에 그려주는 역할을 합니다. <HugeComponent/>는 이름과 같이 엄청나게 용량이 큰 컴포넌트여서, 우리는 이 컴포넌트가 화면에 그려져야 할 때 비동기적으로 사용자에게 전달되기를 바랐습니다.

우리가 원하는 바를 이루기 위해 위 코드에서는 lazy를 사용해서 <HugeComponent/>를 비동기적으로 불러오게끔 구성하였습니다. <ComponentWithSuspense/> 컴포넌트에서는 <HugeComponent/>Suspense를 사용해서 컴포넌트 내부에 비동기적으로 불러오고, <HugeComponent/>가 불러와지는 중에는 Suspensefallback Prop을 통해 <Spinner/>를 화면에 보여줍니다.

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

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


위 예시의 Suspense와 Lazy를 사용한 "동적으로 컴포넌트 불러오기"는 Router와 함께 Code Splitting에 주로 사용됩니다.

데이터 불러오기를 위한 Suspense

위에서 Suspense를 사용하여 비동기적으로 컴포넌트를 불러오는 기능에 대해 살펴보았습니다. Suspense를 사용하여 동적으로 컴포넌트를 불러오는 화면을 선언적으로 구성할 수 있었습니다.

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

(현재 시점 (2022년 7월) 기준으로) 비동기 데이터를 불러오기 위한 Suspense는 실험단계(Experimental)의 기능으로 존재합니다. 다시 말해, 사용은 가능하지만 Production Level에서 안정적인 동작이 보장되지는 않는 상태입니다.

그럼에도 불구하고 카카오페이 프론트엔드 팀은 비동기 데이터를 불러오기 위한 Suspense를 전향적으로 도입하고 있습니다.

비동기 데이터를 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;

위 코드는 일종의 Pseudo 코드로 실제로 동작하는 코드는 아니지만 데이터 불러오기를 위한 Suspense의 이해를 위해 작성된 코드입니다.

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

대신 <UserProfile/> 컴포넌트 내부에서는 Suspense를 지원하는 userProfileRepository 객체를 통해 데이터를 비동기적으로 불러오고 있습니다. 이 userProfileRepository는 Promise를 반환하는 일반적인 fetch 함수가 아닙니다. Suspense를 지원하는 특별한 객체로 지금은 이 객체에 대해 크게 신경쓰지 않도록 합니다. Suspense를 지원하는 특별한 객체를 사용하면 비동기 데이터 불러오기도 Suspense를 통해 처리할 수 있다가 핵심입니다.

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

(다시 한번 이야기 하자면) 선언형 컴포넌트를 사용한 컴포넌트는 「무엇을(WHAT) 보여줄 것이냐」에 집중합니다.

Error Boundary

에러 경계는 하위 컴포넌트 트리의 어디에서든 자바스크립트 에러를 기록하며 깨진 컴포넌트 트리 대신 폴백 UI를 보여주는 React 컴포넌트입니다. 3

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

Error Boundary의 기본적인 사용 방법은 다음과 같습니다.

import { Component } from 'react';

class MyCustomErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // 다음 렌더링에서 폴백 UI가 보이도록 상태를 업데이트 합니다.
    return { hasError: true };
  }
  componentDidCatch(error, errorInfo) {
    // 에러 리포팅 서비스에 에러를 기록할 수도 있습니다.
    logErrorToMyService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // 폴백 UI를 커스텀하여 렌더링할 수 있습니다.
      return <h1>Something went wrong.</h1>;
    }

    return this.props.children;
  }
}

const App = () => {
  return (
    <MyCustomErrorBoundary>
      <MyApp />
    </MyCustomErrorBoundary>
  );
};

export default App;

참고: 위 코드 중 Error Boundary 내부 코드는 React 공식 문서에서 발췌하였음.

Error Boundary는 getDerivedStateFromError 또는 componentDidCatch (혹은 둘 다) 멤버 함수를 갖는 React Component입니다. 하위 컴포넌트 렌더링 과정에서 에러가 발생할 경우 (마치 catch {} 구문처럼) 상위 Error Boundary에서 에러를 받아 Fallback UI를 처리하거나 Error Tracker로 에러 리포팅을 할 수 있습니다.

Error Boundary에서 에러를 받아 Fallback UI를 처리? 이거 완전 선언형 컴포넌트 아닌가요?

(또 다시 말씀드리자면) 선언형 컴포넌트를 사용한 컴포넌트는 「무엇을(WHAT) 보여줄 것이냐」에 집중합니다.

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

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

저희는 Error Boundary를 더 쉽게 사용하기 위해 [react-error-boundary](https://github.com/bvaughn/react-error-boundary)라는 Component를 사용하고 있습니다.

react-error-boundarygetDerivedStateFromErrorcomponentDidCatch를 사용하여 직접 에러 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 공식 문서에서 발췌하였음.

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

더 나아가 resetErrorBoundary 함수를 FallbackComponent 컴포넌트의 Props로 제공하므로 "다시 시도" 등의 UI 요소도 쉽게 추가할 수 있으니, Error Boundary 사용이 필요한 상황에서 선택지 중 하나로 고려하지 않을 이유가 없겠죠?

react-error-boundary와 관련된 더 자세한 내용은 공식 문서에서 확인하세요.

선언형 컴포넌트를 위한 비동기 데이터 불러오기

앞에서 살펴본 Suspense와 Error Boundary를 사용하면 선언형 컴포넌트를 구성할 수 있습니다. 기존의 어떻게(HOW) 화면을 보여줄 것이냐가 아니라 화면에 무엇을(WHAT) 보여줄 것이냐 를 고려하며 화면을 설계하는 쪽으로 패러다임이 바뀌게 되는 것이죠.
그렇다면 어떻게 해야 실제로 우리가 Suspense와 Error Boundary를 사용하여 화면을 구성할 수 있을까요? 본 아티클의 맥락상 비동기 데이터 불러오기 (다시 말해 API 요청)시 로딩 중, 에러 발생, 성공 이렇게 3가지 케이스에 대응하는 UI를 각각 구성하여 화면에 보여주겠다는 내용인 것 같은데 우리가 흔히 사용하는 Axios나 Fetch API와 Suspense, 그리고 Error Boundary를 같이 사용한다는 이야기를 본 적은 없는데 말이죠.

React Query와 함께 Suspense와 Error Boundary 사용하기

React에서 비동기 데이터 관리를 위해 사용되는 라이브러리 React Query에서는 비동기 데이터 요청 시 Suspense와 Error Boundary를 활용할 수 있는 옵션을 제공합니다.

import { useQuery } from 'react-query';

const queryKey = 'user';
const queryFn = () => axios('/user').then((res) => res.data);

const UserProfile = () => {
  const { data } = useQuery(queryKey, queryFn, {
    // 데이터 불러오기를 위한 Suspense를 활성화하는 옵션
    suspense: true,
    // Error Boundary 사용을 위한 옵션.
    // suspense 옵션이 true인 경우에는 기본값이 true로 설정된다.
    useErrorBoundary: true,
  });

  return (
    <span> {data.name} / {data.birthDay} </span>
  );
};

export default UserProfile;

suspense 옵션을 선택할 경우 useQuery hook은 위에서 언급한 Suspense를 지원하는 "특별한 객체"로써 동작하여 데이터 불러오기를 위한 Suspense, 그리고 Error Boundary를 통한 에러 Fallback UI 처리 사용이 가능해집니다.

import { Suspense } from 'react';
import { useQuery } from 'react-query';
import { ErrorBoundary } from 'react-error-boundary';

const queryKey = 'user';
const queryFn = () => axios('/user').then((res) => res.data);

const UserProfile = () => {
  const { data } = useQuery(queryKey, queryFn, { suspense: true });

  return (
    <span> {data.name} / {data.birthDay} </span>
  );
};

const UserProfileFallback = ({ error, resetErrorBoundary }) => (
  <div>
    <p> 에러: {error.message} </p>
    <button onClick={() => resetErrorBoundary()}> 다시 시도 </button>
  </div>
);

const UserProfileLoading = () => <div> 사용자 정보를 불러오는 중입니다. </div>;

const User = () => (
  <ErrorBoundary FallbackComponent={UserProfileFallback}>
    <Suspense fallback={<UserProfileLoading />}>
      <UserProfile />
    </Suspense>
  </ErrorBoundary>
);

export default User;

데이터 불러오기를 위한 Suspense와 React Query의 Suspense 옵션은 아직 실험적 기능입니다. 운영 환경에서는 사용 전 신중한 확인이 필요합니다.

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

바로 위에서 살펴본 React Query, Suspense, Error Boundary를 사용해 구성한 React Component에 대해서 자세히 들여다보겠습니다.

앞에서 이야기 한 바와 같이 데이터 불러오기를 위한 Suspense 사용을 위해서는 우리가 기존에 사용하던 Promise 기반의 API 요청이 아닌 Suspense를 지원하는 특별한 객체를 통한 API 요청이 필요합니다.

다시 말해, API에서 데이터를 불러와 사용자에게 보여줄 아래 <UserProfile/> 컴포넌트에서는 우리가 평소에 사용하던 다른 API 요청 방법이 아닌 Suspense를 지원할 수 있는 특별한 요청 방법을 사용해야 하고 현재로써는 React Query의 suspense 옵션을 사용하여 비동기 데이터를 요청하는 것이 가장 편리한 방법 중 하나입니다.

// ...전략...

const UserProfile = () => {
  const { data } = useQuery(
    queryKey,
    queryFn,
    // suspense 옵션을 통해 useQuery Hook을 "Suspense를 지원하는 특별한 객체"로 사용합니다.
    // suspense 옵션이 켜져 있는 경우 Error Boundary를 통한 에러 처리도 가능합니다.
    { suspense: true },
  );

  return (
    <span> {data.name} / {data.birthDay} </span>
  );
};

// ...후략...

선언형 컴포넌트 아키텍처를 도입하여 "로딩" 상태와 "에러" 상태를 상위 컴포넌트에서 SuspenseError Boundary를 통해 처리하기 때문에 UserProfile 컴포넌트에서는 로딩 화면과 에러 화면을 처리하지 않습니다. UserProfile 컴포넌트는 데이터가 항상 존재하는 것처럼 화면에 필요한 내용을 그려주는 역할만 할 뿐입니다.

// ...전략...

const UserProfileFallback = ({ error, resetErrorBoundary }) => (
  <div>
    <p> 에러: {error.message} </p>
    <button onClick={() => resetErrorBoundary()}> 다시 시도 </button>
  </div>
);

const UserProfileLoading = () => <div> 사용자 정보를 불러오는 중입니다. </div>;

const User = () => (
  <ErrorBoundary FallbackComponent={UserProfileFallback}>
    <Suspense fallback={<UserProfileLoading />}>
      <UserProfile />
    </Suspense>
  </ErrorBoundary>
);

// ...후략...

UserProfile 컴포넌트의 로딩 상태와 에러 상태는 UserProfile 상위의 User 컴포넌트에서 SuspenseError Boundary를 통해 사용자에게 보여집니다.
데이터 불러오기가 진행 중인 경우 Suspense fallback Props를 통해 사용자에게 "데이터 불러오는 중"을 알려주게 되고, 데이터 불러오기가 실패한 경우 Error Boundary FallbackComponent Props를 통해 "데이터 불러오기에 실패하였음"을 보여주게 되는 것이죠.

복잡한 UI 구성시 빛이 나는 선언형 컴포넌트

선언형 컴포넌트는 비동기적으로 데이터를 불러와서 화면에 보여주어야 하는 요소가 많을수록 빛을 발합니다. 비동기 데이터를 사용하는 요소가 적을 때는 명령형으로 간단하게 UI를 구성할 수 있지만, 비동기 요소가 많고 사용자 경험상 복잡한 UI를 구성해야 할 필요가 있는 경우에는 명령형으로는 한계가 느껴지거나 코드가 과도하게 복잡해지기 때문입니다.

비동기 요소가 많은 UI 와이어프레임
비동기 요소가 많은 UI 와이어프레임

위 와이어프레임을 예시로 한번 살펴보겠습니다. 이 페이지는 총 4개의 비동기 요소를 가지고 있습니다. Code Splitting을 통해 컴포넌트 자체도 비동기 요소로 생각한다면 5개의 비동기 요소를 가지고 있다고 볼 수 있겠네요.

이 페이지를 담당하는 기획자가 사용자 경험 향상을 위해 다음과 같은 조건을 함께 부여하였다고 가정해 보겠습니다.

  1. 컴포넌트를 불러오는 중에는 전체 UI 요소를 포함하는 스켈레톤 UI가 노출되어야 합니다.
  2. 배너 영역과 커스텀 메뉴 영역의 데이터가 불러와지는 중에는 적절한 스켈레톤 UI가 노출되어야 합니다.
  3. 사용자 정보와 알림 영역은 둘 다 불러와졌을 때에만 화면이 노출되며 둘 중 하나라도 로딩 중일 때는 적절한 스켈레톤 UI가 노출되어야 합니다.
  4. 배너 영역의 데이터 불러오기가 실패하였을 경우 사용자 경험을 위해 "사전 정의된 자체 배너"를 화면에 노출합니다.
  5. 커스텀 메뉴 영역, 사용자 정보와 알림 영역의 데이터 불러오기가 실패하였을 경우 "데이터 불러오기를 다시 시도할 수 있는 UI"를 화면에 보여주어야 합니다. 단, 이 UI는 모든 화면을 덮지 않고 해당 영역만을 덮으면서 화면에 보입니다.
  6. 데이터를 불러올 때 서버에서 HTTP ErrorCode 500, Error Response 메시지가 "CRITICAL_ERROR"인 경우 "화면 전체를 덮는 에러 화면"을 표시합니다. 이 에러 화면은 다음과 같은 조건을 만족해야 합니다.
    • 사용자에게 "에러가 지속되면 고객센터로 문의하세요"라는 텍스트를 노출해주어야 합니다.
    • 고객센터 인입 시 정확한 사용자 확인을 위해 AEM에서 추적할 수 있는 UUID를 발급하고 화면에 노출해주어야 합니다.
    • 단, 배너 API 호출 시 발생한 에러는 "기본 배너" 화면을 보여주는 것으로 갈음합니다.

컴포넌트를 불러올 때 스켈레톤 UI 노출시키기

Code Splitting을 통해 분리되어 있는 번들을 불러오는 동안 사용자에게 스켈레톤 UI를 표시하는 건 아주 간단합니다.
위 본문에서 언급했던 바와 같이 lazy를 통해 Code Splitting을 적용하고 fallback Prop을 갖는 Suspense 를 통해 해당 컴포넌트를 감싸주는 것 만으로 적용이 가능합니다.

// app.tsx
import { lazy, Suspense } from 'react';

import Skeleton from './MainPage/index.skeleton';

const MainPage = lazy(() => import('./MainPage'));

const App = () => (
  <Suspense fallback={<Skeleton />}>
    <MainPage />
  </Suspense>
);

export default App;

배너 영역과 커스텀 메뉴 영역에서 스켈레톤 UI 노출시키기

Suspense를 사용해서 컴포넌트 내부의 특정 영역에 스켈레톤 UI를 표시해봅시다. Suspense를 사용해서 Fallback UI를 구성할 경우 데이터를 보여주는 컴포넌트에서 비동기 데이터 불러오기 상태에 따른 화면 분기를 완전히 분리할 수 있습니다.

// MainPage/index.tsx
import { Suspense } from 'react';

import Banner from '../Banner';
import BannerSkeleton from '../Banner/index.skeleton';
import CustomMenu from '../CustomMenu';
import CustomMenuSkeleton from '../CustomMenu/index.skeleton';

const MainPage = () => (
  <div>
    <header>
      <div className="title"> Simple Demo App </div>
    </header>
    <section className="banner__container">
      <Suspense fallback={<BannerSkeleton />}>
        <Banner />
      </Suspense>
    </section>
    <main>
      <nav className="custom-menu__container">
        <Suspense fallback={<CustomMenuSkeleton />}>
          <CustomMenu />
        </Suspense>
      </nav>
    </main>
  </div>
);

export default MainPage;

사용자 정보 영역과 알림 영역은 "둘 다 불러와지기 전에는" 스켈레톤 UI를 노출한다

기획 의도상 사용자 정보 영역과 알림 영역은 둘 다 불러온 다음에만 화면이 보여져야 합니다. 둘 중 하나라도 로딩 중일 때에는 스켈레톤 UI가 보여야 하는 것이죠.
명령형으로 UI를 구성한다면 아마 다음과 같이 코드가 구성되어야 할 것입니다.

// User/index.tsx
import { useState } from 'react';
import { useQuery } from 'react-query';

import UserInfo from './components/UserInfo';
import UserInfoSkeleton from './components/UserInfo/index.skeleton';
import Alarm from './components/Alarm';
import AlarmSkeleton from './components/Alarm/index.skeleton';

import { getUserInfo, getAlarm } from '../fetches';

const User = () => {
  const { data: userInfoData } = useQuery(['userInfo'], getUserInfo);
  const { data: alarmData } = useQuery(['alarm'], getAlarm);

  return (
    <section className="user__container">
      {userInfoData && alarmData ? (
        <>
          <UserInfo data={userInfoData} />
          <Alarm data={alarmData} />
        </>
      ) : (
        <>
          <UserInfoSkeleton />
          <AlarmSkeleton />
        </>
      )}
    </section>
  );
};

export default User;

물론 이렇게 컴포넌트를 구성하여도 우리가 원하는 기획 요구사항을 충실하게 달성할 수 있고, 그리 어색해 보이지도 않는 코드입니다. 하지만 이런 Presentational - Container 컴포넌트 구조는 코드의 복잡성을 증가시켜 유지보수를 어렵게 만듭니다. UserInfo 컴포넌트에 새로운 기능이 추가될 때 Props Drilling을 통해 추가 데이터를 받아야 할 수도 있고, 새로운 데이터를 보여주기 위한 컴포넌트를 추가해야 할 경우 User 컴포넌트의 화면 구조나 로직을 추가적으로 신경 써야 할 수도 있죠.

이 컴포넌트를 선언형 UI로 바꾸면 어떻게 구성될까요? UserUserInfo에서 필요로 하는 데이터는 각 컴포넌트에서 알아서 불러오고, User 컴포넌트는 아래와 같이 "하위 컴포넌트의 UI를 구성하기 위한 컴포넌트"로 꾸밀 수 있을 것 같습니다. 만약 .user__container 클래스에 flex를 사용한 스타일을 적용한다면 더더욱 "UI 구성하기 위한 컴포넌트"로써의 역할을 수행할 수 있겠죠.

// User/index.tsx
import { Suspense } from 'react';

import Banner from '../Banner';
import BannerSkeleton from '../Banner/index.skeleton';
import CustomMenu from '../CustomMenu';
import CustomMenuSkeleton from '../CustomMenu/index.skeleton';
import User from '../User/index';

const User = () => (
  <>
    {/* 
      Suspense 하위에 비동기 데이터 불러오기가 여러 개 있을 경우, Suspense는 마치 Promise.all 처럼 동작합니다.
      이 컴포넌트에서는 UserInfo와 Alarm 컴포넌트가 모두 로딩되기 전까지 fallback이 사용자에게 노출됩니다.
    */}
    <Suspense
      fallback={
        <>
          <UserInfoSkeleton />
          <AlarmSkeleton />
        </>
      }
    >
      <>
        <UserInfo />
        <Alarm />
      </>
    </Suspense>
  </>
);

export default User;

이 예제의 컴포넌트는 그리 복잡하지 않아 명령형과 선언형의 차이가 크게 느껴지지 않을 수 있습니다. 하지만 User 컴포넌트의 역할이 이 컴포넌트 (및 하위 컴포넌트)를 UI로 어떻게 표현할 것인가만 담당하느냐, 아니면 이 컴포넌트의 UI를 어떻게 표현할 것인지, 그리고 하위 컴포넌트들에서 사용할 데이터들을 불러오기를 담당하느냐의 기준으로 생각해보면 컴포넌트의 복잡도가 한층 낮아지고 관심의 분리를 더 명확하게 달성할 수 있음이 느껴집니다.

MainPage 컴포넌트에 User 컴포넌트도 다음과 같이 추가해주면 이 요구사항을 포함한 기능구현이 마무리되겠네요.

// MainPage/index.tsx
import { Suspense } from 'react';

import Banner from '../Banner';
import BannerSkeleton from '../Banner/index.skeleton';
import CustomMenu from '../CustomMenu';
import CustomMenuSkeleton from '../CustomMenu/index.skeleton';
import User from '../User';

const MainPage = () => (
  <div>
    <header>
      <div className="title"> Simple Demo App </div>
    </header>
    <section className="banner__container">
      <Suspense fallback={<BannerSkeleton />}>
        <Banner />
      </Suspense>
    </section>
    <main>
      <nav className="custom-menu__container">
        <Suspense fallback={<CustomMenuSkeleton />}>
          <CustomMenu />
        </Suspense>
      </nav>
      <User />
    </main>
  </div>
);

export default MainPage;

참고

Suspense는 계층(Hierarchy) 관계를 갖습니다. A Suspense가 있고 A Suspense 하위에 B Suspense가 존재한다고 가정하였을 때, B Suspense 하위의 컴포넌트가 아직 준비되지 않은 경우 A Suspense 하위의 컴포넌트도 (당연히) 아직 준비되지 않은 것으로 간주합니다. 이러한 Suspense의 특성을 사용하면 복잡한 React Component의 비동기 데이터 불러오기 상태 관리를 간단하게 수행할 수 있습니다.

하지만 이런 Suspense의 동작이 장점만을 갖고 있는 것은 아닙니다. 이러한 Suspense의 동작 방식 때문에 우리는 적재적소에 꼼꼼히 Suspense를 추가해주어야 합니다.
만약 위 예시 코드에서 Suspense를 실수로 누락했다면 사용자에게는 어떤 화면이 보여질까요? Hierarchy를 갖는 Suspense의 특성에 따라 상위 Suspense를 찾아 Component Tree의 상단으로 거슬러 올라가게 됩니다.
이렇게 Suspense가 누락된 경우 User 컴포넌트와 가장 가까운 상위 Suspense는 App 컴포넌트에 있는 "비동기로 MainPage 번들을 불러올 때 처리를 위한 Suspense" 이므로, 위 예시 코드에 Suspense가 누락될 경우 사용자는 "전체 화면 Skeleton"을 보게 됩니다.

만약 이 서비스를 실제로 운영하게 되었다고 가정해봅시다. 서비스 운영 이후 여러 지표와 피드백을 통해 Alarm 컴포넌트에 사용되는 데이터 API의 Latency가 너무 높아서 사용자 이탈이 발생하고 있음을 알게 되었고, 이를 막기 위해 UserInfoAlarm 중 하나라도 준비되면 일단 화면을 보여주게끔 변경하자는 요청이 충분히 들어올 수 있습니다.

명령형으로 UI를 구성할 경우 이러한 변경을 위해 "2번의 삼항연산자"를 사용하여야 합니다. 물론 단순한 컴포넌트이기 때문에 간단히 변경할 수 있고, 코드가 그리 복잡해지지도 않습니다. 하지만 컴포넌트의 규모가 커지게 되면 어떨까요? 여러 API를 사용하고 조작해서 화면을 그려줘야 하는 비즈니스 로직이 들어가있기라도 한다면 마냥 가벼운 마음으로 변경을 진행하기는 어려울 것 같습니다.

선언형 컴포넌트를 사용하면 User 컴포넌트는 정말 단순히 화면에 무엇을 그려줄지에 대한 관심만 있기 때문에, 다음과 같은 단순한 변경만으로 위 케이스에 대응이 가능합니다. 만약 여러 개의 API를 사용하고 데이터를 조작해야 하는 등 복잡한 비즈니스 로직이 포함되어 있더라도 모든 내용은 자식 컴포넌트에서 담당하고 있으므로 이 컴포넌트에서는 신경 쓸 필요 없는 일이 되어버리는 것이죠.

// User/index.tsx
import { Suspense } from 'react';

import Banner from '../Banner';
import BannerSkeleton from '../Banner/index.skeleton';
import CustomMenu from '../CustomMenu';
import CustomMenuSkeleton from '../CustomMenu/index.skeleton';
import User from '../User/index';

const User = () => (
  <section className="user__container">
    <Suspense fallback={<UserInfoSkeleton />}>
      <UserInfo />
    </Suspense>
    <Suspense fallback={<AlarmSkeleton />}>
      <Alarm />
    </Suspense>
  </section>
);

export default User;

배너 영역 데이터 불러오기 실패 시 "사전 정의된 자체 배너" 화면 노출

지금까지는 Suspense를 이용한 선언형 컴포넌트 이야기를 주로 다뤘다면, 이제부터는 ErrorBoundary를 이용한 선언형 컴포넌트와 관련된 이야기를 해볼 차례입니다.

위에서 언급한 바와 같이, react-query를 사용해 비동기 데이터를 불러올 때 suspense 옵션, 또는 useErrorBoundary 옵션을 사용해서 React ErrorBoundary를 사용한 에러 화면 구성이 가능합니다.

배너 영역 데이터 불러오기가 실패하였을 경우 "사전 정의된 자체 배너" 노출 요건은 ErrorBoundary를 사용하면 그리 어렵지 않게 구현할 수 있을 것 같습니다. 위에 언급된 react-error-boundary 컴포넌트를 사용하여 간단하게 구현해 보겠습니다.

// MainPage/index.tsx
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

import Banner from '../Banner';
import BannerSkeleton from '../Banner/index.skeleton';
import FallbackBanner from '../Banner/components/FallbackBanner';
import CustomMenu from '../CustomMenu';
import CustomMenuSkeleton from '../CustomMenu/index.skeleton';
import User from '../User';

const MainPage = () => (
  <div>
    <header>
      <div className="title"> Simple Demo App </div>
    </header>
    <section className="banner__container">
      {/* 
        데이터가 로딩 중일때는 Suspense를 사용하여 fallback을,
        에러가 발생하였을 때는 ErrorBoundary를 사용하여 fallback을 표시합니다. 
      */}
      <ErrorBoundary fallback={<FallbackBanner />}>
        <Suspense fallback={<BannerSkeleton />}>
          <Banner />
        </Suspense>
      </ErrorBoundary>
    </section>
    <main>
      <nav className="custom-menu__container">
        <Suspense fallback={<CustomMenuSkeleton />}>
          <CustomMenu />
        </Suspense>
      </nav>
      <User />
    </main>
  </div>
);

export default MainPage;

ErrorBoundary를 통해 API 에러를 처리함으로써 Banner 컴포넌트는 데이터를 불러오고 그 데이터를 화면에 보여주는 것에 오롯이 관심을 갖게 되고, 데이터를 불러오는 상황이나 에러가 발생한 상황에서의 화면은 Banner 컴포넌트를 화면에 보여주는 MainPage 컴포넌트에서 책임지게 됩니다. Banner 컴포넌트 내부에 별도의 로직이 포함되지 않기 때문에 복잡도가 줄어들어 유지보수에 장점을 갖게 되겠죠.

구체적으로 생각해볼까요? 만약 Banner 컴포넌트에서 여러개의 API를 호출한다고 가정해봅시다. 더 나아가 Banner 컴포넌트의 자식 컴포넌트에서도 여러개의 API를 호출한다고 생각해봅시다. 만약 ErrorBoundary를 사용하지 않는다면 에러 상황을 확인하기 위해 여러 조건들을 비교하는 로직을 추가해야 하고, 더 나아가 자식 컴포넌트로 Props Drilling을 통한 상태 확인을 수행하거나 Global State로 API들의 상황을 관리해야 이 요구사항을 수행할 수 있습니다. 컴포넌트의 규모가 커질수록 유지보수가 점점 어려워지겠죠.

이번엔 ErrorBoundary를 사용해 복잡한 에러 처리와 관련된 요구사항을 어떻게 달성할 수 있는지 살펴볼까요?

일부 영역 데이터 불러오기 실패 시 "재시도 UI" 표시

요구사항이 너무 길어서 타이틀을 조금 많이 줄였습니다. 정확한 요구사항은 커스텀 메뉴 영역, 사용자 정보와 알림 영역의 데이터 불러오기가 실패하였을 경우 "데이터 불러오기를 다시 시도할 수 있는 UI"를 화면에 보여주어야 합니다. 단, 이 UI는 모든 화면을 덮지 않고 해당 영역만을 덮으면서 화면에 보입니다. 였지요.

어차피 동일한 방식으로 작업하게 되니 "커스텀 메뉴 영역"은 생략하고 "사용자 정보와 알림 영역"만 가지고 살펴보겠습니다. 먼저 명령형 컴포넌트로 이 요구사항을 구현해볼까요?

// shared/Retry/index.tsx
interface Props {
  handleRetry: () => void;
}

const Retry = ({ handleRetry }) => (
  <div>
    <p> 데이터를 불러오는데 실패하였습니다. </p>
    <button onClick={handleRetry}> 다시 시도 </button>
  </div>
);

export default Retry;
// User/index.tsx
import { useState } from 'react';
import { useQuery } from 'react-query';

import UserInfo from './components/UserInfo';
import UserInfoSkeleton from './components/UserInfo/index.skeleton';
import Alarm from './components/Alarm';
import AlarmSkeleton from './components/Alarm/index.skeleton';

import Retry from '../shared/Retry';
import { getUserInfo, getAlarm } from '../fetches';

const User = () => {
  const {
    data: userInfoData,
    isLoading: userInfoIsLoading,
    error: userInfoError,
    refetch: userInfoRefetch
  } = useQuery(['userInfo'], getUserInfo);
  const {
    data: alarmData,
    isLoading: alarmIsLoading,
    error: alarmError,
    refetch: alarmRefetch
  } = useQuery(['alarm'], getAlarm);

  return (
    <section className="user__container">
    {
      userInfoIsLoading && alarmIsLoading && (
        <>
          <UserInfoSkeleton/>
          <AlarmSkeleton/>
        </>
      ) : (
        <>
          {
            userInfoError ? (
              <Retry handleRetry={refetchUserInfo}/>
            ) : (
              <UserInfo data={userInfoData!}/>
            )
          }
          {
            alarmError ? (
              <Retry handleRetry={refetchAlarm}/>
            ) : (
              <Alarm data={alarmData!}/>
            )
          }
        </>
      )
    }
    </section>
  )
}

export default User;

위에서 소개되었던 "명령형 컴포넌트를 사용한 데이터 불러오기 표시" 때랑은 조금 상황이 다릅니다. 삼항 연산자가 많이 늘어났고 화면을 처리하기 위해 사용되는 값들도 늘어났습니다. 에러와 관련된 데이터들을 하위 컴포넌트로 전달하여 하위 컴포넌트에서 처리하는 방법도 있겠지만, 이러나저러나 복잡한 건 매한가지입니다. 로딩과 관련된 요건 때문에 데이터의 상태와 화면의 상태를 다르게 가져가야 하기 때문입니다.

이 화면을 ErrorBoundary를 사용해서 선언적으로 바꾸면 어떻게 될까요?

// shared/RetryErrorBoundary/index.tsx
import { PropsWithChildren } from 'react';
import { useQueryErrorResetBoundary } from 'react-query';
import { ErrorBoundary } from 'react-error-boundary';

const RetryErrorBoundary = ({ children }: PropsWithChildren<unknown>) => {
  // 참고: https://react-query.tanstack.com/reference/useQueryErrorResetBoundary
  const { reset } = useQueryErrorResetBoundary();

  return (
    <ErrorBoundary
      onReset={reset}
      fallbackRender={({ resetErrorBoundary }) => (
        <div>
          <p> 데이터를 불러오는데 실패하였습니다. </p>
          <button onClick={() => resetErrorBoundary()}> 다시 시도 </button>
        </div>
      )}
    >
      {children}
    </ErrorBoundary>
  );
};

export default RetryErrorBoundary;
// User/index.tsx
import { Suspense } from 'react';

import Banner from '../Banner';
import BannerSkeleton from '../Banner/index.skeleton';
import CustomMenu from '../CustomMenu';
import CustomMenuSkeleton from '../CustomMenu/index.skeleton';
import User from '../User/index';

const User = () => (
  <section className="user__container">
    <RetryErrorBoundary>
      <Suspense fallback={<UserInfoSkeleton />}>
        <UserInfo />
      </Suspense>
    </RetryErrorBoundary>
    <RetryErrorBoundary>
      <Suspense fallback={<AlarmSkeleton />}>
        <Alarm />
      </Suspense>
    </RetryErrorBoundary>
  </section>
);

export default User;

명령형에 맞추어 작성한 컴포넌트와 비교했을 때 내부 구성이 많이 간단해진 것 같습니다. 삼항 연산자가 사용되지도 않았고, Suspense와 ErrorBoundary에 대한 이해가 있는 사람이라면 User 컴포넌트 내부의 RetryErrorBoundary, Suspense 그리고 실제로 데이터를 받아 화면을 구성하는 UserInfoAlarm 컴포넌트가 각자 어디에 관심을 두고 있는지를 한눈에 이해할 수 있겠죠.

특정 에러 발생 시 "화면 전체를 덮는 에러 화면"을 사용자에게 노출

위에서 살펴본 요구사항은 "재시도를 통해 사용자가 올바른 API 응답을 받을 수 있음"을 상정한 시나리오에 맞춰진 요구사항이었습니다. 하지만 어떤 케이스에는 서버에 심각한 이슈가 있어서 재시도를 진행하여도 사용자가 올바른 API 응답을 받을 수 없는 경우가 있을 수 있습니다. 만약 이 "심각한 이슈"가 특정한 시나리오 하에 있는 사용자에게만 발생하는 에러라면 CS가 인입되더라도 재현이 어렵거나 불가능해 이슈의 해소가 쉽지 않겠지요.

이러한 케이스의 시나리오를 커버하기 위해 기획이 제시한 요구사항은 다음과 같았습니다.

  • 데이터를 불러올 때 서버에서 HTTP ErrorCode 500, Error Response 메시지가 "CRITICAL_ERROR"인 경우 "화면 전체를 덮는 에러 화면"을 표시합니다. 이 에러화면은 다음과 같은 조건을 만족해야 합니다.
  • 사용자에게 "에러가 지속되면 고객센터로 문의하세요"라는 텍스트를 노출해주어야 합니다.
  • 고객센터 인입 시 정확한 사용자 확인을 위해 AEM에서 추적할 수 있는 UUID를 발급하고 화면에 노출해주어야 합니다.
  • 단, 배너 API 호출 시 발생한 에러는 "기본 배너" 화면을 보여주는 것으로 갈음합니다.

명령형으로는 작성할 자신이 도저히 없네요. 😂 선언형 컴포넌트를 사용한 화면만 구성해봅시다. 배너 API 호출 시 발생한 에러는 처리하지 않아도 된다고 하니 배너 부분은 건드리지 않고 위에서 처리했던 사용자 정보 영역과 알람 영역만 한번 처리해봅시다.

우선 하위 Error Boundary에서 특정 에러를 처리하지 않고 위로 올리게끔 구성해야 상위 Error Boundary에서 처리할 수 있을 것 같습니다.

// shared/RetryErrorBoundary/index.tsx
import { PropsWithChildren } from 'react';
import { useQueryErrorResetBoundary } from 'react-query';
import { ErrorBoundary } from 'react-error-boundary';
import { isAxiosError } from 'axios';

const RetryErrorBoundary = ({ children }: PropsWithChildren<unknown>) => {
  // ...전략
  return (
    <ErrorBoundary
      onError={({ error }) => {
        if (
          isAxiosError(error) &&
          error?.response?.status === 500 &&
          error?.response?.data === 'CRITICAL_ERROR'
        ) {
          // 조건에 맞는 에러인 경우 이 ErrorBoundary에서 처리하지 않고
          // 상위 ErrorBoundary 위임을 위해 Throw
          throw error;
        }
      }}
      {/* 후략.. */}
    >
      {children}
    </ErrorBoundary>
  );
};

위와 같이 ErrorBoundary를 구성하면 특정 조건에 해당하는 에러는 이 ErrorBoundary에서 처리되지 않고 다시 Throw 됩니다. Throw 된 에러는 (너무 자연스럽게도) 상위 ErrorBoundary에서 처리되겠죠.

// shared/CriticalErrorBoundary/index.tsx
import { PropsWithChildren, useState } from 'react';
import { useQueryErrorResetBoundary } from 'react-query';
import { ErrorBoundary } from 'react-error-boundary';
import { nanoid } from 'nanoid';

import { sendErrorToErrorTracker } from '../utils';

const CriticalErrorBoundary = ({ children }: PropsWithChildren<unknown>) => {
  const { reset } = useQueryErrorResetBoundary();
  const [errorUuid, setErrorUuid] = useState();

  return (
    <ErrorBoundary
      onReset={() => {
        reset();
        setErrorUuid(undefined);
      }}
      onError={({ error }) => {
        if (
          !(
            isAxiosError(error) &&
            error?.response?.status === 500 &&
            error?.response?.data === 'CRITICAL_ERROR'
          )
        ) {
          // 이 ErrorBoundary에서 처리하면 안되는 오류의 경우 상위 ErrorBoundary로 위임
          throw error;
        } else {
          // 이 ErrorBoundary에서 처리되는 오류의 경우 UUID 부여 후 사용자에게 노출
          const uuid = nanoid(5);
          setErrorUuid(uuid);
          sendErrorToErrorTracker(uuid);
        }
      }}
      fallbackRender={({ resetErrorBoundary }) => (
        <div>
          <h1> 데이터를 불러오는데 실패하였습니다. </h1>
          <p> 에러가 지속되면 고객센터로 문의하세요. </p>
          <footer> {errorUuid} </footer>
        </div>
      )}
    >
      {children}
    </ErrorBoundary>
  );
};

export default CriticalErrorBoundary;

여기까지 구성하고 나면 다 끝난것 같네요. 새로 만든 CriticalErrorBoundary만 적절한 위치에 넣어주면 깔끔하게 처리되겠죠. 애플리케이션 전체의 API 요청을 커버하기 위해 최상단 컨포넌트인 App 컴포넌트에 CriticalErrorBoundary를 넣어주도록 하겠습니다.

<Banner/> 컴포넌트에서 API 에러 발생 시 ErrorBoundary를 사용하여 사전 정의된 배너를 보여주게 작업했던 부분 기억나시나요? <Banner/> 컴포넌트 하위에서 발생하는 API 에러는 모두 해당 ErrorBoundary에서 처리되기 때문에 CriticalErrorBoundary가 최상단 App 컴포넌트에 존재하고 있어도 문제가 없습니다.

// app.tsx
import { lazy, Suspense } from 'react';

import MainPage from './MainPage/index.skeleton';
import CriticalErrorBoundary from './shared/CriticalErrorBoundary';

const MainPage = lazy(() => import('./MainPage'));

const App = () => (
  // RootErrorBoundary: Runtime Error 등 일반적인 에러를 처리하기 위한 ErrorBoundary
  <RootErrorBoundary>
    <CriticalErrorBoundary>
      <Suspense fallback={<Skeleton/>}>
        <MainPage/>
      </Suspense>
    </CriticalErrorBoundary>
  >
);

export default App;

만약 이러한 구성을 명령형 컴포넌트의 기조로 처리하려 했다고 생각해봅시다. 우선 일반적인 방법부터 생각해볼까요?

별도의 Global State Management Library를 사용하지 않는 경우, App 또는 MainPage 컴포넌트에 에러 화면을 보여주기 위한 별도의 State를 두고 Props Drilling을 통해 setStateHandler를 계속 내려서 처리해야 합니다. 10초만 생각해봐도 이건 아닌 것 같죠.

Global State Management Library를 사용한다면 어떨까요? react-query를 사용하지 않는다면 이 요구사항을 구현하기 그리 어렵거나 어색하지 않았겠지만, react-query를 사용한다면 굳이 상태 관리를 위해 Global State Management Library를 사용하는 게 어색하게 느껴질 수 있습니다.

react-query에서 사용하는 QueryClient의 DefaultOption.onError를 사용하는건 어떨까요? 이것도 충분히 유효한 방법일 것 같습니다. 하지만 만약 처리해야 하는 공통 에러의 종류가 다양해질 수 있다는 생각을 해보면 우아한 방법은 아닐 수 있을 것 같다는 생각이 듭니다.

지금까지의 작업을 통해 비동기 데이터를 불러오는 과정에서의 "로딩 화면"에 이미 Suspense를 사용하고 있고, 에러 케이스 처리를 위해 ErrorBoundary를 이미 사용하고 있는 상황이라면 이 특별한 요구사항의 처리를 위해 ErrorBoundary를 더 활용하지 못할 이유가 없다는 생각이 듭니다.

더 나아가서 일반적으로 우리가 어떤 요구사항의 처리를 위해 프로젝트를 살펴볼 때는 "해당 요구사항을 위해 화면에 뿌려지는 컴포넌트의 코드"에서부터 거슬러 올라가며 검토하는 상황을 상상해보면 react-query의 defaultOption이나 Global State Management Library를 통해 "컴포넌트의 생명주기"4와 별개로 화면을 처리하는 방식은 프로젝트의 규모가 커질수록 이슈 해결을 위해 "방탈출 힌트 찾기"를 하는 것과 비슷한 상황을 만들게 됩니다.

마치며

지금까지 React Query를 사용해서 선언형 컴포넌트를 구성하는 방법에 대해 이야기해 보았습니다.

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

하지만 이를 위해 필수적으로 요구되는 "데이터 불러오기를 위한 Suspense"는 아직 React에서 정식으로 지원하는 기능이 아닙니다. 기존 React v17에서 Tricky하게 동작하던 Suspense가 React v18에서 예측 가능하게 동작하게 바뀌는 등 "데이터 불러오기를 위한 Suspense"를 정식 기능으로 도입하기 위한 React 팀의 노력은 계속되고 있지만 아직 Experimental Stage에 있음은 부정할 수 없습니다.

그럼에도 불구하고 카카오페이 프론트엔드 팀에서는 일부 서비스에 선행적으로 데이터 불러오기를 위한 Suspense와 선언형 컴포넌트를 적극적으로 도입하여 사용하고 있습니다. 이를 통해 복잡하고 프로젝트 전반을 알아야만 수정할 수 있는 형태의 사용자 경험 개선 코드들을 전격적으로 제거하여 더 자연스럽고 편안한 개발 경험을 얻을 수 있었고, 평소에는 리소스와 프로젝트 복잡도와 관련된 걱정으로 시도해보지 못했던 다양한 UX 아이디어를 실행할 수 있었습니다.

저희가 준비한 「카카오페이 프론트엔드 개발자들이 React Query를 선택한 이유」, 「React Query와 함께 선언형 컴포넌트를 구성하는 방법」 연작을 통해 카카오페이에서 왜 React Query를 적극적으로 도입하게 되었고, 어떻게 사용하고 있는지에 대해 다루어 보았습니다. 이 글이 독자분들에게 사용자 경험과 개발자 경험이 공존하는 프로젝트 구성에 대한 좋은 인사이트를 드릴 수 있었기를 바랍니다.

참고문헌

Joseph Savona, Building Great User Experiences with Concurrent Mode and Suspense (React Blog, 2019)
Concurrent UI Patterns (Experimental) (React Official Document)
1: 출처: https://ui.dev/imperative-vs-declarative-programming
2: 출처: https://ko.reactjs.org/docs/react-api.html#suspense
3: 출처: https://ko.reactjs.org/docs/error-boundaries.html
4: 우리가 익히 아는 React Component Lifecycle과는 조금 다른 의미로 사용하였습니다. 「컴포넌트 내부의 코드가 오롯이 자연스럽게 동작하는 것」 정도의 뉘앙스로 이해해주세요.

카카오페이에서 주식 서비스와 프론트엔드 인프라 전반을 담당하고 있습니다. FE인프라TF의 TF장을 맡고 있으며 사용자 경험과 개발자 경험의 향상에 관심이 많습니다.
eric.dev

카카오페이에서 주식 서비스와 프론트엔드 인프라 전반을 담당하고 있습니다. FE인프라TF의 TF장을 맡고 있으며 사용자 경험과 개발자 경험의 향상에 관심이 많습니다.

태그
  • react
  • react-query
  • frontend
추천 콘텐츠
© Kakao pay corp.