Skip to Content
PatternsLoading States

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초 미만): 간단한 스피너
    • 중간 대기 (1-3초): 스켈레톤 UI
    • 긴 대기 (3초 초과): 프로그레스 바
  2. 인지된 성능 향상

    • 스켈레톤 UI로 콘텐츠 구조 미리 보기
    • 낙관적 업데이트로 즉각 반응
    • 부분 로딩으로 빠른 초기 렌더링
  3. 접근성

    • role="status" 또는 role="progressbar" 추가
    • aria-busy="true" 로딩 상태 표시
    • aria-live="polite" 스크린 리더 알림
  4. 에러 처리 통합

    • 로딩 실패 시 재시도 버튼
    • 명확한 에러 메시지
    • 타임아웃 설정 (30초 권장)
  5. 사용자 피드백

    • 로딩 시간이 길면 진행률 표시
    • 백그라운드 작업은 토스트 알림
    • 취소 가능한 작업은 취소 버튼 제공

⚠️ 피해야 할 것

  1. UX 문제

    • 모든 작업에 로딩 표시 (100ms 미만 작업은 불필요)
    • 갑작스러운 레이아웃 변경 (CLS 증가)
    • 로딩 상태 없이 대기
  2. 성능 문제

    • 과도한 애니메이션 (CPU 사용량 증가)
    • 동시 다수 스피너 (혼란)
    • 전역 로딩 상태 (부분 로딩 선호)
  3. 접근성 문제

    • 스크린 리더 미지원
    • 키보드 포커스 소실
    • 로딩 상태 미공지

성능 고려사항

스켈레톤 최적화

// ❌ 나쁜 예: 복잡한 애니메이션 <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 예제는 곧 제공될 예정입니다.

로컬에서 실행하기

  1. 프로젝트 생성

    npx @vortex/cli init my-loading-project --template vite-react cd my-loading-project
  2. 컴포넌트 추가

    # 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
  3. 코드 복사 및 실행

    pnpm dev

관련 패턴

Last updated on