[Uket] 선언형 컴포넌트 적용 그리고 고찰

coggiee
15 min readJul 23, 2024

--

명령형 처리

선언형 프로그래밍을 접하기 전의 나는, ‘어떻게’ 화면을 보여줄지에 집중했었고, 보편적으로 아래와 같이 작성했었다.

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를 요청한다.)

Uket 축제 정보 확인 페이지
Uket 축제 정보 확인 페이지

선언형 처리는 ‘무엇을' 화면에 보여줄지에 집중하는 것이다.

즉, 상황에 따라 적절한 UI를 보여주는 것에 집중하는 것이라고 한다.

상황을 3가지로 나누어 볼 수 있다.

  1. 데이터가 있는 상태
  2. 데이터를 기다리는 상태
  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를 도입했는지를 몸소 느껴볼 수 있는 좋은 경험이었다.
(실제 서비스를 시작하게 된다면, 사용자들에게는 어떤 경험을 제공할지 매우 궁금하다)

--

--

coggiee
coggiee

Written by coggiee

0 Followers

Just doing

No responses yet