항상 프론트엔드 개발을 시작하기에 앞서 사용자경험(UX)
을 어떻게 하면 높일 수 있을지 고민하면서 개발을 시작한다.
사용자 경험을 향상시키기 위한 방법으로는 페이지 로드 시간 개선
, API 응답 속도 향상
, 웹 성능 최적화
등 여러 가지가 있지만, 이번 글에서는 비동기 작업이나 에러로 인해 화면이 제대로 표시되지 않는 상황을 효과적으로 처리하는 방법
에 대해 집중적으로 다룰 예정이다.
다른 내용을 확인하고 싶다면 이전에 작성 된 아래 링크를 참고하자.
리액트에서 선언적으로 비동기처리를 하게 되면 개발자는 성공 상태와 비즈니스 로직에만 집중하여 컴포넌트를 개발할 수 있기 때문에 개발자 경험(DX)
또한 개선할 수 있다.
여기에서 가장 중요한 개념이 Suspense
와 ErrorBoundary
이다.
비동기 서버 통신을 React-query를 이용해서 구현한다면 다음과 같이 에러와 로딩 상태
를 처리하게 될 것이다.
function UserProfile() {
const { data, loading, error } = useShopping();
if (loading) return <span>데이터를 불러오는 중입니다...</span>;
if (error) return <span>문제가 발생했습니다</span>;
return <div>...</div>;
}
위 코드는 기능적으로 문제가 없지만, 프로젝트 규모가 커지고 컴포넌트가 복잡해지면 문제가 발생한다.
문제점
개발자 경험 저하
: 생성되는 컴포넌트마다 매번 로딩 상태와 에러 상태를 확인하고 정의하는 반복 작업이 필요하다.
사용자 경험 저하
: 특정 컴포넌트에서 에러 핸들링이 되지 않는다면 전체 서비스가 멈출 수 있다.
Suspense: 로딩이 발생하는 부분에만 fallback을 Render 할 수 있다.
ErrorBoundary: 에러가 발생하는 부분에만 fallback을 Render 할 수 있다.
비동기 작업의 목표는 사용하는 애플리케이션이 멈추지 않고 다른 작업을 동시에 할 수 있도록 하기 위함이다.
만약 로딩이나 에러 상태가 발생했을 때 전체 어플리케이션이 멈추게 된다면 사용자 경험에 치명적인 영향을 줄 것이다. 따라서 부분적으로 로딩, 에러 상태를 보여주는 것이 좋다.
Suspense를 이용하면 Loading 상태를 선언적으로 관리할 수 있다.
function ShoppingList(){
const { data } = useShopping();
return <div>{data.shoppingList}</div>
}
function Main() {
return (
<main>
<Suspense fallback={<Loading />} />
<ShoppingList />
</Supsense>
</main>
)
}
위 코드를 보면 컴포넌트의 loading
, error
처리 상태가 없어지면서 개발자는 선언적으로 개발할 수 있게 되었다.
Suspense의 fallback 으로 로딩 컴포넌트를 넘겨 줘서 로딩 상태에 따른 렌더링 처리를 하였다.
Parent에 위치한 Suspense
에게 Promise를 throw 한다.pending
인 경우에는 fallback props
에 전달된 컴포넌트를 렌더링하고 Promise의 상태가 resolve
가 되면, 해당 컴포넌트를 렌더링
한다.ErrorBoundary
가 도입된 배경은 UI에 존재하는 JS 에러가 전체 애플리케이션을 중단시켜서는 안 된다는 것
이다.
따라서 ErrorBoundary라는 이름처럼 에러를 어떠한 경계 안에 가두고 기존 컴포넌트 대신 fallback UI
를 보여주는 역할을 한다.
getDerivedStateFromError
자식 컴포넌트에서 오류가 발생했을 때 호출된다. 이때 주의할 점은 에러를 throw 받은 시점인 render 단계에서 호출되기 때문에 side effects를 발생시키면 안 된다.
throw된 에러를 catch하고 return 한 값을 기반으로 setState를 실행한다.
componentDidCatch
render 이후의 side effects를 다루는 메서드이다. 에러 로그를 기록하는 용도로 사용될 수 있다.
getDerivedStateFromError -> render -> componentDidCatch 순서에 따라 동작된다.
앞선 동작 원리에서 유추할 수 있듯이 class 컴포넌트의 생명주기 메서드를 이용하여 에러를 catch하기 때문에 ErrorBoundary는 class 컴포넌트로만 구현할 수 있다.
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
// 다음 렌더링에서 Fallback UI가 보이도록 상태를 업데이트 합니다.
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// 에러를 기록합니다.
logErrorToMyService(error, errorInfo);
}
render() {
if (this.state.hasError) {
// Fallback UI를 커스텀하여 렌더링할 수 있습니다.
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
Suspense와 ErrorBoundary를 조합해서 하나의 컴포넌트로 로딩, 에러 상태의 작업을 모두 처리하여 개발자 경험(DX)
을 향상 시킬 수 있습니다.
"use client";
import { ComponentType, PropsWithChildren, ReactNode } from "react";
import { ErrorBoundary, FallbackProps } from "react-error-boundary";
import Spinner from "@/components/spinner/Spinner";
import { Suspense } from "./Suspense";
import ErrorFallback from "./ErrorFallback";
interface Props {
errorFallback?: ComponentType<FallbackProps>;
suspenseFallback?: ReactNode;
}
export default function SearchDataErrorBoundary({
errorFallback,
suspenseFallback,
children,
}: PropsWithChildren<Props>) {
return (
<ErrorBoundary FallbackComponent={errorFallback ?? ErrorFallback}>
<Suspense fallback={suspenseFallback ?? <Spinner />}>{children}</Suspense>
</ErrorBoundary>
);
}
위와 같은 컴포넌트를 아래와 같이 적용할 수 있다.
interface SearchSectionProps {}
const SearchSection: React.FC<SearchSectionProps> = ({}) => {
return (
<section>
<SearchDataErrorBoundary
suspenseFallback={<ImageSkeleton variant="searchList" />}
>
{/* 보여주고 싶은 API 호출 컴포넌트 */}
</SearchDataErrorBoundary>
</section>
);
};
export default SearchSection;
보여주고 싶은 API 호출 컴포넌트를 공통 Suspense + ErrorBoundary로 감싸주기만 하면 된다. 핵심은 API 호출을 Boundary
안에 가두는 것이다.
상태에 따른 UI 렌더링
데이터 로드 전 Pending 상태
suspenseFallback으로 전달한 ImageSkeleton 컴포넌트 렌더링
비동기 작업 중 Error 발생
errorFallback으로 전달한 ErrorFallback 컴포넌트 렌더링
비동기 작업 완료 Fulfilled 상태
'보여주고 싶은 API 호출 컴포넌트' 컴포넌트 렌더링
화면이 정상적으로 노출되지 않아서 사용자 경험이 저하되는 사례는 ErrorBoundary를 이용하여 API를 재호출하거나 특정 페이지로 redirect 하도록 처리하여 해결할 수 있다.
더 나아가, 비동기 로딩과 에러 처리 로직을 담당하는 Suspense와 Errorboundary를 결합한 컴포넌트를 도입함으로써 개발자는 성공 상태와 비즈니스 로직에만 집중할 수 있게 되어 개발 생산성을 높일 수 있다.