이 글은 카카오페이 기술 블로그의 스켈레톤 UI 아이디어를 참고하여 실제 프로젝트에 적용한 경험을 정리한 내용입니다.
💡 Nielsen Norman Group의 연구에 따르면
상황에 따른 로딩/스켈레톤 화면 보여주기
이 글은 카카오페이 기술 블로그의 스켈레톤 UI 아이디어를 참고하여 실제 프로젝트에 적용한 경험을 정리한 내용입니다.
💡 Nielsen Norman Group의 연구에 따르면
로드하는데 1초 미만이 소요되는 모든 항목의 경우 반복되는 애니메이션을 사용하면 주의가 산만해집니다. 사용자는 화면에서 어떤 일이 발생했는지 따라갈 수 없고, 화면에 깜빡이는 내용에 대해 불안을 느낄 수 있습니다.
현재 서비스는 실시간성을 중시하기 때문에 무거운 작업이 거의 없어 화면 전환이 빠르게 진행된다.
일반적인 인터넷 환경에서는 1초 이상 로딩 화면을 보며 대기할 일이 거의 없다. 그런데 1초가 채 안 되는 짧은 시간 동안 스켈레톤 UI를 보여주고 바로 콘텐츠를 표시하는 흐름은 오히려 부자연스러울 수 있다.
문제점: 빠른 네트워크 환경에서 스켈레톤 UI가 번쩍이는 현상이 사용자 경험을 해친다.
실제로 테스트 해본 결과를 살펴보자.
스켈레톤 UI가 약 2초간 표시된 후 실제 콘텐츠로 부드럽게 전환된다. 로딩 상태를 명확히 인지할 수 있어 사용자는 기다림을 예상할 수 있다.
스켈레톤 UI가 0.2초 정도만 번쩍 나타났다가 사라진다. 화면이 깜빡이는 듯한 느낌을 주어 오히려 시각적으로 불안정해 보인다.
스켈레톤 없이 바로 콘텐츠가 렌더링된다. 전환이 매끄럽고 자연스러우며 시각적 노이즈가 없다.
빠른 네트워크 환경에서는 스켈레톤 UI가 번쩍였다가 사라지는 현상 때문에 UX적으로 산만한 느낌이 든다.
오히려 스켈레톤이 없는 쪽이 훨씬 자연스럽다.
이 질문들에 답하기 위해 실제 데이터를 측정하기로 했다.
스켈레톤 표시 여부를 나누는 기준을 잡기 위해 API 응답 속도를 측정했다.
Chrome DevTools의 네트워크 탭에서 측정:
| 네트워크 설정 | API 응답 속도 |
|---|---|
| 제한없음 | 25ms ~ 40ms |
| 빠른 4G | 180ms ~ 200ms |
하지만 실제 배포 환경에서는 어떨까?
실제 프로덕션 환경에서 측정한 결과:
| 네트워크 설정 | API 응답 | 화면 렌더링 완료 |
|---|---|---|
| 제한없음 | 25ms ~ 40ms | 200ms ~ 250ms |
| 빠른 4G | 180ms ~ 200ms | 1800ms ~ 2500ms |
측정 결과를 바탕으로 다음과 같이 결정했다:
⚡ 250ms 이내 응답: 스켈레톤 UI를 표시하지 않음
⏳ 250ms 이상 응답: 스켈레톤 UI를 표시
다행히 API 혹은 소켓 통신을 하는 페이지 모두 <Suspense/>가 적용되어 있어서 작업은 수월할 것으로 예상된다.
핵심 아이디어:
startTransition을 활용하여 우선순위가 낮은 업데이트로 처리startTransition은 리액트의 Concurrent 기능 중 하나로 UI 업데이트의 우선순위를 관리하는 도구이다.
상태 업데이트의 우선순위를 조절할 수 있게 해주는데, startTransition을 통해 중요한 업데이트와 덜 중요한 업데이트를 구분하여 상태값을 업데이트할 수 있다.
리액트에서 상태 업데이트는 2가지로 분류된다:
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 컴포넌트를 구현했다.
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>
);
}
Suspense 컴포넌트를 감싸는 래퍼 컴포넌트를 생성delayMs prop으로 지연 시간을 받음 (기본값: 250ms)fallback 컴포넌트를 보여줌shouldRender는 false를 유지하므로 fallback이 표시되지 않음0ms: CustomSuspense 마운트
→ shouldRender = false
→ 타이머 시작 (250ms)
150ms: 데이터 로드 완료
→ children이 성공적으로 렌더링됨
→ 스켈레톤 UI 표시 안 됨 ✅
250ms: 타이머 실행
→ startTransition(() => setShouldRender(true)) 호출
→ React는 이미 children이 렌더링된 것을 확인
→ transition 업데이트를 무시 (불필요한 상태 업데이트 방지)
0ms: CustomSuspense 마운트
→ shouldRender = false
→ 타이머 시작 (250ms)
250ms: 타이머 실행
→ startTransition(() => setShouldRender(true)) 호출
→ children이 아직 로딩 중
→ shouldRender = true로 업데이트
→ 스켈레톤 UI 표시 ⏳
400ms: 데이터 로드 완료
→ children 렌더링
→ 스켈레톤이 실제 콘텐츠로 대체 ✅
리액트 내부의 최적화 알고리즘 덕분에, startTransition으로 감싼 상태 업데이트는:
스켈레톤 UI 없이 바로 콘텐츠가 표시되어 깜빡임 없이 부드러운 전환이 이루어진다. 사용자는 로딩 과정을 인지하지 못할 정도로 빠른 응답을 경험한다.
250ms 이후부터 스켈레톤 UI가 표시되어 로딩 상태를 명확히 전달한다. 사용자는 콘텐츠가 로드되고 있음을 인지하고 기다릴 준비를 할 수 있다.
이러한 구현을 통해 다음과 같은 개선을 달성할 수 있었다:
리액트의 Concurrent 기능과 startTransition을 활용하여 스켈레톤 UI의 표시 시점을 최적화함으로써, 사용자에게 더 자연스럽고 반응성 있는 인터페이스를 제공할 수 있게 되었다.
핵심 교훈: 모든 로딩 상태가 반드시 표시되어야 하는 것은 아니다. 네트워크 상황과 사용자 경험을 고려한 조건부 렌더링이 더 나은 UX를 만들 수 있다.