지연된 스켈레톤 구현하기

상황에 따른 로딩/스켈레톤 화면 보여주기

# React
/ Contents

이 글은 카카오페이 기술 블로그의 스켈레톤 UI 아이디어를 참고하여 실제 프로젝트에 적용한 경험을 정리한 내용입니다.

💡 Nielsen Norman Group의 연구에 따르면

로드하는데 1초 미만이 소요되는 모든 항목의 경우 반복되는 애니메이션을 사용하면 주의가 산만해집니다. 사용자는 화면에서 어떤 일이 발생했는지 따라갈 수 없고, 화면에 깜빡이는 내용에 대해 불안을 느낄 수 있습니다.

출처: Progress Indicators

문제 인식

현재 서비스는 실시간성을 중시하기 때문에 무거운 작업이 거의 없어 화면 전환이 빠르게 진행된다.

일반적인 인터넷 환경에서는 1초 이상 로딩 화면을 보며 대기할 일이 거의 없다. 그런데 1초가 채 안 되는 짧은 시간 동안 스켈레톤 UI를 보여주고 바로 콘텐츠를 표시하는 흐름은 오히려 부자연스러울 수 있다.

문제점: 빠른 네트워크 환경에서 스켈레톤 UI가 번쩍이는 현상이 사용자 경험을 해친다.

실제로 테스트 해본 결과를 살펴보자.

느린 네트워크 환경

스켈레톤 UI가 약 2초간 표시된 후 실제 콘텐츠로 부드럽게 전환된다. 로딩 상태를 명확히 인지할 수 있어 사용자는 기다림을 예상할 수 있다.

빠른 네트워크 환경 (스켈레톤 O)

스켈레톤 UI가 0.2초 정도만 번쩍 나타났다가 사라진다. 화면이 깜빡이는 듯한 느낌을 주어 오히려 시각적으로 불안정해 보인다.

빠른 네트워크 환경 (스켈레톤 X)

스켈레톤 없이 바로 콘텐츠가 렌더링된다. 전환이 매끄럽고 자연스러우며 시각적 노이즈가 없다.


빠른 네트워크 환경에서는 스켈레톤 UI가 번쩍였다가 사라지는 현상 때문에 UX적으로 산만한 느낌이 든다.

오히려 스켈레톤이 없는 쪽이 훨씬 자연스럽다.

핵심 질문

  1. 빠른 네트워크 환경임을 어떻게 판단할 수 있을까?
  2. "빠른 네트워크"의 기준은 무엇인가?
  3. 어떤 임계값을 기준으로 스켈레톤 UI 표시 여부를 결정할까?

이 질문들에 답하기 위해 실제 데이터를 측정하기로 했다.

기준점 정하기

스켈레톤 표시 여부를 나누는 기준을 잡기 위해 API 응답 속도를 측정했다.

로컬 환경 측정 결과

Chrome DevTools의 네트워크 탭에서 측정:

네트워크 설정API 응답 속도
제한없음25ms ~ 40ms
빠른 4G180ms ~ 200ms

하지만 실제 배포 환경에서는 어떨까?

배포 환경 측정 결과

실제 프로덕션 환경에서 측정한 결과:

네트워크 설정API 응답화면 렌더링 완료
제한없음25ms ~ 40ms200ms ~ 250ms
빠른 4G180ms ~ 200ms1800ms ~ 2500ms

임계값 결정: 250ms

측정 결과를 바탕으로 다음과 같이 결정했다:

  • 250ms 이내 응답: 스켈레톤 UI를 표시하지 않음

    • 이유: 사용자가 로딩을 인지하기 전에 콘텐츠가 표시되어 깜빡임 방지
  • 250ms 이상 응답: 스켈레톤 UI를 표시

    • 이유: 사용자에게 로딩 상태를 명확히 전달하여 기다림에 대한 불안감 해소

구현 방향

다행히 API 혹은 소켓 통신을 하는 페이지 모두 <Suspense/>가 적용되어 있어서 작업은 수월할 것으로 예상된다.

핵심 아이디어:

  • Suspense 컴포넌트 내부에서 250ms까지는 스켈레톤 UI를 표시하지 않음
  • 250ms가 넘어가면 스켈레톤 UI를 표시
  • 리액트의 startTransition을 활용하여 우선순위가 낮은 업데이트로 처리

startTransition 활용

startTransition은 리액트의 Concurrent 기능 중 하나로 UI 업데이트의 우선순위를 관리하는 도구이다.

상태 업데이트의 우선순위를 조절할 수 있게 해주는데, startTransition을 통해 중요한 업데이트와 덜 중요한 업데이트를 구분하여 상태값을 업데이트할 수 있다.

리액트에서의 상태 업데이트

