로딩 UI는 단순한 스피너 선택의 문제가 아닙니다. 로딩을 언제 보여줄지, 아예 안 보여줄 수 있는지, 그리고 데이터가 실시간으로 바뀔 때는 어떻게 갱신할지까지 고려해야 합니다. 이 글에서는 로딩 플리커 문제부터 시작해서, 캐시와 실시간 데이터까지 로딩 UX를 단계적으로 개선하는 전략을 다룹니다.
API 응답이 빠르면 로딩 UI가 아주 잠깐 보였다가 사라집니다. 스피너든 스켈레톤이든 "로딩 상태 → 완료 상태" 전환이 너무 빠르면 시각적 노이즈가 됩니다.
이건 UI 패턴의 문제가 아니라 상태 전환 타이밍의 문제입니다.
Nielsen Norman Group에 따르면 1초 미만으로 로드되는 작업에 루프 애니메이션(스피너)을 사용하는 것은 오히려 방해가 됩니다.
화면에 무언가 번쩍였다는 사실 자체가 사용자를 불안하게 만들기 때문입니다. 스피너는 2~10초 사이의 작업에 적합합니다.
그렇다면 이 플리커를 실무에서는 어떻게 해결할까요?
가장 간단하고 직접적인 해결책입니다. spin-delay 라이브러리가 이 패턴을 공식화했습니다.
const showSpinner = useSpinDelay(loading, {
delay: 500, // 500ms 이후에만 스피너 표시
minDuration: 200 // 표시되면 최소 200ms 유지
});
네트워크 요청이 빠르면 스피너를 아예 보여주지 않고, 스피너가 표시된 경우엔 최소 시간을 유지해서 플리커를 방지합니다.
TanStack Query 메인테이너도 "최소 로딩 시간을 늘리는 것보다, 약 250ms 이후에 스피너를 표시하는 방식으로 접근하라"고 권고합니다.
앱을 필요 이상으로 느리게 만들지 말라는 것이 핵심입니다.
Material UI, GitHub, LinkedIn, YouTube 등이 사용하는 방식입니다.
스피너 대신 콘텐츠의 자리를 미리 채워서 즉시 무언가 일어나는 것처럼 느끼게 합니다.
다만 스켈레톤도 API가 빠르면 똑같이 번쩍이는 문제가 생깁니다. 결국 spin-delay와 조합해야 합니다.
const showSkeleton = useSpinDelay(isLoading, {
delay: 300,
minDuration: 500
});
return showSkeleton ? <Skeleton /> : <Content />;
| 상황 | 권장 패턴 |
|---|---|
| 페이지 초기 데이터 로딩 | Skeleton + delay |
| 버튼 클릭 → 목록 갱신 | Delayed Spinner (delay: 300~500ms) |
| 파일 업로드 등 긴 작업 | Determinate Progress Bar |
Delayed Spinner와 Skeleton은 로딩이 보이는 방식을 개선합니다. 하지만 더 근본적인 질문이 있습니다. 로딩을 아예 안 보여줄 수는 없을까요?
로딩을 어떻게 보여줄지 고민하기 전에, 로딩 자체를 없앨 수 있는지부터 따져봐야 합니다.
TanStack Query의 핵심 동작입니다. 같은 query key로 두 번째 마운트가 발생하면 캐시에 있는 데이터를 즉시 반환하고, 동시에 백그라운드에서 새 네트워크 요청을 보냅니다.
첫 방문: [로딩] → [데이터 표시]
두 번째 방문: [이전 데이터 즉시 표시] → (백그라운드 갱신) → [새 데이터로 교체]
Gmail, Notion, Linear 등이 빠르게 느껴지는 이유입니다.
많은 개발자들이 staleTime을 "캐시 유지 시간"으로 오해합니다. 실제로는 역할이 완전히 다릅니다.
| 옵션 | 역할 | 영향 |
|---|---|---|
staleTime | 백그라운드 리패치 트리거 시점 | 데이터 신선도 |
gcTime | 캐시 메모리 삭제 시점 | 로딩 발생 여부 |
두 옵션은 각각 UX 레이어(로딩 발생 여부)와 데이터 신선도 레이어를 담당하는 독립적인 축입니다. 캐시가 메모리에 남아있는 한 로딩은 발생하지 않습니다. staleTime은 그 캐시로 백그라운드 리패치를 할지 말지를 결정할 뿐입니다.
캐시 전략은 "이전에 한 번 가져온 데이터"를 재활용하는 방식입니다. 그런데 데이터가 다른 사용자에 의해 실시간으로 바뀌는 경우에는 어떨까요? staleTime이 지나기 전에도 서버 데이터가 변경될 수 있고, 이때 사용자는 오래된 데이터를 보게 됩니다.
Jira는 과거에 이 문제를 1분마다 폴링하는 방식으로 해결했는데, 이를 이벤트 드리븐 아키텍처로 전환했습니다.
변경이 발생했을 때만 프론트엔드에 push하는 방식입니다.
// 저빈도 변경 (협업 툴) — 이벤트로 캐시 무효화
socket.on('dataChanged', ({ queryKey }) => {
queryClient.invalidateQueries({ queryKey })
})
// 고빈도 변경 (주식, 실시간 지표) — 데이터 직접 주입
socket.on('priceUpdated', (updated) => {
queryClient.setQueryData(['stock', updated.ticker], updated)
})
저빈도 변경은 invalidateQueries로 리패치를 트리거하고, 고빈도 변경은 setQueryData로 네트워크 요청 없이 캐시를 직접 덮어씁니다.invalidateQueries는 "서버에 다시 물어봐라", setQueryData는 "이 값으로 캐시를 덮어써라"의 차이입니다.
지금까지 로딩 UI를 개선하는 방법(spin-delay), 로딩을 없애는 방법(캐시), 실시간 데이터를 처리하는 방법(이벤트 기반 무효화)을 살펴봤습니다. 그렇다면 실제 프로젝트에서는 어떤 전략을 선택해야 할까요? 결국 데이터의 성격이 전략을 결정합니다.
| 데이터 변경 빈도 | 전략 | 결과 |
|---|---|---|
| 거의 안 바뀜 (프로필, 설정) | gcTime + staleTime 길게 | 로딩 없음, 백그라운드 갱신 |
| 자주 바뀜 (피드, 목록) | 캐시 포기, 매번 리패치 | spin-delay로 자연스럽게 |
| 실시간 (협업 툴) | 소켓 이벤트 → invalidateQueries | 변경 시점에만 갱신 |
| 초고빈도 실시간 (주식) | 소켓 데이터 → setQueryData 직접 주입 | 로딩 자체가 없는 구조 |
자주 바뀌는 데이터는 staleTime 구간에 오래된 데이터를 볼 수 있고, 리패치 시 UI 전환 문제도 존재합니다.
이런 경우 캐시를 포기하고 매번 리패치하되, spin-delay로 처리하는 것이 현실적입니다.
대부분의 API가 300ms 이내에 응답한다면 사용자는 로딩 없이 바로 데이터를 보게 됩니다.
반대로 거의 바뀌지 않는 데이터까지 매번 리패치하면 불필요한 로딩이 발생합니다.
이런 데이터는 캐시 전략으로 로딩 자체를 없애는 것이 맞습니다.
로딩을 없앨 수 있는 곳은 캐시 전략으로 없애고, 없앨 수 없는 곳만 spin-delay로 자연스럽게 처리하는 방향으로 접근하면 대부분의 경우에 괜찮은 결과를 얻을 수 있습니다.