Skip to content

[FE] API 데이터 통신 에러 처리

YoonDH edited this page Feb 24, 2025 · 2 revisions

에러 처리 로직

API 통신

Fetch 모듈화

  • axios를 사용하지 않기로 결정한 이후, fetch의 단점으로는 번거로움이 존재한다.
  • HTTP 통신 메소드별로, 미리 사전에 코드를 생성하여 axios 처럼 .get .post 같은 간단한 방식으로 API를 통신할 수 있도록 모듈화하였다.
export default function Fetch() {
	const baseURL = import.meta.env.VITE_REACT_SERVER_BASE_URL;

	const get = async <T>(url: string, params?: Record<string, string | number | boolean>): Promise<T> => {
		console.log("GET : ", url);

		const paramsURL = new URLSearchParams(
			Object.entries(params || {}).map(([key, value]) => [key, String(value)]),
		).toString();

		const response = await fetch(`${baseURL}${url}?${paramsURL}`, {
			method: "GET",
		});
                ...
	};

	const post = async <T = boolean, K = unknown>(
		url: string,
		body?: Record<string, K | K[]>,
	): Promise<T | boolean> => {
		console.log("POST : ", url);

		const response = await fetch(`${baseURL}${url}`, {
			method: "POST",
			body: JSON.stringify(body),
			headers: {
				"Content-Type": "application/json",
			},
		});
                ...
	};

	const put = async <T, K>(url: string, body?: Record<string, K>): Promise<T> => {
		const response = await fetch(`${baseURL}${url}`, {
			method: "PUT",
			body: JSON.stringify(body),
		});
                ...
	};

	return {
		get,
		post,
		put,
	};
}

const { get, post, put } = Fetch();
export { get as getFetch, post as postFetch, put as putFetch };
  • 사전에 메소드 호출 함수를 선언하여 이를 import하여 쓸 수 있도록 개발 편의성을 증진하였다.

에러의 종류

의도한 에러 의도하지 않은 에러
잘못된 요청, 생성 실패, 조회 실패 서버 실패, 네트워크 오류
  • 의도한 에러가 발생한 경우, 사용자에게 에러 메세지를 정보를 전달해야한다.
  • 의도하지 않은 에러가 발생한 경우, 서버가 응답을 할 수 없는 형태로 간주하고 에러 페이지를 보여준다.

image

Tanstack-Query 적용 범위

종류 적용 범위 특징
useSuspenseQuery get 요청 결과를 바로 화면에 그리는 경우 & 요청 과정 동안 로딩화면을 보여주고 싶은 경우 전역 Suspense를 통한 로딩화면, 오류 시 공통적인 오류 화면
useQuery get 요청 결과가 바로 화면에 그려지지 않는 경우 or 요청 과정을 사용자에게 다른 형태로 보여주고 싶은 경우 요청 성공 및 실패를 모달로 노출
useMutation post 요청의 결과는 화면에 반영되지 않거나 결과를 모달로 처리하는 경우 POST 요청 성공 실패를 모달로 노출

useSuspense 에러 처리 & Global - ErrorBoundary

  • useSuspense는 API가 호출되는 동안, Suspense의 fallback 화면을 보여준다.
  • 현재 서비스에서는 useSuspenseQuery에서 (빌딩, 위험 / 주의 요소)를 호출하기에, 이를 실패할 경우, 지도에 제공하는 정보가 없기에 에러 페이지로 이동한다.
  • 어느 페이지에서든 useSuspenseQuery에서 오류가 발생할 경우, 하나의 에러 페이지를 보여주기 위해서 전역에서 에러 바운더리를 생성한다.
  • react-error-boundary 라이브러리가 존재하나, 서비스에 맞게 커스텀의 가능성 및 번들 사이즈 감소를 위해 직접 구현하였다. Reference PR
import React, { Component, ReactNode } from 'react';

interface ErrorBoundaryProps {
    fallback: ReactNode;
}

interface ErrorBoundaryState {
    hasError: boolean;
}

