본문 바로가기

Javascript

[javascript] 예외처리의 개념, 디자인 패턴, 비동기 함수의 예외처리

 

자바스크립트의 에러와 예외 처리에 대해 알아본다.

 

에러와 예외의 용어 구분

두 용어는 통념상 동일하나, 프로그래밍적으로 다르다.

에러 - 소스코드의 동작 과정 상에서 제어할 수 없는 에러이다. 일반적으로 하드웨어나 저수준 시스템적인 결함에 해당한다.

예외 - 소스코드 로직에서 발생하는 문제를 처리하지 못할 때 발생한다. 따라서, 개발자가 처리하는 부분은 여기에 해당한다.

 

예외의 동작방식


mdn의 참고 문서를 보자.

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/throw

 

throw가 던지는 건 사용자 정의 예외지만 동작은 일반적인 예외와 동일하다.

예외가 발생하면,

  • 현재 함수의 실행이 중지된다. 즉, 해당 함수의 뒷 내용이 실행되지 않는다.
  • 콜스택에서 가장 위에 있는 catch 문을 찾아서 실행한다.
  • 만약 catch 블록이 콜스택 내에 존재하지 않는다면 프로그램이 중지되고 uncaught error가 발생한다. (정확히는 "uncaught $에러명칭 + stack" 형식이다)

 

왜 예외를 일부러 발생시킬까?

  • 코드에서 에러가 발생할 수 있는 부분을 그냥 두면, 예상치 못한 에러가 발생할 시 대응이 어려우므로, 차라리 예상 가능한 에러를 던지는 것이다.
  • 예외를 제대로 발생시키고 처리하지 않으면, 코드는 작동하는데, 의도치 않은 방향으로 진행되는 조용한 에러가 생긴다.
  • 따라서, 코드는 발생가능한 예외를 의도적으로 발생시키면서도, 해당 에러가 프로그램 전체를 정지시키지 않도록 상위 컨텍스트에서 처리해야 한다.

 

사용자 정의 에러 - throw

throw를 사용하면 개발자가 커스텀한 예외를 발생시킬 수 있다.

throw 를 사용하면 다른 예외와 동일하게 해당 함수의 실행을 중단하고 현재 콜스택에서 아래로 가장 가까운 catch문을 찾아 실행시킨다.

 

throw로는 어떤 표현식이든 반환가능하다. 

단순한 문자열이든 객체이든 모두 가능하다. 그런데, 에러 객체를 받을 때 개발자는 무엇을 원할까. 크게 세 가지다.

  1. 에러 내용의 요약 
  2. 에러에 대한 구체적인 설명 
  3. 에러가 발생한 위치

throw는 어떤 객체든 반환가능하므로, 커스텀한 에러 객체 함수를 만들 수도 있다. 하지만, 에러의 발생 위치를 알려주는 건 어려우르모, Error 객체를 사용하는 것이 일반적이다.

 

Error 객체

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Error

Error객체는 런타임에서 오류 발생시 예외와 함께 생성된다. 우리가 콘솔에서 보던 친숙한 에러 메시지가 이거다.

이 객체는 개발자가 접근가능한 속성인 name, message, stack , cause 으로 구성된다.(mdn에 따르면, stack은 표준 속성은 아니지만 모든 주요 브라우저에 구현되어 있으며 표준화가 고려 중이다)

에러 객체가 콘솔에 출력된 예시

  • name
    • 에러의 종류를 요약해서 보여준다.(위 예시에서 TypeError에 해당) 
    • 문자열만 가능
  •  message
    • 에러의 내용을 알려준다. (위 예시에서  Assignment to contant variable에 해당) 
    • 문자열만 가능
  • cause
    • 에러가 복잡할 경우, 위 세 가지 속성으로 전부 설명하기 어려운데, 이 때를 위해서 추가적인 속성 값을 정의할 수 있다.
    • 객체나 함수를 포함한 모든 값을 받을 수 있다.
    • 문자열로 설정하면 stack의 마지막에 "cause by+ $cause값" 형식으로 표출된다.

  •  stack
    • name, message, cause 그리고 오류 발생 이력을 한번에 string으로 출력한다.
    • 오류 발생 이력은 두 가지가 표출된다.
      • 콘솔 출력을 실행한 catch문의 위치 (우측 상단에 표시)
      • 예외가 던져진 위치에서 가까운 순으로 콜스택 내 호출자 함수명
  • uncaught
    • uncaught는 어디에도 속하지 않으며 예외를 처리하지 않는 경우 붙는 수식어이다.

 

error 객체를 활용하기 위해서는 catch  블록을 통해 예외를 잡아내야 한다. 잡아내지 못한 예외는 위 속성들을 참조하여 무언가를 할 수 없는 것 같다. 

