logo

레거시 코드에서 에러 핸들링 시작하기

작성 2023년 5월 19일

업데이트 2023년 5월 19일

일을 할수록 기능을 빠르게 구현하는 것보다는 안정적으로 구현하는 것이 더 중요함을 알게 된다. 물론 비즈니스 관점에서 속도 역시 중요하지만 안정성을 희생할 정도로 속도가 우선시되면 우려스러울 것이다. 특히 런타임 에러는 사용자 불편을 야기하며 비즈니스에도 부정적인 임팩트로 이어질 수 있다. 서비스 이용 중 이해되지 않는 에러 메시지가 뜨거나 흰 화면만 나온다면 사용자는 좌절을 느낄 것이고 이탈 가능성 또한 높아진다. 에러가 절대 발생하지 않는 서비스란 없기 때문에, 안정적인 서비스는 예상 가능한 에러뿐 아니라 예상치 못한 에러에 대해서도 적절히 조치하고 사용자에게 어떤 상황인지 설명함과 동시에 필요한 액션을 안내할 수 있어야 한다.

문제를 인식하고 목표 정의하기

새로 시작하는 서비스라면 처음부터 위 내용을 고려해 안정성을 보장하도록 설계할 수 있겠지만 실제로는 기존 코드베이스에 에러 핸들링에 대한 기반이나 컨벤션이 제대로 갖추어져 있지 않은 경우도 있다. 팀에서 관리 중인 여러 서비스 중 입점 판매자들을 대상으로 하는 파트너 서비스가 그런 케이스였다.

파트너 서비스는 최초 구현 상태로 오랜 기간 개선되지 않은 채 유지됐다. 그러다보니 구현 당시의 히스토리를 알 수 없어 섣불리 무언가를 개선하기보다 불안정한 서비스 경험을 감수하며 개발할 수밖에 없었다. 이러한 패턴이 언젠가는 바뀌어야 한다는 문제의식을 가지고 있던 중 파트너 서비스의 회원 체계를 새롭게 개편하는 장기 프로젝트가 시작되었다. 변화를 시도해 볼 수 있는 기회라고 생각되어 프로젝트에 합류해 가장 시급하다고 생각되는 에러 핸들링 문제를 개선하기로 했다. 때마침 문제의식에 공감하는 동료들이 함께 해주기로 했고, 약 세 달간의 여정이 시작되었다.

1. 에러의 규격화

첫 번째 목표는 에러를 규격화하는 것이었다. 에러 중에서도 특히 예상 가능한 예외 상황을 대상으로, 그것이 발생했을 때 얻을 수 있는 정보들을 담은 객체를 throw함으로써 예외 처리를 공통화하기 위함이다.

예를 들어 자바스크립트에서 기본적으로 제공되는 에러 객체는 message, stack 등의 프로퍼티를 포함하고 있다. 에러가 발생할 수 있는 로직을 try/catch 구문으로 감싼다고 가정하면, 에러 객체 내부에는 message가 항상 존재하기 때문에 이 내용을 리포트하도록 예외 처리 로직 작성이 가능해진다.

function riskyFn(value: number) {
  if (value < 0) throw new Error('value is negative');

  return 'success!';
}

function sampleFn(value: number) {
  try {
    const result = riskyFn(value);
    console.log(result);
  } catch (err: Error) {
    console.log(err.message);
  }
}

sampleFn(3); // success!
sampleFn(-3); // value is negative

그러나 기본 Error 객체에서 포함하고 있는 정보는 제한적이다. 에러를 보다 잘 소비하려면 에러가 발생한 상황에 수집할 수 있는 정보를 최대한 많이 전달받아야 한다. message 프로퍼티를 통해서 어떤 에러 상황인지 설명할 수는 있다. 그러나 에러를 코드별로 분류해서 처리해야 한다거나, 어떤 에러는 무시할 수 있다거나, 에러가 발생한 시각을 알고 싶다거나, 그 외의 여러 가지 방식으로 에러를 소비하고자 하는 요구사항에 대응하기는 어렵다. 이와 같은 요구사항을 수집하여 사전에 규격화된 에러를 throw한다면 에러 처리를 공통화할 수 있고 프로덕션 환경에서 에러가 리포트되었을 때 수정해야 할 부분을 파악하기 조금 더 쉬워질 것이다.

