Loading States
로딩 상태 관리 및 UI 구현 패턴입니다.
개요
Loading States 패턴은 데이터 로딩 중 사용자에게 명확한 피드백을 제공하고, 인지된 성능을 향상시키며, 부드러운 사용자 경험을 제공하는 검증된 구현 방법입니다. 스켈레톤 UI, 스피너, 프로그레스 바, Suspense 기반 로딩, 낙관적 UI 업데이트 등 프로덕션 환경에서 필요한 모든 로딩 상태 시나리오를 다룹니다.
사용 사례:
- 데이터 fetching 중 로딩 표시
- 페이지 전환 로딩
- 파일 업로드 진행률
- 무한 스크롤 로딩
- 낙관적 UI 업데이트
사용하지 말아야 할 때:
- 즉시 완료되는 작업 (100ms 미만)
- 동기 작업 (로컬 상태 변경)
- 백그라운드 작업 (사용자 인지 불필요)
기본 패턴
1. 기본 로딩 스피너
간단한 로딩 상태를 표시하는 기본 패턴입니다.
"use client";
import { useState, useEffect } from "react";
import { Button, Card } from "@vortex/ui-foundation";
interface Data {
id: number;
title: string;
}
export default function BasicLoadingSpinner() {
const [data, setData] = useState<Data[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchData = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch("/api/data");
if (!response.ok) throw new Error("데이터 로드 실패");
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : "오류 발생");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
<p className="ml-4">로딩 중...</p>
</div>
);
}
if (error) {
return (
<div className="p-4 bg-red-50 border border-red-200 rounded">
<p className="text-red-600">{error}</p>
<Button onClick={fetchData} className="mt-2">
다시 시도
</Button>
</div>
);
}
return (
<div className="grid gap-4">
{data.map((item) => (
<Card key={item.id} className="p-4">
<h3 className="font-bold">{item.title}</h3>
</Card>
))}
</div>
);
}2. 스켈레톤 UI
콘텐츠 구조를 미리 보여주는 스켈레톤 로딩 패턴입니다.
"use client";
import { useState, useEffect } from "react";
import { Card } from "@vortex/ui-foundation";
// 스켈레톤 컴포넌트
function Skeleton({ className }: { className?: string }) {
return (
<div
className={`animate-pulse bg-gray-200 rounded ${className}`}
aria-hidden="true"
/>
);
}
// 카드 스켈레톤
function CardSkeleton() {
return (
<Card className="p-4">
<Skeleton className="h-6 w-3/4 mb-2" />
<Skeleton className="h-4 w-1/2 mb-4" />
<Skeleton className="h-24 w-full" />
</Card>
);
}
// 메인 컴포넌트
export default function SkeletonLoading() {
const [data, setData] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
setIsLoading(true);
await new Promise((resolve) => setTimeout(resolve, 2000)); // Simulate delay
setData([
{ id: 1, title: "Item 1", description: "Description 1" },
{ id: 2, title: "Item 2", description: "Description 2" },
{ id: 3, title: "Item 3", description: "Description 3" },
]);
setIsLoading(false);
};
if (isLoading) {
return (
<div className="grid gap-4">
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</div>
);
}
return (
<div className="grid gap-4">
{data.map((item) => (
<Card key={item.id} className="p-4">
<h3 className="text-lg font-bold mb-2">{item.title}</h3>
<p className="text-sm text-gray-600 mb-4">{item.description}</p>
<div className="h-24 bg-gray-100 rounded" />
</Card>
))}
</div>
);
}고급 패턴
3. 프로그레스 바
파일 업로드나 긴 작업의 진행률을 표시하는 패턴입니다.
"use client";
import { useState } from "react";
import { Button } from "@vortex/ui-foundation";
export default function ProgressBar() {
const [progress, setProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const simulateUpload = async () => {
setIsUploading(true);
setProgress(0);
// 업로드 시뮬레이션 (실제로는 FormData와 XMLHttpRequest 사용)
for (let i = 0; i <= 100; i += 10) {
await new Promise((resolve) => setTimeout(resolve, 200));
setProgress(i);
}
setIsUploading(false);
};
return (
<div className="w-full max-w-md">
<Button
onClick={simulateUpload}
disabled={isUploading}
className="mb-4 w-full"
>
{isUploading ? "업로드 중..." : "파일 업로드"}
</Button>
{isUploading && (
<div className="space-y-2">
{/* Progress Bar */}
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
role="progressbar"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
/>
</div>
{/* Progress Text */}
<p className="text-sm text-gray-600 text-center">{progress}% 완료</p>
</div>
)}
{progress === 100 && !isUploading && (
<p className="text-green-600 text-center">업로드 완료!</p>
)}
</div>
);
}4. React Suspense 통합
React 18 Suspense를 사용한 선언적 로딩 패턴입니다.
"use client";
import { Suspense } from "react";
import { Card } from "@vortex/ui-foundation";
// Suspense fallback 컴포넌트
function LoadingFallback() {
return (
<Card className="p-4">
<div className="animate-pulse space-y-4">
<div className="h-4 bg-gray-200 rounded w-3/4" />
<div className="h-4 bg-gray-200 rounded w-1/2" />
<div className="h-32 bg-gray-200 rounded" />
</div>
</Card>
);
}
// 데이터를 fetch하는 컴포넌트
function DataComponent() {
// React Query, SWR 등과 함께 사용
const data = use(fetchData()); // React 19 use() hook
return (
<Card className="p-4">
<h3 className="font-bold">{data.title}</h3>
<p>{data.description}</p>
</Card>
);
}
// 메인 컴포넌트
export default function SuspenseLoading() {
return (
<div className="space-y-4">
<h2 className="text-2xl font-bold">데이터 목록</h2>
{/* Suspense로 감싸서 로딩 처리 */}
<Suspense fallback={<LoadingFallback />}>
<DataComponent />
</Suspense>
<Suspense fallback={<LoadingFallback />}>
<DataComponent />
</Suspense>
</div>
);
}
// fetch 함수 (캐시 포함)
let cache = new Map();
function fetchData() {
const cacheKey = "data";
if (cache.has(cacheKey)) {
return cache.get(cacheKey);
}
const promise = fetch("/api/data")
.then((res) => res.json())
.then((data) => {
cache.set(cacheKey, data);
return data;
});
throw promise; // Suspense가 처리
}5. 낙관적 UI 업데이트
사용자 액션에 즉시 반응하는 낙관적 업데이트 패턴입니다.
"use client";
import { useState } from "react";
import { Button, Card } from "@vortex/ui-foundation";
interface Todo {
id: number;
title: string;
completed: boolean;
isPending?: boolean;
}
export default function OptimisticUpdate() {
const [todos, setTodos] = useState<Todo[]>([
{ id: 1, title: "Task 1", completed: false },
{ id: 2, title: "Task 2", completed: false },
]);
const toggleTodo = async (id: number) => {
// 1. 낙관적 업데이트 (즉시 UI 반영)
setTodos((prev) =>
prev.map((todo) =>
todo.id === id
? { ...todo, completed: !todo.completed, isPending: true }
: todo
)
);
try {
// 2. 서버 요청
const response = await fetch(`/api/todos/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
completed: !todos.find((t) => t.id === id)?.completed,
}),
});
if (!response.ok) throw new Error("Update failed");
// 3. 성공 시 pending 제거
setTodos((prev) =>
prev.map((todo) =>
todo.id === id ? { ...todo, isPending: false } : todo
)
);
} catch (error) {
// 4. 실패 시 롤백
setTodos((prev) =>
prev.map((todo) =>
todo.id === id
? { ...todo, completed: !todo.completed, isPending: false }
: todo
)
);
alert("업데이트 실패");
}
};
return (
<div className="space-y-2">
{todos.map((todo) => (
<Card
key={todo.id}
className={`p-4 flex items-center justify-between ${
todo.isPending ? "opacity-60" : ""
}`}
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
disabled={todo.isPending}
/>
<span className={todo.completed ? "line-through" : ""}>
{todo.title}
</span>
</div>
{todo.isPending && (
<span className="text-sm text-gray-500">저장 중...</span>
)}
</Card>
))}
</div>
);
}6. 무한 스크롤 로딩
무한 스크롤 구현 시 로딩 상태 관리 패턴입니다.
"use client";
import { useState, useEffect, useRef } from "react";
import { Card } from "@vortex/ui-foundation";
interface Item {
id: number;
title: string;
}
export default function InfiniteScrollLoading() {
const [items, setItems] = useState<Item[]>([]);
const [page, setPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
const fetchItems = async (pageNum: number) => {
setIsLoading(true);
try {
const response = await fetch(`/api/items?page=${pageNum}&limit=10`);
const newItems = await response.json();
if (newItems.length === 0) {
setHasMore(false);
} else {
setItems((prev) => [...prev, ...newItems]);
}
} catch (error) {
console.error("Failed to fetch items:", error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchItems(page);
}, [page]);
useEffect(() => {
// Intersection Observer 설정
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !isLoading) {
setPage((prev) => prev + 1);
}
},
{ threshold: 0.5 }
);
if (loadMoreRef.current) {
observerRef.current.observe(loadMoreRef.current);
}
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [hasMore, isLoading]);
return (
<div className="space-y-4">
{items.map((item) => (
<Card key={item.id} className="p-4">
<h3 className="font-bold">{item.title}</h3>
</Card>
))}
{/* 로딩 트리거 */}
<div ref={loadMoreRef} className="h-20 flex items-center justify-center">
{isLoading && (
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
)}
{!hasMore && <p className="text-gray-500">모든 항목을 불러왔습니다</p>}
</div>
</div>
);
}Best Practices
✅ 권장 사항
-
적절한 로딩 유형 선택
- 짧은 대기 (1초 미만): 간단한 스피너
- 중간 대기 (1-3초): 스켈레톤 UI
- 긴 대기 (3초 초과): 프로그레스 바
-
인지된 성능 향상
- 스켈레톤 UI로 콘텐츠 구조 미리 보기
- 낙관적 업데이트로 즉각 반응
- 부분 로딩으로 빠른 초기 렌더링
-
접근성
role="status"또는role="progressbar"추가aria-busy="true"로딩 상태 표시aria-live="polite"스크린 리더 알림
-
에러 처리 통합
- 로딩 실패 시 재시도 버튼
- 명확한 에러 메시지
- 타임아웃 설정 (30초 권장)
-
사용자 피드백
- 로딩 시간이 길면 진행률 표시
- 백그라운드 작업은 토스트 알림
- 취소 가능한 작업은 취소 버튼 제공
⚠️ 피해야 할 것
-
UX 문제
- 모든 작업에 로딩 표시 (100ms 미만 작업은 불필요)
- 갑작스러운 레이아웃 변경 (CLS 증가)
- 로딩 상태 없이 대기
-
성능 문제
- 과도한 애니메이션 (CPU 사용량 증가)
- 동시 다수 스피너 (혼란)
- 전역 로딩 상태 (부분 로딩 선호)
-
접근성 문제
- 스크린 리더 미지원
- 키보드 포커스 소실
- 로딩 상태 미공지
성능 고려사항
스켈레톤 최적화
// ❌ 나쁜 예: 복잡한 애니메이션
<div className="animate-pulse animate-bounce animate-spin" />
// ✅ 좋은 예: 간단한 pulse 애니메이션
<div className="animate-pulse bg-gray-200" />낙관적 업데이트 롤백
// 실패 시 이전 상태로 복구
const previousState = currentState;
try {
updateOptimistically();
await saveToServer();
} catch (error) {
revertTo(previousState);
}Foundation 예제
범용 데이터 로딩
Foundation 컴포넌트로 구현한 중립적인 로딩 UI입니다.
import { Card } from "@vortex/ui-foundation";
export default function FoundationLoading() {
return (
<div className="space-y-4">
<Card className="p-4">
<div className="animate-pulse space-y-4">
<div className="h-6 bg-gray-200 rounded w-3/4" />
<div className="h-4 bg-gray-200 rounded w-1/2" />
<div className="h-32 bg-gray-200 rounded" />
</div>
</Card>
</div>
);
}iCignal 예제
Analytics 차트 로딩
iCignal Blue 브랜드를 적용한 차트 로딩 스켈레톤입니다.
import "@vortex/ui-icignal/theme";
import { Card } from "@vortex/ui-icignal";
export default function ISignalLoading() {
return (
<Card className="p-6 border-blue-500">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-blue-100 rounded w-1/3" />
<div className="h-48 bg-blue-50 rounded" />
<div className="flex gap-4">
<div className="h-4 bg-blue-100 rounded w-1/4" />
<div className="h-4 bg-blue-100 rounded w-1/4" />
</div>
</div>
</Card>
);
}Cals 예제
예약 목록 로딩
Cals Pink 브랜드를 적용한 예약 목록 로딩 스켈레톤입니다.
import "@vortex/ui-cals/theme";
import { Card } from "@vortex/ui-cals";
export default function CalsLoading() {
return (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Card key={i} className="p-4 border-pink-500">
<div className="animate-pulse flex items-center gap-4">
<div className="h-12 w-12 bg-pink-100 rounded-full" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-pink-100 rounded w-1/2" />
<div className="h-3 bg-pink-50 rounded w-3/4" />
</div>
</div>
</Card>
))}
</div>
);
}CodeSandbox
CodeSandbox 예제는 곧 제공될 예정입니다.
로컬에서 실행하기
-
프로젝트 생성
npx @vortex/cli init my-loading-project --template vite-react cd my-loading-project -
컴포넌트 추가
# Foundation npx @vortex/cli add button card --package foundation # iCignal npx @vortex/cli add card --package icignal # Cals npx @vortex/cli add card --package cals -
코드 복사 및 실행
pnpm dev
관련 패턴
- Data Fetching - 데이터 fetching 패턴
- Error Handling - 로딩 실패 처리
- Performance Optimization - 로딩 최적화
Last updated on