초기 렌더링과 데이터 바인딩의 시점 불일치로 인한 애니메이션 소실 현상 수정하기

in-ch·2025년 8월 23일
4

헤딩 기록

목록 보기
18/18

들어가며

대규모 데이터를 그래프로 시각화하는 작업은 생각보다 까다로운 점이 많습니다.
특히 데이터가 수천 개를 넘어가는 그래프를 다루다보면 애니메이션이 뚝뚝 끊기는 현상 등이 많이 발생할 겁니다.

최근 업무에서 저 역시 비슷한 문제를 겪었습니다.
아래 GIF처럼 말이죠.
그래프 애니메이션에 병목 현상 발생

위의 gif를 확인해보면 여러번에 걸쳐서 새로고침을 통해 초기 로딩 애니메이션을 실험하고 있습니다.
문제는 간헐적으로 애니메이션이 동작하지 않는 문제가 발생합니다.

왜 이런 문제가 발생한 걸까요?

이 글에서는 근본적인 원인을 Recharts의 렌더링 애니메이션 파이프라인과 연관 지어 분석하고, 이를 RSC (React Server Components)를 활용해 최적화하는 전략을 탐구해 보겠습니다.


Render 패치 전략

문제 원인을 살펴보기 전에 먼저 여러가지 렌더링 전략에 대해서 간단히 살펴보겠습니다.

React Suspense 공식 문서에서는 다음과 같은 렌더링 패치 전략을 소개합니다. (물론 현재는 아카이브된 문서이므로, 렌더링 전략에 대한 개념만 참고하겠습니다.)

  • Fetch on render (for example, fetch in useEffect): 컴포넌트 렌더링이 시작된 후, useEffect나 생명주기 메서드 내에서 데이터 페칭을 시작합니다.
    이 방식은 종종 데이터 요청이 연쇄적으로 일어나는 워터폴(Waterfall) 현상을 초래합니다.

  • Fetch then render (for example, Relay without Suspense): 다음 화면에 필요한 모든 데이터를 먼저 요청하고, 데이터가 모두 준비되면 화면을 렌더링합니다.
    데이터가 도착하기 전까지는 사용자에게 아무것도 보여줄 수 없습니다.

  • Render as you fetch (for example, Relay with Suspense): 다음 화면에 필요한 데이터 요청을 시작함과 동시에 화면 렌더링도 즉시 시작합니다.
    데이터가 스트리밍되는 동안, React는 데이터가 필요한 컴포넌트의 렌더링을 데이터가 완전히 준비될 때까지 재시도합니다.

Fetch on render

이 전략은 가장 전통적인 방식으로, 간단히 말해 이렇습니다.

useEffect 훅 내부에서 비동기 함수를 호출하여 데이터를 가져오는 방식입니다.

비슷한 주제에 대해서 포스팅을 한적이 있지만, useEffect 안에서 데이터를 패칭하는 것은 Race Condition 상태를 유발할 수 있습니다.

즉, 여러 요청이 있을 때 어떤 요청이 먼저 끝날지 보장할 수 없어 실행 순서에 따라 결과가 달라질 수 있는 것이죠.

이러한 이유로 최근에는 TanStack Query나 SWR과 같은 라이브러리와 Suspense를 조합하여 Render as you fetch 전략을 사용하는 것이 권장됩니다.

경쟁 상태 출처:https://lake0989.tistory.com/121

여기서 Race Condition이란 경쟁 상태를 의미하며 공유 자원에 의해 함수가 실행 순서에 따라 결과가 달라질 수 있음을 의미합니다.

render as you fetch

해당 전략은 다음과 같은 순서를 따릅니다.

render as you fetch

  1. 브라우저가 HTML, CSS, JS 리소스를 요청합니다.

  2. 초기 HTML과 CSS가 화면에 렌더링됩니다.

  3. React가 JS를 실행하여 DOM 요소를 제어하는 하이드레이션(Hydration) 과정을 거칩니다.

  4. 컴포넌트 렌더링과 동시에 데이터 페칭을 시작합니다.

  5. 데이터가 로딩되는 동안에는 Suspensefallback UI (예: 스피너)를 사용자에게 보여줍니다.

  6. 데이터 페칭이 완료되면, fallback UI가 실제 컴포넌트 UI로 자연스럽게 교체됩니다.

참고: 하이드레이션(Hydration)이란?

서버에서 렌더링된 정적 HTML 파일이 브라우저에 도착했을 때, React(혹은 다른 프레임워크)가 이 HTML 구조 위로 자바스크립트 코드를 실행하여 각 DOM 요소에 필요한 이벤트 핸들러나 상태 등을 연결하는 과정을 말합니다.

