
당신의 앱이 고장 난 것처럼 보이는 이유: 로딩과 에러 처리의 미학
사용자가 '앱이 느리다', '고장 났다'고 느끼는 진짜 이유는 API 속도가 아니라 불친절한 UI 때문입니다. 덜덜거리며 깜빡이는 화면 대신 스켈레톤 UI를 도입하고, alert() 창 대신 우아한 에러 처리를 구현하여 UX를 개선한 경험을 공유합니다.

사용자가 '앱이 느리다', '고장 났다'고 느끼는 진짜 이유는 API 속도가 아니라 불친절한 UI 때문입니다. 덜덜거리며 깜빡이는 화면 대신 스켈레톤 UI를 도입하고, alert() 창 대신 우아한 에러 처리를 구현하여 UX를 개선한 경험을 공유합니다.
로그인 화면을 만들었는데 키보드가 올라오니 노란 줄무늬 에러가 뜹니다. resizeToAvoidBottomInset부터 스크롤 뷰, 그리고 채팅 앱을 위한 reverse 팁까지, 키보드 대응의 모든 것을 정리해봤습니다.

클래스 이름 짓기 지치셨나요? HTML 안에 CSS를 직접 쓰는 기괴한 방식이 왜 전 세계 프론트엔드 표준이 되었는지 파헤쳐봤습니다.

분명히 클래스를 적었는데 화면은 그대로다? 개발자 도구엔 클래스가 있는데 스타일이 없다? Tailwind 실종 사건 수사 일지.

시각 장애인, 마우스가 고장 난 사용자, 그리고 미래의 나를 위한 배려. `alt` 태그 하나가 만드는 큰 차이.

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