데이터 페칭이 필요한 화면에서 비어있는 영역 대신 콘텐츠의 윤곽을 먼저 보여주는 Skeleton UI는 체감 속도를 높이고 레이아웃 시프트를 줄이는 핵심 패턴입니다. 실무에서 바로 적용할 수 있는 컴포넌트 설계, 상태 관리, 접근성, 성능 최적화까지 단계별로 정리했습니다.
1. Skeleton UI가 필요한 이유
Skeleton은 실제 콘텐츠의 레이아웃을 모사해 사용자에게 "곧 콘텐츠가 로드된다"는 신호를 줍니다. 이는 로딩 스피너 대비 체감 성능을 개선하고 LCP 개선, CLS 방지에 효과적입니다. 리스트/카드/디테일 뷰에 우선 적용하는 것이 ROI가 높습니다.
2. 가장 단순한 패턴: isLoading 분기
데이터 로딩 여부에 따라 Skeleton을 조건부 렌더링합니다. 아래 예시는 카드 리스트 로딩에 Skeleton을 적용한 기본 골격입니다.
import React, { useEffect, useState } from 'react';
// 1) 전역 CSS로 추가하세요 (예: src/index.css)
/* CSS */
/*
.skeleton { position: relative; overflow: hidden; background: var(--skeleton-base, #e5e7eb); }
.skeleton::after { content: ''; position: absolute; inset: 0; transform: translateX(-100%);
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.5), transparent);
animation: shimmer 1.2s infinite; }
@keyframes shimmer { 100% { transform: translateX(100%); } }
.skeleton-text { height: 12px; border-radius: 6px; }
.skeleton-avatar { border-radius: 9999px; }
.card { border: 1px solid #e5e7eb; padding: 16px; border-radius: 12px; display:flex; gap:12px; }
*/
function SkeletonCard() {
return (
<div className="card">
<div className="skeleton skeleton-avatar" style={{ width: 48, height: 48 }} />
<div style={{ flex: 1 }}>
<div className="skeleton skeleton-text" style={{ width: '60%', height: 14, marginBottom: 8 }} />
<div className="skeleton skeleton-text" style={{ width: '90%', height: 12, marginBottom: 6 }} />
<div className="skeleton skeleton-text" style={{ width: '80%', height: 12 }} />
</div>
</div>
);
}
function Card({ item }) {
return (
<div className="card">
<img src={item.avatar} alt="작성자 아바타" width={48} height={48} style={{ borderRadius: 9999 }} />
<div style={{ flex: 1 }}>
<h4 style={{ margin: 0 }}>{item.title}</h4>
<p style={{ margin: '6px 0 0' }}>{item.summary}</p>
</div>
</div>
);
}
export default function Articles() {
const [items, setItems] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let alive = true;
(async () => {
try {
const res = await fetch('/api/articles');
const data = await res.json();
if (alive) setItems(data);
} finally {
if (alive) setLoading(false);
}
})();
return () => { alive = false; };
}, []);
if (loading) {
return (
<div aria-busy="true" aria-live="polite">
{[...Array(6)].map((_, i) => <SkeletonCard key={i} />)}
</div>
);
}
return (
<div>
{items.map((it) => (<Card key={it.id} item={it} />))}
</div>
);
}
포인트: 실제 콘텐츠와 동일한 높이/폭을 가진 Skeleton으로 레이아웃 시프트를 방지합니다. 리스트의 예상 길이를 모르면 UX 기준값(예: 6~8개)을 노출합니다.
3. 재사용 가능한 Skeleton 컴포넌트 설계
여러 화면에서 재사용하려면 기본 Skeleton 박스를 만들고 조합하는 방식이 유지보수에 유리합니다.
// Skeleton primitives
export function Skeleton({ width = '100%', height = 16, circle = false, style }) {
return (
<span
className="skeleton"
aria-hidden="true"
style={{ display: 'inline-block', width, height, borderRadius: circle ? '50%' : 8, ...style }}
/>
);
}
export function SkeletonText({ lines = 2 }) {
return (
<div>
{Array.from({ length: lines }).map((_, i) => (
<Skeleton key={i} height={12} style={{ width: `${90 - i * 8}%`, marginBottom: i === lines - 1 ? 0 : 8 }} />
))}
</div>
);
}
export function SkeletonAvatar({ size = 40 }) {
return <Skeleton width={size} height={size} circle />;
}
// 조합 예시: ArticleRowSkeleton
export function ArticleRowSkeleton() {
return (
<div className="card">
<SkeletonAvatar size={48} />
<div style={{ flex: 1 }}>
<Skeleton height={14} style={{ width: '55%', marginBottom: 8 }} />
<SkeletonText lines={3} />
</div>
</div>
);
}
라인 수, 원형 여부, 크기를 props로 노출해 다양한 화면에서 유연하게 사용할 수 있습니다.
4. 리스트 길이를 모를 때의 전략
무한스크롤/페이지네이션에서는 placeholderCount를 컴포넌트에 넘겨 제어합니다. 로딩 상태가 짧을 때도 skeleton이 번쩍 보이지 않도록 최소 표시 시간(예: 300ms)을 둘 수도 있습니다.
function ListSkeleton({ count = 6 }) {
return <>{Array.from({ length: count }).map((_, i) => <ArticleRowSkeleton key={i} />)}</>;
}
function useMinLoading(loading, minMs = 300) {
const [show, setShow] = React.useState(loading);
React.useEffect(() => {
let t;
if (loading) {
setShow(true);
} else {
t = setTimeout(() => setShow(false), minMs);
}
return () => clearTimeout(t);
}, [loading, minMs]);
return show;
}
5. React Query/SWR와 통합
서드파티 데이터 라이브러리와 결합하면 캐싱/리트라이와 함께 안정적인 사용자 경험을 제공합니다.
// React Query 예시
import { useQuery } from '@tanstack/react-query';
function Posts() {
const { data, isLoading, isFetching } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then((r) => r.json()),
staleTime: 30_000,
});
if (isLoading) return <ListSkeleton count={8} />;
return (
<div aria-busy={isFetching}>
{data.map((p) => (
<Card key={p.id} item={p} />
))}
</div>
);
}
// SWR 예시
import useSWR from 'swr';
const fetcher = (url) => fetch(url).then((r) => r.json());
function Profile() {
const { data, isLoading } = useSWR('/api/me', fetcher);
if (isLoading) return <ProfileSkeleton />;
return <ProfileView data={data} />;
}
리스트 리프레시(isFetching) 시 전체 Skeleton 교체 대신 컨텐츠는 유지하고 부분적인 로딩 표시(버튼 스피너, 행 단위 skeleton overlay)로 깜빡임을 줄이는 것이 좋습니다.
6. Suspense 활용 (선택)
React Query/SWR의 suspense 모드를 사용하면 컴포넌트 경계에서 Skeleton을 일괄 관리할 수 있습니다.
import { Suspense } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const client = new QueryClient({
defaultOptions: { queries: { suspense: true, staleTime: 30_000 } },
});
function PostsSuspense() {
// useQuery는 suspense로 로딩을 throw
const { data } = useQuery({ queryKey: ['posts'], queryFn: fetcher });
return <PostList data={data} />;
}
export default function App() {
return (
<QueryClientProvider client={client}>
<Suspense fallback={<ListSkeleton count={8} />}>
<PostsSuspense />
</Suspense>
</QueryClientProvider>
);
}
Suspense는 오류 경계와 함께 적용하고, SSR 환경에서는 스트리밍/서스펜스 지원 여부를 확인해야 합니다.
7. 접근성 체크리스트
Skeleton 컨테이너에 aria-busy=true를 설정해 로딩 중임을 알립니다. Skeleton 자체는 스크린리더가 읽지 않도록 aria-hidden=true를 권장합니다. 라이브 영역은 필요한 곳에만 한정해 과도한 알림을 피합니다. 사용자 환경설정에 따라 애니메이션을 줄입니다.
/* CSS - 모션 감소 설정 */
/*
@media (prefers-reduced-motion: reduce) {
.skeleton::after { animation: none; }
}
*/
8. 성능 최적화 팁
DOM 노드 수를 최소화합니다(텍스트 1줄당 1노드 권장). Skeleton 크기는 고정값 또는 min-height로 설정해 미세한 레이아웃 변경을 방지합니다. 이미지 대신 CSS gradient로 셔머 효과를 구현합니다. 긴 리스트는 react-window/virtualized로 가상화하고, 캐시(staleTime)로 재로딩 빈도를 줄입니다.
9. 다크 모드/테마 변환
CSS 변수로 스켈레톤 색을 제어하면 테마 지원이 쉬워집니다.
/*
:root { --skeleton-base: #e5e7eb; --skeleton-shimmer: rgba(255,255,255,0.5); }
[data-theme='dark'] { --skeleton-base: #2a2f36; --skeleton-shimmer: rgba(255,255,255,0.08); }
.skeleton { background: var(--skeleton-base); }
.skeleton::after { background: linear-gradient(90deg, transparent, var(--skeleton-shimmer), transparent); }
*/
10. 실무 적용 체크리스트
1) 실제 콘텐츠의 레이아웃과 동일한 스켈레톤 크기인지 확인합니다. 2) 최소 표시 시간으로 번쩍임을 줄입니다. 3) isFetching 시 전체 교체 대신 부분 로딩으로 전환합니다. 4) aria-busy/aria-hidden, 모션 감소를 지원합니다. 5) 리스트는 가상화, 데이터는 캐싱으로 비용을 최소화합니다.
위 패턴을 베이스로 프로젝트 컴포넌트 키트에 Skeleton을 포함시키면, 신규 화면에서도 일관된 로딩 경험을 빠르게 제공할 수 있습니다.
'React' 카테고리의 다른 글
| React에서 Highcharts로 데이터 시각화하기 (1) | 2026.05.08 |
|---|---|
| React 컴포넌트를 Storybook에서 문서화하고 테스트하기 (1) | 2026.05.08 |
| React에서 서버 사이드 렌더링(SSR)과 클라이언트 사이드 렌더링(CSR) 차이 분석 (0) | 2026.05.07 |
| React 앱에 OAuth 2.0 로그인 기능 추가하기 (Authorization Code + PKCE) (0) | 2026.05.06 |
| React에서 WebRTC를 활용한 실시간 영상 통화 구현하기 (0) | 2026.05.06 |