마치 마른 스펀지에 물을 부어 기능을 활성화하는 것처럼, 정적인 UI에 동적인 상호작용 능력을 부여하는 과정입니다.


문제 원인 분석하기

이제 본격적으로 문제의 코드를 살펴보며 원인을 파헤쳐 보겠습니다.

// ... 생략
       
<div>
   {groupSalesDataLoading ? (
            <div className="flex flex-1 items-center justify-center">
              <LoadingIndicator title="그룹별 매출 정보를 불러오는 중" description="" isExtraLoading={false} />
            </div>
    ) : (
            <>
              <CardContent className="flex-1 p-0">
                <ChartContainer config={chartConfig} className="aspect-square">
                  <PieChart>
                    <ChartTooltip cursor={true} content={<ChartTooltipContent hideLabel />} />
                    <Pie
                      className="cursor-pointer"
                      data={pieChartData1}
                      dataKey="amount"
                      nameKey="data"
                      innerRadius={40}
                      onClick={e => handleGoToSalesByGroupPage(e, new Date().getFullYear().toString())}
                    >
                      <Label
                        content={({ viewBox }) => {
                          if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
                            return (
                              <text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
                                <tspan x={viewBox.cx} y={viewBox.cy} className="fill-foreground text-md font-bold">
                                  {new Date().getFullYear()}</tspan>
                              </text>
                            );
                          }
                        }}
                      />
                    </Pie>
                  </PieChart>
                </ChartContainer>
              </CardContent>
            </>
    )}
</div>
// ... 생략

위 코드는 전형적인 클라이언트 사이드 데이터 페칭 패턴을 따릅니다.
코드를 간단히 설명하면 다음과 같습니다.

  1. groupSalesDataLoading이라는 state를 통해 로딩 상태를 관리합니다.

  2. 로딩 중일 때 (true)는 <LoadingIndicator> 컴포넌트를 보여줍니다.

  3. 로딩이 끝나면 (false) Recharts의 <PieChart> 컴포넌트를 렌더링합니다.

  4. 차트에 표시될 실제 데이터는 pieChartData라는 prop을 통해 전달됩니다.

렌더링 과정을 따라가며 문제 원인 분석하기

문제를 분석하기 위해 렌더링 과정을 단계 별로 나눠서 살펴보도록 하겠습니다.

1단계: 초기 렌더링 (빈 데이터 시점)

  • 부모 컴포넌트가 처음 렌더링될 때, data의 초기값은 [](빈 배열)이고 groupSalesCountDataLoadingfalse인 상태로 자식 컴포넌트(StatusArrivalAndExitByGroupGrape)에 전달됩니다.

2단계: 차트의 첫 번째 렌더링 및 애니메이션 실행

  • StatusArrivalAndExitByGroupGrape 컴포넌트는 groupSalesCountDataLoadingfalse이므로 로딩 인디케이터 대신 차트를 바로 렌더링합니다.
  • 이때 BarChartdata prop으로 빈 배열([])을 전달받습니다.
  • recharts 라이브러리는 데이터가 0 또는 없는 상태에서 차트를 그리면서 초기 마운트 애니메이션을 실행합니다.

즉, 막대가 0에서 0으로 "자라나는" 애니메이션이 눈에 보이지 않게 아주 빠르게 실행되고 끝나버릴 수 있습니다.

3단계: 데이터 Fetching 및 로딩 상태 변경

  • 이제 부모 컴포넌트는 useEffect 등을 통해 실제 데이터 fetching을 시작합니다.
  • 데이터를 불러오기 시작하면서 부모는 groupSalesCountDataLoading 상태를 true로 변경합니다.
  • 이로 인해 자식 컴포넌트는 잠시 로딩 인디케이터를 표시하게 됩니다.

4단계: 실제 데이터 도착 및 두 번째 렌더링

  • 데이터 fetching이 완료되면, 부모는 실제 데이터가 담긴 배열을 data prop으로, 그리고 groupSalesCountDataLoading은 다시 false로 자식에게 전달합니다.
  • StatusArrivalAndExitByGroupGrape 컴포넌트는 이제 실제 데이터를 가지고 차트를 다시 렌더링(리렌더링)합니다.

왜 애니메이션이 동작하지 않는 것처럼 보일까?

결론: Recharts의 초기 애니메이션은 컴포넌트가 처음 마운트될 때 단 한 번 실행되기 때문입니다.

사용자 관점에서는 2단계에서 이미 보이지 않는 애니메이션이 "소모"되었기 때문에, 정작 실제 데이터가 들어와서 차트가 그려지는 4단계에서는 단순한 데이터 업데이트로 인한 리렌더링으로 간주됩니다.

