본문으로 건너뛰기

지연된 스켈레톤 UI 적용하기

· 약 8분
ByeonChan Choi
Front End Engineer

https://tech.kakaopay.com/post/skeleton-ui-idea/

해당 글을 보고 적용하며 쓰는 글 입니다.

개요

우선 현재 서비스는 실시간성을 바탕에 두고 있기 때문에 무거운 작업이 거의 없이 빠르게 화면이 진행된다.

일반적인 인터넷 환경에서 서비스를 이용한다면 일반적으로 1초 이상 로딩화면을 보면서 대기할 일은 거의 없다.

1초가 채 되지 않는 시간 동안 스켈레톤 UI 보여주고 컨텐츠 페이지를 보여주는 흐름은 다소 부자연스러울 수 있다.

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

  • 느린 네트워크 환경 slow-network

  • 빠른 네트워크 환경 (스켈레톤 O) fast-network-skeleton.gif

  • 빠른 네트워크 환경 (스켈레톤 X) fast-network-noskeleton.gif

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

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

빠른 네트워크 환경에서는 스켈레톤 UI를 생략하는 쪽이 UX적으로 뛰어나보인다.

그렇다면 빠른 네트워크 환경임을 어떻게 알 수 있을까?

또 빠른 네트워크라는 기준은 무엇이고 스켈레톤 UI를 표시 여부를 판단할까?

기준점 정하기

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

해당 페이지에서 네트워크 설정을 제한없음으로 설정할 경우 API 응답 속도는 약 25ms ~ 40ms 사이이다.

네트워크 설정을 빠른 4G로 설정할 경우 API 응답 속도는 약 180ms ~ 200ms 사이이다.

배포환경에서 해당 응답이 오는 타이밍은 언제일까

제한없음 환경

api

여러번 테스트해 본 결과 제한없음 환경에서 API가 응답이 오는 타이밍은 약 200ms - 250ms이다.

빠른 4G 환경

api2

빠른 4G 환경에서는 API의 응답이 오는 타이밍이 약 1800ms - 2500ms 이다.

위의 결과를 토대로 250ms 이내로 오는 응답에 대해서는 스켈레톤을 보여주지 않고, 250ms 보다 오래 걸리는 응답에 대해서는 스켈레톤을 보여주는 방향으로 정했다.

구현하기

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

Suspense 컴포넌트 내부에서 250ms까지는 스켈레톤 UI를 띄우지 않다가 250ms가 넘어가면 UI를 띄우는 방향으로

구현 방향을 잡았다.

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 컴포넌트를 변경해주었다.

Suspense 구현

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);
}, []);

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

기존의 Suspense 컴포넌트에 딜레이값을 프롭으로 받게하고 내부에서 타이머를 활용해 딜레이값 이후에 fallback 컴포넌트를 보여주도록 구현했다.

만약 딜레이값 이전에 화면이 렌더링된다면 shouldRener 상태는 false를 유지하므로 fallback 컴포넌트는 보이지 않게 된다.

딜레이값 이전 렌더링

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

150ms: 데이터 로드 완료
- children이 성공적으로 렌더링 됨

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

딜레이값 이후 렌더링

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

250ms: 타이머 실행
- startTransition(() => setShouldRender(true)) 호출
- children이 아직 로딩 중
- shouldRender = true로 업데이트
- fallback UI 표시

400ms: 데이터 로드 완료
- children 렌더링
- fallback이 실제 컨텐츠로 대체

리액트 내부에서 적용된 최적화 알고리즘에 의해 startTransition을 통해 상태값을 변경하는 것이 스킵된다.

결과

fast.gif

slow.gif

이러한 구현으로

  • 불필요한 로딩 상태 표시를 방지
  • 네트워크 상태에 따른 UI 제공
  • 더 매끄러운 사용자 경험 달성

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