React ErrorBoundary 활용

CNHur HyeonBin (Max)
Reponses  01month ago( Korean Version only )

1. 들어가며

리액트로 웹 애플리케이션을 개발하다 보면 누구나 한 번쯤 완벽하게 동작하던 애플리케이션이 갑자기 하얀 화면만 보여주고너, 콘솔에 빨간 에러 메시지가 가득 착 채로 아무것도 렌더링 되지 않는 상황을 마주해 보았을 것입니다. 현대 웹 애플리케이션은 수 많은 컴포넌트들이 유기적으로 복잡하게 연결되어있고, 올바른 에러처리를 하지 않는다면 그중 하나의 컴포넌트에만 에러가 발생해도 프로젝트 전체가 동작을 멈출 수 있습니다. 

하지만, 이런 복잡한 구조에서 하나의 컴포넌트에 오류가 발생했다고 해서 전체 애플리케이션이 멈춰서는 안되며, 사용자는 문제가 발생한 특정 영역을 제외하고는 나머지 기능들을 정상적으로 사용할 수 있어야 합니다.

React 16에서 소개된 ErrorBoundary는 바로 특정 컴포넌트, 프로젝트의 일부분에서 에러가 발생했을 때, 해당 부분만 독립적으로 에러 처리를 수행하여 다른 컴포넌트들은 정삭적으로 동작할 수 있도록 도와주는 컴포넌트입니다. 컴포넌트 트리의 특정 부분에서 발생한 에러를 격리하여, 다른 영역에는 영향을 주지 않으면서도 사용자에게는 적절한 대체 UI(fallback UI)를 제공하여 UX향상에 큰 도움을 줍니다.


2. 에러바운더리의 목적

complecated component diagram.png

위 그림처럼 컴포넌트들이 트리 형태로 구성되어 있을 때, Error Boundary를 통해 **전략적인 에러 분기점**을 만들 수 있습니다. 예를 들어 Component G에서 에러가 발생했을 때, 해당 컴포넌트 내에서 자체적으로 처리할지, 상위의 Component E 레벨에서 처리할지, 아니면 더 상위로 전파할지를 개발자가 설계할 수 있습니다.

실제 개발에서는 인증 오류(Authentication Error), 네트워크 오류(Network Error), 타입 오류(TypeError) 등 다양한 종류의 에러가 발생합니다. Error Boundary를 적절히 활용하면 **하나의 경계 안에서도 에러 유형에 따라 서로 다른 대응**을 할 수 있어, 더욱 세밀하고 사용자 친화적인 에러 처리가 가능합니다.
리액트로 만든 프로젝트는 여러개의 컴포넌트들이 연결되어, react element를 node로 하는 트리형태로 구현된다. 하나의 컴포넌트가 망가졌다고해서, 전체 트리가 멈추어선 안되고, 에러를 핸들링 해야하는 해당 트리에 맞는 UI를 보여주는 방식이 React에서의 올바른 에러처리입니다.

한줄로 간단하게 요약해 보면, 리액트는 여러개의 컴포넌트를 엵어 트리형태로 돔을 생성하고, 화면에 그리고 있습니다. 하나의 컴퍼넌트에서 에러가 발생했다고 하여 프로젝트 전체가 동작을 멈추어선 안되고, 에러가 발생한 최소 트리만을 잘라서 에러

3. 에러바운더리 활용

간단한 어플리케이션이 있다고 가정해 보겠습니다. Category를 선택하고, Sub Category를 선택하면, 해당 카테고리와 서브카테고리에 맞는 올바른 아이템 리스트를 보여주는 간단한 어플리케이션 입니다.

 

sample application iamge.png

 

이를 App.tsx에서 코드로 살펴본다면 이렇게 됩니다.

function App() {
 return (
   <div>
     <CategoryList />
     <SubcategoryList />
     <ItemList />
   </div>
 );
}

 

또한, 각각의 List에서는 Custom Hook을 이용하여 API와 통신하는 등의 추가 로직을 진행하고, Card를 통해 List아이템을 렌더링하고 있습니다.

const ItemList = () => {
 const selectedSubcategoryId = useAppSelector(
   (state) => state.selection.selectedSubcategoryId
 );
 const { filteredItems } = useItems(selectedSubcategoryId);
 
 return (
   <div>
     <h3>Items</h3>
     <div>
       {filteredItems.map((item) => (
         <ItemCard key={item.id} />
       ))}
     </div>
   </div>
 );
};

 

이러한 App, List, Card의 형태는 리액트로 List Item을 보여줄 때 매우 흔하게 사용되는 패턴이고, 트리 형태의 다이어그램으로 표시한다면 아래와 같습니다.

sample application component structure diagram.png

