약식 에러 핸들링(2) - 자재

정세연·2023년 10월 9일
4

약식 에러 핸들링

목록 보기
2/2
post-thumbnail

경험적으로 에러를 하나의 함수, 즉 하나의 ErrorBoundary에서 처리하긴 힘들 것 같다는 느낌이 들었습니다.
따라서 대략적으로 구상했던 핸들링 흐름은 다음과 같습니다.

에러 발생

특정 ErrorBoundary에서 catch

특정 에러인지 검사

조건에 부합하지 않는다면 throw

특정 ErrorBoundary에서 catch

특정 에러인지 검사

조건에 부합하지 않는다면 throw

… 반복

조건에 부합하는 경우 적절하게 대처

다음은 위 흐름을 적용하기에 앞서 필요할 것이라고 예상되는 것들입니다.

  1. 커스텀 에러
  2. ErrorBoundary

이번 글에서는 구상한 핸들링 흐름을 구현하기 위해 필요한 자재들을 마련하는 과정과 고민을 얘기하고자 합니다.

커스텀 에러


수복이 가능 여부에 따라 에러를 분류하고 더 많은 정보를 담기 위해 서비스 전반에서 사용할 커스텀 에러를 구상했습니다.

  1. 런타임에 직접 발생시킨 에러
  2. 통신이 성공한 API 에러

그리고 이 외의 모든 에러는 예측이 힘든 에러라고 판단하여

  1. 예상할 수 없는 에러

이렇게 3가지로 분류했습니다.

코드로 살펴보면 다음과 같습니다

ZipgoError

type ErrorInfo<T extends ErrorCode> = {
  code: T;
}

class ZipgoError<Code extends ErrorCode = 'UNEXPECTED_ERROR'> extends Error {
  cause: ZipgoErrorOptions<Code>['cause'];

	ignore: boolean;

  constructor(info: ErrorInfo<Code>, value?: unknown) {
    const [message, options] = createErrorParams(info, value);

    super(message, options);

    this.name = info.code;

    this.message = message;

    this.cause = options.cause;

	this.ignore = false;
  }
}

커스텀 에러들의 베이스가 되는 에러입니다.

기본적으로 자바스크립트 내장 객체 Error의 인터페이스를 따르는 것이 사용하는 측면에서도 편리할 것이라 생각했습니다.

에러 코드를 통해 에러의 정보를 파악하고 name , message 와 같은 정보들을 저장합니다.

어떤 데이터가 오류 위험이 있는지 명확히 예측 가능한 경우 value 속성을 통해 원인이 되는 데이터도 저장합니다.

ignore 속성은 ErrorBoundary와 함께 설명하겠습니다.

RuntimeError

class RuntimeError<Code extends RuntimeErrorCode> extends ZipgoError<Code> {
  constructor(info: ErrorInfo<Code>, value?: unknown) {
    super(info, value);

    this.ignore = true;
  }
}

ignore 속성이 true인 점을 제외하면 ZipgoError 와 같습니다.

UnexpectedError

class UnexpectedError extends ZipgoError<'UNEXPECTED_ERROR'> {
  constructor(value?: unknown) {
    super({ code: 'UNEXPECTED_ERROR' }, value);

    this.ignore = true;
  }
}

에러 코드가 UNEXPECTED_ERROR인 에러입니다.

ignore 속성이 true인 점, 에러 코드가 고정된 점을 제외하면 ZipgoError와 같습니다.

예상할 수 없는 에러이기에 대부분의 상황에선 런타임에 변환된 결과일 것으로 예상되었습니다.

다만, switch... case 와 같은 특정 패턴 매칭 상황에선 개발자가 직접 발생시킬 수도 있을 것 같아 디버깅에 편리하지 않을까? 하고 value 속성을 남겨두었습니다 😂

APIError

class APIError<T = unknown, D = unknown> extends ZipgoError<APIErrorCode> {
  status: number;

  constructor(error: ManageableAxiosError<AxiosError<WithAPIErrorCode<T>, D>>) {
    /** @description 서버의 코드 미제공 방지 */
    const code = error.response.data.code || API_ERROR_CODE_KIT.API_ERROR_CODE_MISSING;

    super({ code });

    this.status = error.response.status;
  }

