FrontEnd

좋은 UX를 위한 보이지 않는 에러 다루기

뎁희 2024. 12. 16. 12:53

들어가며

 

스크럼블 프로젝트에서 백엔드 개발자와 처음 협업을 경험했다. 이전에도 에러 핸들링에 대한 갈망이 있었지만, 사용자가 없어 이에 대해 우선순위를 두지 못했다. 하지만 서비스를 개발하고, 운영하며 사용자가 직접 사용하게 된다면 기능이 동작하는 것은 기본이고, 발생할 수 있는 에러에 대비해 사용자의 이탈을 막아야 한다. 많이들 하는 얘기로 로딩 시간이 3초가 넘어갈 때부터 이탈률이 높아진다고 하는데 이 확률을 뚫고 기다려줬는데 에러가 발생해서 다음으로 진행하지 못한다면? 끔찍하다.

 

마침 스크럼블은 직접 사용하려고 만들게 된 만큼 에러 핸들링에 대해 더 고민해 볼 수 있는 기회가 되었다. 이전에는 에러 발생 지점을 예측하고 피해 다니며 테스트를 했다면, 이번에는 어떻게든 에러를 만나고 싶어서 안달 난 사람처럼 찾아다니며 스터디한 내용을 정리해 본다.


에러 다루기

 

에러를 다룬다는 것은 무엇을 말하는 걸까?

 

사용자가 서비스를 이용하며 발생할 수 있는 에러를 감지하고, 이를 적절히 처리하여 사용자 경험(UX)을 개선하는 것이다. 이는 곧 서비스 안정성을 유지하는 작업을 의미하기도 한다. 즉, 사용자가 에러로 인해 서비스가 중단되거나 예상치 못한 동작을 겪지 않도록 예방한다. 단순히 에러를 감추는 것이 아니라, 사용자에게 명확히 안내하면서도 불필요한 불편을 최소화하는 것이다.

 

내가 에러를 다루고 싶었던 방법은 이 분류를 따르면서도 사용자에게 현재 에러가 발생한 이유와 어떻게 해야 하는지에 대해 더 명확히 안내하는 것이었다. 예를 들어, 똑같은 401 에러가 발생해도 어떤 경우에는 안내 메시지와 함께 로그인 유무를 물어볼 수도 있고, 묻지도 따지지도 않고 로그인 화면으로 넘길 수도 있다. (스크럼블은 현재 후자로 적용되어 있다.)

 

예외와 에러

예외

프로그램 실행 중에 발생하는 처리 가능한 문제이다. 개발자가 미리 예측 가능하여 적절히 처리할 수 있다. 클라이언트에서 할 수 있는 에러 핸들링이 주로 예외로 분류된다.

  1. 사용자와의 상호작용
  2. 네트워크 통신

에러

실행 환경이나 시스템 자체의 문제로 예측할 수 없어 미리 처리 로직을 작성하여 대응할 수 없다. 프로그램이 중단될 수 있다.

  1. 런타임 에러
  2. 브라우저 에러
  3. 네트워크 장애

보통은 이 두 가지를 구분하지 않고 에러로 표현하는데 이 글에서도 에러로 표현하도록 한다.

 

2가지 에러 구분

프로젝트를 진행하면서 느낀 에러 위주로 나누어보았다.

HTTP 통신에서의 에러

프로젝트를 진행하면서 가장 먼저 다루게 되는 문제가 아닐까 싶다. 서비스 하나만 보더라도 요청과 응답 과정이 굉장히 많다. 이는 try ~ catch 문을 쓰거나, 리액트 쿼리를 사용한다면 onError 옵션 등을 활용해 적절히 핸들링할 수 있다. 그러나 HTTP 통신은 네트워크와 서버 상태에 의존하기 때문에 언제나 안정적으로 실행될 것이라 보장할 수 없다. 특히 운영 환경에서는 예외적인 상황(ex. 네트워크 장애)이 발생할 수 있다. 이러한 에러는 많은 테스트와 핸들링이 필요하다.

서비스 에러