export default class ErrorBoundary extends Component<
    React.PropsWithChildren<ErrorBoundaryProps>,
    ErrorBoundaryState
> {
    constructor(props: React.PropsWithChildren<ErrorBoundaryProps>) {
        super(props);
        this.state = { hasError: false };
    }

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

    render() {
        if (this.state.hasError) {
            return this.props.fallback;
        }

        return this.props.children;
    }
}

<ErrorBoundary key={location.key} fallback={<ErrorPage />}>
    ...
</ErrorBoundary>

useMutation 에러 처리

  • POST 요청의 결과를 화면에서 사용하지 않거나, 요청 실패에 대한 결과를 화면에 보여주는 로직이 반복 사용되었습니다.
  • 기존에 요청 실패를 보여주기 위해서는
const {isError} = useMutation({});


if(isError) return <ErrorFallback/>

///

const {isError} = useMutation({
    onError: (error) => {
        setError(error);
    }
})

if(error) return <ErrorFallback/>
  • 다음과 같이 에러가 발생했을 경우, 페이지 컴포넌트에서 isError 등 state를 사용하여 에러 화면을 만드는 로직이 필요하다.
  • 현재 서비스에서 Mutation이 에러가 발생한 경우, 모두 동일한 모달 형태로 에러 메세지를 출력한다.
  • 다음과 같은, 반복적인 로직이 발생하므로 코드의 간결성 및 책임 분리 원칙을 적용하여 커스텀 훅을 생성하였습니다.
type Fallback = {
	[K in Exclude<ERROR_STATUS, ERROR_STATUS.INTERNAL_ERROR>]?: {
		mainTitle: string;
		subTitle: string[];
	};
};

type HandleError = {
	fallback: Fallback;
	onClose?: () => void;
};

type UseMutationErrorReturn<TData, TError, TVariables, TContext> = [
	React.FC,
	UseMutationResult<TData, TError, TVariables, TContext>,
];

export default function useMutationError<TData, TError, TVariables, TContext>(
	options: UseMutationOptions<TData, TError, TVariables, TContext>,
	queryClient?: QueryClient,
	handleError?: HandleError,
): UseMutationErrorReturn<TData, TError, TVariables, TContext> {
	const [isOpen, setOpen] = useState<boolean>(false);
	const result = useMutation<TData, TError, TVariables, TContext>(options, 1000, true, queryClient);

	const { isError, error } = result;

	useEffect(() => {
		setOpen(isError);
	}, [isError]);

	const close = () => {
		if (handleError?.onClose) handleError?.onClose();
		setOpen(false);
	};

	const Modal: React.FC = () => {
		if (!isOpen || !handleError || !error) return null;

		const { fallback } = handleError;

		let title: { mainTitle: string; subTitle: string[] } = {
			mainTitle: "",
			subTitle: [],
		};

		if (error instanceof NotFoundError) {
			title = fallback[404] ?? title;
		} else if (error instanceof BadRequestError) {
			title = fallback[400] ?? title;
		} else throw error;

		return (
			<div>
				<div>
					<div>
						<p>{title.mainTitle}</p>
						<div>
							{title.subTitle.map((_subtitle, index) => (
								<p>
									{_subtitle}
								</p>
							))}
						</div>
					</div>
					<button onClick={close}>
						확인
					</button>
				</div>
			</div>
		);
	};

	return [Modal, result];
}
  • useMutation의 옵션을 그대로 받아서 Mutation의 결과와 실패했을 경우, 보여질 모달을 return한다.
  • useMutationError를 선언할 때, fallback을 입력받는데, 이때 각 에러에서 보여질 모달의 문구를 입력받는다.