  static canManage<T, D>(error: AxiosError<T, D>): error is ManageableAxiosError<typeof error> {
    return error.config && error.request && error.response;
  }
}

APIError는 통신이 성공한 경우 사용되는 에러입니다.

서버에서 제공하는 에러 코드와 상태 코드를 저장합니다.

canManage 메서드는 발생한 APIError 가 핸들링 가능한 에러인지 파악합니다.

저희 서비스는 모든 api call에 axios를 사용하고 axios 응답 객체는 양식이 정해져 있습니다.

그에 따라 제가 결정한 핸들링 가능 기준은 다음과 같습니다.

응답 객체에 다음 속성이 있는가?

  1. config
  2. request
  3. response

위 속성들은 다양한 이유로 응답 객체에 존재하지 않을 수 있습니다. 그리고 위 속성들이 없다면 에러에 대한 정보를 알 수 없다는 공통점이 있습니다.

따라서 canManage 함수를 통과하지 못한 에러는 UnexpectedError 로 분류하였습니다.

ErrorBoundary


다양한 에러를 분류하여 처리하고, 이 과정을 UI와 관심사를 분리하기 위해 ErrorBoundary를 사용하였습니다.

class BaseErrorBoundary extends Component<
  PropsWithChildren<ErrorBoundaryProps>,
  ErrorBoundaryState
> {
  constructor(props: PropsWithChildren<ErrorBoundaryProps>) {
    super(props);
    this.state = initialState;
  }

  static getDerivedStateFromError(error: Error) {
    return {
      hasError: true,
	  error,
    };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
		const { onError } = this.props;

		onError?.({ error, errorInfo });
  }

  render() {
    const { error, hasError } = this.state;

    const { children, fallback } = this.props;

    if (!hasError) return children;

    return fallback
  }
}

기본적인 인터페이스는 ErrorBoundary와 크게 다르지 않습니다.

다루고자 하는 에러의 형태

에러가 발생하여 최초로 ErrorBoundary에 도착한다면 이 에러는 이제 제가 구현한 흐름을 타고 코드 전반으로 올라가게 될 것입니다.

흘러가는 에러를 건져올렸을 때 어떤 형태가 확장성이 좋을지, DX적으로 편리한 인터페이스일지 고민했습니다.

원본 에러를 훼손한다면 혹시 모를 활용 가능성을 저버리는 것이고..

원본 에러를 throw 한다면 사용하는 쪽에서 매번 에러를 변환하는 과정을 거쳐야 할 것입니다.

고민 끝에 당장 원본 에러를 활용할 부분이 예상되지 않고 추후 원본 에러를 사용하도록 변경하는 것이 큰 리소스를 필요로 할 것 같지 않아 원본 에러를 커스텀 에러로 변환하여 throw 하도록 결정했습니다.

class ZipgoError<Code extends ErrorCode = 'UNEXPECTED_ERROR'> extends Error {
  ...

  static convert(error: unknown) {
	// 이미 변환된 에러라면 무시
    if (error instanceof ZipgoError) return error;
		
	// axios error가 아니거나(RuntimeError)
	// 핸들링 불가능한 에러라면 UnexpectedError로 변환
    if (!isAxiosError(error) || !APIError.canManage(error)) {
      return new UnexpectedError(error);
    }

    return new APIError(error);
  }
}

이제 하나의 ErrorBoundary를 통과한 에러는 모두 커스텀 에러로 변환될 것입니다.

// BaseErrorBoundary

{
	...

	static getDerivedStateFromError(error: Error) {
      return {
        hasError: true,
        // 에러를 커스텀 에러로 변환한다
        error: ZipgoError.convert(error),
      };
  }

	componentDidCatch(_: E, errorInfo: ErrorInfo): void {
	  // catch된 에러가 아닌 변환된 에러를 사용한다
      const { error, hasError } = this.state;

	  if(!hasError) return

	  // 원본 에러가 아닌 변환된 에러를 제공한다
	  onError?.({ error, errorInfo });
  }

	...
}

fallback render props

위와 같이 사용하니 fallback이 에러의 정보를 알 수 없어 ErrorBoundary마다 일일이 적절한 fallback UI를 적용해야만 하는 문제가 발생했습니다.

이를 해결하고자 fallback의 형태를 render props도 가능하도록 변경하였습니다.

type RenderProps<P extends object = object> = (payload: P) => ReactNode;

