React Suspense와 Skeleton UI로 로딩 경험 개선하기
Loading UI는 왜 필요할까?
웹 애플리케이션은 사용자와 서버 간의 상호작용이 필수적입니다.
이 과정에서 서버의 응답을 기다리는 시간이 발생하고, 사용자는 아래 사진과 같이 아무런 피드백 없이 멈춰 있는 화면을 보게 되면 혼란을 느낄 수 있습니다.

이러한 문제를 해결하기 위해 로딩 UI가 필요합니다.
로딩 UI는 데이터를 불러오는 중이라는 피드백을 사용자에게 명확하게 전달해 주며, 다음과 같은 이점을 제공합니다:
사용자에게 시스템이 동작 중임을 전달
기다리는 동안의 불확실성 감소
즉, 단순한 시각적 요소를 넘어서 사용자 경험(UX)의 품질을 결정짓는 중요한 요소입니다.
Loading UI의 종류와 각각의 장단점
로딩 UI는 대표적으로 다음과 같은 형태로 나뉩니다:
1. Spinner (회전 아이콘)

가장 전통적이고 간단한 방식입니다.
장점: 구현이 간단하고 다양한 곳에서 재사용 가능
단점: 사용자에게 무엇이 로딩되고 있는지 알 수 없음
2. Progress Bar (진행 바)

진행률을 시각적으로 보여줍니다. 파일 업로드 등 명확한 완료 시점이 있을 때 사용됩니다.
장점: 완료 시점을 예측 가능
단점: 서버에서 정확한 진행률을 알 수 없는 경우는 적용이 어려움
3. Skeleton UI

