개발을 하면서 여러가지 요소들을 고려해야 하지만 그중에 단연코 노하우가 필요하면서도 섬세하게 다뤄저야 할 부분이 바로 에러처리라고 생각한다.
일반적으로 아무런 생각없이 단순 기능구현을 목적으로 하는 개발은 1차적인 개발이라고 생각한다. 물론 그 자체도 훌륭하지만 완벽하지는 않다. 어느 순간이든 발생할 수 있는 에러에 대해서 적절하게 처리를 하는 능력은 몹시 중요하지만 실제적으로 이것을 쉽게 행하기는 어렵다. 정말로 어렵다. 많은 지식과 경험을 쌓아야 본능적으로 "이게 이렇게 흘러가면 에러 가능성이 있을 것이다" 라는 예견이 가능하기 때문이다.
그래서 보통 이런 어려움을 해결하기 위해 손쉬운 방법인 try~catch를 이용하여 에러 핸들링을 하곤 한다.
이 방법은 몹시 편하다.
try{
//... 무언가를 실행하다가 여기서 에러를 발생시킨다면 자동 "throw Error객체" 를 실행해주기 때문
}catch(err){
// try 블록 안에서 발생한 에러는 자동으로 catch의 인자로 전달된다.
}
위에처럼 감싸주기만 하면 알아서 엔진이 처리해주기 때문에 복잡하게 생각하지 않아도 된다는 장점이 있다.
근데, 이 편안함에 익숙해지다보면 여러가지 예외사항을 만나게 되는데 그것이 바로 내가 어제 개발하면서 만난 에러였다.
try 안에 존재하는 에러내용은 자동으로 catch 블록으로 전달된다.
그러나 중요한 것은 catch 블록에 전달되는 것은 오로지 "동기적 작업" 에 한한다는 점이다.
아마, 편리한 방법으로 async await을 쓰고 있었다면 이런 문제를 경험할 일은 없다.
왜냐면 async문이 붙은 함수 블록 내에서 실행되는 await 문들은 비동기 작업이지만 마치 "동기적인 것처럼" 처리가 마무리되는것을 기다려주기 때문이다.
async function test (){
try{
await somethingAysnc() // 해당 비동기 작업이 완료되어 Promise 객체의 state 슬롯에 들어오기까지 기다려준다.
}catch(err){
console.log(err) // 위 작업에서 에러가 나면 catch가 이것을 캐치한다.
}
}
그런데 문제는 여러저러 이유로 async await을 쓰지 못하는 경우이다.
나의 어제 케이스와 같은 경우, typescript를 사용하고 있었는데 cachstorage의 두번째 인자로 전달해줘야 하는 타입이 무조건 Response 타입으로 고정되어 있어서 axios를 사용했더니 타입에러를 너무 내고 이것을 고치자니 타입수정이 너무 번거로워지고 길어져서 그냥 fetch를 사용했었다.
이미 사진에 결론을 지어놓긴 했는데, fetch를 사용할 경우 최외곽에 있는 try~catch가 이 fetch 작업의 에러를 잡아내지 못한다.
그 이유는 몹시 간단한데, fetch는 비동기 작업이므로 함수 호출을 할 때에 실행 컨텍스트의 내용을 위에부터 차례대로 진행하면서 이 비동기 처리는 web API에 전달하여 task queue에 등록되고 콜스텍이 비워지면 그제서야 들어가서 실행되기 때문이다.
function test (){
try{
fetch()..... // 해당 구문은 비동기 작업이므로 자바스크립트 엔진 입장에선 그냥 web API에 던져주고 넘어가버린다.
}catch(err){
console.log(err) // 따라서, catch로 전달되는 것도 없고, 이 함수 호출이 끝난 후 뒤늦게 fetch가 콜스텍에 들어가 실행되어 에러뿜뿜
}
}
즉, 다시말하자면 비동기 작업을 아무생각없이 try~catch에 넣어두면 에러처리가 전혀 되지 않는다. 따라서 catch메서드를 체이닝으로 연결해주어 처리해주어야 한다.
then,catch,finally 와 같은 체이닝 메서드는 크롬 왈, 마이크로 테스크 큐 라고 하는 특수한 자료구조에 들어가게 되고 비동기 Promise가 처리가 될 때에 해당 [[PromiseResult]] 슬롯의 결과에 따라 이 프로미스와 연결된 체이닝 메서드를 마이크로 테스크 큐 를 순회하여 적절한 메서드를 찾아내 호출한다. 에러의 경우, 만약 catch 구문을 만나지 못했다면 최후로 window에 등록되어 있는 unhandledrejection 이벤트 리스너를 찾고 여기까지 내용이 존재하지 않는다면 에러를 발생시키며 프로세스가 죽는다.
window.addEventListener('unhandledrejection', function(event) {
// 이벤트엔 두 개의 특별 프로퍼티가 있습니다.
alert(event.promise); // [object Promise] - 에러를 생성하는 프라미스
alert(event.reason); // Error: 에러 발생! - 처리하지 못한 에러 객체
});
new Promise(function() {
throw new Error("에러 발생!");
}); // 에러 처리 핸들러, catch가 없음
이 영역은 평소에 전혀 신경을 쓰지 않았던 부분인데 별안간 개발하다가 발견하게 되었다.
예를들어, 특정 페이지에 들어갔을 때 redux store에 존재하는 데이터를 기반으로 페이지를 보여주고 있었다고 가정하자.
이때, 만약 사용자가 새로고침을 했을 때 redux store 내부데이터에 대한 적절한 조치가 없다면 (ex, 캐싱 등)
새로고침을 하는 순간 스토어가 초기화될것이고 페이지 내에는 해당 데이터를 기반으로 UI를 구현하는데 데이터가 없으므로 에러를 발생시키며 프로세스가 죽을 것이다.
이 외에도 routing을 통해 페이지가 이동했는데 뒤로가기를 한다는 등을 하였을 때 필요한 데이터가 없다면 역시 마찬가지로 에러를 발생시키며 프로세스가 죽을 것이다.
즉, 이런 경우를 대비하여 사용자가 history stack을 이용해 접근하는 행위에 대한 적절한 조치가 필요하다.
사실 처음에는 히스토리를 초기화하면 되겠지 하고 안일하게 마음먹었으나 검색해보니 보안 상 히스토리 자체를 삭제하는 기능은 추가되지 않았다고 한다...
따라서, 특정 redux store 데이터가 필요해지는 페이지라면 조건부로 redirect 처리를 하도록 하였다.
// KeywordPage.js
useEffect(() => {
if (!selector.searchList.length && !localStorage.getItem("searchState")) {
navigate("/");
}
}, [navigate, selector]);
/// UrlPage.js
useEffect(() => {
if (!selector.searchTarget && !localStorage.getItem("searchState")) {
navigate("/");
}
}, [navigate, selector]);
이처럼 특정 페이지에서는 어떤 조건을 만족하지 않으면 페이지로 리다이렉트하게 만들었고
// Home.js
useEffect(() => {
//if comming back
dispatch(reset());
localStorage.removeItem("searchState");
}, [dispatch]);
Home으로 접근하는 순간 초기 redux 에 존재하는 검색기록 데이터를 초기화하도록 만들었다.
이렇게 설정하니 만약 뒤로가기 버튼으로 되돌아가려고 시도할 경우 useEffect가 실행되어 다시 홈으로 redirect를 시켜주는 것을 알 수 있었다.
모든 페이지에 대해서 이렇게 예외처리를 하는것은 쉽지는 않겠지만 조금 더 정교한 앱을 구현하기 위한 필요조건이라고 생각이 든다.