이를 모두 고려해서 규격화된 에러 객체를 정의한다면 다음과 같은 형태가 된다.

interface IException {
  message: string; // 에러 메시지
  stack?: string; // 에러 발생을 추적하기 위한 코드 스택
  code?: string; // 에러 코드
  ignore?: boolean; // 에러 무시 여부
  timestamp?: string; // 에러 발생 시점
}

위에서 생략되었으나 IExecption 인터페이스를 구현하는 Exception 클래스를 정의했다고 가정해 보자. 이제 에러 처리 로직에서 자바스크립트 기본 Error 대신 Exception을 throw하면 보다 많은 정보를 기반으로 에러를 소비할 수 있게 된다. 또 이 로직을 공통화하면 여러 곳의 catch 구문에서 재사용할 수도 있다.

function riskyFn(value: number) {
  if (value < 0)
    throw new Exception({
      message: 'value is negative',
      code: 'NumberException',
      ignore: value < -5,
      timestamp: new Date(),
    });

  return 'success!';
}

function handleError(err: unknown) {
  if (err instanceof Exception) {
    if (err.ignore) return;
    return console.log(`${err.code}: ${err.message} - ${err.timestamp}`);
  }

  if (err instanceof Error) {
    return console.log(err.message);
  }
}

function sampleFn(value: number) {
  try {
    const result = riskyFn(value);
    console.log(result);
  } catch (err: unknown) {
    handleError(err);
  }
}

sampleFn(3); // success!
sampleFn(-3); // NumberException: value is negative - Mon May 22 2023 13:56:54 GMT+0900 (Korean Standard Time)
sampleFn(-8); // exception is ignored

2. API 통신 모듈 추상화

두 번째 목표는 API 통신 과정에서 발생하는 에러를 규격화된 객체 형태로 반환하는 것이다. 또한 기존 모듈에서는 HTTP 상태 코드에 따라 비즈니스 로직을 고정적으로 수행하게끔 되어 있었다. 이 구조의 문제는 비즈니스 로직이 변경될 때 유연하게 대응하지 못하다는 점이다. 따라서 모듈의 역할은 비즈니스 요구사항을 각각의 유즈케이스마다 옵셔널하게 수행할 수 있게끔 인터페이스만 제공하는 형태로 추상화해야 했다.

ApiException 정의

먼저 앞서 정의된 Exception을 API 통신의 특성에 맞게 ApiException으로 확장해 보자. API 통신이 실패하는 이유에는 여러가지가 있을 수 있다. HTTP 상태 코드를 통해 어느 정도 그 이유를 짐작할 수 있지만 그 외의 데이터까지 종합적으로 고려해야 한다. 요청과 응답 중 어느 단계에서의 실패인지, 통신은 성공했으나 서버 로직상 실패로 응답한 것인지 등 상태 코드만으로 설명되지 않는 내용들을 함께 리포트받으면 문제를 보다 빠르게 해결할 수 있다. 요구사항에 따라 익셉션 인터페이스에 몇 가지 추가 정보를 포함하면 다음과 같다. (타입은 각 서비스에서 필요한 형태로 정의되었다고 전제한다.)

interface IApiException extends IException {
  status?: number; // HTTP 상태 코드
  request?: Request; // API 요청 데이터
  response?: Response; // API 응답 데이터
}

미들웨어 정의

이제 API 응답 전 에러 여부를 판단하고 에러일 경우 필요한 정보를 채워서 throw하는 중간 처리 모듈을 정의해 보자. axios처럼 대중적인 라이브러리에서는 interceptor라는 개념으로 이 인터페이스를 제공하고 있다. 사용하는 라이브러리 혹은 API 통신 스펙에 따라 세부적인 구현은 달라지겠지만, 수도코딩 해보면 다음과 같은 형태가 된다.