실제 콘텐츠 레이아웃과 비슷한 모양의 블록이나 애니메이션 형태로 구성됩니다.
장점: 레이아웃 유지, 사용자에게 곧 콘텐츠가 들어올 것이라는 예측성 제공
단점: 실제 콘텐츠와 비슷한 레이아웃으로 구현해야해서 다소 복잡
Spinner vs Skeleton: Skeleton 선택
데이터를 기다리는 중에는 어떤 loading UI를 사용할까 고민하였고 저는 Skeleton 방식을 선택했습니다. 그 이유는 다음과 같습니다:
콘텐츠 구조에 대한 예측 가능성 제공
Skeleton은 실제 콘텐츠의 레이아웃을 미리 보여주기 때문에, 사용자는 "어디에 어떤 정보가 들어올지"를 미리 예측할 수 있습니다. 이는 사용자 경험(UX)을 높이는 데 중요한 요소로, 데이터를 기다리는 동안에도 사용자는 앱이 어떻게 구성되어 있는지 쉽게 파악할 수 있습니다.
Layout Shift 최소화
데이터를 불러온 뒤 기존 UI가 갑자기 변경되는 것을 layout shift라고 합니다. 이는 사용자의 시선을 방해하거나 클릭 오류를 유발할 수 있는 좋지 않은 경험입니다. Skeleton은 콘텐츠가 로드되기 전 미리 자리를 잡아두기 때문에, 데이터가 들어와도 화면이 크게 흔들리지 않습니다. 이는 시각적 안정성을 유지하는 데 큰 도움이 됩니다.
심리적 로딩 시간 단축 효과
로딩 인디케이터(스피너 등)는 사용자가 단순히 "기다리는 느낌"을 갖게 만들지만, Skeleton은 정보를 준비하고 있다는 진행감을 줍니다. 사용자는 정보를 곧 받을 수 있다는 기대를 갖게 되어, 실제 대기 시간이 같더라도 더 빠르게 느껴질 수 있습니다.
현대적인 디자인 흐름과의 일치
Skeleton UI는 넷플릭스, 유튜브, 페이스북 등 여러 글로벌 서비스에서 적극 활용하는 방식으로, 현대적인 UI 트렌드와도 잘 맞습니다. 일관성 있고 세련된 사용자 경험을 제공하는 데 효과적입니다.
Loading UI 띄우는 법
loading UI를 띄우는 법은 아래 두 가지 방법이 있습니다.
저는 두 방법 중 책임 분리 측면에서 Suspense 방식을 선택하였습니다.
1. 상태 기반 로딩 UI
function MyComponent() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true); // 로딩 상태
useEffect(() => {
fetch('/api/data')
.then((res) => res.json())
.then((result) => {
setData(result);
setLoading(false); // 데이터 받아온 후 로딩 종료
});
}, []);
if (loading) {
return <div>Loading...</div>; // 로딩 UI
}
return <div>{data.title}</div>;
}
장점
로직이 명시적이라 누구나 쉽게 이해 가능
단점
매 컴포넌트마다
loading
관리가 반복됨로딩 처리 로직과 데이터 UI가 섞이게 되어 컴포넌트의 책임이 여러개임
2. React.Suspense 기반 로딩 UI
function MyComponent() {
return (
<Suspense fallback={<div>Loading...</div>}>
<DataComponent />
</Suspense>
);
}
function DataComponent() {
const { data } = useSuspenseQuery(...);
return <div>{data.title}</div>;
}
장점
로딩 UI 처리와 데이터 UI 컴포넌트가 명확히 책임 분리
공통적으로
Suspense
만으로 로딩 처리 가능 → 중복 코드 줄어듦
단점
기존의 명시적인 상태관리(
isLoading
등)와 달라서 개발자 입장에서 익숙하지 않을 수 있음
Tanstack Query에서 Suspense 사용하기
저희 프로젝트에서는 비동기 데이터 관리를 위해 Tanstack Query를 사용하고 있습니다.
Tanstack Query는 기본적으로 useQuery
훅을 제공하여 로딩, 에러, 성공 상태를 알 수 있게 하지만, React의 Suspense
와 연동되지는 않습니다.
useSuspenseQuery
란?
Tanstack Query에서는 Suspense
를 활용한 로딩 UI 처리를 쉽게 하도록 useSuspenseQuery
라는 별도의 훅을 제공합니다.
이 훅은 내부적으로 Promise를 던져 React가 Suspense fallback UI를 보여주도록 합니다.
즉, useSuspenseQuery
는 다음과 같은 특징을 가집니다:
데이터가 준비되기 전까지 컴포넌트 렌더링을 "일시 중단(suspend)"하고,
상위의
<Suspense fallback={...}>
컴포넌트가 지정한 UI를 보여줍니다.데이터가 준비되면 정상적으로 컴포넌트가 렌더링됩니다.
<Suspense fallback={<Skeleton />}>
<Post />
</Suspense>
function Post() {
const { data } = useSuspenseQuery(...);
return <div>{data.title}</div>;
}
위 코드의 동작 방식
Post
컴포넌트가 처음 렌더링될 때,useSuspenseQuery
가 내부적으로 아직 데이터를 받지 못하면 Promise를 던집니다.React는 이 Promise를 감지하고 렌더링을 일시 중단하며, 가장 가까운
<Suspense>
의fallback
UI인<Skeleton />
을 화면에 보여줍니다.데이터가 준비되면 Promise가 해결되고 React가 다시 렌더링을 시도하며, 실제
Post
컴포넌트가 데이터를 가지고 정상적으로 렌더링됩니다.
Tanstack Query 없이 Suspense를 사용하고싶다면?
만약 Tanstack Query 같이 suspense와 연동된 라이브러리를 사용하지 않아도 Suspense를 사용할 수 있습니다! 핵심은 promise를 throw 하는 것입니다.
// 간단한 데이터 캐시
let promise = null;
let data = null;
function fetchData() {
// 1. 캐시된 데이터가 있으면 바로 반환
if (data) {
return data;
}
// 2. 진행 중인 Promise가 없을 때만 새로 생성
if (!promise) {
promise = new Promise((resolve) => {
setTimeout(() => {
data = "데이터 로드 완료!";
resolve(data);
}, 2000);
});
}
// 3. 데이터가 없으면 Promise throw
throw promise;
}
function DataComponent() {
const result = fetchData(); // Promise throw
return <div>{result}</div>;
}
function App() {
return (
<Suspense fallback={<div>로딩중...</div>}>
<DataComponent />
</Suspense>
);
}

동작 과정
첫 렌더링:
DataComponent
가fetchData()
호출Promise throw: 데이터가 없으므로 Promise를 던짐
Suspense 캐치: Promise를 받아서 fallback(
로딩중...
) 렌더링Promise resolve: 2초 후 데이터가 준비됨 (
data = "데이터 로드 완료!"
)Promise resolve 시 재렌더링: Promise가 완료되면 React가 자동으로 컴포넌트를 다시 렌더링
데이터 반환: 이번에는 캐시된 데이터를 바로 반환
Suspense를 활용하여 Skeleton 적용하기
그렇다면 이제 정말로 React의 Suspense, Tanstack Query의 useSuspenseQuery를 활용해 로딩 UI로 Skeleton을 적용해보겠습니다.
1. useQuery → useSuspenseQuery
Tanstack Query에서는 기존의 useQuery
를 useSuspenseQuery
로 변경하는 것만으로 Suspense와 연동할 수 있습니다.
// 수정 전
function ParticipatingCrewList() {
const { data: crewListData } = useQuery(...)
}
// 수정 후
function ParticipatingCrewList() {
const { data: crewListData } = useSuspenseQuery(...)
}
2. Suspense로 감싸기
(참고) CardListSkeleton은 Skeleton 컴포넌트를 기반으로 만들어져 있습니다.
이제 컴포넌트를 Suspense로 감싸고 fallback으로 스켈레톤 컴포넌트를 적용해주면 로딩 상태를 선언적으로 처리할 수 있습니다.
function MainPage() {
return (
<div>
{/* ... */}
<main>
<ParticipatingCrewHeader />
<Suspense fallback={<CardListSkeleton direction="vertical" />}>
<ParticipatingCrewList />
</Suspense>
</main>
</div>
);
}
3. 결과화면


