React 애플리케이션에서 에러 바운더리를 활용해 예기치 못한 실패를 우아하게 처리하고, 적절한 경계에 배치해 내결함성을 높이는 방법을 다룹니다.
현대적인 웹 앱을 만드는 일은 많은 구성 요소가 맞물려 돌아가는 복잡한 과정입니다. 때로는 이런 구성 요소들이 멈춰 서고, 그때부터 이것저것 망가지기 시작하죠.
우리는 이런 일이 일어나지 않도록 할 수 있는 한 최대한 예방하지만, 현실적으로 우리는 결코 완전히 무오류일 수는 없습니다. 즉, 예상치 못한 방식으로 무언가가 가끔은 반드시 깨질 것을 항상 전제로 해야 하고, 그 상황을 우아하게 처리할 수 있어야 합니다.
다시 말해, 우리는 내결함성(fault tolerance)이 필요합니다:
내결함성은 구성 요소 중 일부(하나 이상의 결함)가 실패하더라도 시스템이 정상적으로 계속 동작할 수 있게 해주는 성질이다.
내 경험상 내결함성은 웹 앱에서 종종 간과되고 과소평가됩니다. 수백 개의 테스트가 있어서 아마도 깨지지 않을 거라는 확신을 갖게 해줄 수는 있지만, 피할 수 없는 실패가 실제로 일어났을 때 무엇이 벌어지는지에 대해서는 같은 수준의 시간을 들여 고민하지 않습니다. 특히 고가용성(high availability)이 우선순위라면(그리고 대부분 그렇죠) 이는 더 중요합니다.
그렇다면 React 애플리케이션이 내결함성을 갖추도록 하려면 어떻게 해야 할까요?
짧게 답하면 에러 바운더리(error boundaries)입니다. 현재 이 API는 클래스 컴포넌트에서만 사용할 수 있고 대략 이런 형태입니다:
jscomponentDidCatch(error) { // 이 메서드가 호출되면 에러가 발생했다는 뜻입니다! // 결함 감지(fault detection) 만세! // 이제 setState 등을 호출해서 대체 UI(fallback UI)를 렌더링해 // 사용자에게 에러가 발생했음을 알릴 수 있습니다. this.setState({ error, showFallback: true }); }
💬
React에서 에러 바운더리는 componentDidCatch 메서드를 가진 클래스 컴포넌트일 뿐입니다. 좋은 출발점이 필요하다면 react-error-boundary를 확인해보세요.
React 문서는 에러 바운더리가 무엇인지, 어떻게 사용하는지 훌륭하게 설명하고 있으니 여기서는 그 부분에 오래 머물고 싶진 않습니다. 먼저 공식 문서를 읽고, 기본 개요가 필요하면 다시 돌아오세요.
애플리케이션에 에러 바운더리를 추가하는 일은 쉽습니다. 코드 몇 줄이면 끝이죠. 어려운 부분은 어디에 배치할지를 찾는 것입니다. 보게 되겠지만 보통 골디락스 원리(Goldilocks principle)를 따라 “딱 적당한 수준”의 에러 바운더리를 두는 게 좋습니다. 하지만 그 “딱 적당한 수준”이란 대체 무엇일까요?
먼저 두 극단을 살펴보고 각각의 단점이 무엇인지 보겠습니다.
첫 번째 극단: 애플리케이션 최상단에 에러 바운더리 하나만 두는 경우입니다.
js// ⚠️ 예시에서는 react-error-boundary를 사용합니다 import ErrorBoundary from "react-error-boundary"; import App from "./App.js"; ReactDOM.render( <ErrorBoundary> <App /> </ErrorBoundary>, document.getElementById("root") );
아마 많은 사람들이 이와 비슷하게 할 겁니다. 서버 렌더링 애플리케이션이 실패했을 때와 본질적으로 비슷한 결과가 나옵니다. 경험이 끔찍하진 않지만, 우리가 할 수 있는 최선도 아닙니다. 문제는 한 군데가 실패하면 나머지도 함께 실패한다는 점입니다.
이 방식은 애플리케이션 어느 부분에서든 실패가 나면 전체가 쓸모없어지는 경우라면 타당합니다. 실제로 그런 경우도 있지만, 흔한 케이스라고 보진 않습니다.
내결함성의 정의로 다시 돌아가 보면:
내결함성은 실패가 발생하더라도 시스템이 계속 동작할 수 있게 해주는 성질
단일 에러 바운더리는 사실 내결함성을 제공하지 못합니다. 하나의 실패가 전체 애플리케이션을 무너뜨리기 때문입니다.
다른 극단으로, 모든 컴포넌트를 에러 바운더리로 감싸려 할 수도 있습니다. 이 접근의 문제는 좀 더 미묘하니, 왜 단일 에러 바운더리보다 더 나쁠 수 있는지 자세한 예시로 보겠습니다.
사용자가 장바구니 내용을 보고, 신용카드 정보를 입력하고, 결제를 진행할 수 있는 <CheckoutForm /> 컴포넌트가 있다고 해봅시다.
jsfunction CheckoutForm(props) { return ( <form> <CartDescription items={props.items} /> <CreditCardInput /> <CheckoutButton cartId={props.id} /> </form> ); }
이제 여기 있는 모든 컴포넌트를 에러 바운더리로 감싸봅시다.
js// 모두에게 에러 바운더리를! <form> <ErrorBoundary> <CartDescription items={props.items} /> </ErrorBoundary> <ErrorBoundary> <CreditCardInput /> </ErrorBoundary> <ErrorBoundary> <CheckoutButton cartId={props.id} /> </ErrorBoundary> </form>
🤚
현실의 예시에서는 각 컴포넌트가 이런 식으로 인라인으로 감싸기보다는(위 예시처럼) 보통 자기 export를 에러 바운더리로 감쌀 겁니다. 예를 들어 react-error-boundary의 withErrorBoundary HOC 같은 방식이죠. 그 부분은 그냥 무시해도 됩니다 🙂
처음 보기에는 괜찮은 생각처럼 보일 수 있습니다. 에러 바운더리가 더 세분화될수록 단일 실패가 앱 전체에 미치는 영향이 줄어드니까요. 그게 내결함성처럼 들립니다! 하지만 문제는 에러의 영향을 최소화하는 것과 내결함성을 갖추는 것은 같은 말이 아니라는 것입니다.
CreditCardInput 컴포넌트 어딘가가 깨졌다고 가정해봅시다.
js<form> <ErrorBoundary> <CartDescription items={props.items} /> </ErrorBoundary> <ErrorBoundary> {/* 이런! 여기 안에서 뭔가가 깨졌어요 😢 */} <CreditCardInput /> </ErrorBoundary> <ErrorBoundary> <CheckoutButton cartId={props.id} /> </ErrorBoundary> </form>
이게 사용자 경험(UX)에 어떤 의미인지 뜯어보면, 사용자를 꽤 괴로운 상황으로 몰아넣을 수 있음을 알 수 있습니다.
CreditCardInput에 자체 에러 바운더리가 있으니 에러는 CheckoutForm의 나머지로 전파되지 않습니다. 하지만 CheckoutForm은 CreditCardInput 없이 쓸 수 없는 UI입니다 🤔. CheckoutButton과 CartDescription은 여전히 마운트되어 있으므로 사용자는 아이템을 볼 수도 있고 결제 시도를 할 수도 있습니다. 그런데 신용카드 정보를 다 입력하지 못한 상태라면요? 만약 CreditCardInput이 크래시 나기 전에 이미 신용카드 정보를 입력했었다면 그 상태는 유지되나요? 그 상태에서 결제를 시도하면 무슨 일이 벌어질까요?
아마 이 컴포넌트들을 만든 개발자조차 이런 상황에서 어떤 일이 벌어질지 모를 겁니다—사용자는 더더욱 그렇고요. 이는 혼란스럽고 좌절감을 주기 쉽습니다.
이 혼란과 좌절감의 정도는 _폴백으로 무엇을 렌더링하느냐_에 따라 달라지기도 합니다. 경고도 없이 컴포넌트가 화면에서 사라져 버리면? 대부분의 사람에게 매우 혼란스러울 겁니다.
그렇지 않다면, 아마도 공용 폴백 UI를 쓰고 있을 가능성이 큽니다. 예를 들어 슬픈 얼굴과 함께 에러에 대한 도움이 되는 정보를 보여주는 식이죠. 아무것도 없는 것보다는 낫지만, 모든 컴포넌트를 이 에러 바운더리로 감싸면 이 폴백은 _모든 가능한 UI 요소_에서 올바르게 렌더링되어야 합니다. 그런데 UI 요소마다 레이아웃 요구사항이 달라서 이를 제대로 맞추기는 거의 불가능합니다. 헤더처럼 페이지 단위 섹션에는 좋은 폴백이 작은 아이콘 버튼에는 나쁠 수 있고, 그 반대도 마찬가지입니다.
이제 문제점이 보일 겁니다: 모든 컴포넌트를 에러 바운더리로 감싸면 혼란스럽고 깨진 사용자 경험을 만들 수 있습니다. 애플리케이션이 일관되지 않은 상태에 빠지기 쉽고, 사용자를 답답하게 만들고 혼란스럽게 할 수 있습니다. 흥미롭게도 “오염된 상태(corrupted state)”를 피하는 것이 바로 에러 바운더리가 존재하는 주요 이유 중 하나입니다:
우리는 이 결정을 두고 논의했지만, 우리의 경험상 오염된 UI를 그대로 두는 것보다 완전히 제거하는 것이 더 낫다
🏎
에러 바운더리에는 본질적인 오버헤드도 있어서, 과도하게 사용하면 성능에 부정적인 영향을 줄 수 있습니다. 다만 이는 정말로 여기저기 모든 곳에 쓸 때 주로 문제가 되니, 이 때문에 에러 바운더리 사용 자체를 두려워하진 마세요.
정리해보면: 에러 바운더리가 너무 적으면 필요 이상으로 앱의 더 큰 부분이 같이 무너지고, 너무 많으면 UI 상태가 오염될 수 있습니다. 그렇다면 에러 바운더리는 얼마나 두는 게 맞을까요?
애플리케이션마다 다르기 때문에 “이 숫자가 정답”이라고 말할 수는 없습니다. 내가 찾은 가장 좋은 접근은 애플리케이션의 기능(Feature) 경계를 식별하고, 그 경계에 에러 바운더리를 두는 것입니다.
어떤 임의의 앱에서도 적용 가능한 “기능”의 보편적 정의는 없습니다. 보통은 “보면 안다(I know it when I see it)” 수준이 최선이지만, 가이드라인으로 삼을 수 있는 흔한 패턴은 있습니다.
대부분의 애플리케이션은 여러 개별 섹션이 조합되어 만들어집니다. 헤더, 내비게이션, 메인 콘텐츠, 사이드바, 푸터 등. 이들은 전체 사용자 경험에 기여하지만, 어느 정도는 서로 독립성을 유지합니다.
예로 Twitter를 봅시다:

페이지 안에 뚜렷한 섹션과 기능들이 있다는 게 즉시 보입니다. 트윗의 메인 타임라인, 팔로우 추천, 트렌드 섹션, 내비게이션 바 등. 각 섹션의 레이아웃과 스타일링 자체가 섹션 간 구분을 보여주는데, 이건 아주 좋은 출발점입니다: 시각적으로 독립적인 섹션은 독립적인 기능일 가능성이 높고, 바로 그런 곳이 에러 바운더리를 두기 좋은 지점입니다.
이 섹션들 중 한 곳에서 컴포넌트가 throw를 한다면, 다른 섹션까지 함께 크래시할 필요는 없다고 말하는 것이 합리적입니다. 예를 들어 팔로우 추천 섹션의 팔로우 버튼이 크래시한다고 해서 메인 타임라인까지 무너지면 안 되겠죠.
UI는 종종 재귀적(recursive)입니다. 페이지 레벨에서는 사이드바나 타임라인 같은 큰 섹션이 있고, 그 안에는 헤더나 리스트 같은 _하위 섹션_이 있으며, 그것들은 또 다른 섹션을 포함하는 식으로 이어집니다.
에러 바운더리를 둘 올바른 위치를 찾을 때 스스로에게 던지기 좋은 질문은 **“이 컴포넌트의 에러가 형제(sibling) 컴포넌트들에 어떤 영향을 줘야 하는가?”**입니다. <CheckoutForm /> 예시에서도 바로 이 질문을 고민했죠: CreditCardInput이 실패하면 CheckoutButton과 CartDescription에는 어떤 영향이 있어야 할까?
이 질문을 컴포넌트 트리에 재귀적으로 적용하면, 기능 경계가 어디인지 빠르게 식별하고 그 경계에 에러 바운더리를 둘 수 있습니다.
Twitter를 다시 예시로 들어 실제로 어떻게 적용되는지 보겠습니다. 위에서 시작해 팔로우 추천 섹션으로 내려가 볼게요.
☝️
이 분석에는 내가 이 기능들이 어떻게 동작하는지에 대한 인식과, _내가 기대하는 방식_에 기반한 의견과 편향이 적지 않게 들어 있습니다. “정답”을 찾는 것이 목적이 아니라, 과정 자체의 감을 잡는 것이 목적입니다.

맨 위에서 시작하면 세 개의 주요 콘텐츠 섹션을 식별할 수 있습니다: Home, Trends for you, Who to follow. 이제 Who to follow 섹션으로 더 들어가 봅시다.
우리는 먼저 이렇게 질문합니다:
이 컴포넌트에서 에러가 나면 형제 컴포넌트에 어떤 영향을 줘야 할까?
이 질문을 좀 더 구체적으로 바꾸면:
이 컴포넌트가 크래시한다면, 형제들도 같이 크래시해야 할까?
따라서 Who to follow를 볼 때 이렇게 묻습니다: Who to follow 섹션이 크래시한다면, _Home_과 Trends 섹션도 같이 크래시해야 할까? 이 경우는 명확하게 “그럴 필요 없다”라고 생각합니다. 다른 섹션들은 서로 의존하지 않는 것으로 보이므로, 여기에 에러 바운더리를 두기 좋은 지점입니다.
이제 같은 질문을 Who to follow 섹션 내부에도 적용합니다.

_Who to follow_에 집중해보면 세 가지 섹션이 눈에 띕니다: 제목, 팔로우할 사용자 리스트, “show more” 버튼. 사용자 리스트로 더 들어가서 다시 묻습니다: 팔로워(추천) 리스트가 크래시한다면 제목과 “show more” 버튼도 같이 크래시해야 할까? 이 경우는 조금 덜 명확하지만, 아마도 그럴 필요는 없다고 생각합니다. 제목이 남아 있어도 크게 문제될 건 없고, “Show more” 버튼은 다른 페이지로 링크되는데 그 페이지는 정상 동작할 수도 있으니까요. 그래서 답은 또 다시 “예(즉, 에러 바운더리를 추가하자)!”입니다.
한 번만 더 해봅시다. 팔로우 추천 항목 하나를 봅니다:

여기에는 섹션이 두 개뿐이니 이렇게만 물으면 됩니다: 사용자의 이름/핸들이 크래시한다면 팔로우 버튼도 같이 크래시해야 할까? 그리고 그 반대는?
이 경우에는 답이 “그렇다(yes)”라고 느껴집니다! 사용자 이름과 핸들이 없다면 누구를 팔로우하는지 알 수 없고, 팔로우 버튼이 없다면 행동 없이 추천만 받는 꼴이 되어 답답할 수 있으니까요.
이제 에러 바운더리와 내결함성에 대해 조금 더 알게 되었으니, 내가 이 주제에서 가장 좋아하는 부분 중 하나를 공유하고 싶습니다: 이 모든 걸 어떻게 테스트하느냐입니다. 내가 찾은 가장 좋은(그리고 노력 대비 효과가 큰) 내결함성 테스트 방법은 직접 들어가서 수동으로 일부러 깨뜨려 보는 것입니다.
jsfunction CreditCardInput(props) { // 여기서 내가 실수하면 무슨 일이 벌어질까? 확인해보자! throw new Error("oops, I made a mistake!"); return <input className="credit-card" />; }
나는 새 컴포넌트를 추가할 때마다 이걸 해보기 시작했는데, 애플리케이션이 실패를 어떻게 처리하는지 확인하는 데 정말 도움이 됐습니다. 다만 throw 문을 커밋하지 않도록 주의하세요 🙂
🤔
내결함성을 테스트하기 위해 의도적으로 에러를 던지는 것은 카오스 엔지니어링(chaos engineering)의 아주 순한(mild) 예시입니다. React 커뮤니티에서 이 개념을 더 잘 활용할 수 있는 유틸리티가 더 많이 나왔으면 좋겠어요.
가령 어떤 컴포넌트(들)를 무작위로 깨뜨려서 내결함성을 시험할 수 있게 해주는 React.ChaosMode 같은 게 있다면 어떨까요?
결국 이 글은 길게 돌아서 다음을 말하고자 한 것입니다:
최상단에 에러 바운더리 하나만 두는 것을 피하세요. 실패를 처리하는 최선의 방법인 경우는 드뭅니다.
에러 바운더리를 과도하게 사용하지도 마세요. 사용자 경험이 나빠지고 성능에도 악영향을 줄 수 있습니다.
앱에서 기능 경계를 식별하고 그 경계에 에러 바운더리를 두세요. React 앱은 트리 구조이므로, 위에서 시작해서 아래로 내려가며 찾는 접근이 좋습니다.
재귀적으로 “이 컴포넌트가 크래시하면 형제들도 같이 크래시해야 할까?”를 자문하세요. 기능 경계를 찾는 좋은 휴리스틱입니다.
에러 상태를 고려해 의도적으로 앱을 설계하세요. 기능 경계에 에러 바운더리를 두면 보기 좋은 커스텀 폴백 UI를 만들기가 훨씬 쉽고, 사용자에게 “무언가 잘못됐다”는 사실을 알려줄 수 있습니다. 기능별 재시도 로직을 구현해 사용자가 전체 페이지를 새로고침하지 않고도 해당 섹션만 새로고침하게 할 수도 있죠.
일부러 무언가를 깨뜨려서 무슨 일이 벌어지는지 확인하세요.