모던 웹 애플리케이션을 구축하는 것은 변화하는 요소가 많은 복잡한 과정입니다. 때로는 이러한 요소들이 변화를 멈추면서 문제가 발생하기도 합니다.
우리는 이러한 문제가 발생하지 않도록 할 수 있는 모든 것을 하지만, 완전히 에러가 없는 상태를 유지하는 것은 현실적으로 불가능합니다. 즉 예상치 못한 방식으로 문제가 발생할 수 있다는 것을 항상 염두에 두어야 하며, 그럴 때 이를 우아하게 처리할 수 있어야 합니다.
다시 말해, 우리는 Fault Tolerance(장애 허용성)가 필요합니다.
Fault Tolerance 란 시스템의 일부 구성 요소에 장애가 발생하더라도 시스템이 정상적으로 작동을 계속할 수 있게 하는 특성을 말합니다.
내 경험상 Fault Tolerance은 웹 애플리케이션에서 종종 간과되고 과소평가됩니다. 잠재적으로 문제가 발생하지 않을 것이라는 자신감을 주는 수백 개의 테스트가 있을 수 있지만, 불가피한 장애가 발생했을 때 어떻게 대응할지에 대해서는 충분히 고려하지 않습니다. 이는 High availability(고가용성)을 우선시하는 경우 특히 중요합니다
그렇다면 리액트 애플리케이션에서 Fault Tolerance를 어떻게 구현할 수 있을까요?
답은 바로 error boundaries입니다. 현재 이 API는 클래스 컴포넌트에서만 사용 가능하며, 다음과 같은 형태로 보입니다.
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI.
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// You can also log the error to an error reporting service
logErrorToMyService(error, errorInfo);
}
render() {
if (this.state.hasError) {
// You can render any custom fallback UI
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
💬 리액트에서 error boundary는
getDerivedStateFromError
메서드와componentDidCatch
메서드를 가진 클래스 컴포넌트입니다. 처음 사용하기에 좋은 예시를 원하신다면, 다음을 확인해보세요. react-error-boundary
리액트 공식 문서에서 error boundaries가 무엇인지와 사용하는 방법에 대해 매우 잘 설명하고 있으니, 제가 이 부분에 대해 길게 설명하지는 않겠습니다. 먼저 공식 문서를 읽어보시고, 기본적인 내용을 잘 이해한 후 다시 돌아오시면 좋을 것 같습니다.
애플리케이션에 error boundary를 추가하는 것은 쉽습니다. 몇 줄의 코드만으로 구현할 수 있죠. 어려운 점은 error boundary를 배치할 적절한 곳을 찾는 것입니다. 보통은 골디락스 원칙을 따르고, "적당한 양"의 error boundaries를 구현하는 것이 이상적이지만 그 "적당한 양"이란 무엇일까요?
먼저, 두 가지 극단적인 상황을 살펴보고 각각의 단점을 이해해 봅시다.
첫 번째 극단적인 상황: 애플리케이션 최상단에 단 하나의 error boundary를 배치하는 경우입니다.
// ⚠️ 이번 예시들에서는 react-error-boundary 라이브러리를 사용할 것입니다.
import ErrorBoundary from "react-error-boundary";
import App from "./App.js";
ReactDOM.render(
<ErrorBoundary>
<App />
</ErrorBoundary>,
document.getElementById("root")
);
이 방법은 아마 대부분의 사람들이 사용하는 방식에 가까울 것입니다. 이는 서버 렌더링 애플리케이션이 실패할 때 발생하는 상황과 본질적으로 비슷합니다. 결코 나쁘지 않지만, 최선의 방법이라고는 할 수 없습니다. 문제는 하나에서 장애가 발생하면, 애플리케이션의 나머지 부분도 모두 장애가 발생한다는 점입니다.
애플리케이션 일부에서 발생한 장애가 애플리케이션 전체를 사용할 수 없음을 의미할 때 이는 옳은 방법일 수 있습니다. 앞선 접근 방식이 옳은 경우가 분명 있지만, 일반적인 경우는 아니라고 생각합니다.
Fault Tolerance의 정의로 돌아가 보면
Fault Tolerance는 시스템의 일부에서 장애가 발생해도 시스템이 정상적으로 작동을 계속할 수 있게 하는 특성을 말합니다.
단일 error boundary 방식은 하나의 장애가 전체 애플리케이션을 무너트리기 때문에 Fault Tolerance을 제대로 구현할 수 없다는 것을 알 수 있습니다.
다른 극단적인 경우로 넘어가 보면, 모든 컴포넌트를 error boundary로 감싸는 방법을 시도할 수 있습니다. 이 접근 방식의 문제는 쉽게 규정 지을 수 없기 때문에, 이것이 왜 단일 error boundary보다 나쁠 수 있는지 구체적인 예시를 통해 살펴보겠습니다.
예를 들어, 사용자가 장바구니를 확인하고, 신용카드 정보를 입력하며, 구매를 완료할 수 있는 컴포넌트가 있다고 가정해 봅시다.
function CheckoutForm(props) {
return (
<form>
<CartDescription items={props.items} />
<CreditCardInput />
<CheckoutButton cartId={props.id} />
</form>
);
}
이제 모든 컴포넌트를 error boundary로 감싸봅시다.
<form>
<ErrorBoundary>
<CartDescription items={props.items} />
</ErrorBoundary>
<ErrorBoundary>
<CreditCardInput />
</ErrorBoundary>
<ErrorBoundary>
<CheckoutButton cartId={props.id} />
</ErrorBoundary>
</form>
🤚 실제 예시에서는 각 컴포넌트가 자신의 export 부분을 error boundary로 감싸는 방식일 것입니다. (예를 들어, react-error-boundary의 withErrorBoundary HOC 패턴 을 사용하는 방식처럼요.) 하지만 여기서는 그 부분은 무시하고 보셔도 됩니다. 🙂
처음에는 이것이 괜찮은 아이디어처럼 보일 수 있습니다. error boundaries를 더 세분화할수록, 하나의 장애가 애플리케이션 전체에 미치는 영향이 줄어들기 때문이죠. 이를 Fault Tolerance라고 할 수도 있습니다! 그러나 문제는 error의 영향을 최소화하는 것이 곧 Fault Tolerance을 의미하지는 않는다는 점입니다.
예를 들어, 컴포넌트에서 어떤 문제가 발생했다고 가정해 봅시다.
<form>
<ErrorBoundary>
<CartDescription items={props.items} />
</ErrorBoundary>
<ErrorBoundary>
{/* 에러 발생 😢 */}
<CreditCardInput />
</ErrorBoundary>
<ErrorBoundary>
<CheckoutButton cartId={props.id} />
</ErrorBoundary>
</form>
이 상황이 사용자 경험(UX)에 어떤 영향을 미칠지 살펴보면, 이것이 사용자에게 큰 불편을 줄 수 있다는 것을 알 수 있습니다.
<CreditCardInput />
컴포넌트가 자체적인 error boundary를 가지고 있기 때문에, 에러가 발생해도 <CreditCardForm />
컴포넌트의 나머지 부분으로 전파되지 않습니다. 하지만 <CreditCardInput />
컴포넌트 없이는 <CreditCardForm />
컴포넌트가 제대로 작동할 수 없죠 🤔. <CheckoutButton />
컴포넌트와 <CardDescription />
컴포넌트는 여전히 렌더링되기 때문에 사용자는 항목을 보고 결제를 시도할 수 있지만, 신용카드 정보를 다 입력하지 않았다면 어떻게 될까요? 만약 <CreditCardInput />
컴포넌트에서 장애가 발생하기 전에 신용카드 정보를 입력했다면 그 상태는 유지될까요? 사용자가 결제를 시도하면 무슨 일이 일어날까요?
아마도 이 컴포넌트들의 작성자조차도 이 상황에서 어떤 일이 일어날지 확신하지 못할 겁니다. 하물며 사용자야 말할 것도 없죠. 이는 사용자에게 혼란스럽고 실망스러울 수 있습니다.
이 상황이 얼마나 혼란스럽고 답답한지는 fallback으로 무엇을 렌더링하느냐 에 따라 달라집니다. 만약 컴포넌트가 경고 없이 화면에서 갑자기 사라진다면, 대부분의 사용자들은 매우 혼란스러울 것입니다.
만약 그렇지 않다면, 아마도 공통 fallback UI를 사용하고 있을 겁니다. 아마도, sad face 😭 와 함께 에러에 대한 유용한 정보를 제공할 것입니다. 없는 것보다는 낫지만, 모든 컴포넌트를 error boundary로 감싼다면 fallback이 가능한 모든 UI 요소에 맞게 적절하게 렌더링되어야 한다는 의미입니다. 그러나 이는 거의 불가능에 가깝습니다. 왜냐하면 각각의 요소들은 서로 다른 레이아웃 요구 사항을 가지고 있기 때문이죠. page-level의 section(예: header)에 적합한 fallback은 작은 아이콘 버튼에 적합하지 않을 수 있으며, 그 반대의 경우도 마찬가지입니다.
문제의 핵심: 모든 컴포넌트를 error boundary로 감싸는 것은 사용자에게 혼란스럽고 불완전한 사용자 경험을 초래할 수 있습니다. 애플리케이션이 일관성 없는 상태로 변해, 사용자를 혼란스럽게 할 수 있습니다. 흥미로운 점은, 이러한 "망가진 상태"를 피하는 것이 바로 error boundary가 존재하는 주요 이유 중 하나라는 것입니다.
이 결정을 두고 많은 고민을 했지만, 우리의 경험상 망가진 UI를 그대로 남겨두는 것보다 완전히 제거하는 것이 더 나은 선택이라고 판단했습니다.
🏎 성능 저하
Error Boundaries는 어느 정도의 오버헤드를 수반하기 때문에, 남용할 경우 성능에 부정적인 영향을 미칠 수 있습니다. 하지만 이것은 error boundary를 모든 곳에 사용할 때만 문제가 되므로, 이 때문에 error boundary 사용을 두려워할 필요는 없습니다.
정리하자면, error boundaries가 너무 적으면 불필요하게 애플리케이션의 더 많은 부분이 중단되고, 너무 많으면 망가진 UI state로 이어질 수 있습니다. 그렇다면 error boundaries는 몇 개가 적당할까요?
이는 애플리케이션에 따라 다르기 때문에 정확한 숫자를 말하기는 어렵습니다. 제가 찾은 최선의 방법은 애플리케이션의 feature boundaries를 식별한 후, 그 경계 지점에 error boundaries를 배치하는 것입니다.
임의의 애플리케이션을 보고 그 경계를 식별하는 데 사용할 수 있는 '기능'에 대한 보편적인 정의는 없습니다. "보면 안다"는 것이 일반적으로 우리가 할 수 있는 최선이지만, 참고할 수 있는 몇 가지 공통적인 패턴이 있습니다.
대부분의 애플리케이션은 개별 섹션들이 모여서 구성됩니다. 예를 들어 header, navigation, main content, sidebars, footers 등이 있죠. 이 각각의 섹션은 전체적인 사용자 경험에 기여하면서도 어느 정도 독립성을 유지합니다.
트위터(현 X)를 예로 들어 살펴보겠습니다:
한눈에 봐도 페이지에는 분명히 구분된 섹션들과 기능들이 존재합니다. 트윗을 보여주는 메인 타임라인, 팔로우 추천, 트렌드 섹션, 네비게이션 바가 있습니다. 이 섹션들의 레이아웃과 스타일링만 봐도 섹션 간의 분리가 있다는 것을 알 수 있으며, 이는 error boundaries를 배치하기에 좋은 시작점입니다. 시각적으로 독립적인 섹션은 기능적으로도 독립적일 가능성이 크고, 그런 곳에 error boundaries를 두는 것이 적절합니다.
만약 여러 섹션 중 하나의 섹션의 특정 컴포넌트에서 에러가 발생한다면, 다른 섹션까지 같이 중단되지 않아야 한다는 것은 타당해보입니다. 예를 들어, 팔로우 추천 섹션에서 팔로우 버튼이 장애를 일으켜도 메인 타임라인까지 멈추게 해서는 안 됩니다.
UI는 종종 재귀적 구조를 가집니다. 페이지 레벨에서는 사이드바나 타임라인 같은 큰 섹션들이 있지만, 이 섹션들 내부에도 헤더나 리스트 같은 하위 섹션들이 있고, 그 안에도 또 다른 섹션들이 있습니다.
error boundaries를 어디에 배치할지 고민할 때, 스스로에게 던질 좋은 질문은 "이 컴포넌트에서 발생한 에러가 형제 컴포넌트들에게 어떤 영향을 미치는가?" 입니다. 예시에서 우리가 고려했던 질문도 바로 이것이었죠: 만약 CreditCardInput 이 실패하면 CheckoutButton과 CardDescription은 어떤 영향을 받을까요?
이 질문을 재귀적으로 컴포넌트 트리에 적용하면, 각 feature boundires를 쉽게 식별할 수 있고, 그 경계에 맞춰 error boundaries를 배치할 수 있습니다.
다시 트위터를 예로 들어 이 접근 방식이 어떻게 작동하는지 살펴보겠습니다. 최상단부터 시작해서 팔로우 추천 섹션까지 자세히 파고들어 보겠습니다.
이 분석에는 제가 이 기능들이 어떻게 작동하는지에 대한 인식과 기대를 바탕으로 한 의견과 편견이 많이 포함되어 있습니다. 이는 "정답"을 제시하려는 것이 아니라 과정 자체에 대한 감을 잡기 위한 것입니다.
우리는 최상단부터 시작해서 세 가지 메인 컨텐츠 섹션을 확인할 수 있습니다: Home, Trends for you, 그리고 Who to follow. 이제 Who to follow 섹션을 자세히 살펴보겠습니다.
먼저 스스로에게 질문합니다:
이 컴포넌트에서 발생한 에러가 형제 컴포넌트들에게 어떤 영향을 미치는가?
이 질문을 더 구체적으로 바꿔보면:
이 컴포넌트가 고장나면 형제 컴포넌트도 고장나야 할까?
만약 Who to follow 섹션에서 장애로 인해 멈췄을 때 Home과 Trends for you 섹션도 함께 멈춰야 하는지 고민해보자면, 저는 명확하게 아니라고 생각합니다. 각 섹션은 서로 의존하지 않기 때문에, 이곳에 error boundary를 배치하는 것은 적절한 선택입니다.
이제 같은 질문을 Who to follow 섹션에도 적용해 봅니다.
Who to follow 섹션을 보면, 명확하게 구분된 세 개의 섹션이 있습니다: 제목, 팔로우할 사용자 목록, 그리고 더 보기 버튼. 이제 사용자 목록을 자세히 들여다보며 동일한 질문을 다시 던져봅니다: 만약 팔로워 목록에 장애가 발생한다면, 제목과 더 보기 버튼도 함께 장애가 발생해야 할까요? 이번 경우는 조금 덜 명확하지만, 제 생각에는 역시 그럴 필요가 없을 것 같습니다. 제목을 유지한다고 해서 문제가 될 건 없고, 더 보기 버튼은 작동 중일 수 있는 다른 페이지로 연결되기 때문입니다. 그래서 팔로워 목록에 또 하나의 error boundary를 추가하는 것이 좋겠습니다!
한 번 더 해봅시다. 이번에는 팔로우 추천 섹션을 살펴보겠습니다.
여기에는 두 가지 섹션만 있으니 간단하게 질문할 수 있습니다: 만약 사용자의 이름과 고유 사용자명(@)이 장애를 일으킨다면, 팔로우 버튼도 함께 장애가 발생해야 할까요? 반대의 경우에는요?
저는 그렇다라고 생각됩니다! 사용자의 이름과 고유 사용자명(@)이 사라지면 우리가 누구를 팔로우하려는지 알 수 없게 됩니다. 그리고 팔로우 버튼이 사라지면 사용자는 추천만 받고 아무런 행동을 할 수 없기 때문에 불만족스러운 경험이 될 수 있습니다.
이제 error boundaries와 Fault Tolerance에 대해 좀 더 알게 되었으니, 제가 이 과정에서 가장 좋아하는 부분을 하나 공유하고 싶습니다: 바로 이 모든 것을 테스트하는 방법입니다. 제가 발견한 애플리케이션의 Fault Tolerance를 테스트하는 가장 쉽고 간단한 방법은 일부러 에러를 일으키는 것입니다.
function CreditCardInput(props) {
// What happens if I messed up here? Let's find out!
throw new Error("oops, I made a mistake!");
return <input className="credit-card" />;
}
이건 제가 새로운 컴포넌트를 추가할 때마다 했던 작업인데, 애플리케이션이 장애를 어떻게 처리하는지 확인하는 데 정말 유용했습니다. 다만, throw 문을 사용해서 일부러 장애를 발생시킨 코드를 커밋하지 않도록 주의하세요 🙂
🤔 참고: 카오스 엔지니어링 (Chaos Engineering)
의도적으로 에러를 발생시켜 fault tolerance를 테스트하는 것은 카오스 엔지니어링 의 매우 가벼운 예시입니다. 리액트 커뮤니티에서도 이 개념을 활용한 도구들이 더 많이 생겼으면 좋겠어요.
만약 React.ChaosMode 같은 것이 있어서, 랜덤으로 일부 컴포넌트들을 고장 내서 fault tolerance를 테스트할 수 있다면 어떨까요?
모든 내용을 요약하자면, 다음과 같습니다:
최상단에 단일 error boundary만 두는 것은 피하세요. 이런 방식이 장애를 처리하는 최선의 방법인 경우는 드뭅니다.
error boundaries를 과도하게 사용하지 마세요. 이것은 사용자 경험을 저하시키고 성능에 악영향을 미칠 수 있습니다.
애플리케이션의 feature boundaries를 식별하고, 그곳에 error boudaries를 배치하세요. 리액트 앱은 트리 구조로 되어 있으니, 상단에서 시작해 하위로 내려가며 error boundary를 추가하는 것이 좋은 접근법입니다.
"이 컴포넌트가 고장나면 형제 컴포넌트도 고장나야 할까?"라는 질문을 재귀적으로 던져보세요. 이 방법은 feature boundaries를 찾는 데 좋은 기준이 됩니다.
에러 상태를 사전에 고려하여 애플리케이션을 설계하세요. feature boundaries에 error boudaries를 배치하면, 사용자에게 문제 발생을 알리고, 사용자 친화적인 fallback UI를 제공하기가 쉬워집니다. 또한, 기능별로 재시도 로직을 구현하면 사용자가 페이지 전체를 새로고침하지 않고도 해당 섹션만 새로고침할 수 있습니다.
일부러 에러를 발생시키고, 어떤 일이 일어나는지 확인해 보세요