문제 상황1) 데이터가 빠르게 불면 오히려 깜빡거림
1. 문제
그런데…!!! 스켈레톤을 적용했더니 데이터가 빠르게 불러와지면 Suspense의 fallback UI에서 기존의 컴포넌트로 빠르게 변환되고, 이에 따라 오히려 화면이 덜그럭거리고 깜빡거리는 것처럼 보이는 현상이 발생했습니다.

2. 해결 방안 모색
이 현상을 다른 사람들은 어떻게 해결했나.. 싶어 찾아보니 아래 글을 발견했습니다.
위 글에서는 다음과 같은 핵심 내용들을 제시하고 있습니다:
카카오페이의 실제 사례:
API 응답이 100ms로 빠른 경우: 스켈레톤이 너무 짧게 보여져서 오히려 거슬리고 "덜그럭거리는" 느낌
API 응답이 300ms인 경우: 스켈레톤을 통해 빠르고 쾌적한 상호작용 느낌
해결 방안 - DeferredComponent:
200ms 동안은 스켈레톤을 보여주지 않고, 그 이후에만 스켈레톤을 노출하는 방식
빠른 응답(200ms 이하)인 경우: 잠시 빈 화면 → 바로 컨텐츠 (더 자연스러움)
느린 응답(200ms 이상)인 경우: 잠시 빈 화면 → 스켈레톤 → 컨텐츠
데이터 기반 검증:
Firebase Performance Monitoring 데이터를 활용해 75%의 사용자가 192ms 이내에 응답을 받는다는 것을 확인
200ms 지연 시 전체 사용자의 75%는 덜그럭거림을 느끼지 않지만, 15%는 덜그럭거림을 느낄 수 있음
카카오페이에서는 Firebase Performance Monitoring 데이터를 통해 200ms라는 구체적인 지연 시간을 도출했지만, 저희 프로젝트에는 아직 성능 모니터링 도구가 구축되어 있지 않습니다.
따라서 일단은 임시로 200ms를 적용해보고, 추후 모니터링 도구를 도입한 후 실제 사용자 데이터를 바탕으로 최적의 지연 시간을 재조정하는 것이 좋을 것 같습니다.
3. 해결
DeferredComponent 구현
위의 블로그를 참고하여 DeferredComponent를 구현하였습니다.
interface DeferredComponentProps extends PropsWithChildren {
delay?: number;
}
function DeferredComponent({ children, delay = 200 }: DeferredComponentProps) {
const [isDeferred, setIsDeferred] = useState(false);
useEffect(() => {
const timeoutId = setTimeout(() => {
setIsDeferred(true);
}, delay);
return () => clearTimeout(timeoutId);
}, []);
if (!isDeferred) {
return null;
}
return <>{children}</>;
}
export default DeferredComponent;
Suspense fallback에 적용
function MainPage() {
return (
<div>
{/* ... */}
<main>
<ParticipatingCrewHeader />
<Suspense
fallback={
<DeferredComponent>
<CardListSkeleton direction="vertical" />
</DeferredComponent>
}
>
<ParticipatingCrewList />
</Suspense>
</main>
</div>
);
}
개선된 사용자 경험
Before (기존):
모든 로딩 상황에서 즉시 스켈레톤 노출
빠르게 로드되는 경우 깜빡거리는 현상 발생
After (DeferredComponent 적용):
200ms(delay) 이내 로딩: 빈 화면 → 바로 컨텐츠 (자연스러움)
200ms(delay) 이상 로딩: 빈 화면 → 스켈레톤 → 컨텐츠 (의도된 로딩 표시)
추후 개선 계획
성능 모니터링 도구 도입
실제 사용자 데이터 수집 후 delay 최적 시간 도출 (API 응답 시간 분포 분석)
사용자 피드백 수집 (실제 체감 경험 조사)
이렇게 하면 무조건적인 스켈레톤 노출이 아닌, 데이터 기반의 최적화된 로딩 UX를 제공할 수 있게 됩니다!
4. 결과

