현재 개발하고 있는 솔루션은 화면에서 Error가 발생하면, 해당 Error 발생 시점부터 사용자가 새로고침을 하기 전까지 애플리케이션 전체가 정상적으로 동작하지 않는 이슈가 있습니다. 이로 인해 사용자 입력이 반영되지 않거나 화면이 다시 렌더링 되지 않는 등의 현상이 나타납니다. 이를 해결하기 위한 방법인 Error Boundary에 대해 리서치 하고 테스트 한 결과를 정리했습니다.
UI의 일부분에 존재하는 자바스크립트 에러가 전체 애플리케이션을 중단시키면 안 됩니다. Error boundary 컴포넌트는 하위 컴포넌트에서 발생한 에러가 상위 컴포넌트로 전파되는 것을 막고, 깨진 컴포넌트 트리 대신 Fallback UI를 보여줍니다. 즉, 상위 컴포넌트는 하위 컴포넌트의 에러를 모른 채 정상적으로 계속 작동 할 수 있습니다.
또한, 에러 종류에 따라 적절한 화면을 정의하여 보여줌으로써, 애플리케이션 전반에서 에러와 관련된 일관성 있는 사용자 경험을 제공할 수 있습니다.
<template>
<div>
<SiblingComponent />
<ErrorBoundary>
<ChildComponent />
</ErrorBoundary>
</div>
</template>
<script setup lang="ts">
import ErrorBoundary from './ErrorBoundary.vue';
import ChildComponent from './ChildComponent.vue';
import SiblingComponent from './SiblingComponent.vue';
위의 코드에서, ErrorBoundary 컴포넌트로 감싸진 ChildComponent에서 발생한 에러는 상위 컴포넌트와 형제 컴포넌트인 SiblingComponent에 영향을 주지 않습니다.
React는 v16 부터 공식적으로 Error Boundary 컴포넌트를 제공합니다. React의 Error Boundary는 렌더링 도중 생명주기 메서드 및 그 아래에 있는 전체 트리에서 에러를 잡아냅니다.
에러 경계(Error Boundarise) - React 공식문서

