UX
loading 상태를 대충 구현하면 생기는 UX 문제들
스피너 하나 올려두는 게 전부라고 생각했다가, 버튼 중복 클릭과 레이아웃 시프트로 사용자 불만이 쌓였다.
비동기 요청이 진행되는 동안 UI를 어떻게 처리하느냐는 코드 몇 줄의 문제가 아니다. 로딩 상태를 제대로 다루지 않으면 사용자 입장에서 앱이 멈춘 건지 동작 중인 건지 알 수 없다. 겪었던 문제들을 정리하면 패턴이 보인다.
버튼 중복 클릭
폼 제출 버튼을 빠르게 두 번 누르면 요청이 두 번 가는 경우가 있다. 서버에서 idempotency를 보장하면 괜찮지만, 결제나 포인트 차감 같은 작업이라면 중복 실행이 치명적이다.
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit() {
if (isSubmitting) return;
setIsSubmitting(true);
try {
await submitForm(data);
} finally {
setIsSubmitting(false);
}
}
return (
<button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? '처리 중...' : '제출'}
</button>
);
disabled 속성과 함께 텍스트도 바꿔주면 사용자가 처리 중임을 바로 인식한다. 스피너만 보여주고 버튼 텍스트를 그대로 두면 뭔가 반응하고 있는 건지 모르는 경우가 많다.
레이아웃 시프트
데이터를 불러오는 동안 빈 컨테이너를 두다가 데이터가 오면 갑자기 콘텐츠가 채워지면서 아래 요소들이 밀리는 현상이다. 높이가 확정되지 않은 상태에서 내용이 들어오기 때문에 생긴다.
스켈레톤 UI로 해결한다. 데이터 자리에 실제 콘텐츠와 비슷한 크기의 회색 블록을 먼저 보여주면 레이아웃이 고정된다.
function PostCard({ post }: { post?: Post }) {
if (!post) {
return (
<div className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2" />
<div className="h-3 bg-gray-200 rounded w-1/2" />
</div>
);
}
return <div>{post.title}</div>;
}
에러 상태를 무시하는 경우
요청이 실패했을 때 스피너가 그냥 사라지고 아무 피드백이 없으면 사용자는 제출이 됐는지 안 됐는지 모른다. 다시 시도하는 방법도 제공해야 한다.
const [status, setStatus] = useState<'idle' | 'loading' | 'error' | 'success'>('idle');
// 에러 상태에서 재시도 버튼을 보여준다
{status === 'error' && (
<div>
<p>요청에 실패했습니다.</p>
<button onClick={retry}>다시 시도</button>
</div>
)}
낙관적 업데이트가 필요한 경우
좋아요 버튼이나 체크박스처럼 즉각적인 반응이 중요한 경우, 서버 응답을 기다리지 않고 UI를 먼저 바꾸고 나중에 롤백하는 방식이 자연스럽다. React Query나 SWR의 mutate가 이 패턴을 지원한다.
로딩 상태는 결국 사용자에게 '지금 일이 일어나고 있다'는 신호를 보내는 것이다. 신호가 명확할수록 불안감이 줄고 앱이 더 빠르게 느껴진다.