interface ErrorBoundaryProps {
  ...
  fallback?: ReactNode | RenderProps<ErrorBoundaryValue>;
}

이제 사용처에서 원하는 형태로 fallback을 적용할 수 있습니다.

<BaseErrorBoundary fallback={({ error }) => <ErrorPage error={error} /> }>
  {children}
</BaseErrorBoundary>

// or

<BaseErrorBoundary fallback={<div>Error!</div>}>
  {children}
</BaseErrorBoundary>

fallback의 타입에 따라 렌더링하도록 render 메서드도 수정해 주었습니다.

{
	...

	render() {
      const { error, hasError } = this.state;

      const { children, fallback } = this.props;

      if (!hasError) return children;

      // fallback의 type에 따라 실행 혹은 리턴한다
      return resolveRenderProps(fallback, { error });
  }

	...
}

이렇게 변경하니 좀 더 유연한 fallback UI를 사용할 수 있었습니다.

onError

에러가 발생했을 때 실행되며 함수의 인자에 변환된 에러가 전달됩니다.

구상한 흐름에 따르면 ErrorBoundary에서 catch한 에러는 현재 ErrorBoundary와 같은 관심사를 갖고 있는지 검증된 후 throw 여부가 결정됩니다.

이 과정을 구현할 수 있는 두 가지 방법이 떠올랐습니다.

  1. BaseErrorBoundary를 확장한 특정 ErrorBoundary(클래스 형 컴포넌트)
  2. BaseErrorBoundary를 Wrapping한 특정 ErrorBoundary(함수 형 컴포넌트)

1번의 방식으로 구현하면 getDerivedStateFromError 또는 componentDidCatch를 오버라이딩하여 해당 메서드 내부에서 에러 검증을 거치면 될 것입니다.

실제로 구현해보니 정상적으로 동작하는 것을 확인하였습니다.

class APIBoundary extends BaseErrorBoundary {
  static getDerivedStateFromError(error: Error) {
    const state = super.getDerivedStateFromError(error);

    if (!(state.error instanceof APIError)) throw state.error;

    return state;
  }
}

다만 컴포넌트 간 상속은 React에서 지양하는 방식이며 개발자들 사이에서도 컴포넌트 상속에 대해 많은 논란이 있었습니다.

위와 같이 간단한 오버라이딩은 사용에 문제가 없을 것이라 생각되지만, 앞으로의 확장성을 고려한다면 익숙하지 않은 초행길을 위험 부담을 안은 채 가고 싶지 않았습니다.

따라서 제가 선택한 방법은 2번입니다.

다만 2번을 선택하니 앞서 말한 에러 검증 로직을 실행할 곳이 마땅치 않았습니다.

현재 코드로는 ErrorBoundary의 생명주기에 직접 접근하는 방법이 없기 때문입니다.

그렇게 탄생한 propsonError입니다.

간단하게 componentDidCatch에서 onError를 실행시켜 주었습니다.

{
	...

	  componentDidCatch(_: Error, errorInfo: ErrorInfo): void {
		const { error, hasError } = this.state;
			
		if(!hasError) return
	
		onError?.({ error, errorInfo });
  }

	...
}

이제 ErrorBoundary가 에러가 발생하였을 때 사용하는 쪽에서 원하는 로직을 주입할 수 있게 되었습니다.


const SomeBoundary = (props: PropsWithChildren<SomeBoundaryProps>) => {
  const { onError, ...restProps } = props;

  const handleError = ({ error } : { error : ZipgoError }) => {
	// 원하는 로직 수행
	if(error.critical) throw error
  }

  return (
    <ErrorBoundary<APIError>
      onError={composeFunctions(handleError,onError)}
      {...restProps}
    />
  );
};

reset

불가피하게 에러가 발생하여 에러 fallback을 보여주게 되었다면 사용자에게 다음 행동 수단을 제시해 주어야 합니다.

일반적으로 많이 제공되는 행동 수단은 ‘홈으로 돌아가기’, 그리고 ‘다시 시도하기’입니다.

스타벅스 에러 페이지

집사의 고민 에러 페이지

하지만 ErrorBoundary에 에러가 catch된 순간 hasErrortrue로 변하기 때문에 이것을 초기화 시켜주지 않는 이상 항상 fallback UI를 렌더링 하게 됩니다.

