스켈레톤 UI 관련하여 사용자 경험을 향상시킨 내용을 공유합니다.
프론트엔드 개발을 하면서 로딩 상황에서 사용자에게 스켈레톤 화면을 보여주는 방식을 사용했습니다.
그런데 로딩이 있으면 무조건 스켈레톤을 보여주는 게 정말 좋은 방법일까요?
개발 환경에서는 API 응답이 빠르다 보니 스켈레톤이 0.1초 정도만 보이고 바로 사라지는 현상이 발생했습니다. 스켈레톤이 깜빡이는 것처럼 보였고, 실제 사용자 경험에 어떤 영향을 미치는지 궁금해졌습니다.
카카오페이 기술블로그에서 같은 문제를 다루고 있는 글을 발견했습니다. 특히 닐슨 노먼 그룹의 지침에 따르면
"로드하는데 1초 미만이 소요되는 모든 항목의 경우 반복되는 애니메이션을 사용하면 주의가 산만해집니다. 사용자는 화면에서 어떤 일이 발생했는지 따라갈 수 없고, 화면에 깜빡이는 내용에 대해 불안을 느낄 수 있습니다."
빠른 응답 시간(100-200ms)에서는 스켈레톤을 보여주는 것보다 바로 콘텐츠를 표시하는 것이 더 나은 사용자 경험을 제공합니다. 반면 느린 응답 시간(500ms 이상)에서는 스켈레톤을 보여주어 사용자가 로딩 중임을 인지할 수 있도록 해야 합니다.
200ms 지연 후 스켈레톤을 보여주는 DeferredComponent를 구현하여 문제를 해결했습니다
이를 통해 상황에 맞는 적응형 접근이 가능해졌습니다.
Skeleton.tsx)// client/src/components/ui/Skeleton.tsx
'use client'
import { ReactNode } from 'react'
interface SkeletonProps {
className?: string
children?: ReactNode
}
// 기본 스켈레톤 (애니메이션 포함)
export function Skeleton({ className = '', children }: SkeletonProps) {
return (
<div
className={`animate-pulse bg-gray-200 rounded ${className}`}
>
{children}
</div>
)
}
// 이미지 스켈레톤
export function ImageSkeleton({
width = 'w-full',
height = 'h-48',
className = ''
}: {
width?: string
height?: string
className?: string
}) {
return (
<Skeleton className={`${width} ${height} rounded-lg ${className}`} />
)
}
// 버튼 스켈레톤
export function ButtonSkeleton({
width = 'w-24',
height = 'h-10',
className = ''
}: {
width?: string
height?: string
className?: string
}) {
return (
<Skeleton className={`${width} ${height} rounded-lg ${className}`} />
)
}
// 텍스트 스켈레톤 (여러 줄)
export function TextSkeleton({
lines = 1,
className = ''
}: {
lines?: number
className?: string
}) {
return (
<div className={className}>
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
className={`h-4 mb-2 ${i === lines - 1 ? 'w-3/4' : 'w-full'}`}
/>
))}
</div>
)
}
DeferredComponent.tsx)// client/src/components/ui/DeferredComponent.tsx
"use client"
import React, { useState, useEffect } from 'react'
interface DeferredComponentProps {
children: React.ReactNode
delay?: number
}
/**
* DeferredComponent: 지정된 시간 후에 children을 렌더링하는 컴포넌트
*
* @param children - 지연 후 렌더링할 컴포넌트
* @param delay - 지연 시간 (밀리초, 기본값: 200ms)
*/
const DeferredComponent = ({ children, delay = 200 }: DeferredComponentProps) => {
const [isDeferred, setIsDeferred] = useState(false)
useEffect(() => {
// 지정된 시간 후 children Render
const timeoutId = setTimeout(() => {
setIsDeferred(true)
}, delay)
return () => clearTimeout(timeoutId)
}, [delay])
if (!isDeferred) {
return null
}
return <>{children}</>
}
export default DeferredComponent
LoadingWithDeferredSkeleton.tsx)// client/src/components/ui/LoadingWithDeferredSkeleton.tsx
"use client"
import React from 'react'
import DeferredComponent from './DeferredComponent'
interface LoadingWithDeferredSkeletonProps {
loading: boolean
skeleton: React.ReactNode
children: React.ReactNode
delay?: number
backgroundClassName?: string
}
/**
* LoadingWithDeferredSkeleton: 로딩 상태에 따라 지연된 스켈레톤을 보여주는 컴포넌트
*/
const LoadingWithDeferredSkeleton = ({
loading,
skeleton,
children,
delay = 200,
backgroundClassName = "min-h-[88vh] flex items-center justify-center bg-white"
}: LoadingWithDeferredSkeletonProps) => {
if (loading) {
return (
<div className={backgroundClassName}>
<DeferredComponent delay={delay}>
{skeleton}
</DeferredComponent>
</div>
)
}
return <>{children}</>
}
export default LoadingWithDeferredSkeleton
// client/src/components/skeletons/CartSkeleton.tsx
"use client"
import React from 'react'
import { Skeleton, ImageSkeleton, ButtonSkeleton } from '@/components/ui/Skeleton'
const CartSkeleton = () => {
return (
<div className="mx-auto max-w-6xl px-4 py-8 min-h-[60vh]">
{/* 헤더 스켈레톤 */}
<div className="flex items-center justify-between mb-6">
<div>
<Skeleton className="h-6 w-24 mb-2" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-4 w-20" />
</div>
<div className="grid gap-8 lg:grid-cols-3">
{/* 장바구니 상품 목록 스켈레톤 */}
<div className="lg:col-span-2">
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-white rounded-lg p-4 border border-gray-200">
<div className="flex gap-3">
<Skeleton className="w-4 h-4 mt-1" />
<ImageSkeleton width="w-20" height="h-20" className="flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex justify-between items-start mb-2">
<div className="flex-1 min-w-0">
<Skeleton className="h-4 w-3/4 mb-2" />
<Skeleton className="h-3 w-1/2" />
</div>
<Skeleton className="w-4 h-4 ml-2" />
</div>
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-20" />
<div className="flex items-center border border-gray-200 rounded-lg">
<Skeleton className="w-6 h-6" />
<Skeleton className="w-8 h-6 mx-2" />
<Skeleton className="w-6 h-6" />
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
{/* 주문 요약 스켈레톤 */}
<div className="lg:col-span-1">
<div className="bg-white rounded-lg p-4 border border-gray-200 sticky top-6">
<Skeleton className="h-6 w-32 mb-4" />
<div className="space-y-2 mb-4">
<div className="flex justify-between">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-20" />
</div>
<div className="flex justify-between">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-16" />
</div>
</div>
<div className="border-t border-gray-200 pt-3 mb-4">
<div className="flex justify-between">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-5 w-20" />
</div>
</div>
<Skeleton className="h-3 w-32 mb-4" />
<ButtonSkeleton width="w-full" height="h-12" />
</div>
</div>
</div>
</div>
)
}
export default CartSkeleton
import LoadingWithDeferredSkeleton from '@/components/ui/LoadingWithDeferredSkeleton'
import { CartSkeleton } from '@/components/skeletons'
export default function CartPage() {
const [loading, setLoading] = useState(true)
return (
<LoadingWithDeferredSkeleton
loading={loading}
skeleton={<CartSkeleton />}
>
<CartContent />
</LoadingWithDeferredSkeleton>
)
}
<LoadingWithDeferredSkeleton
loading={loading}
skeleton={<MySkeleton />}
delay={300} // 300ms 지연
backgroundClassName="min-h-screen bg-blue-50" // 커스텀 배경
>
<MyContent />
</LoadingWithDeferredSkeleton>
// client/src/components/skeletons/YourPageSkeleton.tsx
"use client"
import React from 'react'
import { Skeleton, ImageSkeleton, ButtonSkeleton, TextSkeleton } from '@/components/ui/Skeleton'
const YourPageSkeleton = () => {
return (
<div className="mx-auto max-w-7xl px-4 py-8">
{/* 헤더 스켈레톤 */}
<div className="mb-8">
<Skeleton className="h-8 w-48 mb-4" />
<Skeleton className="h-4 w-32" />
</div>
{/* 메인 콘텐츠 스켈레톤 */}
<div className="space-y-6">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-20" />
))}
</div>
</div>
)
}
export default YourPageSkeleton
import { Skeleton, ImageSkeleton, ButtonSkeleton, TextSkeleton } from '@/components/ui/Skeleton'
// 기본 스켈레톤
<Skeleton className="h-4 w-32" />
// 이미지 스켈레톤
<ImageSkeleton width="w-20" height="h-20" />
// 버튼 스켈레톤
<ButtonSkeleton width="w-full" height="h-12" />
// 텍스트 스켈레톤 (여러 줄)
<TextSkeleton lines={3} />
h-6 ~ h-8h-4h-3h-10 ~ h-12mb-6 ~ mb-8space-y-3 ~ space-y-4gap-4 ~ gap-6로딩 시작 → 즉시 스켈레톤 표시 → 100ms 후 실제 콘텐츠 → 스켈레톤 깜빡임
로딩 시작 → 200ms 대기 → 100ms 로딩 완료 → 스켈레톤 없이 바로 콘텐츠 표시
로딩 시작 → 200ms 대기 → 스켈레톤 표시 → 500ms 후 실제 콘텐츠
aria-label 등)clearTimeout 사용무조건 스켈레톤을 보여주는 것보다 상황에 맞는 적응형 접근이 더 나은 사용자 경험을 제공합니다.
프론트엔드 서비스는 모든 사용자의 환경에서 동일한 경험을 제공하기 어렵지만, 상황에 맞는 적응형 접근을 통해 더 나은 사용자 경험을 만들 수 있습니다.