
AI 플랫폼을 디자이너 없이 개발하면서 가장 고민했던 부분 중 하나가 바로 로딩 상태 처리였습니다.
모델 학습이나 대용량 데이터셋 로딩처럼 시간이 오래 걸리는 작업이 유독 많았기 때문이죠.
이런 경우 대부분의 개발자들이 첫 번째로 떠올리는 건 아무래도 로딩 스피너일 것입니다.
물론 로딩 스피너가 필요한 순간은 존재하지만,
항상 스피너만이 정답은 아닙니다.
스피너는 "언제 끝날지 모르는 기다림"을 사용자에게 요구하는 반면,
Skeleton UI는 "곧 이런 모습이 될 것"이라는 예측 가능한 대기 경험을 제공해요.
스켈레톤 스크린은 페이지가 궁극적으로 어떻게 보일지에 대한 단서를 제공하여 긴 로딩 시간에 대한 인식을 줄여줍니다.
같은 시간을 기다려도
사용자의 불안감을 덜어줄 수 있는 한 끝 차이가 될 수 있는 것이죠.
그래서 저는 프로젝트에 스켈레톤을 적극 도입해보기로 했습니다.
먼저 스켈레톤, 스피너, 프로그레스바 중 어떤 방식을 선택할지에 대한 기준을 정리했습니다.
1초 미만 요청에는 아무런 로딩 표시도 하지 않는 것이 가장 좋다고 생각합니다.
이 판단 기준은
저의 고민에 더불어
Nielsen Norman Group의 공식 가이드라인에 기반합니다.
Perceived Performance: 단순 대기 대신 "곧 화면이 완성된다"는 인상을 주어 체감 성능을 높입니다.
예측 가능성과 레이아웃 안정성: 실제 UI와 동일한 구조를 모방해 Layout Shift를 방지합니다.
제가 프로젝트에서 스켈레톤을 도입한 방법을 간략하게나마 설명해볼게요.
Atomic Design을 다른 컴포넌트에 적용했던 것처럼,
스켈레톤 시스템에도 이를 적용해 재사용성과 확장성을 크게 높였습니다.
const SkeletonElement = ({
type = 'text',
width = '100%',
height = '16px',
className = ''
}) => {
return (
<div
className={`skeleton skeleton-${type} ${className}`}
style={{ width, height }}
aria-hidden="true"
/>
);
};

const SkeletonText = ({
lines = 3,
lastLineWidth = '60%'
}) => {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{Array.from({ length: lines }, (_, index) => (
<SkeletonElement
key={index}
width={index === lines - 1 ? lastLineWidth : '100%'}
height="16px"
/>
))}
</div>
);
};
마지막 줄을 의도적으로 짧게 만드는 디테일이 자연스러움을 만듭니다.