function makeError1() {
    //  타입 에러: constant 변수를 변경하려 함
  const a = 10;
  a = 20;
  console.log("error");
}


function makeError2() {
    // 일부러 에러를 던짐
  const err = new Error("hello2");
  err.name = "adasdsad";
  err.message = "hello3";
  err.cause = { foo: "bar" };
  throw err;
}

// 에러를 catch하면 에러 객체 내부 속성을 참조하여 이용할 수 있다.
try {
    sync2();
} catch (err) {
  console.log(err);
  console.log(err.name);
  console.log(err.cause);
  console.log(err.stack);
}

// catch로 잡지 못하면 프로그램이 멈춘다.
console.log(sync2());

 

에러를 잘 처리하는 방법

커스텀 에러 객체 만들기

에러의 내용을 잘 설명하기 위해서는 cause 속성을 잘 사용해야 한다. 따라서, 이 속성의 값에 들어갈 속성을 미리 정의한 함수를 생각해볼 수 있다.

혹은 class를 사용하여 Error 객체를 상속한 커스텀 에러 객체를 만들고 추가적인 속성을 부여할 수 있다(mdn 참조)

이 경우, 에러의 타입 검사 때 `instance of $에러종류`를 사용하면 분기 처리하기가 용이해진다.

 

 

 

예외 던지기 체이닝, 예외 처리 허브

예외를 발생가능한 구문의 함수에서 곧바로 처리하면 간단하나, 에러 처리 위치가 이리저리 흩어져서, 종종 예외가 발생해야 하는데, 발생하지 않아버리고 성공한 것처럼 엉뚱한 값을 반환하여 조용한 에러를 일으킬 수 있다.

그래서, 모든 위치에서 바로 에러를 처리하기 보단, throw로 에러를 상위 콜스택으로 던지고, 이를 다시 상위 콜스택으로 던지는 걸 반복하여 예외 처리를 중앙 집중화할 수 있다.

이 때, error 객체의 cause를 잘 사용하여야 처리 허브에서 에러의 상세한 내용을 파악할 수 있다.

 

* 커스텀 에러 객체와 에러 허브에서의 분기처리 패턴을 구체적인 사례와 함께 알고 싶다면 아래 글 참조

https://medium.com/@yujso66/%EB%B2%88%EC%97%AD-%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%EC%97%90%EC%84%9C-%EC%A0%84%EB%AC%B8%EA%B0%80%EC%B2%98%EB%9F%BC-%EC%97%90%EB%9F%AC-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0-39d14f5cc6a2

 

정리

  • 예외 발생 위치와 가장 가까운 스택에서 로그를 뽑으면 온전한 stack을 추적할 수 있다.
  • cause는 체이닝을 하는 catch 블럭에서 예외 발생 위치의 구체적인 에러 내용을 상단으로 전달할 수 있는 중요한 속성이다. 따라서, 반드시 위로 전달하거나 커스터마이징하여 전달하자.
  • 에러를 모아 처리하는 허브에서는 에러를 name이나 message, cause, 혹은 별도의 에러 클래스를 만들고 instanceof로 분기처리하자.
// 에러 발생 위치
function fireError() {
  // 일부러 에러를 던짐
  const err = new Error("custom error is fired!!");
  err.name = "custom error";
  err.cause = { type: "cause1" };
  throw err;
}

// 에러로깅, 상위 호출 스택으로 던짐
function chainError() {
  try {
    fireError();
  } catch (err) {
    // 구체적 stack을 찍기 위한 로깅
    console.error(err);

    const error = new Error(err.name + ": " + err.message);
    // 위 코드는 이것과 동일하게 작동함;
    // const error = new Error(err);

    // 에러 객체의 속성 전달
    error.cause = err.cause;

    throw error;
  }
}

// 에러를 모아 처리하는 허브
function hubError() {
  try {
    // fireError();
    chainError();
  } catch (err) {
    if (err.cause.type === "cause1") {
      console.log("cause1 에러 분기 처리");
    }
  }
}

hubError();

 

비동기 함수의 예외 처리


이전 섹션에서는 동기 함수에서 예외가 발생할 시, 처리 방법에 대해 알아보았다. 동기 함수에서는, 의도적인 throw든 의도치 않은 예외이든(api 호출 실패라던지, 네트워크 호출 에러, api 응답의 무결성 문제 등), 예외 발생 시 하위 콜스택의 호출자 함수중 가장 가까운 catch문이 자동으로 실행된다.

하지만, 비동기 함수에서 발생한 예외는 상위 catch문에서 직접 잡을 수 없다. 왜냐하면 비동기 함수에서 발생한 예외는 그냥 예외가 아니라, Promise(reject) 객체이기 때문이다. 따라서, 함수 내부에서 반드시 try catch 문으로 에러를 한번 잡아서 직접 던져줘야 한다(throw). 아니면 promise chain이나 async await로 예외를 잡아줘야 한다.

 

