Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ Feat ] 에러 처리를 위한 Error-boundary 적용 #184

Open
wants to merge 8 commits into
base: develop
Choose a base branch
from

Conversation

suwonthugger
Copy link
Member

@suwonthugger suwonthugger commented Aug 21, 2024

🔥 Related Issues

✅ 작업 리스트

  • GlobalErrorboundary 구현
  • ApiErrorBoundary 구현
  • router 폴더 생성하여 관련 파일을 정리
  • route 구조 변경 및 404 페이지 추가

🔧 작업 내용

(현재 UI가 나오지 않아, UI는 모두 임시로 작업하였습니다.)

시작

에러 처리에 대한 고민을 많이 했고, 그 과정에서 선언적으로 에러 처리를 할 수 있는 ErrorBoundary를 사용하게 되었는데요.
페이지에서는 데이터가 제대로 받아올 수 있는 경우만을 처리하고, 에러 관련사항은 에러바운더리에서 처리하는게 관심사를 더 분리할 수 있는 방법이라고 생각하여 적용하게되었습니다.

에러 구분

이 때 에러를 예측 가능한 에러와, 예측할 수 없는 에러로 구분하여 처리하고 싶었습니다. 그래서 카카오테크 블로그를 참고해 두가지 에러 바운더리를 구현하였습니다.

  • GlobalErrorboundary : 클라이언트 입장에서 예측하기 힘든 에러 처리 ( 알수없는 에러, 네트워크 에러, 서버 점검 중 등등)
  • ApiErrorBoundary : 클라이언트 입장에서 어느정도 예측가능한 api 에러 ( 401,403,404,413 ... )

그래서 Global에러는 큰 화면에서 retry 버튼이 있는 구조로 UI를 적용하고, Api에러는 부분적인 error fallback UI를 적용하고자 했습니다.
(디자인팀에게 문의 완료)

다른 방법




GlobalErrorboundary / ApiErrorBoundary 구현


의도한 동작

에러가 throw되게 되면 가장 가까운 부모 로 전달이 되게 됩니다. 두 에러바운더리에서 의도한 플로우는 다음과 같습니다.

  • 예측가능한 api 에러 발생
  1. 에러 throw
  2. ApiErrorBoundary로 해당 에러를 전달 받아서 처리
  • 예측 불가능한 에러 발생
  1. 에러 throw
  2. ApiErrorBounday에서 예측 가능한 경우가 아니라면 다시 error throw
  3. GlobalErrorBounday에서 해당 에러를 전달 받아서 처리

코드 설명

에러 바운더리 코드는 클래스로 작성되어 있어 클래스가 익숙하지 않다면 이해가 어려울 수 있습니다. 공식문서를 참고하는것을 추천드릴게요.
두 에러바운더리 모두 구조가 비슷하므로 ApiErrorBoundary를 코드를 설명드리겠습니다. 주석 설명 읽어주시면 됩니다.

class ApiErrorBoundary extends Component<ApiErrorBoundaryProps, ApiErrorBoundaryState> {
	constructor(props: ApiErrorBoundaryProps) {
		super(props);
		this.state = {
			shouldHandleError: false, // 에러를 현재 Api에러 바운더리에서 처리할것인지
			shouldRethrow: false, // 현재 Api 에러 바운더리에서 처리가 힘들어 글로벌 에러바운더리에서 처리할것인지
			error: null,
		};
	}
//리액트 생명주기에서 발생한 에러를 감지하는 부분
	static getDerivedStateFromError(error: AxiosError | Error): ApiErrorBoundaryState {
		// 에러를 특정 API 에러로 가정하고 처리할 수 있는지 확인
		if (
			error instanceof AxiosError &&
			error?.response?.status &&
			SHOULD_HANDLE_ERROR.includes(error?.response?.status) // 에러가 예측가능한 에러인가 확인
		) {
			return {
				shouldHandleError: true, // 현재 Api 에러 바운더리에서 처리
				shouldRethrow: false,
				error,
			};
		}

		// 처리할 수 없는 에러는 상위 에러 바운더리로 전달
		return {
			shouldHandleError: false,
			shouldRethrow: true, // 이 부분을 true로 변경
			error,
		};
	}

	// 에러를 리셋하여 재시도를 할 수 있게하는 함수
	resetError = () => {
		this.props.handleError?.();

		this.setState({
			shouldHandleError: false,
			shouldRethrow: false,
			error: null,
		});
	};

	render() {
		// Todo: 실제 UI가 나오면 fallback에 적용하기
		const { shouldHandleError, shouldRethrow, error } = this.state;
		// const { fallback: Fallback } = this.props;

		if (shouldRethrow && error) { 
			throw error; // 상위 Error Boundary로 에러를 전달
		}

		if (!shouldHandleError) { 
			return this.props.children; // 에러가 처리 대상이 아니면 원래의 UI를 그대로 렌더링
		}

		// // 에러에 따라 UI를 분기 처리
		// if (error instanceof AxiosError) {
		// 	return <Fallback error={error} resetError={this.resetError} />;
		// }

		if (error instanceof AxiosError) {
			return (
				<button onClick={this.resetError} className="text-3xl">  // 예측 가능한 에러를 처리
					API 에러 발생했어요~~
				</button>
			);
		}
	}
}