이렇듯, 여러 컴포넌트가 자식 컴포넌트를 가지고 있고, 루트 컴포넌트로부터 자식 컴포넌트가 깊이 있을 때 Error Boundary는 다양한 에러를 트리별로 관리하기 용이하도록 도와줍니다. 구체적인 예시를 들어보겠습니다. 만약 ItemList에서 API 요청을 시도했는데 올바르게 동작하지 않아서 에러가 발생했다면, 적절한 에러 처리가 되어있지 않을 경우 프로덕션 모드에서는 하얀 화면과 함께 콘솔에 빨간 에러 메시지가 출력되고, 개발 모드에서는 프로젝트가 더이상 동작하지 않으면서 화면에 빨간 글씨로 에러가 표시됩니다.

하지만 Error Boundary를 사용하면 상황이 완전히 달라집니다. ItemList에서 에러가 발생했을 때 ItemList와 관련된 컴포넌트만 에러 상태가 되고, ItemList와 직접적인 연관이 없는 CategoryList와 SubCategoryList는 문제없이 정상 동작하게 됩니다.

React 공식문서에서 제공하는 ErrorBoundary는 아래와 같습니다. 

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {    
     return { hasError: true };  
	}
	
  componentDidCatch(error, errorInfo) {    
	logErrorToMyService(error, errorInfo);  
   }
  render() {
    if (this.state.hasError) {     
        return <h1>Something went wrong.</h1>;    
    }
    return this.props.children;
  }
}

 

getDerivedStateFromError()를 통하여 렌더 페이즈에서 throw된 에러를 감지하여 state변경을 통한 fallback UI를 보여주고, componentDidCatch()를 통하여 에러 발견시 커밋페이즈에서 부수효과를 처리하며, 둘중 하나(혹은 둘다)를 정의하여 에러바운더리를 정의하고 사용할 수 있습니다.

이렇듯 기본적인 에러바운더리를 사용한다면, 부모는 자식 컴포넌트에서 던진 에러를 렌더페이즈에서 getDerivedStateFromError()를 통해 감지하고, render()를 통하여 fallbackUI를 렌더링하여, 전체 트리의 동작을 멈추는 것이 아닌 특정 트리 내에서 에러를 핸들링 할 수 있습니다.

하지만, 프로젝트를 작성하다보면 Auth Error, Network Error, TypeError, Server Error 등 다양한 상황에 마주하게 됩니다. 이런 상황에서 에러바운더리는 유연하게 대처가 가능합니다.

저는 프로젝트에서 예측된 에러를 던질 때, JS에서 제공하는 일반적인 에러를 던지는 것이 아닌 CustomError를 만들어 던지곤 합니다. 

export default class CustomError extends Error {
 public readonly code: string;
 public readonly statusCode?: number;
 
 constructor(message: string, code: string, statusCode?: number) {
   super(message);
   this.name = "CustomError";
   this.code = code;
   this.statusCode = statusCode;
 }
}


위의 코드와 같이 CustomError를 정의하고, 예측된 상황에서 정의한 CustomError를 던져줍니다. 이번 상황에서는 ItemCard에서 에러를 던져보았습니다.

export default function ItemCard({ id, name }: ItemComponentProps) {
 if (아이템을 성공적으로 받아오지 못하는 에러 발생) {
   throw new CustomError(
     "Failed to fetch item data from server",
     "ITEMS_FETCH_ERROR",
     500
   );
 }
 
 if (네트워크 에러 발생) {
   throw new CustomError(
     "Failed to fetch item data from server",
     "ITEMS_FETCH_ERROR",
     500
   );
 }
 
 if (접근 권한 에러 발생) {
   throw new CustomError(
     "You don't have permission to access this item",
     "PERMISSION_DENIED",
     403
   );
 }
 
 if (예상치 못한 에러 발생) {
   throw new CustomError("Item data not found", "ITEM_NOT_FOUND", 404);
 }
 
 return (
   <div>
     <div>
       <span>#{id}</span>
       <span>{name}</span>
     </div>
   </div>
 );
}

 

이렇듯, 예상가능한 에러를 CustomError를 통해 새로 정의하고 던져주면, Errorboundary는 CustomError의 name, code, statusCode를 통해 현재 어떤 에러가 발생했고, 어떤 fallbackUI를 보여주어야 할 지 판단하여 보여줄 수 있으며, 다양한 에러에 대응이 가능하게 됩니다.

export default class ItemErrorBoundary extends React.Component
 { children: React.ReactNode },
 ErrorBoundaryState