Vue는 Error Boundary 컴포넌트를 공식적으로 제공하지는 않지만, 자식 컴포넌트에서 전파된 에러가 캡쳐되었을 때 호출될 콜백을 등록할 수 있는 onErrorCaptured lifecycle hook을 제공합니다. 해당 훅을 사용하여 Error Boundary를 구현하였습니다.
Life Cycle Hook #onErrorCaptured() - Vue 공식문서
완성본이 아닌 임시로 구현한 코드입니다.
<template>
<div v-if="hasError" class="error-boundary-overlay">
<div class="error-container">
<div v-if="errorStatus === 403" class="error-boundary-content">
<span
class="material-symbols-outlined material-symbols-filled material-symbols-14 material-symbols-default error-icon"
>lock</span
>
<p class="error-message">
작업을 수행할 권한이 없습니다. 관리자에게 문의하세요.
</p>
</div>
<div v-else-if="errorStatus === 500" class="error-boundary-content">
<span
class="material-symbols-outlined material-symbols-filled material-symbols-14 material-symbols-default error-icon"
>error_outline</span
>
<p class="error-message">API에 문제가 발생했습니다.</p>
</div>
<div v-else-if="errorStatus === 503" class="error-boundary-content">
<span
class="material-symbols-outlined material-symbols-filled material-symbols-14 material-symbols-default error-icon"
>network_check</span
>
<p class="error-message">
시스템에 문제가 발생했습니다. 잠시후 다시 시도해주세요.
</p>
</div>
<div v-else class="error-boundary-content">
<span
class="material-symbols-outlined material-symbols-filled material-symbols-14 material-symbols-default error-icon"
>warning</span
>
<p class="error-message">예상치 못한 에러가 발생했습니다.</p>
</div>
</div>
</div>
<slot v-else></slot>
</template>
<script lang="ts" setup>
import {
ref,
onErrorCaptured,
watch,
toRaw,
ComponentPublicInstance,
} from 'vue';
import { useRoute } from 'vue-router';
import { AxiosHeaders } from 'axios';
import { DefaultResType } from '@/types/common/DefaultResType';
const hasError = ref(false);
const errorStatus = ref();
const route = useRoute();
interface FetchError {
config: any;
data: DefaultResType;
headers: AxiosHeaders;
request: any;
status: number;
statusText: string;
}
// 함수가 true를 반환하면 함수가 호출된 블록 스코프에서 타입이 FetchError로 추론
function isFetchError(error: any): error is FetchError {
return (
typeof error === 'object' &&
error !== null &&
error.headers &&
error.headers.constructor?.name === 'AxiosHeaders'
);
}
onErrorCaptured(
(err: unknown, instance: ComponentPublicInstance | null, info: string) => {
hasError.value = true;
if (isFetchError(err)) {
errorStatus.value = err.status;
} else {
errorStatus.value = 0;
console.error(
'Error captured in ErrorBoundary Component: \n',
err,
'\n in Component: ',
instance?.$?.type
);
}
// 에러가 상위 컴포넌트로 전파되지 않도록 막음
return false;
}
);
// 라우트 변경 시 에러 상태 초기화
watch(
() => route.path,
() => {
hasError.value = false;
errorStatus.value = 0;
}
);
</script>
<style scoped lang="scss">
.error-boundary-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(230, 230, 230); /* 흐린 배경 */
display: flex;
justify-content: center;
align-items: center;
z-index: 10; /* 오류가 발생한 UI 구역 위에 표시 */
border-radius: 8px;
}
.error-container {
text-align: center;
padding: 20px;
border-radius: 10px;
max-width: 400px; /* 에러 박스의 최대 크기 */
width: 100%; /* 컨테이너가 부모 요소의 크기를 채우도록 */
}
.error-boundary-content {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.error-icon {
font-size: 48px;
margin-bottom: 10px;
color: #333; /* 아이콘 색상 */
}
.error-message {
font-size: 16px;
color: #333;
margin: 0;
}
.material-icons {
font-size: 48px; /* 아이콘 크기 */
}
</style>
ErrorBoundary 컴포넌트의 onErrorCaptured() 훅에서 false를 반환하면 에러가 더이상 상위 컴포넌트로 전파되지 않습니다. hasError 속성이 true이면 Fallback UI를 렌더링합니다.
- hasError 속성이 true 일 때 errorStatus 값에 따라 서로 다른 UI를 렌더링합니다.
Vue의 onErrorCaptured() 훅은 에러, 에러를 트리거한 컴포넌트 인스턴스, 에러 소스 유형을 지정하는 정보 문자열, 총 세 개의 인자를 받습니다.
onErrorCaptured(
(err: unknown, instance: ComponentPublicInstance | null, info: string) => {
})
첫번째 인자인 err에는 모든 타입의 Error가 들어올 수 있기 때문에 기본적으로 unknown 타입을 갖습니다.
따라서 이 err를 에러의 종류(Axios Error / 그 밖의 Error)에 따라 분기하여 처리하려면 각 err의 속성에 안전하게 접근하기 위한 타입 가드가 필요합니다.
타입스크립트 instanceof 연산자를 이용해 타입가드를 설정하려고 했으나, 403, 500과 같은 Error response가 AxiosError 타입으로 판단되지 않는 이슈가 있습니다. 원인을 아직 명확히 알 수 없으나, tanstack-query로 response가 한번 더 감싸지면서 AxiosError 타입으로 추론되지 않는 것으로 짐작하고 있습니다. (추후 명확한 원인 파악 후 개선되어야 할 부분입니다.)
우선은 FetchError 타입인지 판단할 수 있는 사용자 정의 타입 가드 함수를 정의하여 캡쳐한 err가 Axios Error인 경우 에러 코드를 errorStatus로 설정하는 로직을 구현했습니다. 그 외의 JS, 혹은 렌더링 및 라이프사이클 오류의 경우 errorStatus를 0으로 설정했습니다.
// 함수가 true를 반환하면 함수가 호출된 블록 스코프에서 타입이 FetchError로 추론
function isFetchError(error: any): error is FetchError {
return (
typeof error === 'object' &&
error !== null &&
error.headers &&
error.headers.constructor?.name === 'AxiosHeaders'
);
}
onErrorCaptured(
(err: unknown, instance: ComponentPublicInstance | null, info: string) => {
hasError.value = true;
if (isFetchError(err)) {
errorStatus.value = err.status;
} else {
errorStatus.value = 0;
console.error(
'Error captured in ErrorBoundary Component: \n',
err,
'\n in Component: ',
instance?.$?.type
);
}
// 에러가 상위 컴포넌트로 전파되지 않도록 막음
return false;
}
);
Fallback UI를 보여줄것인지를 판단하는 hasError 속성은 기본적으로 애플리케이션이 처음 mount 되었을 때, ErrorBoundary 컴포넌트가 새로고침 등의 이유로 unMount되었다가 다시 mount 되었을 때, route.path가 이동했을 때 초기화 되도록 구현하였습니다.
Error Boundary 컴포넌트는 솔루션 전체에서 일관성 있게 적용 되어야 하기 때문에, host에 두고 관리하는 것이 좋을 것 같습니다. host에서 Error Boundary 컴포넌트를 작성해서 expose 하고, 해당 컴포넌트를 remote에서 import해서 사용하는 방식으로 구현했고, 테스트 완료 했습니다.
host-app vue.config 파일
exposes: {
...
'./components/ErrorBoundary': './src/views/error/ErrorBoundary',
...
},
각 remote-app의 App.vue
<template>
<TheLNB />
<div class="container">
<ErrorBoundary>
<Content />
</ErrorBoundary>
</div>
</template>
<script lang="ts" setup>
import TheLNB from 'hostMaestro/components/TheLNB';
import ErrorBoundary from 'hostMaestro/components/ErrorBoundary';
<script/>
일반적으로 에러 바운더리를 두는 위치는 다음과 같습니다.

현재는 remote에서 LNB를 제외한 Content 영역에 ErrorBoundary 컴포넌트를 두었습니다.
일반적으로 에러는 특정 페이지에서 발생합니다. 특정 페이지에서 에러가 발생해도 사용자는 LNB 클릭을 통해 다른 페이지로 이동하여 정상적인 이용을 지속할 수 있어야 한다고 생각했기 때문에 LNB 컴포넌트를 제외하고 Content 영역에만 두었으나, 의견 주시면 감사하겠습니다.
ErrorBoundary 컴포넌트를 더 하위 레벨에서 사용할 수도 있습니다.
이 경우, ErrorBoundary로 감싸진 컴포넌트에 해당하는 영역만 Fallback UI가 노출됩니다. 차트 등, 일시적인 이슈가 생길 가능성이 조금 더 높은 컴포넌트를 감싸서 사용하면 UX 향상에 도움됩니다.
하위 컴포넌트에서 발생한 Error가 캡쳐되면 onErrorCaptured() 훅에서 false를 반환하고 Fallback UI가 렌더링됩니다. 이 때 사용자가 다른 특정 행동을 하면 Fallback UI에서 벗어나서 사용자의 행동대로 계속 작동해야합니다.
대표적으로, 사용자가 LNB 등을 클릭하여 다른 페이지로 이동해서 라우트가 변경되었을 때, Fallback UI가 아닌 해당 페이지의 UI를 렌더링해야 합니다. 이를 위해 ErrorBoundary 컴포넌트 내에서 route.path를 감시(watch)해서 route.path에 변화가 생기면 hasError 를 false로 초기화하도록 구현하였습니다.
기획 및 정책에 따라 Fallback UI에 ‘다시 시도하기' 버튼을 두고, 버튼 클릭 시 에러 바운더리 상태를 reset하는 경우도 있습니다. GET 요청 실패 시 해당 버튼을 적용한다면 사용자는 직접 새로고침을 하지 않고도 다시 데이터를 fetching 할 수 있을 것입니다.
개발자 경험(DX)를 위해서는 화면에서 자바스크립트 에러가 발생했을 때 콘솔 출력을 통한 에러 추적이 ErrorBoundary 컴포넌트 적용 전/후가 비슷한 수준으로 가능해야 합니다.

초록색 박스
기존에는 해당 초록색 박스 부분을 클릭하여 에러가 발생한 컴포넌트의 소스를 확인 할 수 있었습니다. 하지만 에러 바운더리 적용 이후에는 에러가 최종 캡쳐된 에러 바운더리 컴포넌트로 연결되어 디버깅에 도움되진 않습니다.
빨간색 박스
대신, 기존에 확인 가능했던 수준의 에러 추적은 보통 빨간색 박스 부분을 클릭하여 확인 할 수 있습니다. 해당 링크 클릭 시, 에러가 발생한 컴포넌트가 js로 변환된 파일의 소스를 볼 수 있어 대략적인 에러 추적이 가능합니다.
파란색 박스
조금 더 편리한 에러 추적을 위해 에러 캡쳐 시, 콘솔에 에러와 함께 에러가 발생한 인스턴스에 관한 정보를 출력하도록 하였습니다. 에러가 발생한 컴포넌트의 이름, 파일 경로 등을 확인 할 수 있습니다.
결론적으로, ts 파일, vue 파일 내의 template태그, script태그 각각에 에러를 발생시켜 테스트 해 본 결과, 에러 바운더리 적용 전후에서 유사한 수준으로 에러추적이 가능합니다.
Error Boundary가 제대로 작동하는지 확인하기 위해 테스트 용으로 만든 임시 UI입니다.
테스트를 위해 운영자 메뉴관리 페이지에 의도적으로 에러를 발생시킨 상황입니다.
사내 컨플루언스 문서에는 영상을 첨부하였으나, 회사 제품이므로 이 포스트에서는 영상을 생략하겠습니다.
에러 바운더리 컴포넌트를 적용하지 않았을 때 사용자가 애플리케이션을 사용하던 중 에러가 있는 페이지(운영자 메뉴관리 페이지)에 접속하면, 그 이후로 새로고침 하기 전까지는 애플리케이션 전체가 어떠한 작동도 하지 않습니다.
에러 바운더리 컴포넌트를 적용했을 때, 사용자가 에러가 있는 페이지(운영자 메뉴관리 페이지)에 접속하면 해당 페이지에서 발생한 error가 Error Boundary 컴포넌트에서 캡쳐되어 더이상 상위로 전파되지 않으며, Fallback UI가 노출됩니다. 애플리케이션이 중단되지 않기 때문에 사용자는 새로고침 없이 다른 행동을 이어갈 수 있습니다.
( 사진 생략 )
403(권한없음) / 503(Service Unavailable) / ... / 그 외의 Error Fallback UI를 다르게 구현하였습니다.
( 사진 생략 )
에러 바운더리로 감싼 컴포넌트의 영역에만 Fallback UI가 노출되도록 구현하였습니다.
에러 타입(ex. 자바스크립트 에러 / Axios 403, 500 에러 등)별로 각각 어떤 UI를 보여줄 것인지 기획/디자인/퍼블리싱이 필요합니다.
API 403(권한없음) response를 받았을 경우 Fallback UI에서 문의하기 이동할 수 있는 버튼 또는 링크를 제공해도 좋을 것 같습니다.
API 401(Unauthorized) response를 받았을 경우 로그인 페이지로 리다이렉트 시키는것이 가능한지 확인이 필요합니다.
적절한 수준에서 Error Boundary를 배치하면 오류가 발생해도 사용자 경험을 최대한 보호할 수 있습니다.
기본적으로 Content를 감싸도록 Error Boundary를 설정해 두었고,
데이터 위젯, 차트, 리스트 등 특정 컴포넌트 단위로 에러 바운더리를 추가로 설정하여 개별 UI가 깨져도 나머지 UI는 정상 작동하도록 설정할 수 있습니다. 이와 관련한 정책이 필요합니다.
현재는 route.path의 변화가 있을 때만 에러바운더리 상태를 리셋하여 Fallback UI가 아닌 기존 화면을 재렌더링하도록 구현하였습니다.
그 밖의 어떤 상황에서 에러 바운더리 상태를 리셋할 것인지에 대한 정책이 필요합니다.
403, 500, JS 에러 외에도 다양한 Error Status로 분기처리 할 수 있는 확장성을 고려하여 hasError 상태를 변경하거나, 특정 커스텀 동작을 수행할 수 있도록 Composable 함수(custom hook)로 구현하는 것을 고려하고 있습니다.
onErrorCaptured 훅에서 error를 사용자 정의 타입 가드 함수가 아닌, instanceof 연산자를 사용해 공식적인 AxiosError 타입으로 타입을 좁힐 수 있도록 수정이 필요합니다. 문제 원인을 찾는중입니다.
구현 가능성 정도만 테스트 했습니다. CMP에 실제로 적용을 위해서는 컴포넌트 고도화와 더 많은 논의가 필요합니다.