에러 핸들링 개선하기

Juno·2022년 9월 4일
1
post-custom-banner

✨ 들어가기

안녕하세요 🙌 이번 포스팅은 에러 핸들링과 관련된 글을 작성하고자 합니다.
어떻게 제목을 잘 지을까 고민하고 있었던 터라, 사실 제목에 비해 거창한 글은 아니에요😂

하지만, 해당 주제를 고도화 시키기 위한 태스크로 백로그로 쌓아두고 해결한 뒤 좀 더 확장한 글도 작성해볼 예정이니 재밌게 읽어주셨으면 좋겠습니다.

🤔 무슨 문제가 있었냐면요,

사내 서버는 MSA로 만들어져 있고, 서비스를 확장되는 과정에서 많은 레포들이 더 생겨나고 있었어요.
이 때문에 모든 서버에서 내려주는 에러형식이 모두 통일되지는 못했습니다.

유저앱 뿐만 아니라 샐러앱도 결국 고객들에게 보여줄 메시지기 때문에 사용자 친화적인 에러메시지를 내려주어야 했고,
에러 상황에 대한 분기를 클라이언트에서 해줄수도 있지만, 실수가 일어날 수도 있고 비교적 수정이 용이한 서버쪽으로 관리주체를 두어 서버가 에러메시지를 내려주는 것으로 규칙을 정해두고 있어요.

하지만 명확한 규칙이 정해지기 이전의 코드는 에러 상황에 대한 내용을 내려주는 api도 있고(ex: 'not_logged_in') Error 객체의 message 격으로 쓰이는 code 라는 프로퍼티도 존재하는 등 클라이언트에서 콘솔을 찍어보고 각 경우에 대한 대응을 컴포넌트 끝단에서 해주고 있었죠.

생각하기

기존의 코드는 다음과 비슷한 형태를 띄고 있었어요.

import axios, { AxiosRequestConfig, RequestOptions } from 'axios';

