원문: https://www.brandondail.com/posts/fault-tolerance-react
by Brandon Dail
주: 어느 분야건 오류 처리의 중요성은 아무리 말해도 지나치지 않죠. 특히 프론트엔드는 최종 사용자에게 직접 전달되는 만큼 프론트에서 오류가 발생하고 이것이 제대로 핸들링되지 않는다면 UX에 치명적인 영향을 줄 수 있습니다.
이 글에서는 React에서 UI 오류를 핸들링하는 방법인 Error Boundary를 효과적으로 사용하는 Best Practice 에 대해 고민합니다.
모던 웹 앱을 만드는 것은 수많은 부속품들을 조합하는 복잡한 과정입니다. 가끔 이런 부속품들에 고장이 발생하면 이곳저곳이 삐걱거리게 되죠.
우리는 문제가 발생하는 것을 방지하기 위해 많은 노력을 하지만, 현실적으로 오류로부터의 완전한 해방은 불가능합니다. 그말인 즉슨, 우리는 언젠가는 예상치 못한 곳에서 문제가 생길 수 있다는 사실을 인지하고 이를 우아하게 다룰 수 있어야 합니다.
다른 말로는 Fault Tolerance(내결함성)가 필요하죠:
내결함성이란 시스템이 시스템을 구성하는 컴포넌트 중 일부가(하나 또는 그 이상) 고장나더라도 계속 동작할 수 있도록 하는 속성입니다.
제 경험상 웹 애플리케이션에서 내결함성은 간과되거나 절하당하는 경우가 많습니다. "아마 고장 안날거야" 라는 자신감을 위한 테스트 수백개를 작성하는 경우가 있을지라도, 그만큼의 시간을 들여 불가피한 오류가 발생했을 때 어떻게 대처할 것인지를 고민하는 경우는 드뭅니다. 고가용성(high availability)이 주 목표인 경우에는 더더욱 그런데도요 (보통 주 목표고요).
그래서 우리가 만든 React 앱에 어떻게 내결함성을 부여할까요?
한마디로 말하면, Error Boundary를 사용하면 됩니다. 지금은 클래스 컴포넌트에서만 API가 제공되고, 대충 이런 모습입니다:
componentDidCatch(error) {
// 이 메소드가 호출된다면 오류가 발생한거에요!
// 오류 감지 만세!
// 이제 우리의 사용자들이 오류가 발생했다는걸 알 수 있도록
// fallback UI를 렌더한다던가 따위의 조치를 할 수 있습니다.
this.setState({ error, showFallback: true });
}
React에서 Error Boundary란 그냥 componentDidCatch 메소드를 가진 클래스 컴포넌트입니다. 적절한 템플릿이 필요하다면 react-error-boundary를 참고해 보세요.
Error Boundary가 뭐고 어떻게 사용하는지는 React 공식 문서에서 훌륭하게 설명하고 있기 때문에 그 부분은 넘어갈게요. 기초 지식을 쌓고 싶다면 공식 문서를 읽고 다시 이 글로 돌아오세요.
Error Boundary를 여러분의 애플리케이션에 추가하는 일은 아주 쉽습니다. 몇줄의 코드로도 가능하죠. 그런데 이를 어디에 배치할지가 까다로운 부분입니다. 당신은 골디락스의 원리를 따라 "딱 적당한" 만큼 Error Boundary를 배치하고 싶겠지만, "딱 적당한" 의 기준이 뭐죠?
우선 양극단의 예시를 각각 보면서 어떤 문제가 생기는지 파악해보도록 하죠.
첫번째 극단: 애플리케이션 최상단에 위치한 단 하나의 Error Boundary
// ⚠️ 예시들에서는 react-error-boundary 를 사용할게요
import ErrorBoundary from "react-error-boundary";
import App from "./App.js";
ReactDOM.render(
<ErrorBoundary>
<App />
</ErrorBoundary>,
document.getElementById("root")
);
아마 많은 사람들이 이런 방식을 사용할거에요. 서버측에서 렌더링되는 애플리케이션이 고장날때와 유사하죠. 뭐 그렇게까지 최악의 경험은 아니겠지만, 그렇다고 우리가 할 수 있는 최선의 조치도 아닙니다. 이 방식의 문제는 한군데만 고장나도 전체가 따라서 고장난다는 것입니다.
이런 접근법도 애플리케이션의 한 부분만 고장나더라도 전체가 사용 불가능한 상태가 되는 상황이라면 설득력이 있을 것입니다. 가끔은 그런 경우가 있긴 하겠지만, 흔한 경우는 아니죠.
내결함성의 정의로 돌아가서:
내결함성이란 시스템이 시스템을 구성하는 컴포넌트 중 일부가(하나 또는 그 이상) 고장나더라도 계속 동작할 수 있도록 하는 속성입니다.
단일 Error Boundary의 경우 하나의 기능만 고장나도 전체 애플리케이션이 망가지기 때문에 사실상 내결함성을 제공하지 못한다는 점을 확인할 수 있습니다.
반대쪽 극단으로 가서, 모든 컴포넌트를 Error Boundary로 싸는 방식도 시도해볼수 있을 것입니다. 이 접근법의 문제점은 좀 더 미묘해서 구체적인 예제를 통해 이게 왜 단일 Error Boundary 방식보다 오히려 나빠질 수 있는지 확인해보도록 하죠.
유저가 장바구니에 무엇이 담겼는지 확인하고, 카드 정보를 입력하고, 결제를 진행할 수 있는 <CheckoutForm />
이라는 컴포넌트가 있다 상상해 봅시다.
function CheckoutForm(props) {
return (
<form>
<CartDescription items={props.items} />
<CreditCardInput />
<CheckoutButton cartId={props.id} />
</form>
);
}
이제 모든 컴포넌트를 Error Boundary로 감싸면...
// 모두가 Error Boundary를 가져요!
<form>
<ErrorBoundary>
<CartDescription items={props.items} />
</ErrorBoundary>
<ErrorBoundary>
<CreditCardInput />
</ErrorBoundary>
<ErrorBoundary>
<CheckoutButton cartId={props.id} />
</ErrorBoundary>
</form>
현실 세계에서는 아마 이런식의 인라이닝보다는 각 컴포넌트를 미리 Error Boundary로 래핑해서 (react-error-boundary의
withErrorBoundary
HOC를 사용한다던가) export 하는 경우가 대다수일 것입니다. 이 점은 무시해 주세요 🙂
언뜻 보기엔 이 방식이 생각보다 괜찮은 아이디어로 보일 수 있습니다. Error Boundary가 더 세세할수록 하나의 오류가 전체 애플리케이션에 미치는 영향이 줄어들 테니까요. 이러면 내결함성을 달성할 수 있을까요? 이 방식의 문제점은 단순히 오류가 미치는 영향을 최소화하는것과 내결함성은 다르다는 것입니다.
CreditCardComponent
의 무언가가 고장난 상황이라면 어떨까요?
<form>
<ErrorBoundary>
<CartDescription items={props.items} />
</ErrorBoundary>
<ErrorBoundary>
{/* 이런, 뭔가가 고장났어요 😢 */}
<CreditCardInput />
</ErrorBoundary>
<ErrorBoundary>
<CheckoutButton cartId={props.id} />
</ErrorBoundary>
</form>
이것이 UX 측면에서 무엇을 의미하는지 분석해 보면 이 방식이 유저들을 곤경에 빠트릴 수 있다는 것을 확인할 수 있습니다.
CreditCardInput
은 자체적인 Error Boundary를 가지기 때문에 이 오류는 CheckoutForm
컴포넌트로 전파되지 않습니다. 하지만 CheckoutForm
컴포넌트는 CreditCardInput
컴포넌트 없이는 사용이 불가하네요 🤔. CheckoutButton
과 CardDescription
컴포넌트는 여전히 마운팅된 상태기때문에 유저는 장바구니를 확인하고 결제 시도를 진행할 수 있을텐데, 유저가 카드 정보를 입력하지 않은 상태라면 어떻게 하죠? 카드 정보를 CreditCardInput
에 크래시가 발생하기 전에 미리 입력했다면 그 상태는 보존될까요? 그 상태로 결제 시도를 하면 어떻게 되죠?
아마 제 생각에는 이 컴포넌트를 제작한 사람도 어떤 동작이 발생할지 모를 것 같네요. 하물며 유저 입장에서는 말할 것 도 없죠. 혼란스럽고 실망스러운 경험일 것입니다.
fallback으로 무엇을 보여주냐도 실망스럽고 혼란스러운 정도에 영향을 줍니다. 컴포넌트가 아무 경고도 없이 사라져 버리나요? 그런 경우면 거의 모든 사람이 혼란스러울 거에요.
그런 경우는 아니라면, 공유 fallback UI를 사용하는 상황일수도 있겠네요. 우는 이모지와 함께 오류에 대해 도움되는 정보를 제공하는 경우라던가요. 이건 아무것도 없는 상황보단 낫겠지만, 모든 컴포넌트를 다 Error Boundary로 감싼 상황이라면 각각의 UI 요소에 상황에 맞게 fallback을 제공해야 할 것입니다. 각각의 요소들은 각기 다른 레이아웃 요구사항을 가지고 있기 때문에 이를 올바르게 구현하는 것은 사실상 불가능합니다. 페이지 단위의 섹션에 대한 fallback은 조그마한 버튼에는 어울리지 않을 것이고, 그 반대의 경우에도 마찬가지겠죠.
이제 문제가 보이시나요? 모든 컴포넌트를 Error Boundary로 감싸는 것은 혼란스럽고 조잡한 유저 경험을 제공하게 됩니다. 이는 애플리케이션 상태의 일관성을 해쳐 유저들에게 혼동과 실망을 초래할 수 있습니다. "망가진 상태" 의 발생을 피하는 것이 Error Boundary의 주 목적 중 하나라는 것도 언급할 수 있겠네요.
이 문제에 관해 논의해봤는데, 우리의 경험 상 망가진 UI를 그대로 놔둘 바에는 아예 없애버리는게 나았어요.
성능 패널티
Error Boundary는 오버헤드를 가지고 있어서, 과도하게 사용할 경우 성능에 부정적인 영향을 줄 수 있습니다. 다만 아무 곳에나 막 쓸 경우 문제가 된다는 것이니, 쓰는 것 자체를 두려워하지는 마세요.
앞선 내용들을 요약하면, Error Boundary가 부족하면 오류 발생 시 필요 이상으로 애플리케이션의 다른 부분을 사용 불가로 만들고, 너무 많은 Error Boundary는 UI 상태를 손상시킬 수 있습니다. 그래서 딱 적당한 만큼이 얼만큼일까요?
애플리케이션마다 상황이 다르기 때문에 "이만큼이 딱 적당하다" 라는 수치를 명확하게 제시하는 것은 어렵습니다. 제가 찾은 가장 좋은 접근방식은 애플리케이션의 기능 경계를 파악하고 Error Boundary를 그곳에 배치하는 것입니다.
임의의 앱의 경계를 파악하는 데 적용할 수 있는 "기능" 단위에 대한 보편적인 기준은 없습니다. 딱 보면 알아요 가 보통 우리가 할 수 있는 최선이지만, 가이드라인으로 쓸 만한 몇 가지 일반적인 패턴이 있습니다.
대부분의 애플리케이션은 독립적인 섹션들을 조합해서 만들어집니다. 헤더, 내비게이션, 메인 컨텐츠, 사이드바, 푸터 등등요. 각 요소의 기여가 합쳐져서 전체적인 유저 경험을 제공하게 되지만, 개별 요소들은 어느정도의 독립성을 유지하게 됩니다.
Twitter를 예시로 들어볼까요:
페이지 안에 분리된 섹션과 기능이 있다는 것을 직관적으로 확인할 수 있습니다. 트윗이 보이는 메인 타임라인, 팔로우 추천, 트렌딩 섹션과 내비게이션 바로 구분되죠. 레이아웃과 스타일링도 각각의 섹션 사이에 경계가 있다는 것을 알려주네요. 이는 좋은 시작지점인데, 일반적으로 시각적으로 분리된 섹션들은 독립적인 기능을 가지고, 이곳이 Error Boundary를 배치하기 딱 좋은 지점입니다.
컴포넌트가 이 섹션들 중 하나에 속한다면 다른 섹션이 따라 고장나지 않아야 한다고 볼 수 도 있겠네요. 예를 들어서, 팔로우 추천 섹션의 팔로우 버튼이 고장난다고 메인 타임라인이 따라서 문제가 생기면 안되겠죠.
UI는 재귀적인 경우가 많습니다. 페이지 단위에서는 사이드바나 타임라인같은 커다란 섹션들이 있고, 그 섹션 안에는 헤더나 리스트같은 서브 섹션이 있고, 그 서브 섹션들 안에는 또 다른 섹션이 있고 이런식으로요.
Error Boundary를 배치할 적절한 위치를 찾는 도중 스스로에게 하면 좋은 질문은 "이 컴포넌트의 에러가 형제 컴포넌트들에게 어떻게 영향을 미쳐야 할까?" 입니다. 우리가 <CheckoutForm />
예제에서 고민했던 문제가 바로 이것인데요, CreditCardInput
이 고장났을때, CheckoutButton
과 CardDescription
에 어떤 영향을 미쳐야 할까요?
이 질문을 재귀적으로 컴포넌트 트리에 적용하면 기능의 경계를 빠르게 파악하고 그 주변에 Error Boundary를 배치할 수 있습니다.
이게 어떻게 작동하는지 확인하기 위해 다시 트위터를 예시로 들어보겠습니다. 최상단부터 시작해 팔로우 추천 섹션까지 내려가보도록 하죠.
이 분석에는 이 기능들이 어떻게 작동해야 하는지에 대한 제 개인적인 관점의 의견이 포함되어 있습니다. "정답"을 말하고자 하는 것이라기 보단 과정에 대한 전체적인 느낌을 제시하는 것이라고 생각해 주세요.
최상단부터 보면 Home, Trends for you, Who to follow 세 개의 섹션으로 구성되어 있다는 것을 확인할 수 있습니다. Who To Follow 섹션으로 내려가 볼까요?
스스로에게 이런 질문을 하면서 시작해 볼 수 있습니다:
이 컴포넌트 내의 에러가 형제에 어떤 식의 영향을 미쳐야 할까?
질문을 좀 더 구체화하기 위해 이런 식으로 고쳐말할수도 있겠네요:
이 컴포넌트에서 크래시가 발생하면, 형제 컴포넌트도 따라 발생해야 할까?
Who to follow 섹션에 이를 적용하면, Who to follow 섹션에 크래시가 발생했을때, Home과 Trends 섹션도 따라서 고장나야 할까요? 제 생각에는 확실하게 그런 상황이 발생하면 안 될 것 같네요. 다른 섹션들은 서로 의존적이지 않으니까 Error Boundary를 배치하기 적절한 위치라 생각됩니다.
같은 질문을 Who to Follow 섹션에 적용해 보겠습니다.
Who to Follow 섹션 안에는 제목, 팔로우할 유저 목록, 더보기 버튼 세개의 명확한 섹션이 보이네요. 유저 목록으로 다시 내려가서 똑같은 질문을 하게 될 수 있는데, 팔로우 유저 목록이 크래시가 났을 때 제목과 더보기 버튼도 따라서 고장나야 할까요? 이 경우에는 해답이 덜 명확해 보이지만 저는 그렇지 않다고 생각합니다. 제목을 놔둔다고 별 문제가 생기지는 않을 것 같고, 더보기 버튼은 정상적으로 동작할 가능성이 높은 다른 페이지로의 링크이기 때문에 괜찮을 것 같네요. 이 문제에 대한 답도 역시 Error Boundary를 배치하는 것입니다.
각 팔로우 추천 아이템에 이를 한번 더 적용해볼까요?
두 개의 섹션만으로 구성되었기때문에 유저 닉네임과 핸들이 고장났을때 팔로우 버튼이 따라 고장나야 할 지 (혹은 그 반대의 경우에도) 에 대해 질문해보면 될 것 같네요.
이 경우에는 함께 고장나야 한다고 생각되네요! 유저 닉네임과 핸들이 사라졌다면 누구를 팔로우하는지 알 수 가 없으니까요. 반대로 팔로우 버튼이 사라진 경우에도 아무 액션도 없는 추천이 생겨서 실망스러운 경험이 될 수 있겠네요.
이제 Error Boundary와 내결함성에 대해 지식이 늘었으니, 제가 가장 좋아하는 부분을 소개할 차례네요. 앞서 말한 것들을 어떻게 테스트할지를요. 제가 발견한 가장 쉽고 빠르게 내결함성을 테스트하는 방법은 직접 고장내보는 것입니다.
function CreditCardInput(props) {
// 여기서 문제가 생기면 어떻게 될까요? 확인해보죠!
throw new Error("oops, I made a mistake!");
return <input className="credit-card" />;
}
저는 새 컴포넌트를 만들 때마다 이런 식으로 테스트를 진행하는데, 애플리케이션이 어떻게 오류를 처리하는지 파악하는 것이 굉장한 도움이 됩니다. 다만 throw문을 실수로 커밋해버리지 않도록 조심하세요 🙂
잡설: 카오스 엔지니어링
내결함성을 테스트하기 위해 의도적으로 오류를 발생시키는 것은 카오스 엔지니어링(chaos engineering)의 가장 가벼운 예제 중 하나입니다. 이 개념과 관련된 유틸리티가 React 커뮤니티에 더 많이 생기면 좋겠네요. 예를 들어React.ChaosMode
같은 API가 있어서 내결함성 테스트를 위해 랜덤으로 컴포넌트를 고장낸다던가 하면 어떨까요?
앞선 장황한 내용들을 요약해 보면 오류 처리를 하는 좋은 방법은 다음과 같습니다:
최상단에 단일 Error Boundary를 배치하는 것을 피하세요. 대부분의 상황에서 오류를 처리하는 최선의 방법이 아닙니다.
그렇다고 Error Boundary를 남용하지도 마세요. 사용자 경험이 나빠지고 잠재적으로 성능이 저하될 수 있습니다.
애플리케이션의 기능 경계(Feature Boundary)를 파악하고 Error Boundary를 그곳에 배치하세요. React 앱은 트리 구조로 이루어져 있으므로 탑다운 방식의 접근법이 적합합니다.
재귀적으로 "이 컴포넌트가 고장났을 때, 다른 컴포넌트도 따라서 고장나야 할까?" 라는 질문을 던져보세요. 기능 경계를 찾는 좋은 접근법입니다.
오류 상태일 경우를 의도적으로 고려해 애플리케이션을 설계하세요. Error Boundary를 기능 경계에 보기 좋은 커스텀 fallback UI를 구현해 사용자들에게 문제를 알리기 훨씬 쉬워집니다. 사용자가 전체 페이지를 새로고침하지 않고도 해당 섹션만 갱신할 수 있도록 기능별 재시도 로직을 구현할 수도 있겠네요.
일부러 오류를 만들어서 어떤 일이 일어나나 확인해 보세요.