export default ApiErrorBoundary;

사용 방법

  • GlobalErrorBoundary 는 최상위에 적용
image
  • ApiErrorBoundary는 필요한 컴포넌트(api 통신을 하는 컴포넌트)에 적용
    Api에러는 모두 에러바운더리에서 처리하므로 react-queryerror 객체는 사용할 필요가 없겠죠?
const sushi = () => {
      return (
          <Wrapper>
             <Title/>
             <ApiErrorBoundary>
                <Profile/>
             </ApiErrorBoundary>
          </Wrapper>
      )
}



router 폴더 생성하여 관련 파일 정리 및 라우트 구조 변경


router 폴더 생성

라우트를 보려고 왔다갔다하는것 같아서 비슷한 코드들을 가까운곳에 배치했어유
routesConfig에 url 명과 url이 담긴 상수 객체를 정의해놨습니다.

image

router 구조 변경

라우터는 로그인이 필요한 부분과 필요하지 않은 부분, 그리고 404페이지로 분리하였습니다.
ProtectedRoute는 로그인 기능이 제대로 구현되면 로그인을 하지 않은 사용자를 다시 login 페이지로 리다이렉션 해야되기 때문에 생성했습니다.(권한관련 로직) - 기존의 권한 로직hoc페이지를 일일이 감싸서 권한 로직을 부여 했지만, 라우터 방식으로 권한로직을 적용하면 상위 라우터의의 로직으로 하위 라우터에 동일한 로직을 적용할 수 있기 때문에 방식을 변경하였습니다.

이와 관련해서 궁금하신 분들은 react의 권한 분리에 대해서 공부해보시면 좋을것 같습니다.
대표적으로 라우트 기반 권한 분리컴포넌트 기반 권한 분리가 있습니다.

const router: Router = createBrowserRouter([
	{
		//public 라우트들
		path: '/',
		element: <Outlet />,
		children: [
			{
				path: ROUTES_CONFIG.login.path,
				element: <LoginPage />,
			},
			{
				path: ROUTES_CONFIG.redirect.path,
				element: <RedirectPage />,
			},
		],
	},

	{
		//권한이 있어야 접근 가능한 라우트들
		path: '/',
		element: <ProtectedRoute />,
		children: [
			{
				path: ROUTES_CONFIG.home.path,
				element: <HomePage />,
			},
			{
				path: ROUTES_CONFIG.timer.path,
				element: <TimerPage />,
			},
		],
	},

	{
		//404 페이지
		path: '*',
		element: <div className="text-3xl">잘못 찾아오셨어요!</div>,
	},
]);

🧐 새로 알게된 점

  • react-query에서 error-boundary를 적용하기 위해서는 queryClient에서 throwOnError(과거 useErrorBoundary)를 true로 설정하여 에러를 throw할수 있게 해야합니다.
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
	defaultOptions: {
		queries: {
			throwOnError: true,
		},
		mutations: {
			throwOnError: true,
		},
	},
});

🤔 궁금한 점

prettier 설정 변경하여 빨간줄 뜨면 vscode 한번 껐다가 키시면 됩니다!!

📸 스크린샷 / GIF / Link

에러바운더리 정상 작동!
image
image



20240917 이후 UI 적용 된 내용


  • rebase로 최신 브랜치와 싱크 맞춤
  • ApiErrorBoundary, GlobalErrorBoundary UI 적용 - (단순 UI 구성입니다.)
  • GlobalErrorBoundary는 적용되어있는 상태이고 ApiErrorBoundary는 API 함수가 동작하는 하위 컴포넌트 아래 감싸서 사용해야합니다.

ApiErrorBoundary, GlobalErrorBoundary UI 구현

/shared/components/FallBackApiError


import { HomeLargeBtnVariant } from '@/shared/types/global';

import ErrorIcon from '@/shared/assets/svgs/error.svg?react';

import HomeLargeBtn from '@/components/atoms/HomeLargeBtn';

interface ErrorProps {
	resetError: () => void;
}

const FallbackApiError = ({ resetError }: ErrorProps) => {
	return (
		<div className="flex w-full justify-center">
			<div className="flex w-full flex-col items-center">
				<ErrorIcon className="mt-[32.3rem]" />
				<h2 className="title-bold-36 mt-[7.75rem] text-white">일시적인 오류가 발생했습니다.</h2>
				<p className="title-med-32 text-white">잠시 후 다시 이용해 주세요.</p>

				<div className="mt-[4.4rem]">
					<HomeLargeBtn onClick={resetError} variant={HomeLargeBtnVariant.LARGE}>
						다시 시도하기
					</HomeLargeBtn>
				</div>
			</div>
		</div>
	);
};

export default FallbackApiError;

/page/NotFoundPage.tsx


import { useNavigate } from 'react-router-dom';

import { HomeLargeBtnVariant } from '@/shared/types/global';