네트워크 통신 외에도, 서비스의 기능을 이용하는 과정에서 발생할 수 있는 에러가 있다. 예를 들어, 유효성 검사를 통과하지 못하거나, 참조 문제, 컴포넌트 내에서 발생하는 에러 등이 있다. 개발 환경에서 쉽게 드러나기 때문에 적절히 핸들링할 수 있지만, 사용자와 상호작용하는 기능(사용자는 예상 못한 방법을 시도한다)에서 방어 로직이 충분하지 않을 경우 운영 환경에서 에러가 발생할 수 있다. 이로 인해 사용자 경험이 저하될 가능성이 있다.

  • 폼 필드에서 필수 입력값 누락
  • React 컴포넌트의 렌더링 과정에서 발생하는 에러 (ex. 참조되지 않은 속성 접근)

React와 에러 핸들링

 

HTTP 에러와 같은 비동기 에러를 처리하는 방법컴포넌트 렌더링 과정에서 발생하는 에러를 처리하는 방법으로 나누어 생각해 보자.

 

HTTP 에러는 try ~ catch를 이용해 로직 내에서 직접 처리할 수 있고, 리액트 컴포넌트 렌더링 에러는 ErrorBoundary를 이용해 트리의 특정 영역을 보호할 수 있다. 하지만 이 개념을 아는 것만으로는 에러를 효과적으로 다루기가 어렵다. 에러를 예상하고, 적절히 처리하며, 사용자 경험을 유지하는 방법에 대한 고민이 필요하다.

 

01. try ~ catch

주로 비동기 통신이나 이벤트 핸들러 내에서 에러를 처리하는 데 사용된다. ErrorBoundary는 비동기 통신, HTTP 요청 과정, 이벤트 핸들러에서 발생하는 에러를 처리하지 못하기 때문에 이러한 에러는 try ~ catch로 직접 처리해야 한다.

 

HTTP 에러 핸들링
async function fetchData() {
  try {
    const response = await fetch('/api/data');
    if (!response.ok) {
      throw new Error(`HTTP 에러 발생!`);
    }
    const data = await response.json();
    return data;
  } catch (error) {
    console.error(error.message);
  }
}
이벤트 핸들러에서의 에러 처리
const handleClick = () => {
  try {
    // 에러 발생
  } catch (error) {
    console.error(error.message);
    // 에러 안내
  }
};

 

02. 컴포넌트와 상태로 관리하기

ErrorBoundary를 사용하지 않으면서 컴포넌트 렌더링 중 발생하는 에러는 다음 두 가지 방식을 고려해 볼 수 있다.

  1. 에러를 필터링하는 Wrapper 컴포넌트
    • 렌더링 전에 에러를 필터링하거나 대체 UI를 제공하는 Wrapper를 사용한다.
  2. useState를 이용해 수동으로 관리
    • 에러를 상태로 관리하여, 에러 발생 시 UI를 제어한다.

Wrapper를 이용한 방법은 ErrorBoundary와 비슷한 느낌을 주지만, 필요한 컴포넌트에 대해 더 세밀하게 제어할 수 있다. 특히, 특정 컴포넌트에 렌더링 조건이 필요한 경우 활용도가 높다. 렌더링 전에 데이터를 검증하거나 조건부로 렌더링을 제어하는 데 적합하다.

 

ErrorBoundary는 렌더링 중 발생한 에러를 처리한다는 점에서 상호 보완적이다. 두 방법을 함께 조합하여, Wrapper로 렌더링 조건을 검증한 뒤 ErrorBoundary로 렌더링 과정의 에러를 처리하는 방식으로도 사용할 수 있다.

 

Wrapper 예시

(좌)새로고침 시 에러 / (우)가드 적용

 

스쿼드에 진입한 후 서브 메뉴인 멤버 초대 페이지로 이동한다. 멤버 초대 페이지에서는 스토어에 저장 중인 `squadId`를 사용하고 있다.

 

왼쪽의 경우 이 페이지에서 새로고침 하면 이상이 없어보이지만, 상태가 초기화 되었기때문에 다시 서브 메뉴에 접근하면 현재 스쿼드 id는 `undefined`가 되어 에러가 발생한다. 

 

 

