명령형 처리
선언형 프로그래밍을 접하기 전의 나는, ‘어떻게’ 화면을 보여줄지에 집중했었고, 보편적으로 아래와 같이 작성했었다.
const ChatSection = ({ chatId }) => {
const { data, isLoading, isError } = useQueryChatList(chatId);
if (isLoading) {
<div className="flex flex-col gap-4 pb-24">
{Array.from({ length: 6 }).map((_, index) => (
<ActivityCardSkeleton key={index} />
))}
</div>
}
if (isError) {
toast({
title: '채팅 목록이 없습니다.',
description: '새로고침 후 다시 시도해 주세요.',
variant: 'destructive',
})
}
useEffect(() => {
if (data) {
// ... 데이터 처리
} else {
// ... 데이터가 없는 경우 처리
}
}, [data]);
return (
{data && data.length > 0 &&
<ChatItem>
// ...
</ChatItem>
}
);
}
비동기 데이터를 사용하는 곳 거의 대부분의 코드가 위와 같은 형태로 작성되어있다.
if
문을 사용하여 비동기 요청의 상태에 따라 <Skeleton />
컴포넌트를 보여주거나 에러를 포착하면 토스트 메시지를 띄워준다. 데이터를 받아왔다면, useEffect
를 사용하여 데이터 후처리를 한다.
즉, UI를 ‘어떻게’ 보여줄 것이냐에 집중하고 있다.
이러한 명령형 처리는 어플리케이션이 규모가 커질수록 코드가 복잡해지고, 유지보수도 어려워졌다. 그리고, 하나의 컴포넌트에 에러 처리, 로딩 처리, 데이터 처리 등 여러 관심이 분포해 있어서 컴포넌트의 역할이 모호해졌다.
선언형 처리
Uket의 축제 정보 확인 페이지를 예시로 살펴보자.
총 2개의 비동기 데이터를 사용한다. (= 2개의 API를 요청한다.)
선언형 처리는 ‘무엇을' 화면에 보여줄지에 집중하는 것이다.
즉, 상황에 따라 적절한 UI를 보여주는 것에 집중하는 것이라고 한다.
상황을 3가지로 나누어 볼 수 있다.
- 데이터가 있는 상태
- 데이터를 기다리는 상태
- 오류가 발생한 상태
나는 1번과 2번 상황을 하나로 묶어서, 컴포넌트는 데이터가 항상 있음을 보장한 상태만을 처리하도록 관심을 분리했다.
데이터가 항상 있음을 보장하기
데이터가 항상 있음을 보장하기 위해서는, 데이터가 도착할 때 까지 기다려야 한다.
Suspense 를 활용하여 컴포넌트가 렌더링되기 전에 다른 작업이 먼저 이루어지도록 대기할 수 있다. (Tanstack Query를 사용한다면, useSuspenseQuery
를 함께 사용하자)
아래의 코드는 축제 정보 확인 페이지를 간결화한 코드이다.
const HomePage = () => {
// 두 개의 비동기 데이터가 도착할 때 까지, Suspense의 Fallback을 보여준다.
return (
<>
<Suspense fallback={<UnivSelectorSuspenseFallback />}>
<UnivSelector />
</Suspense>
<Suspense fallback={<FestivalSectionSuspenseFallback />}>
<FestivalSection />
</Suspense>
</>
)
}
const UnivSelector = () => {
// useSuspenseQuery 커스텀 훅
const { data } = useQueryFestivalUnivList();
// 데이터는 항상 존재한다고 보장한다.
return (
<Select>
<SelectTrigger />
<SelectContent>
{data.map(({id, name}) => (
<SelectItem key={id} value={name}>{name}</SelectItem>
))}
</SelectContent>
</Select>
)
}
const FestivalSection = () => {
// useSuspenseQuery 커스텀 훅
const { data } = useQueryFestivalInfoByUniversity();
// 데이터는 항상 존재한다고 보장한다.
return (
<div>
<SectionItem item={data.banners} />
<SectionItem item={data.location} />
</div>
)
}
<HomePage />
컴포넌트에서는 2개의 컴포넌트를 Suspense를 통해 독립적으로 불러오고 있다.
1. <UnivSelector />
컴포넌트는 축제가 진행중인 대학교 목록 데이터를 비동기로 불러온다.
2. <FestivalSection />
컴포넌트는 현재 선택한 대학교의 축제 정보 데이터를 비동기로 불러온다.
각 컴포넌트를 보면, 데이터가 항상 보장되어 있다는 전제하에 코드를 작성한 것을 볼 수 있다. 분리된 데이터 로딩 처리는 Suspense가 담당하며, 컴포넌트는 데이터를 보여주는 것에만 집중한다.
만약, Suspense를 활용하지 않으면 아래의 코드가 된다.
const UnivSelector = () => {
// useQuery 커스텀 훅
const { data, isLoading } = useQueryFestivalUnivList();
// 데이터가 항상 존재함이 보장되지 않는다.
// 즉, 컴포넌트 내부에서 로딩 처리를 진행한다.
return (
{isLoading ? (
<UnivSelectorSuspenseFallback />
):(
<Select>
<SelectTrigger />
<SelectContent>
{data.map(({id, name}) => (
<SelectItem key={id} value={name}>{name}</SelectItem>
))}
</SelectContent>
</Select>
)}
)
}
오류가 발생한 상태
만약, 비동기 데이터를 불러오는데 오류가 발생하면 어떻게 처리할까?
Error Boundary는 일종의 에러를 포착하는 경계이며, 에러가 발생한 경우 미리 정의된 UI를 사용자에게 보여준다.(목적에 맞게 useQuery
에서 throwOnError: true
로 설정하거나 QueryClient의 defaultOptions
에서 모든 queries에 대해 throwOnError: true
로 설정한다.
const data = useQuery({
queryKey: ...,
queryFn: ...,
throwOnError: true
});
// 또는
const queryClient = new QueryClient({
defaultOptions: {
queries: {
throwOnError: true,
},
},
});
그리고 Suspense와 동일하게 ErrorBoundary로 컴포넌트 외부를 감싸준다.
const HomePage = () => {
// 두 개의 비동기 데이터가 도착할 때 까지, Suspense의 Fallback을 보여준다.
return (
<>
<ErrorBoundary FallbackComponent={<UnivSelectorErrorFallback />}>
<Suspense fallback={<UnivSelectorSuspenseFallback />}>
<UnivSelector />
</Suspense>
</ErrorBoundary>
<ErrorBoundary FallbackComponent={<FestivalSectionErrorFallback />}>
<Suspense fallback={<FestivalSectionSuspenseFallback />}>
<FestivalSection />
</Suspense>
</ErrorBoundary>
</>
)
}
이제 컴포넌트(비동기 데이터를 사용하는)에서 오류(비동기 요청에서 발생하는 오류)가 발생하면, ErrorBoundary로 에러가 전파되어 미리 정의된 FallbackComponent를 사용자에게 보여준다.
만약, Error Boundary를 사용하지 않는다면 `isError` 옵션으로 미리 정의된 에러 화면을 보여주거나, 토스트를 띄워주는 방식으로 처리할 수 있다. 그리고 로딩 처리도 수행한다면 코드는 아래와 같아진다.
const UnivSelector = () => {
// useQuery 커스텀 훅
const { data, isLoading, isError } = useQueryFestivalUnivList();
// 데이터가 항상 존재함이 보장되지 않는다.
// 오류도 발생할 가능성이 있다.
return (
{isLoading ? (
<UnivSelectorSuspenseFallback />
):(
<>
{isError ? (
<UnivSelectorErrorFallback />
):(
// 실제로 보여주고자 하는 내용
<Select>
<SelectTrigger />
<SelectContent>
{data.map(({id, name}) => (
<SelectItem key={id} value={name}>{name}</SelectItem>
))}
</SelectContent>
</Select>
)}
</>
)}
)
}
HTTP ErrorCode에 따라 서로 다른 화면을 보여주기
더 나아가 어떤 오류인지에 따라 사용자에게 다른 UI를 보여줄 수도 있겠다.
Uket에서는 HTTP ErrorCode 500를 치명적인 오류로 간주하여, 이를 기준으로 분리했다.
- HTTP ErrorCode 500: 전체 화면을 덮는 Error Fallback (CriticalErrorBoundary
)
- 나머지 ErrorCode(400, 401, 403…): 특정 컴포넌트만 덮는 Error Fallback (RetryErrorBoundary
)
API를 재호출 할 수 있는 재시도 UI를 포함하기 때문에, RetryErrorBoundary로 명명한다. 또한, 컴포넌트 별로 다른 Fallback을 사용할 수 있도록 fallbackComponent를 props로 전달받고, 필수가 아닌 optional하게 설정하여 전달된 fallbackComponent가 없다면 DefaultErrorFallback을 사용하도록 설계했다.
import { ErrorBoundary, FallbackProps } from "react-error-boundary";
import { useQueryErrorResetBoundary } from "@tanstack/react-query";
interface RetryErrorBoundaryProps {
children: React.ReactNode;
resetKeys?: unknown[];
fallbackComponent?: (props: FallbackProps) => React.ReactNode;
}
const RetryErrorBoundary = ({
children,
resetKeys,
fallbackComponent,
}: RetryErrorBoundaryProps) => {
const { reset } = useQueryErrorResetBoundary();
return (
<ErrorBoundary
onReset={reset}
resetKeys={resetKeys}
onError={error => {
if (isAxiosError(error) && error?.response.status === 500) {
throw error;
}
}
FallbackComponent={fallbackComponent || DefaultErrorFallback}
>
{children}
</ErrorBoundary>
);
};
CriticalErrorBoundary는 아래와 같다.
전체 화면을 덮는 Error Fallback이기 때문에 fallbackComponent를 props로 전달받지 않고 미리 정의된 단 하나의 fallback을 사용하도록 했다.
import { ErrorBoundary } from "react-error-boundary";
import { useQueryErrorResetBoundary } from "@tanstack/react-query";
import {
ErrorContainer,
ErrorDescription,
ErrorHeader,
ErrorTitle,
} from "./CustomError";
const CriticalErrorBoundary = ({ children }: { children: React.ReactNode }) => {
const { reset } = useQueryErrorResetBoundary();
const navigate = useNavigate();
const back = () => {
navigate(-1);
};
const home = (callback: (...args: any[]) => void) => {
navigate("/");
callback();
};
return (
<ErrorBoundary
onReset={reset}
FallbackComponent={({ resetErrorBoundary }) => (
<ErrorContainer className="container flex-col gap-10">
<ErrorHeader className="mt-16 flex grow flex-col justify-center space-y-3 text-center">
<ErrorTitle className="text-2xl font-black">
잠시 후 다시 시도해 주세요!
</ErrorTitle>
<ErrorDescription className="container flex flex-col text-center">
<span>이용에 불편을 드려 죄송합니다.</span>
<span>
네트워크 또는 데이터 오류로 페이지를 불러오지 못하였습니다.
</span>
</ErrorDescription>
</ErrorHeader>
<footer className="mb-10 flex w-full flex-col justify-end gap-2">
<Button
onClick={() => home(resetErrorBoundary)}
className="rounded-xl border border-[#5E5E6E] bg-[#5E5E6E] py-6 text-xs font-bold hover:bg-[#757583]"
>
홈 화면으로
</Button>
<Button
onClick={back}
className="rounded-xl border border-[#5E5E6E] bg-white py-6 text-xs font-bold text-[#5E5E6E] hover:bg-slate-100"
>
이전 페이지로
</Button>
</footer>
</ErrorContainer>
)}
>
{children}
</ErrorBoundary>
);
};
(커스텀 Error Fallback에 동일한 레이아웃을 적용하기 위하여 컴파운드 패턴을 적용한 ErrorContainer, ErrorHeader, ErrorTitle, ErrorDescription
컴포넌트를 사용했다)
고찰 ?
카카오에서 작성한 선언형 처리에 관련한 포스트를 접했는데, 너무 인상깊고 유익했다.
이전의 다른 프로젝트에서 명령형 처리로 인한 유지보수의 어려움 그리고 가독성 저하를 경험했다. 몇 번 언급 했듯이 로딩과 에러 처리를 컴포넌트 내부에서 한 번에 하려다보니, 코드 작성이 피곤하고 내가 작성한 코드임에도 불구하고 추후 리팩토링 때 코드를 다시 열어보면, 한참을 읽어야 이해가 됐었다.
그래서 Uket 프로젝트에는 선언형 처리를 반드시 적용해 보고자 했었다.
선언형으로 UI를 구성하니 무엇보다 관심이 분리 되어서 코드의 흐름이 한 눈에 보였고, 사용자 경험 향상을 위한 ErrorBoundary를 활용한 에러 처리도 인상 깊었다.
카카오에서 글로써는 잘 와닿지 않을 수 있는, 왜 적극적으로 선언형 UI를 도입했는지를 몸소 느껴볼 수 있는 좋은 경험이었다.
(실제 서비스를 시작하게 된다면, 사용자들에게는 어떤 경험을 제공할지 매우 궁금하다)