import NotFoundIcon from '@/shared/assets/svgs/404.svg?react';
import BellIcon from '@/shared/assets/svgs/bell.svg?react';
import FriendSettingIcon from '@/shared/assets/svgs/friend_setting.svg?react';

import HomeLargeBtn from '@/components/atoms/HomeLargeBtn';

import SideBarHome from '../HomePage/components/SideBarHome';

const NotFoundPage = () => {
	const navigate = useNavigate();

	return (
		<div className="relative flex h-screen bg-gray-bg-01">
			<SideBarHome />

			<div className="absolute right-[4.4rem] top-[5.4rem] flex gap-[0.8rem]">
				<button>
					<FriendSettingIcon className="rounded-[1.6rem] hover:bg-gray-bg-04 active:bg-gray-bg-05" />
				</button>
				<button>
					<BellIcon className="rounded-[1.6rem] hover:bg-gray-bg-04 active:bg-gray-bg-05" />
				</button>
			</div>

			<div className="flex w-full flex-col items-center">
				<NotFoundIcon className="mt-[35.65rem]" />
				<h2 className="title-bold-36 mt-[7.75rem] text-white">페이지를 찾을 수 없습니다.</h2>
				<p className="title-med-32 text-white">올바른 URL을 입력하였는지 확인하세요.</p>

				<div className="mt-[4.4rem]">
					<HomeLargeBtn onClick={() => navigate('/home')} variant={HomeLargeBtnVariant.LARGE}>
						홈으로 돌아가기
					</HomeLargeBtn>
				</div>
			</div>
		</div>
	);
};

export default NotFoundPage;

ApiErrorBoundary 사용법


다음과 같이 사용

<ApiErrorBoundary fallback={<ApiErrorFallback}>
      <에이피아이 함수를 호출하는 컴포넌트/>
</ApiErrorBoundary>
  • GlobalErrorBoundary는 App.tsx 최상위에 적용되어있습니다.

image image

@suwonthugger suwonthugger linked an issue Aug 21, 2024 that may be closed by this pull request
1 task
@suwonthugger suwonthugger self-assigned this Aug 21, 2024
@suwonthugger suwonthugger added the ✨ Feature 기능 개발 label Aug 21, 2024
@suwonthugger suwonthugger changed the title [ Feat ] 렌더링 오류 로깅을 위한 Error-boundary 적용 [ Feat ] 에러 처리를 위한 Error-boundary 적용 Aug 21, 2024
Copy link
Collaborator

@KIMGEONHWI KIMGEONHWI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

정성스러운 pr 작성 감사합니다! 에러 바운더리의 정의 정도만 알고 있었는데, 적용 방법과 상세 설명덕분에 완벽하게는 아니지만 이해할 수 있었습니다. 또한, 라이프사이클에 대해서도 명확한 이해가 필요하겠다고 느꼈네요. 아래는 리액트 공식 페이지에서 Error-Boundary 관련한 문서인데 다른분들도 참고하면 좋을거 같아서 남겨봅니다. 고생하셨습니다.
https://ko.legacy.reactjs.org/docs/error-boundaries.html

Copy link
Collaborator

@seueooo seueooo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

에러 바운더리라는 개념에 대해 처음 알게 되었는데요! 덕분에 대략적으로나마 이해할 수 있었습니다 에러만 따로 처리하면 관심사가 분리되어 관리하기 더 편리하겠네용
저도 이참에 에러처리, 권한 분리, 생명주기 등등에 대해 더 공부해봐야 될 것 같네요…! 자세한 설명 감사합니다!

Copy link
Collaborator

@Ivoryeee Ivoryeee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pr에서 자세히 설명해 주신 덕분에 각 코드가 어떤 역할을 하는지 더 잘 이해할 수 있었습니다! 이와 같이 에러 관련 관심사를 분리함으로써 앞으로 더 명료히 코드를 작성할 수 있을 것 같아 기대됩니다. 항상 대원님 코드를 보면서 항상 많이 배우는 것 같아요. 고생 많으셨습니다! 👍🏻

static getDerivedStateFromError(error: AxiosError | Error): ApiErrorBoundaryState {
// 에러를 특정 API 에러로 가정하고 처리할 수 있는지 확인
if (
error instanceof AxiosError &&
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

비교연산자를 사용해 error가 AxiosError의 인스턴스인지 판단하셨군요! instanceof 연산자를 실제로 사용해 본 경험이 많지 않은데 이렇게 활용될 수 있음을 배우고 갑니다!


class GlobalErrorBoundary extends Component<GlobalErrorBoundaryProps, GlobalErrorBoundaryState> {
constructor(props: GlobalErrorBoundaryProps) {
super(props);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super()이 아닌 super(props)를 사용하는 이유, super()이 반드시 사용되어야 하는가라는 의문점이 있었는데 이번 기회에 왜 그렇게 사용되는지 아티클을 찾아 보았습니다. 저와 같은 궁금증을 가진 분들이 읽어 보시면 좋을 것 같아요!
https://min9nim.github.io/2018/12/super-props/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
✨ Feature 기능 개발
Projects
Status: No status
Development

Successfully merging this pull request may close these issues.

[ Feat ] 에러 처리를 위한 Error-boundary 적용
4 participants