가드를 적용한 오른쪽은 id 유무와 일치 여부를 확인하여 스쿼드 id가 설정되는 상세페이지로 라우팅 되도록 가드 컴포넌트를 적용했다.

<SquadIdGuard>
  <InvitePage />
</SquadIdGuard>

 

03. ErrorBoundary

 

컴포넌트 렌더링, 라이프사이클 메서드, 하위 컴포넌트 트리에서 발생하는 에러를 잡고 처리하기 위한 React 컴포넌트이다. 일부 트리의 에러로 인해 전체 애플리케이션이 중단되지 않도록 보호하고, 사용자에게 적절한 fallback UI를 보여줄 수 있도록 한다.

  • 라이프사이클 메서드에서 발생한 JavaScript 에러를 잡는다.

클래스형 컴포넌트로 구현되어 있으며, 이는 라이프사이클 메서드를 활용해 렌더링 과정에서 발생하는 에러를 세밀하게 제어하기 위한 설계로 보인다. 편리한 사용을 위해 라이브러리를 많이 사용하기도 한다.

 

등장 배경

리액트는 트리 구조로 컴포넌트를 렌더링 하기 때문에, 하나의 컴포넌트에서 에러가 발생하면 전체 애플리케이션이 멈추게 된다. 특정 컴포넌트에서 에러가 발생했을 때, 그 영향을 해당 컴포넌트에 국한시키는 것이 어려웠다. 또, 리액트는 컴포넌트의 라이프사이클에 따라 렌더링이 이루어지는데, 이 과정에서 발생하는 에러를 적절히 처리하는데 한계가 있었다.

 

React16에서 도입된 ErrorBoundary는 에러를 컴포넌트 단위로 가두고, 에러가 발생하지 않은 컴포넌트 트리는 정상적으로 렌더링을 유지할 수 있게 한다. 부분적인 에러 핸들링이 가능해 사용자에게 에러 발생 위치를 명확히 안내할 수 있으므로, 사용자 경험이 좋아진다.


직접구현 vs 라이브러리

디버스에서는 직접 구현하여 사용했고, 스크럼블에서는 라이브러리를 사용했다. 어떻게 구현하느냐에 따라 다를 수 있지만, 2가지 방법에 비교가 될 정도로 큰 차이점은 느끼지 못했다. 사용법도 비슷하기 때문에 실행 흐름이나 fallback UI 활용 위주로 정리해 보도록 한다.

기본 사용 구조

<ErrorBoundary
  FallbackComponent={FallbackComponent}
  onReset={reset}
  onError={(error, errorInfo) => {}}
>
  <Component />
</ErrorBoundary>

하위 트리에서 에러가 발생하면, 트리 구조를 따라 올라가면서 가장 먼저 만나는 ErrorBoundary가 이를 처리한다. 처음 사용 예제로 ErrorBoundary를 최상위 App 컴포넌트에 배치해 "전역 에러 핸들링" 용도로 사용하는 것으로 익혀, 이를 전역적 처리 방식으로만 인식했었다. 하지만 ContextAPI처럼 특정 컴포넌트 트리에만 적용하여 부분적인 에러 처리도 가능하다.

 

Fallback UI

에러 핸들링 지옥에 빠지게 만든 원인이다. 컴포넌트에서 에러가 발생하면 보여줄 UI 화면이라고 보면 된다. 그냥 보면 “에러 났다는 화면 하나 보여주면 되겠다” 싶지만, 사용자에게 보다 세심하게 안내하고자 했으므로 1개만 받는 FallbackComponent를 어떻게 활용해야 하는지 감이 오지 않았다.

 

01. HTTP 상태 코드 관리

HTTP 요청에서 반환된 상태 코드는 사용자에게 적절한 문구를 제공하거나 UI를 제어할 수 있다. 흔히 발생하는 권한 에러(401)는 사용자가 로그인이 필요하다는 안내를 받을 수 있도록 처리할 수 있다.

 