> {
 constructor(props: { children: React.ReactNode }) {
   super(props);
   this.state = {
     hasError: false,
     isNetworkError: false,
     isPermissionError: false,
     isNotFoundError: false,
     isGeneralError: false,
     errorMessage: "",
   };
 }

 static getDerivedStateFromError(error: Error): ErrorBoundaryState {
   // 기본 상태
   const baseState: ErrorBoundaryState = {
     hasError: true,
     error,
     isNetworkError: false,
     isPermissionError: false,
     isNotFoundError: false,
     isGeneralError: false,
     errorMessage: error.message,
   };

   // CustomError인지 확인하고 에러 코드에 따라 플래그 설정
   if (isCustomError(error)) {
     baseState.errorCode = error.code;
     baseState.statusCode = error.statusCode;
     switch (error.code) {
       case "ITEMS_FETCH_ERROR":
         baseState.isNetworkError = true;
         console.log("Setting isNetworkError flag");
         break;
       case "PERMISSION_DENIED":
         baseState.isPermissionError = true;
         console.log("Setting isPermissionError flag");
         break;
       case "ITEM_NOT_FOUND":
         baseState.isNotFoundError = true;
         console.log("Setting isNotFoundError flag");
         break;
       default:
         baseState.isGeneralError = true;
         console.log("Setting isGeneralError flag for unknown custom error");
     }
   } else {
     baseState.isGeneralError = true;
   }

   return baseState;
 }

 render() {
   if (this.state.hasError) {
     // 플래그에 따라 다른 UI 렌더링
     if (this.state.isNetworkError) {
       return <NetworkError/>;
     }
     if (this.state.isPermissionError) {
       return <PermissionError/>;
     }
     if (this.state.isNotFoundError) {
       return <NotFoundError/>;
     }
     if (this.state.isGeneralError) {
       return <GeneralError/>;
     }
   }
   return this.props.children;
 }
}

 

 위와 같이 여러개의 에러타입을 정의하여 대응하고, 원하는 트리별로 작게 나누어 사용한다면, 해당 에러처리의 트리와 보여지는 UI는 아래와 같습니다. 올바르게 Errorboundary를 생성하였다면, ItemCard를 렌더링해주는 부분을 감싸주기만 한다면 모든 적용은 끝이났습니다.

const ItemList = () => {
 const selectedSubcategoryId = useAppSelector(
   (state) => state.selection.selectedSubcategoryId
 );
 const { filteredItems, error } = useItems(selectedSubcategoryId);
 
 return (
   <div>
     <h3>Items</h3>
     <div>
       {filteredItems.map((item) => (
         <ItemErrorBoundary key={item.id}>
           <ItemCard id={item.id} name={item.name} />
         </ItemErrorBoundary>
       ))}
     </div>
   </div>
 );
};

Errorboundary Flow Diagram.png

여러 개의 ItemCard가 존재하고, 각각의 ItemCard가 서로 다른 에러를 던지더라도 Error Boundary를 통해 전체 프로젝트는 정상적으로 동작하면서 각 에러를 올바르게 처리할 수 있게 됩니다.

 

Handled Error Example.png

지속적으로 언급되고 있는 트리단위로 에러를 처리함은, 이처럼 에러가 발생했다고 하더라도 프로젝트 전체가 동작을 멈추는 것이 아닌, 내부 작은 트리 단위로 에러처리하여 전체 동작에는 문제가 없도록 함에 있습니다. ErrorBoundary를 통해, 컴포넌트를 re-rendering 시키거나, 페이지 전체를 refresh하고, 이전 페이지로 넘어가는 등 에러에 대한 추가적인 대처가 가능합니다.

또한, 현재의 트리에서는 핸들링 할 수 없는 에러라면 상위 에러바운더리로 에러를 전달하여 더 높은 부모 트리에서 에러를 처리하는 등의 응용이 있을 수 있습니다.

현재의 코드에서는 특정 트리를 기반으로 ErrorBoundary를 정의하여 사용하고 있지만, 트리 및 컴포넌트 기반이 아닌, 기능별, 에러별 에러 바운더리를 생성하여 프로젝트에 접목시킨다면 에러바운더리의 재활용성까지 크게 향상 시킬 수 있습니다.

이를테면, AuthErrorBoundary를 생성하여 특정 루트를 감싸주어, 특정 루트 내부에서 Auth Error를 감지한다던가, NetworkError만을 관리하는 Errorboundary를 생성하여 여러 트리에서 중복적으로 발생하는 에러를 처리하는 것 또한, 에러바운더리의 흔한 패턴 중 하나입니다.

function App() {
 return (
   <div>
     <h1 className="app-title">3-Level Hierarchical List System</h1>
     <CategoryList />
     <AuthErrorBoundary> 
       <SubcategoryList />
     </AuthErrorBoundary>
     <AuthErrorBoundary>
       <ItemList />
     </AuthErrorBoundary>
   </div>
 );
}

 

CNHur HyeonBin (Max)
Reponses  0