문제 상황2) DeferredComponent로 인한 Layout Shift 발생
Layout Shift란? 페이지 로딩 중에 요소들의 위치가 예기치 않게 변경되는 현상을 말합니다. 사용자가 클릭하려던 버튼이 갑자기 다른 위치로 이동하거나, 읽고 있던 텍스트가 아래로 밀려나는 등의 불편한 경험을 유발합니다.
1. 문제
기존 Suspense (Layout Shift 없음)
<Suspense fallback={<CardListSkeleton direction="vertical" />}>
<ParticipatingCrewList />
</Suspense>
동작 과정:
초기 렌더링: 즉시
CardListSkeleton
표시 (높이 확보)데이터 로딩 완료:
ParticipatingCrewList
로 교체결과: 스켈레톤과 실제 컨텐츠의 높이가 비슷해 Layout Shift 최소화
DeferredComponent 적용 후 (Layout Shift 발생)
<Suspense
fallback={
<DeferredComponent>
<CardListSkeleton direction="vertical" />
</DeferredComponent>
}
>
<ParticipatingCrewList />
</Suspense>
문제가 되는 동작 과정:
0~200ms:
DeferredComponent
가null
반환 → 높이 0px인 빈 공간200ms 후:
CardListSkeleton
표시 또는ParticipatingCrewList
표시→ 갑자기 높이 증가
Google에서는 CLS 지표가 0.1 이하여야 좋은 사용자 경험을 제공한다고 권장하고 있습니다. 하지만 아래 사진을 보면 Layout shift가 0.115를 넘어갑니다.


2. 해결 방안 모색
1. CSS를 활용하여 opacity로 지연 처리
.skeleton {
background-color: var(--surface-tertiary);
display: inline-block;
opacity: 0;
animation: fadeIn 0.3s ease-in-out var(--skeleton-delay) forwards;
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
function Skeleton({...}: SkeletonProps) {
const delayStyle = {
'--skeleton-delay': `${delay}ms`,
};
return (
<div
// ...
style={{ width, height, ...delayStyle }}
/>
);
}
export default Skeleton;
장점
JavaScript 타이머 불필요
간단한 구현
단점
확장성 부족
2. DeferredComponent에 minHeight 적용
interface DeferredComponentProps extends PropsWithChildren {
delay?: number;
minHeight?: string;
}
function DeferredComponent({ children, delay = 200, minHeight }: DeferredComponentProps) {
const [isDeferred, setIsDeferred] = useState(false);
useEffect(() => {
const timeoutId = setTimeout(() => {
setIsDeferred(true);
}, delay);
return () => clearTimeout(timeoutId);
}, []);
if (!isDeferred) {
return <div style={{ minHeight }} aria-hidden={true} />;
}
return <>{children}</>;
}
export default DeferredComponent;
장점
컴포넌트 별 동적 지연 시간 설정 가능
단점
콘텐츠 변경 시
minHeight
도 함께 수정해야 함
3. 해결
위의 1. CSS를 활용하여 opacity로 지연 처리
방법과 2. DeferredComponent에 minHeight 적용
방법 중에 1
방식을 선택하여 해결하였습니다.
왜냐하면 2
방식은 콘텐츠가 변경될 때마다 minHeight 값을 함께 수정해야 하고, 각 사용처마다 적절한 높이를 개별적으로 관리해야 하기 때문입니다.
반면 1
방식은 스켈레톤 자체가 실제 콘텐츠와 비슷한 구조를 가지므로 별도의 높이 관리가 불필요하고, 콘텐츠 변경 시에도 추가 작업이 필요하지 않기 때문에 선택하였습니다.
즉, DeferredComponent
자체를 걷어내고 단순히 skeleton의 css에 opacity와 애니메이션을 사용한 delay를 추가해줬습니다.
<Suspense
fallback={<CardListSkeleton direction="vertical" />}
>
<ParticipatingCrewList />
</Suspense>
3. 결과
Layout shift가 0.046으로 0.1 이하로 개선되었습니다!!


느낀점 및 배운점
같은 로딩 시간이라도 스피너와 스켈레톤의 사용자 체감은 완전히 다르다는 것을 경험했습니다. 스켈레톤이 제공하는 예측 가능성이 심리적 대기 시간을 단축시킨다는 점이 가장 인상깊었습니다.
현재는 로딩 UI가 나오기까지 200ms를 지연하고 있지만, 추후 실제 사용자 데이터를 바탕으로 최적의 지연 시간을 찾아 개선해보고 싶습니다.
결과적으로, 단순한 로딩 UI 구현을 넘어 사용자 경험과 성능 최적화를 종합적으로 학습한 의미 있는 시간이었습니다!
참고 자료
Last updated