ErrorBoundary는 HTTP 요청 자체에서 발생한 에러는 포착하지 못하지만, HTTP 요청 결과가 렌더링 중 사용되다가 발생한 에러라면 감지할 수 있다. 예를 들어, 리액트 쿼리 useSuspenseQuery로 데이터를 비동기로 조회할 때, 서버에서 401 상태 코드를 반환하면 해당 에러가 렌더링 중 발생하는 것으로 에러 바운더리에 잡힌다. 이 경우, 상태 코드에 따라 다른 UI를 보여주고 싶다면 조건부 렌더링을 활용할 수 있다.

상태별 조건부 렌더링

FallbackComponent는 props로 errorresetErrorrBoundary를 받는다. 이때 에러는 하위 컴포넌트로부터 발생된 에러의 정보를 담고 있다. 만일 HTTP 응답 에러라면 status 값을 가지므로 이를 활용해서 조건부 렌더링이 가능하다.

const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => {
  const statusCode = error.response?.status;
	...
};

02. 불필요한 화면 전환

에러가 발생했을 때, ErrorBoundary의 fallback UI를 렌더링 하면 화면이 전환되었다. 하지만 일부 서비스에서는 에러가 발생하더라도, 기존 화면은 그대로 유지한 채 모달이나 토스트로 안내하는 경우가 있다. 이런 방식을 이용하면 사용자 경험이 더 좋게 느껴진다.

 

특히, 네트워크 장애와 같이 사용자 선에서 처리할 수 없어 서비스 실행이 어려운 경우가 아니라면, 대부분의 예측 가능한 에러에서는 화면을 유지하는 편이 더 나은 사용자 경험을 제공한다. 이에 모달을 이용해 에러를 처리하는 방법을 시도했지만 실패했다. 당시에는 다음 진도 나가기에 바빠 넘겼는데 이번에 다시 스터디하며 원인을 알게 되었다.

 

시도했던 방법

전역 ErrorBoundary fallback UI에서 status 코드에 따라 조건부 렌더링을 하고 있었다. 내가 사용하는 모달은 모달 컴포넌트가 호출된 위치의 JSX에서 렌더링 되는 구조였기 때문에, fallback 컴포넌트의 JSX에 모달 컴포넌트를 렌더링해야 한다. 하지만 이렇게 구현하면 이미 화면이 전환된 상태에서 모달이 표시되는 문제가 있다. 화면이 전환된 후 모달을 띄우는 것은 의미가 없었고, 당시에는 모달의 구조가 문제라고 결론지으며 스트레스를 받았다.

 

해결

이 주제를 스터디하면서 모달의 구조가 아닌, ErrorBoundary를 사용하는 방식이 문제라는 것을 알았다.

  1. ErrorBoundary는 Provider처럼 특정 컴포넌트 트리만 감싸 부분적으로 활용할 수 있다.
  2. React는 에러가 발생한 컴포넌트를 업데이트하지만, 에러가 발생하지 않은 나머지 DOM 트리는 그대로 유지한다.

이 두 가지 원칙을 고려하면, 동일한 컴포넌트 내의 DOM 트리라도 부분적으로 ErrorBoundary로 감싸면 에러가 발생했을 때 해당 영역만 처리되고, 나머지 트리는 영향을 받지 않는다.


onError

에러를 처리하기 위한 콜백 함수를 설정하여 Sentry 같은 모니터링 도구와 연동하여 에러를 기록하는 데 사용된다. ErrorBoundary에 에러가 잡히면 onError가 호출된다.

onError={(error, errorInfo) => {}}

// error: 잡힌 에러 객체
// errorInfo: 추가 정보

에러 초기화는 ErrorBoundary의 props인 resetErrorBoundary와 함께 알아본다.

 

onReset

ErrorBoundary 상태가 초기화될 때(resetErrorBoundary가 호출될 때) 실행되는 콜백 함수이다.

  • 컴포넌트 상태 초기화: 에러 원인이 컴포넌트 내 상태일 경우
  • API 캐시 초기화: React Query와 같은 상태 관리 도구를 사용할 경우
  • 초기화를 위한 로직

resetErrorBoundary

ErrorBoundary에 잡힌 상태를 초기화하고 에러가 발생한 컴포넌트를 다시 렌더링 한다.

  • 주로 사용자의 클릭(예: ‘다시 시도’ 버튼) 등을 통해 실행

 

