내가 만든 hook을 소개합니다. 라는 주제로 시리즈를 작성하려고 합니다.
왜 이런 hook을 만들었는지 배경과 고민, 그리고 구현과 활용법을 공유합니다.
데이터를 불러올 때 로딩 상태를 어떻게 보여줄지는 UX에서 매우 중요한 부분입니다.
잘못 보여주면 화면이 깜빡이거나 사용자가 불안감을 느낄 수 있습니다.
반면 전략적으로 잘 보여준다면, 기다림이 자연스럽게 느껴지고 신뢰성을 높일 수 있습니다.
이번 글에서는 "UX 근거 + 코드 구현 + 실무 적용"을 한 번에 정리합니다.
Step 1. 깜빡이는 로딩 UX 해결하기
문제 상황
페이지 진입 시 API 호출이 발생할 때, 로딩 상황은 항상 일정하지 않습니다.
빠르게 응답이 오면 스피너가 잠깐 깜빡이며 화면이 불안정하게 느껴지고, 사용자는 깜박임이라는 불편한 UX를 겪을 수 있고, 버그가 있는 것처럼 오해할 수 있습니다.
반대로 응답이 느리면 빈 화면이 오래 유지되어 사용자가 기다림을 제대로 인지하지 못하고 불안감을 느낄 수 있습니다.
해결 전략
- 로딩 시작 후 일정 시간(
delayMs
)이 지나야 로더를 표시 - 표시되면 최소 시간(
minDisplayMs
) 동안 유지 → 깜빡임 제거
UX 근거
- 0.1~0.2초 이하: 대부분 사용자가 인지하지 못함 → 불필요한 UI 노출은 혼란 유발
- 200~500ms 이상: 로더 표시가 필요 → 기다림을 자연스럽게 인지
Step 2. Skeleton vs 로더 구분 전략
Skeleton은 페이지 구조를 즉시 보여주어, 빠른 응답에서도 깜빡임 없이 안정적인 화면을 제공하고, 느린 응답에서도 사용자에게 기다림을 자연스럽게 안내합니다.
추가적으로, 데이터 갱신이나 추가 로딩 시에는 기존 화면을 유지하면서 Loader를 부분적으로 보여주는 방식으로 사용자의 인지를 돕습니다.
Skeleton vs 로더
구분 | 특징 | 사용 시점 |
---|---|---|
Skeleton | 컨텐츠 모양을 미리 보여주는 플레이스홀더 | 초기 페이지 진입 |
로더(Loader/Spinner) | 진행 중임을 알려주는 아이콘 | 화면이 이미 렌더된 후 추가/갱신 데이터 로딩 |
전략
- 초기 진입 → 전체 페이지 Skeleton
- 추가 데이터 갱신 → 기존 화면 유지 + 부분 스피너
👉 이렇게 구분하면 사용자의 시선을 방해하지 않고, 데이터가 자연스럽게 업데이트 될수 있습니다.
Step 3. 구현: useDelayedLoading hook + React Query
hook 정의
tsimport { useEffect, useState } from 'react' interface UseDelayedLoadingProps { isLoading: boolean delayMs?: number minDisplayMs?: number } interface UseDelayedLoadingReturn { shouldShowLoading: boolean } export function useDelayedLoading({ isLoading, delayMs = 200, minDisplayMs = 500, }: UseDelayedLoadingProps): UseDelayedLoadingReturn { const [showLoading, setShowLoading] = useState(false) const [loadingStartTime, setLoadingStartTime] = useState<number | null>(null) useEffect(() => { let delayTimer: ReturnType<typeof setTimeout> | undefined let minTimer: ReturnType<typeof setTimeout> | undefined if (isLoading) { // 로딩이 시작되면 딜레이 후 Loading 표시 delayTimer = setTimeout(() => { setShowLoading(true) setLoadingStartTime(Date.now()) }, delayMs) } else { // 로딩이 끝났을 때 if (showLoading && loadingStartTime) { const elapsed = Date.now() - loadingStartTime const remaining = minDisplayMs - elapsed if (remaining > 0) { // 최소 시간이 남았으면 기다림 minTimer = setTimeout(() => { setShowLoading(false) setLoadingStartTime(null) }, remaining) } else { // 최소 시간이 지났으면 즉시 숨김 setShowLoading(false) setLoadingStartTime(null) } } } return () => { if (delayTimer) clearTimeout(delayTimer) if (minTimer) clearTimeout(minTimer) } }, [isLoading, delayMs, minDisplayMs, loadingStartTime, showLoading]) return { shouldShowLoading: showLoading, } }
이 hook은 로딩 상태를 단순히 boolean으로 표시하는 대신, UX적인 시간 조건을 반영합니다.
delayMs
: 로딩 시작 후, 일정 시간이 지나야 로딩 UI를 보여줌
→ 너무 짧은 로딩에선 스피너가 깜빡이는 문제 해결
minDisplayMs
: 한 번 표시된 로딩 UI는 최소 시간 동안 유지
→ UI가 갑자기 사라져 깜빡임처럼 보이는 문제 해결
즉, "로딩 UI가 보여질지 여부"를 계산하는 로직을 캡슐화하여 shouldShowLoading
이라는 상태 값으로 내려줍니다.
이를 사용하면 컴포넌트는 UX 세부 로직을 몰라도, 단순히 boolean 값만으로 UI를 제어할 수 있습니다.
React Query와의 결합: isLoading vs isFetching
React Query는 로딩 상태를 두 가지로 구분합니다.
상태 | 설명 | 사용 사례 |
---|---|---|
isLoading | 데이터가 아직 캐시에 없고, 최초로 불러오는 중 | 페이지 최초 진입 → 전체 Skeleton |
isFetching | 데이터가 캐시에 있지만, 새로운 요청을 보내는 중 | 화면은 유지 + 일부 데이터만 갱신 → 스피너 |
👉 이 차이를 활용하면
- 최초 로딩 → Skeleton
- 갱신 로딩 → Loader(Spinner)
를 명확히 구분할 수 있습니다.
정리
-
짧은 로딩 시간은 표시하지 않고, 표시할 땐 최소 시간 유지 → 깜빡임 없는 UX
-
Skeleton
은 "구조를 먼저 보여주기",Loader
는 "진행 중임을 알려주기"에 각각 최적 -
React Query
의isLoading
/isFetching
구분을 통해 이 전략을 자연스럽게 적용 가능
👉 이 설계를 적용하면 사용자는 안정감을 느끼고, 개발자는 hook을 통해 재사용 가능한 패턴으로 관리할 수 있습니다.
회고
로딩 경험을 단순히 "로딩 중 → 완료"라는 2단계로만 나누는 대신,
loading
과 fetching
을 구분하고, skeleton
과 loader/spinner
를 상황에 맞게 조합하는 과정은 단순한 기술 구현을 넘어 UX 그 자체를 설계하는 일이였습니다.
이 작은 차이가 사용자에게는 "이 제품은 세심하다" 라는 신뢰로 이어진다고 생각합니다.
그리고 이런 시선을 유지하는 것이야말로, 코드를 짜는 개발자를 넘어 product를 함께 만드는 engineer로 성장하는 길이라 생각합니다.