따라서 초기 애니메이션이 다시 실행되지 않고, 차트가 그저 '뿅'하고 나타나는 것처럼 보이는 것입니다.


Recharts 공식 문서나 관련 논의를 살펴보면 isAnimationActive prop이 초기 렌더링 시 애니메이션을 제어하며, 이 애니메이션은 첫 마운트에 트리거된다고 설명합니다.

우리의 로직은 이 첫 마운트의 기회를 빈 데이터와 함께 날려버린 셈입니다.

해결 방안

이 문제를 해결하는 가장 확실하고 현대적인 방법은 데이터 페칭 패러다임을 바꾸는 것입니다.

즉, 클라이언트에서 데이터를 가져오는 것이 아니라, 서버에서 데이터를 완벽하게 준비해 컴포넌트를 렌더링하는 것입니다.

Next.js의 App Router 환경을 예로 들어보겠습니다.

참고: App Router란?

App Router 는 Server Components 와 같은 React의 최신 기능을 사용하는 파일 시스템 기반 라우터입니다.

참고: RSC(React Server Component란?)

RSC란 번들링 전에 클라이언트 앱이나 SSR(Server Side Rendering) 서버와는 분리된 환경에서 미리 렌더링되는 새로운 유형의 컴포넌트를 의미합니다.

export default async function DashboardPage() {
  const queryClient = new QueryClient();

  // 1. 서버에서 데이터를 '미리 가져와서' 캐시에 저장합니다 (Prefetching).
  await queryClient.prefetchQuery({
    queryKey: ['groupSalesData'],
    queryFn: getGroupSalesData,
    staleTime: 5 * 60 * 1000, // 5분간 fresh 상태 유지
  });

  return (
    // 2. 서버의 쿼리 캐시 상태를 클라이언트로 전달합니다.
    <HydrationBoundary state={dehydrate(queryClient)}>
      <SalesByGroupChart />
    </HydrationBoundary>
  );
}
  1. 서버에서 데이터 프리페칭 (Prefetching): 페이지를 렌더링하는 서버 컴포넌트(DashboardPage)에서 queryClient.prefetchQuery를 호출합니다. 이 함수는 클라이언트에 UI를 보내기 전에 서버 환경에서 데이터를 미리 가져와 TanStack Query의 캐시에 저장합니다.

  2. 캐시 데이터 전송 (Dehydration & Hydration): dehydrate 함수를 통해 서버에 저장된 쿼리 캐시 데이터를 직렬화(serialize)하고, <HydrationBoundary> 컴포넌트로 감싸 클라이언트에 전달합니다.
    클라이언트에서는 이 데이터를 받아 자신의 QueryClient를 초기 상태로 하이드레이션합니다.

  3. 클라이언트에서의 즉각적인 데이터 접근: 클라이언트 컴포넌트인 <SalesByGroupChart> 내부의 useQuery(['groupSalesData']) 훅은 렌더링되자마자, 이미 채워져 있는 캐시에서 데이터를 즉시 발견합니다. 따라서 별도의 로딩 상태 없이 바로 data를 사용할 수 있게 됩니다.

  4. 첫 마운트와 완벽한 애니메이션: 브라우저에서 차트 컴포넌트가 처음 마운트될 때는 이미 모든 데이터를 갖춘 상태입니다. 따라서 Recharts의 초기 애니메이션은 0에서부터 실제 데이터 값까지 부드럽게 늘어나는 정상적인 애니메이션을 보여주게 됩니다.

그 결과, 아래 GIF처럼 더 이상 간헐적으로 애니메이션이 끊기는 현상은 발생하지 않습니다.

최적화 후

실제 결과를 확인해보면 간헐적으로 발생하던 '뚝' 끊기던 초기 렌더링 애니메이션이 더이상 발생하지 않는 것을 확인해 볼 수 있습니다.

결론

Recharts 차트에서 초기 애니메이션이 간헐적으로 스킵되던 문제는 클라이언트 사이드 렌더링의 생명주기와 데이터 페칭 시점의 불일치 때문에 발생한 것이었습니다.

컴포넌트가 빈 데이터로 먼저 마운트되면서 소중한 첫 애니메이션 기회를 '소모'해버렸던 것이죠.

이번 글에서는 이를 해결하기 위해 React Server Components 환경에서 TanStack QueryprefetchQuery를 활용하여 서버단에서 미리 데이터를 가져와 캐시를 채우고, 이 데이터를 클라이언트에 하이드레이션하는 전략을 사용하였습니다.

긴 글 읽어주셔서 감사합니다.

끝 ... !

profile
인치

0개의 댓글