levi

리바이's Tech Blog

Tech BlogPortfolioBoard
AllActivitiesJavascriptTypeScriptNetworkNext.jsReactWoowacourseAlgorithm
COPYRIGHT ⓒ eunwoo-levi
eunwoo1341@gmail.com

📚 목차

    [React] 빠른 로딩에서 지연 렌더링(DeferredComponent)과 스켈레톤으로 CLS와 플리커 없애기

    ByEunwoo
    2025년 12월 21일
    react

    로딩 UX를 개선할 때 가장 흔히 떠올리는 해법이 스켈레톤이다.
    빈 화면 대신 콘텐츠 형태를 미리 보여주면 사용자는 “앱이 잘 동작하고 있다”는 확신을 얻고, 기다림의 체감도 줄어든다.
    그래서 나도 리스트/피드성 화면에 스켈레톤을 넣었다. 그리고 초기에는 확실히 좋아졌다.

    그런데 배포 후 실제 사용자 환경(특히 빠른 네트워크 / 빠른 디바이스)에서 예상치 못한 문제가 생겼다.

    • 스켈레톤이 0.1~0.2초 정도 ‘잠깐’ 보였다가 바로 실제 UI로 바뀜
    • 이 짧은 순간이 오히려 **플리커(flicker)**처럼 느껴짐
    • 게다가 스켈레톤의 높이/폭이 실제 카드와 조금이라도 다르면, 전환 순간에 레이아웃이 툭 튀는 CLS가 발생

    아이러니하게도,

    • 느린 네트워크에서는 스켈레톤이 충분히 오래 떠 있어서 자연스럽고
    • 빠른 네트워크에서는 스켈레톤이 “깜빡이는 잡음”이 되어버렸다.

    즉, 내가 해결하려던 “로딩 불안감”보다
    **“순간 점멸 + 레이아웃 튐”**이 더 먼저 체감되는 상황이 된 거다.

    이 문제는 디테일 같지만, 피드처럼 진입이 잦은 화면에서는 UX 체감이 확 커진다.
    그래서 나는 로딩 UI를 “항상 보여주는 것”이 정답이 아닐 수 있다는 결론에 도달했다.

    문제 정의: 빠르게 로딩될수록 스켈레톤은 ‘노이즈’가 된다

    여기서 핵심은 “로딩이 짧다”는 사실 자체가 아니라, 로딩 UI가 화면에 올라왔다가 내려가는 비용이 존재한다는 점이다.

    플리커가 생기는 구조

    • 데이터가 매우 빨리 도착하면
    • Suspense fallback(스켈레톤)이 마운트 → 바로 언마운트
    • 사용자는 “깜빡임”으로 인지

    CLS가 생기는 구조

    • 스켈레톤의 height/spacing이 실제 UI와 조금이라도 다르면
    • fallback → 실제 UI 전환 시 레이아웃이 이동
    • 이 이동이 누적되면 CLS로 잡히거나(측정 환경에 따라), 적어도 눈에 보이는 “툭”이 된다

    결국 목표는 두 가지였다.

    1. 스켈레톤이 나오더라도 레이아웃은 절대 안 움직이게(= 실제 UI와 동일 크기)
    2. 로딩이 너무 짧으면 아예 스켈레톤을 보여주지 않게(= 지연 렌더링)

    해결 전략 A: “실제 UI와 동일 크기” 스켈레톤로 CLS 원천 차단

    CLS를 줄이는 가장 확실한 방법은 간단하다.

    스켈레톤을 “대충 비슷하게” 만들지 말고, 실제 렌더링될 UI와 같은 박스 모델로 만든다.

    • 카드 높이, 이미지 영역 비율, 텍스트 라인 수, padding, gap
    • 리스트에서 한 아이템의 전체 높이
    • “더보기 버튼/하단 여백” 같은 주변 요소까지

    레이아웃 이동은 결국 ‘크기가 달라서’ 생기는 문제라서,
    크기를 동일하게 만들면 CLS의 대부분은 설계 단계에서 사라진다.

    본인은 해당 부분을 고려하여 CommonSkeletonCard 컴포넌트를 구현하여 props로 각 도메인에 맞는 스켈레톤을 쉽게 만들어 재사용할 수 있도록 하였다.

    CommonSkeletonCard 코드

    interface CommonSkeletonCardProps {
      variant?: 'moment' | 'comment' | 'rewardHistory';
    }
     
    export const CommonSkeletonCard: React.FC<CommonSkeletonCardProps> = ({ variant = 'moment' }) => {
      return (
        <S.SkeletonCard variant={variant}>
          <S.SkeletonCardTitle>
            <S.SkeletonTitleRow>
              <Skeleton width='16px' height='16px' borderRadius='50%' />
              <Skeleton width='120px' height='16px' />
            </S.SkeletonTitleRow>
            <SkeletonText lines={2} lineHeight='18px' />
          </S.SkeletonCardTitle>
     
          {variant === 'moment' && (
            <S.SkeletonMomentContent>
                ...
            </S.SkeletonMomentContent>
          )}
     
          {variant === 'comment' && (
            <>
              <S.SkeletonSection>
                ...
              </S.SkeletonCardAction>
            </>
          )}
     
          {variant === 'rewardHistory' && (
            <S.SkeletonRewardHistoryTable>
                ...
            </S.SkeletonRewardHistoryTable>
          )}
        </S.SkeletonCard>
      );
    };

    해결 전략 B: 스켈레톤 “지연 렌더링”으로 플리커 제거

    두 번째가 핵심이다.

    로딩이 200ms 안에 끝나면, 스켈레톤은 사용자에게 도움이 되기보다 방해가 된다.
    그래서 일정 시간 이상 로딩이 지속될 때만 fallback을 렌더링한다.

    이를 위해 만든 컴포넌트가 DeferredComponent다.

    DeferredComponent 코드

    import { PropsWithChildren, useEffect, useState } from 'react';
     
    export interface DeferredComponentProps {
      delay?: number;
    }
     
    export const DeferredComponent = ({
      children,
      delay = 200,
    }: PropsWithChildren<DeferredComponentProps>) => {
      const [isDeferred, setIsDeferred] = useState(false);
     
      useEffect(() => {
        const timeoutId = setTimeout(() => {
          setIsDeferred(true);
        }, delay);
     
        return () => clearTimeout(timeoutId);
      }, [delay]);
     
      if (!isDeferred) {
        return null;
      }
     
      return <>{children}</>;
    };

    최초 렌더링 시 isDeferred = false는 아무것도 렌더링하지 않는다.

    delay ms 이후에만 isDeferred = true -> 그때부터 children 렌더링한다.

    로딩이 delay 안에 끝나면?
    -> fallback이 화면에 올라오기 전에 실제 UI가 렌더링되므로 플리커 자체가 사라짐

    cleanup에서 clearTimeout을 해주기 때문에 언마운트 시 불필요한 setState도 방지된다.

    실제 적용

    interface SuspenseSkeletonProps {
      variant?: 'moment' | 'comment' | 'rewardHistory';
      count?: number;
    }
     
    const SkeletonContainer = styled.section`
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: 30px;
      margin: 20px;
    `;
     
    export const SuspenseSkeleton = ({ variant = 'moment', count = 3 }: SuspenseSkeletonProps) => {
      return (
        <SkeletonContainer>
          <DeferredComponent>
            {Array.from({ length: count }).map((_, index) => (
              <CommonSkeletonCard key={`skeleton-${variant}-${index}`} variant={variant} />
            ))}
          </DeferredComponent>
        </SkeletonContainer>
      );
    };

    SuspenseSkeleton은 여전히 fallback 역할을 수행하지만
    그 안에서 **“지금 당장 스켈레톤을 보여줄지”**를 DeferredComponent가 결정한다.

    빠른 로딩에서는
    fallback이 존재하더라도 실제로는 null 렌더링 -> 플리커 없다.

    느린 로딩에서는
    200ms 이후 스켈레톤 등장 -> “기다림에 대한 안내” 제공한다.

    delay는 왜 200ms인가?

    정답은 없고, 서비스에 맞춰 조정해야 한다고 생각한다.
    나는 아래와 같은 이유로 200ms를 선택했다.

    너무 짧으면: 여전히 “잠깐 보였다 사라짐”이 남는다
    너무 길면: 느린 상황에서 사용자가 “아무 반응이 없네?”라고 느낄 수 있다.

    실무적으로는 이런 감각이 유용하다고 생각한다.

    • 100~200ms: “깜빡임 제거”에 효과적이면서, 느린 상황도 크게 불안하지 않음
    • 피드/리스트처럼 진입이 잦은 화면일수록 delay 전략의 체감 효과가 커짐

    추가로, delay를 고정값으로 박아두기보다,

    • 특정 페이지(피드 vs 상세),
    • 특정 데이터(프로필 vs 추천 리스트),
    • 특정 디바이스/환경(모바일)
      에 따라 다르게 두는 것도 충분히 합리적이다

    트레이드오프와 주의점

    (1) delay 동안 “빈 공간”이 생길 수 있다

    DeferredComponent는 delay 이전에 null을 반환하니까, 그 구간엔 fallback이 비어 있을 수 있다.

    그래서 나는 여기서 ‘동일 크기 스켈레톤’ 전략과 함께 가야 한다고 봤다.

    스켈레톤이 안 보이는 구간이 있더라도 “레이아웃 틀”이 이미 안정적이면 사용자는 덜 불편하다

    만약 “빈 화면”이 너무 싫다면,

    delay 이전에는 아주 가벼운 placeholder(예: header만)만 보여준다든지 혹은 최소 높이만 확보하는 래퍼를 둔다든지 같은 타협도 가능하다.

    (2) delay가 길수록 “로딩 시작 인지”가 늦어진다

    느린 네트워크에서 스켈레톤이 늦게 나오면 답답해질 수 있다.
    그러니 delay는 “플리커 제거”에 필요한 만큼만 하는 것이 좋다.

    마무리 - 로딩 UX의 목표는 “보여주는 것”이 아니라 “거슬리지 않는 것”

    이번 개선에서 내가 얻은 결론은 이거다.

    • 스켈레톤은 무조건 좋은 UX가 아니다.
    • 특히 빠른 환경에서는 스켈레톤이 가치(안심)보다 비용(플리커/CLS)을 더 만들 수 있다.
    • 그래서 fallback은 “항상 렌더링”이 아니라
      **“필요할 때만 렌더링”**이 더 좋은 선택이 될 수 있다.

    내가 적용한 조합은 단순하지만 효과가 확실했다.

    1. 실제 UI 동일 크기 스켈레톤 → CLS 원천 차단
    2. 지연 렌더링 DeferredComponent → 빠른 로딩에서 플리커 제거
    Posted inreact
    Written byEunwoo