이 문제를 해결하고자 reset 메서드를 구현했습니다.

{
	...

    reset(){
	  this.setState(initialState);
	}

	...

}

간단하게 상태를 초깃값으로 변경해 주는 메서드입니다.

다만 이렇게 끝낸다면 reset 메서드를 ErrorBoundary 외부에서 활용할 수 없습니다.

따라서 예상되는 주 사용처인 fallback에서 사용할 수 있도록 render props 인자로 함께 넣어주었습니다.

{
	...

  render(){
	const { children, fallback } = this.props;
	...	
	// this가 바인딩된 reset 메서드를 함께 제공한다
	return resolveRenderProps(fallback, { reset: this.reset, error });
      
  }

	...
}

치명적인 에러


RuntimeError , UnexpectedError와 같이 수복이 어려운 에러나 혹은 APIError 라도 페이지에서 핵심적인 API인 경우 에러 페이지를 보여주어야 할 것입니다.

다만 생각해 보니 ErrorBoundary가 겹겹이 쌓인 컴포넌트 트리 깊숙한 곳에서 발생한 에러는 에러 페이지를 보여주기 힘들다는 문제가 예상되었습니다.

다음과 같은 경우입니다.

const Page = () => {
	return (
		<Layout>
			<ComponentA/>
			<ComponentB/>
		</Layout>
	)
}

const ComponentA = () => {
	return (
			// 돔 트리 중간에 에러 페이지가 렌더링된다
			<ErrorBoundary fallback={<ErrorPage />}>
				<CriticalError />
			</ErrorBoundary>
		)
}

이 같은 문제를 해결하기 위해선 에러 페이지를 렌더링 하기 원하는 경우 에러를 최상단까지 throw 시켜주어야 할 필요가 있었습니다.

따라서 발생한 에러가 최상단까지 도달해야 하는 에러라면 중간의 어떤 ErrorBoundary에도 catch 되지 않도록 ignore 속성을 통해 검사하였습니다.

이것이 RuntimeErrorUnexpectedErrorignore 속성이 존재하는 이유입니다.

에러를 무시할 수 있도록 componentDidCatch를 변경해 주었습니다.

{
	...
	
	  componentDidCatch(_: Error, errorInfo: ErrorInfo): void {
			const { onError } = this.props;

			const { error, hasError } = this.state;

			// ignore가 true라면 throw한다
			if (shouldIgnore(error)) throw error;
	
			onError?.({ error, errorInfo });

	...
}

const shouldIgnore = <E extends Error>(error: E) =>
  Object.prototype.hasOwnProperty.call(error, 'ignore') && error.ignore === true

그리고 최상단에서 ErrorBoundary로 감싸줍니다.

// index.tsx

root.render(
	<ErrorBoundary fallback={({error})=> <ErrorPage error={error} />}>
		<App/>
	</ErrorBoundary>
);

이제 ignore 속성을 설정하여 최상단에서 에러 페이지를 렌더링 할 수 있을 것입니다.

const SomeBoundary = (props: PropsWithChildren<SomeBoundaryProps>) => {
  const { onError, ...restProps } = props;

	const handleError = ({ error } : { error : ZipgoError }) => {
		if(!(error instanceof SomeError)) throw error

		if(error.code.startsWith('CRITICAL'))  
          throw Object.assgin(error, { ignore: true })
        
	}

  return (
    <ErrorBoundary
      onError={composeFunctions(handleError,onError)}
      {...restProps}
    />
  );
};

맺으며


본격적으로 에러 핸들링을 적용하기에 앞서 ErrorBoundary를 필요한 만큼 보강해 보았습니다.

실제로 적용하는 과정에서는 추가적으로 필요한 기능들이 있어 변경 사항이 많이 발생했습니다.

다음 글에서는 이 자재들을 활용하는 과정에서 어떤 것이 부족했는지, 어떻게 활용했는지 얘기하겠습니다.

감사합니다.

Reference


2개의 댓글

comment-user-thumbnail
2023년 10월 11일

꼼꼼하고 섬세한 에러핸들링이 인상적이네요👍
다음 글은 언제 업로드되나여?🌝

답글 달기
comment-user-thumbnail
2023년 10월 17일

빨리 다음글 올려주세요 현기증난단말이에요

답글 달기