동작 흐름

  1. 에러 발생: 자식 컴포넌트에서 렌더링 에러 발생
  2. ErrorBoundary에 에러 잡힘: onError가 호출되고, 에러 정보 저장
  3. Fallback UI 렌더링: ErrorBoundary는 내부 상태를 에러 상태로 전환하고 FallbackComponent 렌더링
  4. 사용자가 resetErrorBoundary 호출: onReset 자동 호출
  5. ErrorBoundary 상태 초기화: 에러(error, hasError) 초기화
  6. 자식 컴포넌트 리렌더링 시도: 에러가 해결되었다면 정상적으로 렌더링

 

에러는 어떻게 저장되어 있을까?

static getDerivedStateFromError(error) {
  return { hasError: true, error }; // 에러를 상태에 저장
}

에러바운더리는 클래스형 컴포넌트로 되어 있다고 했다. 에러가 발생하면 getDerivedStateFromError 메서드가 실행되어 에러를 저장하고, resetErrorBoundary를 호출하면 error를 null로 변경한다.


언제, 어떤 방법이 좋을까?

이제 ErrorBoundary를 얼마나, 어디에 적용해야 할지 고민이 많아진다. 그 이유는 다음과 같다.

  1. 모든 에러를 화면 전환으로 보여줄 필요는 없다.
  2. 서비스가 커질수록 에러 처리 경우의 수가 증가한다.
  3. 특정 영역에서 발생하는 에러를 모두 각각의 ErrorBoundary로 처리하면 오히려 관리가 어려워진다.
  4. 재사용성과 유지보수성
  5. 중복된 에러 처리 로직을 줄이고, 적절히 추상화하여 관리의 복잡성을 낮추는 방법이 필요할 것 같다.

어느 정도가 정답인지 명확한 판단이 서지 않지만, 간단히 분류해서 기준을 잡아보았다.

 

01. 사용자에게 안내가 필요한 에러

  • 사용자가 에러를 해결할 수 있도록 한다.
  • 에러를 인지하고, 다른 액션을 할 수 있다면 부분적으로 처리하도록 한다.
  • 전체 서비스 사용에 영향을 줄 수 있는 에러는 전역적으로 처리한다.

02. 로그 및 모니터링용 에러

  • 사용자에게 직접 표시할 필요는 없지만, 모니터링 도구로 기록이 필요한 경우이다.
  • fallback UI는 null을 반환하여 UI에 영향을 주지 않도록 하고, 에러는 로깅 도구에 전달한다.