function middleware(request: Request, response: Response) {
  if (response.status >= 400) {
    const apiException = new ApiException({
      message: response.message,
      code: response.code,
      status: response.status,
      timestamp: new Date(),
      ignore: false,
      request,
      response,
    });

    return Promise.reject(apiException);
  }

  return Promise.resolve(response);
}

api.onBeforeResponse = middleware;

수도코드 동작에 따르면 실제 HTTP 상태 코드가 200이더라도 API 스펙에 따라 response 내부의 상태 코드를 참조하여 에러를 반환하게 된다.

콜백 인터페이스 정의

여기서 추가로 기존 코드의 결함인 비즈니스 로직과의 의존성을 해결하려고 한다. 만약 서버에서 내려준 response 내부의 상태 코드가 401이라면 로그인 페이지로 이동하는 요구사항이 있다고 가정해 보자. 미들웨어에서 이를 곧이곧대로 처리해 버린다면 어떻게 될까? ApiException을 반환하기 전에 조건문을 추가하여 페이지를 이동시키는 형태가 된다. 이렇게 되면 당장은 요구사항을 만족할 수 있겠지만, 이 모듈을 여러 서비스에서 공유하고 있다면 문제가 생긴다. 그 중 한 서비스에서는 401 케이스에 로그인 페이지로 이동하지 않고 토큰을 갱신한 후에 재요청하는 것이 요구사항이라면 어떨까? 기존처럼 비즈니스 로직이 내부에 묶여 있는 형태로는 이와 같은 요구사항에 유연하게 대응할 수 없기 때문에 콜백 인터페이스를 정의하여 개선해 보자.

function middleware(
  onBeforeResponse?: (
    request: Request,
    response: Response,
  ) => Promise<ApiException> | ApiException | void,
) {
  return async (request: Request, response: Response) => {
    const callback = await onBeforeResponse?.(request, response);
    const finalResponse = callback?.response ?? response;

    if (finalResponse.status >= 400) {
      const apiException = new ApiException({
        message: finalResponse.message,
        code: finalResponse.code,
        status: finalResponse.status,
        timestamp: new Date(),
        ignore: finalResponse.ignore,
        request,
        response: finalResponse,
      });

      return Promise.reject(apiException);
    }

    return Promise.resolve(finalResponse);
  };
}

// A 서비스
api.onBeforeResponse = middleware((request: Request, response: Response) => {
  if (response.status === 401) {
    window.location.href = '/login';
  }
});

// B 서비스
api.onBeforeResponse = middleware(async (request: Request, response: Response) => {
  if (response.status === 401) {
    const retryResponse = await refreshTokenAndRetry(request);

    return { response: retryResponse };
  }
});

3. 공통 에러 바운더리 구현

이전까지는 예상 가능한 에러에 대한 예외 처리였다면, 세 번째 목표는 예상치 못한 에러에 대한 처리이다. 특히 예상치 못한 에러로 인해 화면이 깨지는 등 사용성에 치명적인 현상을 방지하고자 했다. 파트너 서비스를 비롯해 팀에서 관리중인 모든 서비스가 리액트로 구현되었기 때문에 공통 에러 바운더리 컴포넌트를 구현하여 간단하게 fallback UI를 표시할 수 있었다.

다만 에러 바운더리가 API 에러도 캐치하도록 하는 데는 어려움이 있었다. react-query를 적극 도입한 상황에서 QueryClientProvider 레벨에 useErrorBoudnary 옵션을 설정했을 때 사용성이 좋지 않았다. API 통신과 관련된 UI는 화면 곳곳에 랜덤하게 분포되어 있다 보니 에러 바운더리를 루트에 씌우면 화면 전체에 너무 자주 fallback UI가 표시되면서 사용성에 블로킹이 발생했다. 이를 유의미한 단위로 쪼개자니 컴포넌트 위계가 중구난방이었다. 정해진 룰이 없으니 각 개발자의 스타일대로 구현되었기 때문이다. 이를 개선하려면 기획과 디자인 담당자가 함께 참여해서 정책을 수립하는 것이 먼저라고 판단되어 useErrorBoudnary 옵션은 설정하지 않았고, API 에러에 대한 fallback UI는 토스트로 임시 조치했다.