const [ErrorModal, { data: reportedRoute, mutate, status }] = useMutationError(
		{
			mutationFn: ({
				startNodeId,
				coordinates,
				endNodeId,
			}: {
				startNodeId: NodeId;
				endNodeId: NodeId | null;
				coordinates: Coord[];
			}) => postReportRoute(university.id, { startNodeId, endNodeId, coordinates }),
			onSuccess: (data) => {
				...
			},
			onError: () => {
				...
			},
		},
		undefined,
		{
			fallback: {
				400: {
					mainTitle: "경로를 생성하는데 실패하였습니다!",
					subTitle: [
						"선택하신 경로는 생성이 불가능합니다.",
						"선 위에서 시작하여, 빈 곳을 이어주시기 바랍니다.",
					],
				},
				404: {
					mainTitle: "경로를 생성하는데 실패하였습니다!",
					subTitle: ["선택하신 점이 관리자에 의해 제거되었습니다.", "다른 점을 선택하여 제보 부탁드립니다."],
				},
			},
		},
	);
  • 다음과 같이 400 에러에서는 어떤 문구를 보여주고 404에서는 어떤 문구를 보여줄지 정해진다면, 에러가 발생했을 경우, 해당 화면에서 모달과 함께 에러 타입에 맞는 메세지가 보이게 될 것이다.
오류 코드 예시 오류 코드 예시
길 생성 실패 400 스크린샷 2025-02-24 10:41:23 길 생성 실패 404 스크린샷 2025-02-24 10:45:17

useQueryError

  • useQuery에서도 POST 요청과 마찬가지로, GET 요청의 결과가 화면에서 바로 사용되지 않는 경우에 적용할 수 있습니다.
  • 현재 메인 서비스에서는 지도 메인페이지에서 경로를 탐색하고 성공하면 길 찾기 결과 페이지로 안내되고 실패한 경우 모달을 보여주기 위해 적용하였습니다.
type Fallback = {
	[K in Exclude<ERROR_STATUS, ERROR_STATUS.INTERNAL_ERROR>]?: {
		mainTitle: string;
		subTitle: string[];
	};
};

type HandleError = {
	fallback: Fallback;
	onClose?: () => void;
}

type UseQueryErrorReturn<TData, TError> = [
	React.FC,
	UseQueryResult<TData, TError>
];

export default function useQueryError<TQueryFnData, TError, TData>(
	options: UseQueryOptions<TQueryFnData, TError, TData>,
	queryClient?: QueryClient,
	handleSuccess?: () => void,
	handleError?: HandleError,
): UseQueryErrorReturn<TData, TError> {
	const [isOpen, setOpen] = useState<boolean>(false);
	const result = useQuery<TQueryFnData, TError, TData, readonly unknown[]>(options, queryClient);

	const { isError, error, status } = result;

	useEffect(() => {
		setOpen(isError)
	}, [isError])

	useEffect(() => {
		if (status === "success" && handleSuccess) handleSuccess();
	}, [status])

	const close = () => {
		if (handleError?.onClose) handleError?.onClose();
		setOpen(false);
	}

	const Modal: React.FC = () => {
		if (!isOpen || !handleError || !error) return null;

		const { fallback } = handleError;

		let title: { mainTitle: string, subTitle: string[] } = {
			mainTitle: "",
			subTitle: [],
		}

		if (error instanceof NotFoundError) {
			title = fallback[404] ?? title;
		}
		else if (error instanceof BadRequestError) {
			title = fallback[400] ?? title;
		}
		else if (error instanceof UnProcessableError) {
			title = fallback[422] ?? title;
		}

		else throw error;

		return (
			<div>
				<div>
					<div>
						<p>{title.mainTitle}</p>
						<div>
							{title.subTitle.map((_subtitle, index) =>
								<p>{_subtitle}</p>)}
						</div>
					</div>
					<button onClick={close}>
						확인
					</button>
				</div >
			</div >
		)
	}

	return [Modal, result];
}
  • useMutationError와 동일하게 동작하나, onSuccess를 입력받아 GET 요청에 성공한 경우 동작을 지정할 수 있습니다.

문제 해결

전역 에러바운더리 key 부여

  • 어느 페이지에서든 에러 발생으로 인해 에러바운더리가 보이게 된 경우, 이전 페이지로 되돌아가도 화면에 보이는 문제가 존재하여 key로 현재 라우터의 위치를 할당하여 문제를 해결할 수 있었습니다.

PR

PR

Clone this wiki locally