무슨 프로그래밍이든 마찬가지로 에러를 잡기위해 노력해야합니다.
프론트엔드에서는 사용자가 해결할 수 있는 에러, 해결할 수 없는 에러를 분리하여 사용자에게 에러마다 다른 화면이나 알림창을 보여줘야 합니다.
에러 메시지가 명확하고 사용자 친화적이면 사용자는 문제를 빠르게 이해하고 대응할 수 있습니다. 반면, 불명확하거나 부적절한 에러 메시지는 사용자를 혼란스럽게 만들고, 애플리케이션의 신뢰성을 저하시킬 수 있습니다.
저희 모두의 택시팀에선 어떻게 프론트엔드의 에러를 처리했는지 알아보겠습니다.
프로그래밍에 의한 실수로 발생하는 에러는 잠시 뒤로 미뤄두고, 프론트에서 우리가 가장 많이 접할 수 있는 에러는 역시나 서버와의 통신에서 발생하는 에러일 것입니다. React에서 네트워크 에러를 다루는 방법에는 여러가지가 있습니다.
try {
await axios.get('/posts');
} catch (error) {
console.log(error);
}
먼저 try-catch로 에러를 잡는 방법입니다.
먼저 첫번째로 모든 구문을 try-catch로 감싸다간 코드의 가독성이 좋아지지 않고, 서버에서 받아온 데이터를 useState에 한번 다시 저장해야해서 코드도 길어지게 될 것 입니다. 또한 try-catch는 외부에서 발생하는 에러는 캐치되지 않기 떄문에 예상치 못한 에러가 무시되거나 처리되지 않을 수 있습니다.
하지만 에러가 발생한 곳에서 곧바로 에러를 처리할 수 있어, 에러를 세분화할 수 있다는 점이 장점이 있습니다.
axiosInstance.interceptors.response.use(
async (response) => {
return response;
},
async (error) => {
return Promise.reject(error);
},
);
export default axiosInstance;
그 다음, axios instance를 활용한 에러 처리 입니다. 서버와의 통신과 관련된 에러를 한번에 모두 처리할 수 있고, 해당 과정에서 모든 서버와의 통신에러를 한번에 관리할 수 있다는 장점이 있습니다.
또한 특정 로직만 검사하고, Promise.reject(error)로 에러를 다음단계로 넘겨 다음번에서 try-catch나 react-query의 onError로 재처리 할 수 있습니다.
const { data, error } = useQuery({
queryKey: ["todos"],
queryFn: getTodos,
});
return (
<div>
{ error && <div>에러입니다!</div> }
</div>
)
그 다음 react-query를 이용한 에러 처리입니다.
useState를 사용하지 않고 바로 error응답을 받아올 수 있어, 가독성도 좋아진 것을 확인할 수 있습니다. react-query 도입기에 대한 글은 다음에 더 구체적으로 작성하도록 하겠습니다.
function App(): React.JSX.Element {
return (
<ErrorHandler>
<RootStack.Screen name="SignInScreen" component={SignInScreen} />
<RootStack.Screen
name="CheckPermissionScreen"
component={CheckPermissionScreen}
/>
<RootStack.Screen
name="AuthenticationScreen"
component={AuthenticationScreen}
/>
<RootStack.Screen
name="PhoneAuthenticationCodeScreen"
component={PhoneAuthenticationCodeScreen}
/>
</ErrorHandler>
);
}
마지막으로 error-boundary 입니다. 직접 구현할 수도 있지만 react-error-boundary 라는 검증된 라이브러리가 있습니다.
react-error-boundary를 사용하여 에러를 잡고 싶은 컴포넌트의 상위 컴포넌트에 배치시킨다면, 하위 컴포넌트에서 발생하는 에러를 error-boundary애서 잡아줄 수 있게 됩니다.
그렇다면 react-error-boundary 로 모든 하위 컴포넌트의 에러를 처리하면 되지 않나요? 라는 질문을 할 수있습니다.
하지만 react-error-boundar는 비동기 코드와 이벤트 핸들러에서 발생하는 에러를 잡을 수 없는 치명적인 단점이 있습니다. 왜 잡을 수 없을까요?
React에선 모든 이벤트가 사전에 Root 에 등록되어 있기 때문입니다.
root는 최상위 tag 이고, 이곳에서 이벤트 핸들링이 이루어집니다. 즉, 우리가 만든 Button은 해당 코드가 들어가 있는 컴포넌트에서 onClick 이벤트가 다루어지는 것처럼 보이지만, 이는 root tag 에서 다루어지고 있고, 그곳에서 에러가 발생한다면 그것의 실행 컨텍스트는, ErrorBoundary 내부에 있지 않습니다.
const mainPage = () => {
return (
<button onClick={(e) => { console.log(e.nativeEvent.currentTarget) }}>
click
</button>
)
}
위의 console을 찍어보면 root 에서 이벤트가 다뤄지고 있음을 알 수 있습니다.
root에서 모든 이벤트가 다뤄지기 떄문에 이벤트 핸들링 에러가 발생하여도 root 내부에 있는 react-error-boundar 는 에러를 잡을 수 없게 됩니다. 이벤트 핸들러에서 에러를 핸들링하고 싶다면 try-catch 구문을 사용해야 합니다.
try {
setTimeout(() => {
throw new Error("Error occured"}
}, 1000);
} catch (e) {
console.log(e);
}
또한 react-error-boundar 는 비동기 코드에서 발생하는 에러를 잡을 수 없습니다. 왜그럴까요?
예시를 하나 들어 먼저 설명해보겠습니다. setTimeout 에서 Error를 throw 하고, 저렇게 try/catch 를 감싸면 에러가 catch 문에 잡힐까요? 잡히지 않게됩니다. 왜 이런 현상이 발생했을까요? 이 부분은 javascript의 실행 컨텍스트라는 개념을 통해 알 수 있습니다.
실행 컨텍스트는 식별자(변수, 함수, 클래스 등의 이름)를 등록하고 관리하는 스코프와 코드 실행 순서 관리를 구현한 내부 매커니즘으로, 실행 컨텍스트는 곧 자바스크립트의 핵심 원리입니다.
즉 함수가 실행되면 함수 실행에 해당하는 실행 컨텍스트가 생성되고, 자바스크립트 엔진에 있는 콜 스택에 차곡차곡 쌓입니다. 그리고 가장 위에 쌓여있는 컨텍스트와 관련 있는 코드를 실행하면서(LIFO), 전체 코드의 환경과 순서를 보장하게 됩니다.
function foo () {
console.log('foo')
function bar () {
console.log('bar')
}
bar();
}
foo();
이렇게 말하면 너무 어려우니 다시 예시를 들어보겠습니다.
위의 코드가 동작하는 순서를 따라가며 컨텍스트를 알아보도록 합시다.
중요한 점은 함수 실행 컨텍스트는 함수가 실행될 때 만들어진다는 점입니다. 함수를 선언할 때가 아니라 실행할 때 이지요.
해당 예제에선 전역 컨텍스트와 관련된 코드들을 순차로 진행하다가 foo 함수를 호출하면 자바스크립트 엔진은 foo에 대한 환경 정보를 수집해서 foo 실행 컨텍스트를 생성한 후 콜 스택에 담습니다.
그러면 콜 스택의 맨 위에 foo 실행 컨텍스트가 놓였으므로 전역 컨텍스트와 관련된 코드의 실행을 중지하고 foo 실행 컨텍스트와 관련된 코드, 즉 foo 함수 내부의 코드들을 순차로 실행합니다.
그리고 bar 함수의 실행 컨텍스트가 스택의 가장 위에 담기면 foo 실행 컨텍스트와 관련된 코드의 실행을 중단하고 bar 함수 내부의 코드를 순서대로 진행합니다. 코드가 모두 진행되고 bar 함수의 실행이 종료되면 bar 실행 컨텍스트가 콜 스택에서 제거되고, 그러면 foo 실행 컨텍스트가 콜 스택의 맨 위에 존재하게 되므로 중단했던 부분부터 이어서 실행하게 됩니다.
try {
setTimeout(() => {
throw new Error("Error occured"}
}, 1000);
} catch (e) {
console.log(e);
}
그렇다면 다시 위의 예제 코드로 돌아가서, 이 에러를 왜 catch가 잡지 못하였는지 알아봅시다.
setTimeout 내의 callback 은 1초 후에 실행 컨텍스트에서 실행됩니다. 그때는 이미 try/catch 문이 있던 컨텍스트가 pop 된 이후의 상황이기 때문에 catch 가 잡을 수 없고, 최상단에 에러가 던져지게 됩니다.
try/catch 문의 컨텍스트 내에서 에러가 발생하지 않았기 때문에, catch문에서 에러를 포착할 수 없는 것입니다.
즉, 에러가 발생했을때는 이미 try/catch 문이 있던 컨텍스트가 pop 된 이후의 상황이기 때문에 catch 가 잡을 수 없게되고, 최상단에 에러가 던져지게 되는 것이죠.
const MainPage = () => {
const getPost = async () => {
await axios.get('/post/1');
}
useEffect(() => {
getPost();
}, []);
return (
<div>Main Page</div>
)
}
const App() {
return (
<ErrorBoundary>
<MainPage />
</ErrorBoundary>
)
}
그렇다면 위에서 발생한 에러도 왜 이 MainPage의 상위 컴포넌트인 error-boundary에서 잡지 못했는지 알 수 있습니다.
에러가 발생한 직후에는 Error-boundary 컨택스트가 pop되어 사라져 Error-boundary 에서 잡을 수 없게되고, 에러는 더 최상위 컴포넌트로 흘러가게 되는 것이죠. 위에서 도출한 원리를 그대로 활용하자면, ErrorBoundary 내의 컨텍스트 내에서 throw 을 일으켜야 합니다. 그러므로 error 상태를 별도로 분리하고, 에러가 있으면 실행 컨텍스트 내에서 동기적으로 직접 던져버리면 됩니다.
이러한 제한은 React의 설계 철학 중 하나인 explicitness와 관련이 있습니다. React는 개발자에게 에러를 명시적으로 처리하도록 유도하려고 합니다. 이벤트 핸들러나 비동기 코드와 같은 복잡한 상황에서는 에러가 발생하기 쉬우므로, 개발자는 명시적으로 에러를 처리하는 로직을 구현해야 합니다.
const { data } = useQuery({
queryKey: ["todos"],
queryFn: getTodos,
throwOnError: true,
});
하지만 react-query 를 함께 사용한다면 비동기 에러도 처리해 줄 수 있습니다.
우리의 위와 같은 비동기 코드에서 발생할 수 있는 에러를 react-query의 onThrowError 옵션으로 해결할 수 있습니다. 이 옵션을 해결하면 상위 컴포넌트로 에러가 전파되고 비동기적으로 발생한 에러도 Error-boundary 에서 잡을 수 있게 됩니다.
const postTodo = async () => {
try {
await postTodo();
} catch (error: any) {
showBoundary(error);
}
};
또한 react-error-boundary 는 showBoundary 라는 옵션을 제공하고 있기 때문에, react-query 를 사용하지 않아도 try-catch를 이용하여 Error-boundary 로 에러를 보낼 수 있는 것이죠
저희는 이제 위 모든 것을 조합하여 에러를 핸들링 해 볼 것입니다.
axiosInstance.interceptors.response.use(
async (response) => {
return response;
},
async (error) => {
// 토큰 만료되었을때 토큰 갱신
if (error.response?.data?.code === 'AUTH_003') {
try {
const refreshToken = await getRefreshToken();
if (!refreshToken) {
// 여기서도 로그아웃 처리
throw new Error('토큰 없음');
}
// refresh 요청
const response = await axios.patch(
`${Config.SERVER_URL}api/members/refresh`,
{},
{ headers: { refreshToken: refreshToken } },
);
console.log('토큰 갱신');
const { accessToken: newAccessToken, refreshToken: newRefreshToken } =
response.data;
await setAccessToken(newAccessToken);
await setRefreshToken(newRefreshToken);
// 만료때문에 반려된 api 재요청 보내기
return axiosInstance(error.config);
} catch (refreshTokenError) {
// 여기선 무슨 에러가 발생하더라도 로그아웃 처리
console.log('로그아웃 처리하세요');
await deleteToken();
// return Promise.reject(error.response.data);
}
}
// 잘못된 토큰 -> 로그 아웃
if (
error.response?.data?.code === 'AUTH_001' ||
error.response?.data?.code === 'AUTH_002' ||
error.response?.data?.code === 'AUTH_004'
) {
// 로그아웃 시키기
console.log('잘못된 토큰');
await deleteToken();
}
return Promise.reject(error);
},
);
먼저 토큰 만료나, 잘못된 토큰 같은 경우 모든 api가 사용해야 하므로 error 401 권한에러 (저희는 410으로 사용하고 있습니다) 는 axios interceptor에서 모두 관리합니다.
즉 axios interceptor에서는 토큰 에러만을 검사하고 Promise.reject(error) 을 통해 다음 로직으로 에러를 넘겨주었습니다.
function ErrorFallback({
error,
resetErrorBoundary,
}: {
error: any;
resetErrorBoundary: () => void;
}) {
useEffect(() => {
// 400 Error시에는 toast message 띄우기
if (error.response.status == 400 && error?.response?.data?.message) {
resetErrorBoundary();
Toast.show({
type: 'error',
text1: '에러발생!',
text2: error?.response?.data?.message,
position: 'bottom',
});
return;
}
}, []);
// 500 에러시에는 해당화면 보여주기
return (
<View style={[styles.container]}>
<View>
<Text> Something went wrong: </Text>
<Button title="try Again" onPress={resetErrorBoundary} />
</View>
</View>
);
}
이와 같은 에러들 즉, 사용자가 잘못 입력하는 등 사용자가 핸들링 할 수 있는 에러에 대해서는 Error-boundary 로 넘긴 다음 toast 메세지를 띄워주었습니다.
저희 어플리케이션에서는 이메일 형식이 잘못되었다는지 하는 사용자 발생 에러가 있겠네요. 해당 에러 메세지들은 화면에 직접적으로 노출해주는 것이 사용자 편의성에도 좋을 것입니다.
const [errorMessage, setErrorMessage] = useState<string>("");
const sendMail = async () => {
try {
await emailAuthentication({ mailAddress: email });
navigation.navigate('EmailAuthenticationCodeScreen');
} catch (error: any) {
if (error.response.status == 400 && error?.response?.data?.message) {
return setErrorMessage(error.response.data.message);
}
showBoundary(error);
}
};
return (
<div>
{ errorMessage && <div>{errorMessage}</div>
</div>
)
하지만 위의 경우 사용자가 핸들링 할 수 없는 에러에 대해서는 서버의 응답코드를 통해 구분하여 showBoundary 를 통해 error-boundary에서 처리할 수 있게 하였습니다. 서버가 에러 코드와 메세지를 잘 구분하여 작성해야하는 것이 이 때문입니다.
그리고 발생하는 모든 에러들은, 추후 사용자 편의성 개선을 하기 위해 Sentry 에 기록하고 있습니다.
이렇게 저희 모두의 택시 팀이 React에서 발생하는 에러들을 처리하는 방법들에 대해서 알아보았습니다.
프론트엔드에서 에러를 핸들링하는 것은 매우 중요합니다. 사용자가 이해할 수 에러들이 화면에 자주 표시된다면 사용자의 이탈을 막을 수 없게 되기 때문이지요. 다들 에러를 잘 핸들링하여 견고한 프론트엔드 어플리케이션을 만들어보도록 합시다 :)