4. 프로덕션 환경의 에러 모니터링

마지막 목표는 실제 서비스가 운영되는 환경에서 에러를 모니터링하는 것이다. 에러 모니터링 기능을 제공하는 여러 서비스 중 팀에서 도입한 서비스는 sentry다. sentry는 코드로 정의한 에러 규격 외에도 사용자의 디바이스 및 네트워크 사양, 브라우징 히스토리 등 에러 발생시 프로덕션 환경에서 수집할 수 있는 다양한 컨텍스트를 제공한다. 또한 에러 리포트의 임계치를 자유롭게 조절하고 통계를 확인할 수 있어 어떤 에러를 우선순위 높여 해결하고 안정적으로 서비스를 운영해 나갈지 의사결정하는 데 많은 도움을 받을 수도 있다. 슬랙 채널과도 손쉽게 연동할 수 있어 평상시에는 슬랙으로 리포트 받다가 자세히 살펴보아야 할 때 대시보드로 이동하면 효율적으로 에러를 모니터링할 수 있다.

sentry 사용법에 대한 자세한 내용은 공식 문서를 통해 참고 가능하다.

영향 범위 정하기

일반적인 웹 서비스를 기준으로 에러의 가장 많은 비중을 차지하는 것은 바로 API 에러일 것이다. 따라서 API 에러 처리가 개선되면 거의 모든 화면이 영향 범위에 포함된다. 서비스 내의 모든 API 통신 구간을 조사하고 테스트를 수행하는 것이 간단한 일은 아니기 때문에, 개선 사항은 신규 추가되는 코드에만 점진적으로 적용하는 방향을 선택할 수도 있다.

그러나 테스트 비용을 감수하고 기존 코드를 포함해 전면적으로 개선하기로 했는데 여기에는 두 가지 장점이 있다. 첫째, 기존의 API 통신 모듈이 지니고 있는 근본적인 결함을 해결함으로써 프로젝트의 성과를 높일 수 있고 둘째, 많은 테스트 케이스를 확보해 개선된 로직의 결함을 미리 보완할 수 있다.

그럼에도 매뉴얼 테스트가 번거로워 섣부르게 코드를 수정하기 어려운 점은 개선되어야 할 것이다. 추후에는 API 테스트를 자동화하는 것도 필요하겠다고 느꼈다.

성과 측정하기

프로젝트 이후 성과를 측정하기 위해 두 단계로 배포가 이루어졌다. 작업 순서로는 sentry 연동이 가장 마지막이었으나 이 부분만 선배포하여 한 달 가량 운영해 보고, 나머지 개선 사항이 적용된 버전으로 최종 배포하였다. 덕분에 개선 사항이 적용되기 전의 에러 리포트를 먼저 받아볼 수 있었다.

최종 배포 전후의 일주일간 에러 리포트를 비교했을 때, Exception을 제외하면 에러 리포트가 26개에서 23개로 줄었다. 영향 범위에 레거시 코드가 포함되는 것을 고려했을 때 이 수가 조금이라도 줄어든 것은 긍정적인 변화라고 할 수 있다. 에러의 종류를 세부적으로 살펴보면 신규 코드에서 발생한 에러는 없고 레거시 코드의 에러가 일부 해결되었다. 또한 모니터링으로 에러를 인지하고 개선할 수 있게 된 점도 좋았다. 최종 배포 한 달 뒤의 일주일간 리포트를 보면 Exception을 제외한 에러 리포트는 12개로 줄었다.

운영상의 효율도 증가했다. 이전에는 파트너 서비스에서 에러가 발생한 경우 화면에 아무런 안내가 되지 않았기 때문에 사용자가 에러를 제보하더라도 해결의 실마리가 적었다. 프로젝트 이후에는 sentry로 모니터링이 가능해졌을뿐 아니라 화면에 토스트 UI로 에러 메시지가 표시되기 때문에 사용자 레벨에서 1차적으로 문제를 인지하고 해결하려는 시도가 가능해졌고, 제보를 통해서 문제의 원인을 파악하는 것도 쉬워졌다.

남은 과제