이게 무슨 말일까.

 

동기 함수에서 발생한 예외는 자동으로 상위의 catch문에서 잡을 수 있다.

예시로 동기작업을 하는 코드를 만들어보자.

아래 코드는 동기작업1, 동기작업2를 순차 실행한다.

동기작업2에서 예외를 던지면 가장 가까운 catch문인 errorHub 호출자 함수의 catch문이 실행될 것이다.

예외 처리가 완료되면, errorHub 실행이 끝나고 그 이후의 코드가 정상작동하는 걸 확인할 수 있다.

function 동기작업1() {
  console.log("동기작업1 시작");
  console.log("동기작업1 완료");
}
function 동기작업2() {
  console.log("동기작업2 시작");
  throw new Error("동기작업2 실패");
}

// 에러 분기처리를 전담하는 레이어
function errorHub() {
  try {
    // 정상작동합니다
    동기작업1();
    // 에러가 날 겁니다.
    동기작업2();
  } catch (err) {
    // 콘솔로 출력합니다. 예시를 위해 log로 출력하나, 실제로는 console.error로 출력하는 게 직관적으로 에러라 보입니다
    console.log("errorHub", err);
    // 에러의 분기 처리
  }
}

function init() {
  errorHub();
  // errorHub의 예외처리가 성공한다면, 아래 콘솔이 실행
  console.log("에러 처리 후 정상작동");
}

init();

 

결과

동기작업2에서 던진 예외가 상위 catch문에서 정상적으로 처리 됨

 

 

위 코드를 비동기로 전환해보자.

함수를 async await로 전환하고 실제 비동기처럼 작동하도록 작동이 1초 지연되는 setTimeout을 넣었다.

아래 코드는 비동기작업1, 비동기작업2를 순차 실행한다.

예상대로라면, 비동기작업2에서 예외를 던지면 가장 가까운 catch문인 errorHub 호출자 함수의 catch문이 실행될 것이고, 예외 처리가 완료되면 errorHub 실행이 끝나고 그 이후의 코드가 정상작동할 것이다.

async function 비동기작업1() {
  console.log("비동기작업1 시작");
  await new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(() => {
        console.log("비동기작업1 완료");
      });
    }, 1000);
  });
}
async function 비동기작업2() {
  console.log("비동기작업2 시작");
  await new Promise((resolve, reject) => {
    setTimeout(() => {
      // 예기치 않은 문제로 예외 던짐
      throw new Error("비동기작업2 실패");

      resolve(() => console.log("비동기작업2 완료"));
    }, 1000);
  });
}

// 에러 분기처리를 담당하는 레이어
async function errorHub() {
  try {
    // 정상작동합니다
    await 비동기작업1();
    // 에러가 날 겁니다.
    await 비동기작업2();
  } catch (err) {
    // 콘솔로 출력합니다. 예시를 위해 log로 출력하나, 실제로는 console.error로 출력하는 게 직관적으로 에러라 보입니다
    console.log("errorHub", err);
    // 에러의 분기 처리
  }
}

function init() {
  (async () => {
    await errorHub();
    // errorHub의 예외처리가 성공한다면, 아래 콘솔이 실행
    console.log("에러 처리 후 정상작동");
  })();
}

init();

 

실행해본 결과, 예외가 잡히지 않았다.

왼쪽: log, 오른쪽: error - 비동기작업2에서 던져진 예외가 catch문에서 잡히지 않아서 프로그램이 중단되었다.

 

원인: 비동기 함수는 호출자 함수에서의 호출 시점에 컨텍스트를 생성하나, 콜스택에서 단독으로 실행된다.

자바스크립트 엔진은 싱글 쓰레드라는 말을 들어봤을 것이다. 여기서 동기적으로 실행되는 함수들은 콜스택에 쌓여서 "first in, last out"원칙에 따라 처리된다. 반면, 비동기 함수들은 호출 시점에 큐에 저장되었다가, 콜스택 내 함수들이 모두 완료되어 제거되고 나면 이벤트 루프가 큐에 있는 함수들의 대기가 끝났는지 확인하고 콜스택으로 가져와서 실행한다. 즉, 비동기 함수가 콜스택에서 실행될 때에는 자신을 실행한 호출자 함수와 콜스택에서 연결된 상태가 아니다.

따라서, 호출자 함수에서 비동기 함수를 호출하여 값을 받고 후속 작업을 할 때에는 콜백, Promise, 혹은 async await를 사용한다. 이 때, promise나 async await를 사용하면, promise 객체를 반환하며, pending, fullfill, reject 등의 내부 상태를 감시한다. 만약 비동기 함수의 작업이 완료되어 fullfill이나 reject가 이뤄지면 거기에 연결된 then이나 catch 등이 실행되는데, 이를 promise chain이라 부른다.