function CustomErrorBoundary({ children }) {
  return (
    <ErrorBoundary
      fallbackRender={() => null}
      onError={(error, errorInfo) => { 에러 로깅 }
    >
      {children}
    </ErrorBoundary>
  );
}

03. 안내가 필요하지 않은 에러

  • 사용자의 액션 없이도 서비스가 문제없이 동작되는 경우이다.
  • 이런 에러는 방어 로직(ex. 기본값)으로 처리한다.

 

스크럼블에서의 에러 초기화

최상위 App 컴포넌트에서 에러바운더리를 쓰고, 모든 리액트 쿼리 캐시가 초기화되도록 했다.

 

에러바운더리를 전역적으로 쓰고 있기 때문에 하위 트리에서 에러가 나도 새로고침 시 전체 캐시가 초기화 되게 된다. 스크럼블은 에러 발생 시 화면 전환으로 핸들링을 하고 있어 이 부분이 큰 문제가 되지 않았다.

 

하지만 이후 부분 적용으로 개선하게 되면 쿼리 키를 기반으로 특정 캐시만 초기화한다거나, 로컬 상태만 초기화하는 등의 최적화가 필요하겠다.


try ~ catch 더 알아보기

Error 객체 커스텀

컴포넌트 내에서 try ~ catch로 에러를 처리해야 하는 상황도 발생한다. 이러한 경우, catch 블록을 활용해 발생 지점에서 문제를 해결할 수도 있지만, 때로는 의도적으로 에러 객체를 던져야 하는 상황이 있다.

 

나는 보통 assert 유틸 함수를 만들어 활용했다. 기본 제공되는 Error 객체를 던지도록 했는데 커스텀 에러 객체를 만들어 사용하면 더 선언적인 에러 핸들링이 가능할 것 같다는 생각이 들었다.

 

예를 들어, assert 함수가 호출된 위치에서 에러를 구분할 수 있는 코드나 추가 정보를 포함한 커스텀 에러 객체를 사용하면, 에러의 맥락을 전달하고 처리 방식을 더 분명히 할 수 있다. 코드의 가독성을 높이고, 에러 처리 로직을 더 선언적으로 작성할 수 있게 된다.

export class StatusError extends Error {
  response: {
    status: number;
  };

  constructor(status: number, message?: string) {
    super(message);
    this.response = {
      status,
    };
    this.name = 'StatusError';
  }
}

다만, 이러한 로직을 구현해 두고 아직 제대로 활용하지 못하고 있다. 분명 필요하다고 생각했지만, 에러바운더리와 리액트 쿼리를 더 이해하고 나니 필요성이 어느 정도 해결되었다. 이후 상황에 따라 활용하거나, 기존 방식을 알아보고 더 단순하게 문제를 해결하는 방식을 병행해 나가야겠다.

 

에러 전파

에러를 단순히 catch 블록에서 처리하는 것만이 전부는 아니다. 때로는 에러를 상위로 전달하여 더 적합한 위치에서 처리해야 하는 경우도 있다. 즉, try ~ catch문은 에러 핸들링의 기본적인 도구지만, 에러를 어디에서 처리해야 하는지도 중요하다.

상위로 전파하지 않음
async function fetchData() {
  try {
    const response = await fetch('URL');
    if (!response.ok) {
      throw new Error('에러 발생!');
    }
    return await response.json();
  } catch (error) {
    console.warn('에러 경고');
    return 기본값
  }
}

async function test() {
  const data = await fetchData(); // 에러 대신 기본값 반환
  console.log(data);
}

test();
상위로 전파함
async function fetchData() {
  try {
    const response = await fetch('URL');
    if (!response.ok) {
      throw new Error('에러 발생!');
    }
    return await response.json();
  } catch (error) {
    throw error; // 에러를 상위로 전달
  }
}

async function test() {
  try {
    const data = await fetchData(); // 에러 전달
  } catch (error) {
    console.error(error.message); // 호출한 곳에서 에러 처리
    alert('데이터 불러오기 실패!');
  }
}

test();

필요성

에러 전파는 코드를 작성하며 자연스럽게 사용하지만, 그 필요성을 명확히 인지하지 못한 채 활용해 온 경우가 많았다. 이번에 에러 전파에 대해 정리하면서 느낀 가장 큰 필요성은 바로 책임 분리다.

 

현재 함수에서 에러를 처리하는 것이 적절하지 않은 경우, 상위 호출자가 더 적합하게 처리할 수 있도록 위임하는 것이 에러 전파의 핵심이다. 이를 통해 코드는 더 간결해지고, 에러 처리가 맥락에 맞게 이루어질 수 있다.

 

앞서 살펴본 예제에서, 두 가지 접근 방식을 생각해 볼 수 있다.

  1. 에러가 발생한 곳에서 바로 처리
    • 발생한 에러를 즉시 해결하고, 상위 호출자에게는 영향을 주지 않는다.
  2. 비동기 함수를 호출한 상위에서 처리:
    • 에러를 상위로 전달하여 호출자가 제어한다.

지금까지 에러는 항상 catch문 안에서 어떻게든 해결했고, 두 접근 방식 중 어떤 것이 더 좋다고 단정 지을 수는 없다. 중요한 것은 "어디에 에러 처리의 책임을 두고, 사용자에게 안내할 것인가"를 고민하는 것이다. 각 상황에 따라 적합한 에러 처리 전략을 선택하는 것이 중요하다.


마치며

 

에러 핸들링은 단순히 예외를 처리하는 것을 넘어, 어떻게 복구하고 사용자 경험을 유지할 것인가를 고민하는 과정이라 할 수 있다.

 

다음 글에서는 보이지만 잡히지 않는 에러에 대한 트러블 슈팅을 정리해 보며, 이 주제를 마무리하도록 한다.