완벽하지 않더라도 첫 발걸음을 내딛으면 좋은 점은 다음에 무엇을 해야 할지 명확해진다는 점이다. 남은 과제를 정리해 보았는데, 주로 정책과 장기적인 방향성이 함께 고민되어야 하는 것들이다 보니 에러 핸들링을 잘 하기 위한 노력은 계속 필요하겠구나 싶다.

1. Concurrent UI

리액트에서 지원되는 에러 바운더리와 서스펜스를 함께 조합하면 API 상태별 선언적인 UI 렌더링이 가능하다. 서스펜스는 원래 코드 스플리팅 기법에서 레이지 로딩으로 컴포넌트를 불러올 때 fallback UI를 렌더링하기 위한 기능이었으나 실험적으로 data fetching에도 적용 가능하다. 리액트 18 버전 기준으로 정식 지원되기 시작했으나 서스펜스에서 감지할 수 있는 형태로 처리해주는 라이브러리 기능을 사용해야만 한다.

react-query를 도입한 경우 suspense 옵션을 통해 이 기능을 사용할 수 있지만 useErrorBoundary같은 이슈가 있었다. 선언적으로 UI를 렌더링하는 것은 코드의 가독성과 유지보수성을 크게 개선하고 리액트의 기본 컨셉과도 부합하지만, 로딩 및 에러 처리의 UI 단위를 구분하려면 개발자의 관점으로만 의사결정할 수 없기에 보다 장기적인 관점으로 협업을 통해 개선해 나가야 할 과제이다.

2. 라이브러리 의존성 최소화

가장 큰 비중을 차지하는 API 에러 처리에서 라이브러리 의존성을 최소화해야 한다. 특히 react-query에서 제공하는 onError 콜백 사용에 대한 컨벤션이 정의되어야 한다. 이 콜백이 다소 혼란스러운 부분은 throw 단계와 catch 단계 사이에 독자적으로 동작한다는 것이다. useErrorBoundary 옵션을 설정하면 에러 바운더리와도 연결되는데 일반적인 에러 캐치 과정에 추가적인 레이어가 덧붙여지는 흐름이 직관적이지 않고 복잡도를 높인다. 게다가 QueryClientProvider 레벨에서 onError의 디폴트값을 정의할 수 있는데 이로 인해 레이어가 최대 4개로 늘어날 수 있다.

미래에 만약 react-query를 제거하거나 다른 라이브러리를 도입한다는 의사결정이 이루어진다면 문제가 더 커진다. react-query만을 위해 작성된 onError 로직을 이관하려면 또 많은 변경 사항이 가해지고 테스트 과정이 필요할 것이기 때문에 지금의 코드가 변경에 유연하지 않은 상황임을 알 수 있다. 결국 onError 사용을 지양하고 자바스크립트 기본 문법인 try/catch를 권장하는 등 보다 변경에 유연한 컨벤션이 필요한 상황이다.

3. 에러 메시지 관리

API 에러가 발생하면 화면에 토스트가 뜨는데, 이때 표시되는 메시지에 대한 관리 정책이 따로 없다. 게다가 사용자에게 노출되는 메시지이기 때문에 UX writing이 관여되는 부분이기도 하다. 현재는 서버에서 응답한 에러 메시지를 그대로 표시하는데 가끔씩 서버 개발자만 알아볼 수 있는 내용이 표시되어 제보가 들어오기도 한다.

서버 드리븐 UI 정책이라면 발생 가능한 에러 코드를 전수조사하여 정제된 메시지를 서버에 전달할 필요가 있을 것이고, 반대의 정책이라면 프론트엔드에서 에러 코드와 에러 메시지를 맵핑하는 과정이 필요할 것이다. 만약 에러 메시지가 자주 변하지 않는다면 서버 드리븐을 선택해야 할 동기가 충분하지 않을 수도 있다. 그러나 에러 메시지가 하드코딩되는 것 역시 이상적인 방향은 아닐 것이다. 에러 메시지처럼 비교적 정적이면서도 하드코딩하기 애매한 데이터를 관리하기 위해 CMS 툴을 도입하는 것도 검토해 볼만하다고 생각된다.