프론트엔드 개발 요소 중 중요한 것은 뭐가 있을까?
'유용한 기능', '화려한 디자인', '빠른 속도' 등 여러가지가 있겠지만, 이렇게 외적으로 보여지는 것이 뿐만 아니라 에러 핸들링 또한 중요하다고 생각한다.
나는 개발을 하며 에러 핸들링이 중요하다고 생각했고 지금도 그러하지만, 아직까지도 어떤 방법으로 하는 것이 좋은 건지는 알쏭달쏭하다.
그럼에도 이 글을 작성하게 된 이유는, 나와 비슷한 상황에 처한 다른 많은 개발자들에게 이러한 생각을 공유하고 이야기를 나눠보고 싶어서다.
이제 내가 고민했던 프론트엔드 에러 핸들링에 대해서 이야기해볼까 한다.
에러 핸들링의 중요성에 대해 알아보기에 앞서, 이 게시글에서 사용하는 에러 핸들링에 대한 정의부터 가볍게 설명하고자 한다.
Error + Handling
단어만 봐도 이해할 수 있듯이, 에러가 발생했을 때 어떻게 처리해주는지에 대한 내용이다.
이렇게 설명하면 좀 거창해보이지만, 사실 우리는 이미 많은 에러 핸들링을 경험하고 있다.
이렇듯 대규모 기업의 서비스에서조차 완벽한 서비스는 존재하지 않기에, 에러 핸들링을 촘촘하게 해두는 편이다.
또한 에러 핸들링 이라고 해서 반드시 서버나 클라이언트에 기술적인 문제가 생기는 것 뿐 만 아니라, 전화번호를 숫자로만 입력해야 하는데 특수문자를 쓰는 등 의도치 않은 행위를 차단하는 과정에서도 사용이 된다.
고객과 서비스 사이에 중요한 것 중 하나는 신뢰이다.
신뢰를 느끼는 부분은 여러가지가 있을 수 있다.
그러나 역시 이들의 가장 앞에는 '이 서비스가 얼마나 신뢰되는 서비스 인지' 가 있을 것이다.
생각해보자.
이 때 서비스 B가 에러가 났을 때는 아래와 같은 화면이다.
사람마다 다르겠지만. 나라면 서비스 B 보다는 서비스 A를 사용할 것 같다.
우리는 그나마 개발자이기 때문에 어떤 상황인지 짐작이라도 할 수 있다.
그치만 컴퓨터와 아무런 관련 없는 일반인들의 시점에서 이러한 화면은 굉장히 당혹스러울 것이다. (심하게는 해킹을 당한건가? 하고 생각 할 수도 있을 것 같다.)
에러가 발생하는 게 잘못됐다는 것이 아니다.
앞서 말했다시피 에러가 발생하지 않는 완벽한 서비스는 세상에 존재하지 않기 때문이다.
하지만 에러 날것을 사용자들에게 그대로 보여주는 것과, 정제한 화면을 보여주는 것은 천지차이다.
서비스에 신뢰를 잃고 떠난 사용자는 다시 돌아오는 경우가 거의 없다.
그렇기 때문에 신뢰에 큰 영향을 줄 수 있는 에러 핸들링은 정말 중요하다고 생각한다.
무턱대로 일단 에러 핸들링을 알아볼 수도 있지만, 그 전에 먼저 에러 란 무엇인지 뭐가 있는지 부터 알아보고 넘어가고자 한다.
지금까지는 에러 라는 단어 하나만을 사용했지만, 사실 서비스를 개발하며 에러 핸들링이라 불리우는 에러의 종류는 여러가지다.
우선 에러의 종류와 범위부터 정의하고, 그 다음을 알아보자.
첫번째로, 발생하는 에러가 예측이 가능한지 불가한지에 따라서 두 종류로 나눌 수 있다.
프론트엔드 관점에서는 백엔드 개발자와 사전에 내용을 공유하고, 서버에서 명확하게 내려주는 에러코드라고 생각할 수 있다.
이는 일반적으로 4xx번대 상태코드로 내려준다.
예를 들어 회원가입을 진행하는 POST: /signup
이라는 API가 있다고 가정해보자.
이 API는 email
과 password
를 request
로 받는데, 만약 회원가입이 되어있는 계정 중 동일한 email
을 사용하면 409 Conflict
상태 코드를 내려주기로 미리 내용을 공유해두었다.
그럼 프론트엔드 개발자는 어떤 상황일 때 409 Conflict
를 받게 되는지 알 수 있고, 이를 예측하여 에러 핸들링을 하는것이 가능하다.
그렇다면 예측 불가능한 에러는 무엇일까?
앞서 회원가입 API 예시를 다시한번 가져와보자.
request
로 email
을 전달받은 서버가 database
에서 email
을 찾던 중 오류가 발생했다고 가정해보자.
이는 개발자가 예상하지 못한 에러로, 서버는 5xx번대 에러를 내려준다.
프론트엔드 개발자는 5xx번대 에러를 받지만 이게 어떤 이유로써 발생했는지는 알 지 못한다.
이처럼 개발자가 미리 알고 내용을 사용자에게 설명할 수 없는 에러가 예측할 수 없는 에러이다.
(물론 백엔드 개발자가 에러 핸들링을 촘촘히 해놨다면 이 또한 4xx번대 에러 코드로 사전에 공유할 수 있다)
앞선 분류가 예측 가능/불가능에 따른 여부였다면, 이번에는 이 에러를 사용자가 해결할 수 있는지/없는지에 따른 에러 종류다.
앞선 회원가입 예제의 409 Conflict
가 그 종류 중 하나라고 할 수 있다.
이는 해당 email
이 이미 가입되어 발생하는 에러로, 사용자가 다른 email
을 입력하여 이 오류를 해결할 수 있다.
따라서 이 오류를 받으면 사용자에게 어떤 행동을 취하여 에러를 해결할 수 있게끔 안내한다.
앞선 회원가입 예제에서 database
에서의 오류 발생이 그 예이다.
database
내부적으로 해결 할 발생한 것은 사용자가 어떤 행동을 하던 에러를 해결할 수 없다.
그 외에도 사용자의 기기 사양이 낮아 해당 기기로는 진행이 불가한 경우나, 관리자 권한을 가진 계정으로만 접근이 가능한 경우가 있을 것이다.
✅️ 저는 React 17, 18, Next.js 12, JavaScript(ES5, ES6+), TypeScript 를 경험해 보았고, 이를 기반해서 작성한 내용임을 참고 부탁드립니다.
❗️ 본 게시글은 에러 핸들링 중
REST API Request<->Response
과정만 고려합니다.
❗️ 본 게시글에 나오지만 설명하지 않는 사전지식 입니다.
- HTTP Status Code (HTTP 상태 코드)
- Error Boundary
사실 에러 핸들링이 중요하다는 사실은 이제 대부분의 주니어 개발자들도 알고 있다.
그런데 에러 핸들링을 어떻게 해야하는지에 대해서는 아직까지 잘 모르겠다. (내 얘기)
구글에 검색을 해보면 Error Boundary
, Suspense
등 기능적인 설명들은 많지만, 이를 어떻게 설계하고 구성해야하는지는 잘 나오지 않는다.
도움이 될 지는 모르겠지만, 내가 생각한 (작성일 기준에서) 가장 바람직한 설계를 적어보고자 한다.
프론트엔드의 개발 환경에서 에러 라는게 발생하는 곳은 어디일까?
아무래도 백엔드 서버와의 비동기 처리를 진행할 때 가장 많이 발생하게 된다.
Error Boundary
나 Suspense
가 고안 된 이유 중 하나도 이러한 비동기 처리 에러를 간편하게 하기 위해서이다. (물론 그렇다고 비동기처리만을 위해서는 아님)
이 내용은 프론트엔드 개발자 혼자서 해결할 수 있는 건 아니고, 백엔드 개발자와의 논의가 필요한 부분이다.
내 경험 상으로는 API 에러 핸들링을 처리할 때 status code
만 사용해서는 정교한 에러 핸들링이 불가했다.
여기서 정교한 에러 핸들링 이란 사용자에게 어떤 에러이며, 어떤 행동을 취해야 하는지 각 에러마다 보여주는 것을 의미한다.
예를 들어 회원가입 API가 email
, phone
, password
를 입력받는다고 생각해보자.
가장 간단한 에러 처리는 뭐가 있을까?
email
, phone
, password
중 잘못 된 정보가 있을 때 400 Bad Request
를 내려주는 방법이 있을 거다.
{
status:400
}
그럼 프론트엔드 개발자는 이를 기반으로 "입력된 정보 중 잘못 입력된 정보가 있습니다." 와 같은 알림창을 보여 줄 것이다.
if(status===400){
alert('입력된 정보 중 잘못 입력된 정보가 있습니다. 수정 후 다시 입력해주세요.');
}
그러나 이 방법은 우리가 생각하는 바람직한 에러 핸들링은 아니라고 생각이 든다.
사용자 입장에서는 이메일이 잘못된 건지, 비밀번호가 잘못된 건지, 전화번호가 잘못된 건지 알 길이 없기 때문이다.
따라서 모든 입력폼을 다시 한번씩 더 확인해봐야 하고, 결국 지친 사용자는 회원가입을 완료하지 못하고 이탈하게 된다.
이 같은 상황을 막기 위해 많은 회사에서는 기본적인 HTTP Status Code
이외에 구분 가능한 다른 데이터를 내려주어 이를 해결하고자 한다.
(카카오톡 채널의 API 문서.HTTP 400 Bad Request
이외에 code
라는 데이터를 내려주어 정교한 에러 핸들링을 할 수 있도록 한다.)
code = -501
과 같이 400 Bad Request
와는 별개의 값을 통해 이 에러가 어떤 에러인지 명확하게 설명해주고 있다. msg
를 통한 간단한 설명은 덤.
이와 같이 백엔드 개발자와의 세부적인 에러 핸들링 규칙을 만들어 놓는 것이 좋다.
클라이언트에서 서버로 REST API
를 요청하는 과정을 한번 생각해보자.
보통 과정은 아래와 같다.
우리는 이 중 클라이언트 부분을 더 자세히 봐보자.
만약 Response
가 에러였다면 어떤 과정을 통할 수 있을까?
이런식으로 에러의 종류를 HTTP Status Code
를 기준해서 1차 분류를 진행해보는게 좋을 것 같다.
각각의 Status Code
는 의미를 가지고 있기 때문에, API
중 필요한 공통적인 처리를 해 줄 수 있을 것이다.
그리고 이전에 위에서 봤던 각 Status
별 코드를 통해 Status Code
내부에서 더 자세한 분기 및 처리가 가능할 것이다.
지금 재직중인 회사 서비스의 경우에는 모든 API의 401
에러에 대한 처리가 동일하다.
(별도의 분기 status
없음)
해당 경우에도 이 같은 구조를 사용하면 반복적으로 작업을 하지 않고 한번에 처리가 가능한 이점이 있다.
또한, 모든 에러에 공통적으로 해줘야 하는 작업이 있을 경우에도 이 같은 구조가 도움이 된다.
여기까지가 에러 핸들링 과정 설계이다.
이를 기반으로 코드를 구현하면 된다.
에러 처리의 구조를 설계가 완료 되었다면, 이제 실제 페이지에서 에러를 잡아(catch)야 한다.
아래는 우리가 흔히 보는 네이버 페이지이다.
설명을 위해 내가 임의로 상상해서 컴포넌트를 API 단위로 나누어보았다.
코드로 보면 이런 상태다. (편의상 다른 부분은 생략)
function Home() {
return (
<div>
<Article />
<Weather />
<Shopping />
<Finance />
</div>
);
}
개발하면서 흔히 볼 수 있는 구조이다.
각 컴포넌트들은 API를 호출해서 받아온 값을 보여주는 구조로 개발되어 있다고 가정해본다.
나는 여기에 <FetchErrorBoundary />
라는 특수 Error Boundary
를 만들어 API Fetch 할 때 발생하는 에러를 먼저 잡아내고자 한다.
적용하면 아래와 같다.
function Home() {
return (
<div>
<FetchErrorBoundary>
<Article />
</FetchErrorBoundary>
<FetchErrorBoundary>
<Weather />
</FetchErrorBoundary>
<FetchErrorBoundary>
<Shopping />
</FetchErrorBoundary>
<FetchErrorBoundary>
<Finance />
</FetchErrorBoundary>
</div>
);
}
<FetchErrorBoundary />
에서는 catch
된 에러가 Fetch
를 하면서 발생한 에러인지를 판단한 후 맞다면 설계했던 에러 처리 과정을 진행하고, 만약 아니라면 에러를 다시 throw
해서 상위의 Error Boundary
가 처리하도록 구현한다.
// 의사코드
function FetchErrorBoundary (error:unknown) {
if(isFetchError(error)){
// Fetch 에러가 맞다면 globalErrorHandling 진행 후 FetcherFallback render
globalErrorHandler(error);
return <FetcherFallback />
// 아니면 globalErrorHandler 자체가 상황에 맞게 FetcherFallback 컴포넌트를
// return하도록 설계해도 됨
// const FetcherFallback = globalErrorHandler(error);
// return <FetcherFallback />
}
// Fetch 에러가 아닐 시 다시 한번 에러 throw 하여 상위 Error Boundary로 전달
throw error;
}
이렇게 구현을 하게 되면 만약 4개의 API 중 하나에서 오류가 발생하더라도 큰 문제 없이 UI를 보여줄 수 있다.
이를 응용해 특정 페이지 전용 Page Error Boundary
나 루트 레벨에서 사용하는 Global Error Boundary
를 만들어 단계별로 촘촘한 에러 핸들링을 진행 할 수 있다.
이 때 Global Error Boundary
는 더이상 에러를 전파하지 않고 정해진 시나리오 내에서 화면을 렌더한다.
function App() {
return (
<GlobalErrorBoundary>
<Routes>
<Route path='/' element={<Home/>}/>
<Route path='/signin' element={<SignIn/>}/>
</Routes>
</GlobalErrorBoundary>
);
}
function Home() {
return (
<div>
<HomePageErrorBoundary>
<FetchErrorBoundary>
<Article />
</FetchErrorBoundary>
<FetchErrorBoundary>
<Weather />
</FetchErrorBoundary>
<FetchErrorBoundary>
<Shopping />
</FetchErrorBoundary>
<FetchErrorBoundary>
<Finance />
</FetchErrorBoundary>
</HomePageErrorBoundary>
</div>
);
}
간단히 말해 몇 개의 거름망을 통해서 사용자에게는 깨끗한 (이미 처리된) 화면만 보여주게끔 한다.
지금 이렇게 정리를 해보았지만, 아직까지도 최고의 방법인지에 대해서는 의구심이 든다.
그러나 사실 최고라고 규정하는 순간 더이상의 발전은 없기에, 앞으로도 계속 고민해봐야 하는 주제인 것 같다.
+ 읽어주셔서 감사합니다.
+ 오타, 내용 지적, 피드백을 환영합니다. 많이 해주실 수록 제 성장의 밑거름이 됩니다.