0. 왜 속도가 돈인가 (Pre-reading)
본론으로 들어가기 전에, 왜 우리가 로딩 처리에 목숨을 걸어야 하는지 이야기해보려 한다. 아마존(Amazon)의 유명한 연구 결과가 있습니다. "페이지 로딩이 0.1초 느려질 때마다 매출이 1% 감소한다." 반대로 말하면, 로딩 화면만 잘 만들어도 사용자 이탈률을 획기적으로 줄일 수 있다는 뜻입니다. 물리적인 API 속도를 줄이는 건 어렵지만(물리학의 영역), 심리적인 대기 시간을 줄이는 건 디자인으로 충분히 가능합니다.
1. "앱이 왜 이렇게 덜덜거려요?"
초기 버전의 제 앱을 본 친구가 한 말이었습니다. "기능은 되는데... 뭔가 싸구려 같아. 누를 때마다 화면이 번쩍거리고 내용이 팍 튀어나와."
원인은 소위 '깜빡임(Layout Shift)'이었습니다.
데이터를 불러오는 동안은 아무것도 안 보여주다가(return null), 데이터가 도착하면 갑자기 화면을 그렸으니까요.
/* 나쁜 예 */
if (loading) return null; // 로딩 중엔 흰 화면
if (error) return <div>Error!</div>;
return <Content data={data} />;
이 짧은 0.5초의 흰 화면이 사용자에겐 "멈췄나?"라는 불안감을 주고, 갑자기 튀어나오는 콘텐츠는 눈의 피로를 유발했습니다.
1.1. 구글이 싫어하는 CLS (Cumulative Layout Shift)
혹시 기사를 읽다가 갑자기 광고가 툭 튀어나와서 다른 버튼을 잘못 누른 적 있나요?
구글은 이걸 CLS(Cumulative Layout Shift)라고 부르며, UX 점수를 깎아먹는 주범으로 봅니다.
단순히 "보기 싫다"가 아니라 "사용자를 속이는 UI"라고 판단하는 거죠.
초기 로딩 때 높이(height)를 지정하지 않은 이미지가 로딩 후 공간을 밀어내면서 발생합니다.
2. 기다림을 디자인하다 - 스켈레톤(Skeleton) UI
유튜브나 페이스북을 켜보세요. 로딩 중에 뱅글뱅글 도는 스피너 대신, 회색 박스(뼈대)들이 먼저 보이죠? 이게 바로 스켈레톤 UI입니다.
저는 react-loading-skeleton 라이브러리를 도입해서 모든 로딩 화면을 바꿨습니다.
/* 좋은 예 */
if (loading) {
return (
<div className="card">
<Skeleton height={200} /> {/* 이미지 자리 */}
<Skeleton count={3} /> {/* 텍스트 3줄 자리 */}
</div>
);
}
결과는 놀라웠습니다. API 응답 속도는 똑같은데, 사용자들은 "앱이 훨씬 빨라졌다"고 느꼈습니다. 뇌가 "아, 저기에 이미지가 뜨겠구나"라고 미리 인지하기 때문에 기다림이 지루하지 않은 겁니다.
교훈: 성능 최적화는 서버뿐만 아니라 '체감 속도(Perceived Performance)'를 올리는 것도 포함된다.
2.1. 좋은 스켈레톤의 조건
스켈레톤이라고 다 같은 게 아닙니다. "잘 만든" 스켈레톤의 법칙 3가지가 있습니다.
- 높이가 맞아야 한다: 실제 콘텐츠와 높이가 다르면 로딩 후 레이아웃이 덜컹거립니다(CLS).
- 은은한 애니메이션: 펄스(Pulse)나 웨이브(Wave) 애니메이션이 없으면 앱이 멈춘 것처럼 보입니다.
- 너무 오래 보여주지 마라: 스켈레톤이 10초 이상 보이면 사용자 인내심이 바닥납니다. 그땐 차라리 "로딩이 늦어지네요"라는 문구를 띄우세요.
3. "에러 났는데요?" (alert 창의 최후)
더 큰 문제는 에러 처리였습니다. API가 실패하면 저는 귀찮아서 이렇게 처리했습니다.
.catch(err => alert("서버 에러가 발생했습니다."))
사용자 입장에선 최악입니다. 갑자기 경고창이 뜨고, 확인을 누르면 화면이 하얗게 변하거나 아무 반응이 없으니까요. "그래서 뭐 어쩌라고? 새로고침을 해야 하나? 앱을 다시 깔아야 하나?"
저는 Error Boundary와 Graceful Degradation(우아한 성능 저하) 개념을 도입했습니다.
부분 에러 처리
인스타그램 피드 중 사진 하나가 로딩 실패했다고 앱 전체가 꺼지면 안 되죠? 실패한 부분만 "이미지를 불러올 수 없습니다 [재시도]" 버튼을 보여주고, 나머지는 정상적으로 보여줘야 합니다.
/* React Query의 위엄 */
if (isError) {
return (
<div className="error-box">
<p>데이터를 불러오지 못했습니다.</p>
<button onClick={() => refetch()}>다시 시도</button>
</div>
);
}
이렇게 "망한 부분"과 "살아남은 부분"을 격리하자 사용자는 앱이 '고장 났다'고 느끼지 않게 되었습니다.
3.1. 기술적 구현: React Error Boundary
try-catch는 비동기 코드나 이벤트 핸들러의 에러를 잡지 못할 때가 있습니다. (React 렌더링 라이프사이클 밖이라서요.)
그래서 저는 react-error-boundary 라이브러리를 강력 추천합니다.
import { ErrorBoundary } from 'react-error-boundary';
function App() {
return (
<ErrorBoundary
FallbackComponent={ErrorFallback}
onReset={() => {
// 에러 발생 시 상태 초기화 (예: 쿼리 재시도)
resetQueries();
}}
>
<MyComponent />
</ErrorBoundary>
);
}
선언구문(Declarative)으로 에러 처리를 할 수 있어 코드가 깔끔해집니다.
4. 로딩 중 클릭 방지 (Optimistic UI는 신중하게)
"좋아요" 버튼을 눌렀는데 반응이 느려서 여러 번 눌러본 적 있나요?
이걸 막으려고 로딩 중에 버튼을 disabled 처리하곤 합니다.
하지만 더 나은 방법은 낙관적 업데이트(Optimistic UI)입니다. "서버는 성공할 것이다"라고 믿고, UI부터 숫자를 1 올려버리는 거죠.
- 사용자 클릭
- UI: 하트 빨간색으로 변경 (+1)
- 백그라운드: API 요청 전송
- (만약 실패하면?) -> 조용히 롤백하고 토스트 메시지 띄움
이러면 앱이 손가락에 붙어있는 듯한 즉각적인 반응성을 줄 수 있습니다. (물론 결제 같은 중요 기능엔 쓰면 안 됩니다. 중복 결제 되면 큰일 나니까요.)
4.1. 선언형 로딩: React Suspense
React 18부터는 return null이나 if (loading) 같은 방어 코드가 필요 없어졌습니다.
<Suspense>가 알아서 로딩 상태를 감지하고 스켈레톤을 보여주기 때문이죠.
/* Before: 명령형 (Imperative) */
if (loading) return <Skeleton />;
return <Data />;
/* After: 선언형 (Declarative) */
<Suspense fallback={<Skeleton />}>
<DataComponent />
</Suspense>
이것이 모던 프론트엔드의 방향성입니다. "어떻게(How)" 로딩할지가 아니라 "무엇을(What)" 보여줄지에 집중하는 것이죠.
5. 마무리 - 빈 화면은 죄악이다
개발자는 코드를 짜는 시간보다 로딩 바를 쳐다보는 시간이 적습니다. (로컬 서버는 빠르니까요.) 하지만 사용자는 불안정한 와이파이, 느린 LTE 환경에서 우리 앱을 씁니다.
- 흰 화면 절대 금지 (스켈레톤 쓰세요)
- alert() 금지 (예쁜 에러 컴포넌트 보여주세요)
- 재시도 버튼 제공 (사용자에게 통제권 주세요)
이 세 가지만 지켜도 여러분의 앱은 "학생 과제물" 티를 벗고 "프로덕트"처럼 보일 겁니다. 사용자가 기다리는 시간조차도, 우리 서비스의 일부입니다.
5.1. 대기 심리학 (Maister's Law)
데이비드 마이스터(David Maister)의 '서비스 대기 심리학'에 따르면:
- 아무것도 안 하고 기다리는 시간은 무언가 하고 있을 때보다 더 길게 느껴집니다. (그래서 스켈레톤을 보여주는 겁니다.)
- 설명 없는 기다림은 설명 있는 기다림보다 더 깁니다. (그래서 "로딩 중..." 텍스트라도 보여줘야 합니다.)
- 불안한 기다림은 편안한 기다림보다 더 깁니다. (에러가 났는지 안 났는지 모르면 사용자는 불안해합니다.)
🎁 보너스 - "있어 보이는" 앱을 위한 체크리스트
- 폰트 로딩 최적화:
font-display: swap을 써서 글씨가 안 보이는 현상(FOIT)을 막았나요? - 이미지 레이지 로딩: 스크롤 아래 있는 이미지는 나중에 불러오나요? (
loading="lazy") - 버튼 클릭 피드백: 눌렀을 때 색이 변하거나 리플(Ripple) 효과가 있나요?
- 404 페이지 디자인: 길 잃은 사용자에게 홈으로 가는 버튼을 줬나요?
- 토스트 메시지: 성공/실패 여부를 우아하게 알려주고 있나요?
이것들만 챙겨도 당신의 앱은 상위 1%입니다.