리액트에서 상태 업데이트는 2가지로 분류된다:

  • Urgent 업데이트: 즉각적인 피드백이 필요한 상호작용 (클릭, 입력 등)
  • Transition 업데이트 (Non-urgent): 하나의 화면에서 다른 화면으로의 전환

Urgent 업데이트는 즉각적인 처리를 하고, Transition 업데이트는 브라우저에 여유가 있을 경우 처리한다고 생각하면 된다.

// React 내부 동작 개념
class Scheduler {
  urgentQueue = [];
  transitionQueue = [];

  processUpdates() {
    // 1. 긴급 업데이트 먼저 처리
    while (this.urgentQueue.length > 0) {
      const update = this.urgentQueue.shift();
      this.processUpdate(update);
    }

    // 2. 여유 시간에 transition 처리
    if (hasIdleTime()) {
      while (this.transitionQueue.length > 0) {
        const update = this.transitionQueue.shift();
        this.processUpdate(update);
      }
    }
  }
}

이를 활용하여 아래처럼 Suspense 컴포넌트를 구현했다.

CustomSuspense 구현

export default function CustomSuspense({
  fallback = <QuizLoading />,
  children,
  delayMs = 250,
}: CustomSuspenseProps) {
  const [shouldRender, setShouldRender] = useState(false);

  useEffect(() => {
    const timer = setTimeout(() => {
      startTransition(() => {
        setShouldRender(true);
      });
    }, delayMs);

    return () => clearTimeout(timer);
  }, [delayMs]);

  return (
    <Suspense fallback={shouldRender ? fallback : null}>{children}</Suspense>
  );
}

동작 원리

  1. 기존의 Suspense 컴포넌트를 감싸는 래퍼 컴포넌트를 생성
  2. delayMs prop으로 지연 시간을 받음 (기본값: 250ms)
  3. 타이머를 활용해 지연 시간 이후에만 fallback 컴포넌트를 보여줌
  4. 지연 시간 이전에 렌더링이 완료되면 shouldRenderfalse를 유지하므로 fallback이 표시되지 않음

시나리오 1: 빠른 렌더링 (< 250ms)

0ms: CustomSuspense 마운트
  → shouldRender = false
  → 타이머 시작 (250ms)

150ms: 데이터 로드 완료
  → children이 성공적으로 렌더링됨
  → 스켈레톤 UI 표시 안 됨 ✅

250ms: 타이머 실행
  → startTransition(() => setShouldRender(true)) 호출
  → React는 이미 children이 렌더링된 것을 확인
  → transition 업데이트를 무시 (불필요한 상태 업데이트 방지)

시나리오 2: 느린 렌더링 (> 250ms)

0ms: CustomSuspense 마운트
  → shouldRender = false
  → 타이머 시작 (250ms)

250ms: 타이머 실행
  → startTransition(() => setShouldRender(true)) 호출
  → children이 아직 로딩 중
  → shouldRender = true로 업데이트
  → 스켈레톤 UI 표시 ⏳

400ms: 데이터 로드 완료
  → children 렌더링
  → 스켈레톤이 실제 콘텐츠로 대체 ✅

핵심 포인트

리액트 내부의 최적화 알고리즘 덕분에, startTransition으로 감싼 상태 업데이트는:

  • 이미 렌더링이 완료된 경우: 불필요한 업데이트로 판단하여 스킵됨
  • 아직 로딩 중인 경우: 낮은 우선순위로 처리되어 사용자 경험을 해치지 않음

결과

빠른 네트워크 (< 250ms)

스켈레톤 UI 없이 바로 콘텐츠가 표시되어 깜빡임 없이 부드러운 전환이 이루어진다. 사용자는 로딩 과정을 인지하지 못할 정도로 빠른 응답을 경험한다.

느린 네트워크 (> 250ms)

250ms 이후부터 스켈레톤 UI가 표시되어 로딩 상태를 명확히 전달한다. 사용자는 콘텐츠가 로드되고 있음을 인지하고 기다릴 준비를 할 수 있다.


마무리

이러한 구현을 통해 다음과 같은 개선을 달성할 수 있었다:

  • 불필요한 로딩 상태 제거: 빠른 네트워크에서 깜빡임 현상 방지
  • 적응적 UI 제공: 네트워크 상태에 따라 자동으로 최적의 경험 제공
  • 매끄러운 사용자 경험: 시각적 노이즈 감소로 인한 집중도 향상

리액트의 Concurrent 기능과 startTransition을 활용하여 스켈레톤 UI의 표시 시점을 최적화함으로써, 사용자에게 더 자연스럽고 반응성 있는 인터페이스를 제공할 수 있게 되었다.

핵심 교훈: 모든 로딩 상태가 반드시 표시되어야 하는 것은 아니다. 네트워크 상황과 사용자 경험을 고려한 조건부 렌더링이 더 나은 UX를 만들 수 있다.