에러도 UX다: 프론트엔드 3단계 에러 방어 설계법

에러도 UX다: 프론트엔드 3단계 에러 방어 설계법

컴포넌트 → 전역 스토어 → ErrorBoundary, 에러가 '사고'가 아니라 '시나리오'가 되는 아키텍처를 설계하는 법

에러 핸들링 ErrorBoundary Axios Interceptor React-Query 프론트엔드 아키텍처 UX 설계 TypeScript 커스텀 에러 클래스
광고

"비밀번호가 틀렸습니다"라는 메시지가 화면 한가운데 모달로 뜬다고 상상해 보세요. 사용자 입장에서는 로그인 폼 바로 아래에 빨간 텍스트 한 줄이면 충분한 상황인데, 앱 전체를 가리는 모달이 올라옵니다. 닫기 버튼을 누르고, 다시 입력 필드에 포커스를 맞추고, 이전에 뭘 잘못 쳤는지 기억을 더듬어야 합니다. 사실 이건 에러 처리가 '작동'은 하지만 UX가 '파괴'되는 전형적인 패턴입니다. 최근 Velog에 올라온 한 팀 프로젝트의 전역 에러 핸들링 리팩토링 사례가 정확히 이 문제를 다루고 있어서, 프론트엔드 에러 설계의 관점에서 깊이 뜯어보겠습니다.

1단계 실패: '중앙 집권식' 에러의 함정

원글의 저자가 처음 설계한 구조는 심플했습니다. Axios Interceptor가 HTTP 에러를 잡고, React-Query의 MutationCache.onError가 모든 에러를 Zustand 스토어에 저장하면, ErrorCatcher 컴포넌트가 이를 구독해서 모달이나 토스트를 자동으로 띄우는 방식이죠. 컴포넌트에서는 mutate() 한 줄이면 끝—개발자 경험(DX)만 놓고 보면 꽤 매력적인 구조입니다.

그런데 문제는 모든 에러가 같은 무게로 취급된다는 것이었습니다. 로그인 폼에서 400 에러가 발생했을 때, 그 에러가 전역 스토어를 타고 올라가 화면 중앙에 모달을 띄워버린 겁니다. 사용자 입장에서는 "비밀번호 틀림"이라는 지극히 로컬한 피드백이 앱 전체를 블로킹하는 모달로 변환된 셈이죠. Figma에서 에러 상태 UI를 설계할 때도 이 구분을 놓치면 동일한 문제가 생깁니다—인라인 에러 메시지와 글로벌 에러 모달은 완전히 다른 디자인 토큰, 다른 z-index 레이어, 다른 인터랙션 패턴이니까요.

리팩토링의 핵심: isHandled 플래그와 3단계 방어벽

저자가 도달한 리팩토링의 핵심은 RequestError라는 커스텀 에러 클래스에 mode(toast | modal | none)와 isHandled 플래그를 심은 것입니다. 이걸 TypeScript interface로 타입을 잡아두면 에러 객체의 구조가 컴파일 타임에 보장되니까, 팀원 누군가가 mode 필드를 빼먹거나 잘못된 문자열을 넣는 실수를 사전에 막을 수 있습니다. TypeScript interface의 '계약서' 역할이 빛을 발하는 지점이 바로 여기입니다.

완성된 3단계 구조를 정리하면 이렇습니다:

  1. 컴포넌트 레벨 (지역 처리)onError 콜백에서 return true를 하면 에러가 전역으로 전파되지 않습니다. 로그인 폼의 400 에러는 여기서 잡혀서 인라인 메시지로 처리됩니다.
  2. 전역 스토어 레벨 (공통 처리) — 지역에서 처리되지 않은 에러(isHandledLocally === false)만 Zustand 스토어에 저장되고, ErrorCatcher가 모달이나 토스트를 띄웁니다.
  3. ErrorBoundary 레벨 (최종 방어)undefined.map() 같은 예측 불가능한 런타임 에러를 잡습니다. 이건 Axios가 알 수 없는 영역이니까요.

사실 이 패턴에서 가장 눈여겨볼 부분은 클로저의 실전 활용입니다. useAxios 훅 내부의 sendRequest 함수가 options.onError 콜백의 반환값을 확인하고, 그 결과에 따라 전역 스토어 저장 여부를 분기하는 로직—이게 결국 외부 렉시컬 환경에 데이터를 보관하고 내부 함수에서 접근하는 클로저 패턴의 응용입니다. 디바운스에서 timeoutId를 클로저로 유지하는 것처럼, 여기서는 isHandledLocally라는 플래그를 catch 블록의 스코프 안에서 관리하고 있는 거죠.

사용자 경험에서 시사하는 것

React의 상태 기반 UI 렌더링 구조에서 "UI는 항상 데이터의 결과물"이라는 원칙을 생각하면, 에러 상태도 결국 하나의 state입니다. 문제는 그 state가 어디에 위치하느냐에 따라 사용자가 보는 화면이 완전히 달라진다는 것입니다. useState로 컴포넌트에 두면 인라인 메시지, 전역 스토어에 두면 모달, ErrorBoundary에 걸리면 폴백 UI—같은 에러인데 렌더링 결과가 세 가지입니다.

프론트엔드 개발자로서 솔직히 말하면, 저는 이 구조에서 한 가지가 더 아쉽습니다. 로딩 → 에러 → 복구 사이의 트랜지션이요. 에러 모달이 뜨고 닫힌 후에 사용자의 포커스가 어디로 돌아가는지, 스크린리더가 에러 메시지를 제대로 읽어주는지, aria-live="assertive" 영역에 토스트가 올바르게 주입되는지—이런 a11y 디테일까지 3단계 방어 구조에 녹여야 진짜 '에러 UX 설계'가 완성됩니다.

전망: 에러 설계는 '아키텍처' 문제다

원글 저자의 회고에서 가장 인상 깊었던 문장은 이겁니다—"내가 생각한 대로 쭉 가는 건 설계가 아니었다. 그저 구상이었을 뿐." 에러 핸들링은 해피 패스(happy path) 이후에 끼워 넣는 방어 코드가 아니라, 컴포넌트 설계 단계에서부터 에러 상태의 소유권을 결정하는 아키텍처적 판단입니다.

앞으로 React Server Components와 Suspense가 본격 확산되면, 서버 단에서 발생하는 에러와 클라이언트 단의 에러 경계가 더 복잡하게 얽힐 겁니다. 지금 이 3단계 방어 구조를 체화해 두면, 그때 가서 "이 에러는 서버 컴포넌트의 error.tsx에서 잡을지, 클라이언트 ErrorBoundary에서 잡을지"를 결정하는 감각이 훨씬 빨라집니다. 결국 에러를 '버그'가 아니라 '시나리오'로 바라보는 관점의 전환—그게 이 리팩토링 사례가 던지는 진짜 메시지입니다.

출처

더 많은 AI 트렌드를 Seedora 앱에서 확인하세요