가장 중요한 원칙은 실제 컴포넌트와 정확히 동일한 DOM 구조를 유지하는 것입니다.
const SkeletonCard = () => {
return (
<div className="card-container">
<SkeletonElement type="avatar" width="40px" height="40px" />
<div className="card-content">
<SkeletonElement type="title" width="80%" height="20px" />
<SkeletonText lines={2} />
</div>
<div className="card-actions">
<SkeletonElement width="80px" height="32px" />
<SkeletonElement width="60px" height="32px" />
</div>
</div>
);
};
.skeleton {
background: #e5e7eb;
border-radius: 4px;
position: relative;
overflow: hidden;
}
.skeleton::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.6),
transparent
);
animation: shimmer 2s infinite;
}
@keyframes shimmer {
0% { transform: translateX(-150%); }
50% { transform: translateX(-60%); }
100% { transform: translateX(150%); }
}
/* 다크 모드 대응 */
@media (prefers-color-scheme: dark) {
.skeleton { background: #374151; }
.skeleton::after {
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
}
}
애니메이션 속도는 2초가 적당합니다.
중간에 -60%를 거쳐 부드러운 곡선을 만들어 자연스러움을 더했습니다.
가장 고민이 많았던 부분 중 하나가 바로 코드 에디터의 스켈레톤이었습니다.
단순한 박스가 아니라 실제 코드처럼 보이는 스켈레톤이 필요했거든요.
간추린 코드 예시를 보여드리겠습니다.
const CodeEditorSkeleton = ({ lines = 15 }) => {
// 일관된 패턴을 위한 고정 너비 배열
const lineWidths = useMemo(() => {
const patterns = [85, 45, 70, 60, 90, 35, 75, 55, 80, 40];
return Array.from({ length: lines }, (_, index) =>
patterns[index % patterns.length]
);
}, [lines]);
return (
<div className="editor-skeleton">
{lineWidths.map((width, index) => (
<div key={index} className="code-line">
<SkeletonElement width="20px" height="16px" /> {/* 라인 넘버 */}
<SkeletonElement
width={`${width}%`}
height="16px"
style={{ marginLeft: '12px' }}
/>
</div>
))}
</div>
);
};
랜덤이 아닌 고정된 패턴을 사용해봤습니다.


Math.random()을 써도 좋지만?
리렌더링 때마다 너비가 바뀌어서 어색해져요.
미리 정의된 패턴을 반복하면 자연스러우면서도 일관된 모습을 만들 수 있습니다!
카카오페이 기술 문서에 의하면,
스켈레톤을 200ms 지연시켜 노출하면 75%의 사용자에게 불필요한 깜빡임을 방지할 수 있다고 합니다.
위 글을 읽어보시는 것을 추천드립니다.
const useDelayedSkeleton = (isLoading, delay = 200) => {
const [showSkeleton, setShowSkeleton] = useState(false);
useEffect(() => {
if (isLoading) {
const timer = setTimeout(() => setShowSkeleton(true), delay);
return () => clearTimeout(timer);
} else {
setShowSkeleton(false);
}
}, [isLoading, delay]);
return showSkeleton;
};
데이터가 사용 가능할 때 실제 콘텐츠가 스켈레톤을 즉시 대체해야 합니다.
전체 데이터를 기다릴 필요는 없습니다.
// 로딩 상태 컨테이너
<div aria-busy={isLoading} aria-label="콘텐츠 로딩 중">
{isLoading ? <SkeletonCard /> : <ActualCard />}
</div>
저는 라이브러리를 사용하지 않고 스켈레톤을 개발해봤습니다.
커스텀 스켈레톤들을 다양한 형태로 만들어보고싶었기 때문인데요.
이미 나와있는 라이브러리들을 사용하는 것도 효율적인 방법입니다.
Material-UI, Ant Design 같은 주요 라이브러리들은 이미 최적화된 스켈레톤 컴포넌트를 제공합니다.
Material-UI의 Skeleton, Ant Design의 Skeleton 등을 적극 활용하는 것을 권장합니다.
저는 협업을 위해 storybook을 자주 사용합니다.
스켈레톤도 storybook을 만들어두면, 다양한 상태를 테스트하고 공유하기 좋겠죠.
// Skeleton.stories.js
export default {
title: 'Components/Skeleton',
component: SkeletonCard,
};
export const Gallery = {
render: () => (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '20px',
padding: '20px'
}}>
<div>
<h3>Card Skeleton</h3>
<SkeletonCard />
</div>
<div>
<h3>Text Skeleton</h3>
<SkeletonText lines={4} />
</div>
<div>
<h3>Code Editor Skeleton</h3>
<CodeEditorSkeleton lines={10} />
</div>
</div>
),
};
export const LoadingStates = {
render: () => (
<SkeletonGrid count={6} />
),
};
Next.js 개발자 분들은 loading.js와 React Suspense를 활용하면 스켈레톤 적용이 한층 수월해집니다.
// app/dashboard/loading.tsx
export default function Loading() {
return <SkeletonPageTemplate showSidebar={true} />;
}
// app/dashboard/page.tsx - 자동으로 Suspense 경계가 생성됨
export default function Dashboard() {
return (
<div>
<Suspense fallback={<SkeletonCard />}>
<DataSection />
</Suspense>
</div>
);
}
설계 단계
구현 단계
prefers-reduced-motion 대응접근성
aria-busy 적용aria-hidden="true"플랫폼을 개발하며 깨달은 것은 스켈레톤 UI가 단순한 로딩 처리 기법이 아니라는 점입니다.
사용자는 빈 화면을 마주할 때 "페이지가 깨진 것은 아닐까", "네트워크에 문제가 있는 건 아닐까"라는 불안감을 느낍니다.
하지만 스켈레톤 UI는 "곧 이런 콘텐츠가 나올 것"이라는 명확한 신호를 보내죠.
실제 로딩 시간을 1초 줄이는 것보다도
사용자가 기다림을 자연스럽게 받아들이도록 하는 세심한 배려가 더 중요할 때가 분명 존재한다고 생각합니다.
대기하며 빈 화면을 바라보는 사용자는 "혹시 에러가 난 것은 아닐까" 불안하지만,
스켈레톤 UI를 보는 사용자는 "곧 이런 모습이 되겠구나"라는 기대를 가지겠죠?
이런 작은 차이가 전체적인 사용자 경험을 크게 좌우한다는 것을 기억합시다.
다양한 시도를 통해 UX에 대한 관점을 가진 프론트엔드 개발자로 성장하고 싶어 도전해본 스켈레톤 UI 제작기였습니다.
피드백은 언제나 환영입니다!