const axiosRequest = async (config: AxiosRequestConfig, options: RequestOptions, url: string) => {
	try {
		const response = await axios.request({
			url,
			...config,
		})
		if(response.includes('/graphql') {
			// graphql의 경우 apollo-server는 status code가 200으로 성공하기 때문에 여기서 에러를 직접 던집니다. (1)
			throw response.data.errors[0];
		}
	} catch (error) {
		if(error.code === 'not_logged_in'){
			// 에러 후처리 로직 (ex: 로그인 하지 않았을 경우 리다이렉트 로직) (2)
		}		
		throw error;
	}
}

간단히 코드를 설명하자면, graphql을 사용하고 있었는데 이를 구축한 기반이 apollo-server 였고, 이는 에러상황에서도 status_code200을 내려주고 있어 이에 대한 에러처리를 해주어야 했습니다. - (1)

그리고 로그인이 되지 않았을 경우 페이지를 리다이렉션 시켜주는 것과 같이 후처리 로직이 필요했습니다. - (2)

여기서 문제는 서버에서 내려주는 에러 형태가 통일되지 않았기 때문에 (2) 로직을 타지 못하는 경우가 생겼고, 일부 rest 형식의 api도 남아있었기 때문에 (1)을 타지 않아 에러 형식이 맞춰지지 않는 문제도 있었죠.

이렇게 해결했어요

우선, 서버에서 내려주는 에러 케이스들을 나열해서 어떤식으로 통합시킬 수 있을지 고민해보았어요. javascript의 Error객체 자체가 name과 message 그리고 stacktrace로 이뤄져 있기에 각각의 역할을 하는 프로퍼티는 남기고 비슷한 역할을 하는 다른 이름의 프로퍼티를 message에 할당해주도록 했습니다.

descriptionmessage에 할당해 주었고, code와 비슷한 격으로 쓰이던 errorcode에 할당해 주었습니다. 이렇게 에러객체가 어떠한 모양으로 내려오는지 handleError라는 함수를 만들어서 Error 객체를 가공해주는 역할을 주었습니다.

const handleError = (error: RequestError) => {
  const { message, extensions, description } = error;
  const error_code = extensions?.code ?? error.error ?? 'unexpected error';
  const error_message = message ?? description ?? extensions?.description ?? default_message;

  const _error = Object.assign(new Error(error_message), {
    code: error_code,
  });
  return _error;
};

이렇게 가공해주는 로직이 간단하게 존재했으면 좋겠지만,
graphQL로 작성된 api와 rest 방식으로 작성된 api가 혼재했기 때문에 각 타입에 따라 우리가 원하는 정보가 다른 형태로 들어있다는 문제점도 있었죠.

import axios, { AxiosRequestConfig, RequestOptions } from 'axios';

const axiosRequest = async (config: AxiosRequestConfig, options: RequestOptions, url: string) => {
	try {
		const response = await axios.request({
			url,
			...config,
		})
		if(response.includes('/graphql') {
			throw response.data.errors[0];
		}
	} catch (error) {
      	let _error = handleError(error.response.data.errors?.[0] ?? error.response.data, error.message);
      else if(error.request){
      	// 네트워크 에러
        _error = error;
      }
      	
		if(_error.code === 'not_logged_in'){
			// 에러 후처리 로직
		}		
		throw error;
	}
}

이에 대한 의도는 명확했지만, 좀 더 이를 구분하고자 catch문을 하나 더 작성하는 식으로 각각의 블록이 어떤 역할을 하는지를 명확히 드러내고 분리하고자 했습니다.

따라서 최종 형태는 다음과 비슷한 형태를 띄고 있습니다.

import axios, { AxiosRequestConfig, RequestOptions } from 'axios';

const axiosRequest = async (config: AxiosRequestConfig, options: RequestOptions, url: string) => {
	try {
		const response = await axios.request({
			url,
			...config,
		})
		if(response.includes('/graphql') {
			throw response.data.errors[0];
		}
	} catch(error) {
      // (1) 에러 전처리(가공) 로직이 들어가는 블럭
       if (error.response?.data) {
        throw handleError(error.response.data.errors?.[0] ?? error.response.data, error.message);
      } else if (error.request) {
       	// 네트워크 에러에 대한 처리
        throw error;
      }
      throw error;
    } catch(error) {
      	// (2) 에러 후처리 로직이 들어가는 블록
		if(error.code === 'not_logged_in'){
			// 에러 후처리 로직
		}		
		throw error;
    }

}

에러 객체를 확장하기

javascript에는 Error 라는 객체가 기본적으로 정의되고 있어요.
name, message 그리고 stacktrace를 포함하고 있는 형태입니다.
해당 형태를 확장하고 있음을 알려주기 위해 new Error(); 형태로 에러 객체를 생성한 뒤 이를 가공하도록 했습니다.

에러임을 명확히 드러내고, 어디에서 error가 발생했는지 드러내며(stacktrace) 에러를 잡아내는 센트리에서도 에러객체를 포함해야(error instaceof Error 가 true인 경우) 정상적으로 에러를 잡아냈기 때문에 에러객체를 확장하는 형태를 사용했습니다.

sentry에선 Error 타입의 에러가 아니어도 잡아내긴 하지만, 어디서 발생했는지와 같은 stacktrace를 담고 있지 않아 다음과 같이 적재됩니다.
'Non-Error exception captured with keys: message, code'

실제로 처리한건 typescriptinterface를 활용하여 타입적으로만 어떠한 형태를 띄는지 처리하여 마무리 했습니다.

interface CustomError extends Error {
	code: string;
}

const handleError: CustomError = (error: RequestError) => {
	// ...
};

더 나아가기

사실 좀 더 개선할 여지가 남아있는 부분들이 더 있다고 생각해요.
CustomError 자체를 Error 클래스를 확장한 클래스 형태로 만들고 싶었거든요. graphql Error의 경우와 network Error 등 여러 형태에 따라 만들고 또 각각 어떤 프로퍼티를 가지는지도 명확하게 드러낼 수 있어서 프론트엔드에선 드물지만 클래스를 사용하면 좀 더 괜찮겠다고 생각했기 때문이에요.

다만, 아직 이에 대한 지식이 부족하고 관련된 레퍼런스를 찾아본 다음 더 적절한 방법이 무엇인지에 대해 고민은 조금 미뤄두고, 당장 급한 서버에서 내려주는 에러의 전처리 로직과 후처리 로직을 모두 타서 컴포넌트 단에서 에러의 형태를 알고 있을 필요가 없도록 바꾸는 걸 우선으로 작업해 두었어요.

좀 더 고민하고 최적의 형태가 무엇인지에 대한 연구가 충분히 이루어진 다음, 더 고도화 된 형태의 처리를 또 공유해보려고 해요.

감사합니다!

profile
사실은 내가 보려고 기록한 것 😆
post-custom-banner

0개의 댓글