(async await는 promise를 동기적인 문법처럼 보기 좋게 바꿔주는 sugar syntax이며, 실제 작동 방식은 await 뒤의 후속 코드를 then과 같은 chain으로 연결시켜준다. 만약 await로 호출된 비동기 함수에서 예외를 던지면 promise(reject)로 자동 변환된다)

다른 말로 하면, promise, async await의 chain은 비동기로 혼자서 콜스택에서 실행되는 함수를 호출자 함수가 재실행될 때 성공 혹은 실패 같은 상태를 받을 수 있게 한다.


이제 위 코드에서 "비동기작업2" 함수를 보자.

async function 비동기작업2() {
  console.log("비동기작업2 시작");
  await new Promise((resolve, reject) => {
    setTimeout(() => {
      // 예기치 않은 문제로 예외 던짐
      throw new Error("비동기작업2 실패");

      resolve(() => console.log("비동기작업2 완료"));
    }, 1000);
  });
}

이 함수 내부의 promise 안에서 setTimeout 안에 있는 콜백 함수는 비동기적으로 실행된다.

콜스택으로 따지면, 비동기작업2 =>promise의 excutor (비동기)=> settimeout의 콜백 (비동기) 이다.

그런데, setTimeout 콜백은 await 같은 처리가 없이 예외를 던진다. 따라서, 이 예외는 promise의 excutor로 전달되지 않는다.
자신을 호출한 호출자 함수에게 예외를 전달하지 못하고, 비동기함수이므로 콜스택에서 혼자 실행된 이 콜백 함수는 예외를 잡지 못하므로 uncaught error가 발생하는 것이다.

 

이제 해결방안을 알아보자. 

방안1: 호출된 비동기 함수 내부에서 예외처리를  직접 한다.

첫번째 방안은 비동기작업2 함수에서 호출된 비동기 함수(setTimeout 내 콜백) 내부에서 직접 catch 문으로 에러를 잡아서 호출자 함수에 reject해주는 것이다. 

async function 비동기작업2() {
  console.log("비동기작업2 시작");
  await new Promise(async (resolve, reject) => {
    setTimeout(() => {
      try {
        // 비동기 작업
        // 예기치 않은 문제로 예외 던짐
        throw new Error("비동기작업2 실패");
      } catch (error) {
        console.log("setTimeout 내에서 예외 처리", error);

        reject(error);
      }

      resolve(() => console.log("비동기작업2 완료"));
    }, 1000);
  });
}

이러면 setTimeout 콜백의 예외는 promise로 전달되고, promise로 전달된 예외는 다시 비동기 작업2, 그리고 다시 외부의 호출자 함수로 전달된다..

 

방안2: 호출자 함수에서 비동기 함수를 호출할 때 promise chain이나 async await로 내부 상태를 전달받아야 한다. 

사실, 이 사례는 setTimeout이 동기적으로 호출하고 비동기적으로 작업하는 특수한 경우이기 때문에 발생한다. api 호출같은 일반적인 함수들은 아주 쉽게 가능하다. 그냥 호출할 때 await를 붙이거나 promise chain으로 예외를 받으면 된다.

async function 비동기작업2() {
  console.log("비동기작업2 시작");

  return (async function apiCall() {
    throw new Error("api 호출 에러");
  })();
}

// 에러 분기처리를 담당하는 레이어
async function errorHub() {
  try {
    // 정상작동합니다
    await 비동기작업1();
    // 에러가 날 겁니다.
    await 비동기작업2();
  } catch (err) {
    // 콘솔로 출력합니다. 예시를 위해 log로 출력하나, 실제로는 console.error로 출력하는 게 직관적으로 에러라 보입니다
    console.log("errorHub", err);
    // 에러의 분기 처리
  }
}

잊지 말자. await는 후속 코드가 promise의 then catch chain 처럼 연결되도록 하는 sugar syntax다.

 

만약 promise 인스턴스와 async await를 섞어 쓴다면 promise는 chain으로 catch 문을 잡아줘야 한다.

아래는 비동기 함수 apiCall의 예외를 promise chain으로 잡아주는 것이다. 

async function 비동기작업2() {
  console.log("비동기작업2 시작");

  await new Promise((resolve, reject) => {
    (async function apiCall() {
      throw new Error("apiCall1");
    })().catch((err) => reject(err));
  });
}

 

결국, 비동기 함수는 동기 함수처럼 호출자 함수와 피호출자 함수가 동일한 콜스택에서 실행되지 않으므로, 피호출자 함수 내에서 던진 예외를 호출자 함수로 전달하기 위해서는 "async await + try catch" 기법이나 promise chain의 catch를 사용해야 한다.