22. 1. 25 자바스크립트) 에러 핸들링

divedeepp·2023년 1월 25일
0

JavaScript

목록 보기
10/11

try catch와 에러 핸들링

에러가 발생하면 스크립트는 즉시 중단되고, 콘솔에 에러가 출력된다.

그러나 try catch 문법을 사용하면 스크립트가 중단되는 것을 방지하고, 에러를 잡아서 처리할 수 있다.

try catch 문법

trycatch라는 두 개의 주요 블록으로 구성된다.

try {
  // ...
} catch (err) {
  // ...
}

동작 알고리즘은 다음과 같다.
1. 먼저, try { ... } 안의 코드가 실행된다.
2. 에러가 없다면 try { ... } 안의 코드만 실행되고, catch 블록은 건너뛴다.
3. 에러가 있다면 try { ... } 안 코드의 실행이 중단되고, catch(err) { ... } 블록으로 제어 흐름이 넘어간다. err에는 무슨 일이 발생했는지에 대한 설명이 담긴 에러 객체르 포함한다.

이렇게 try 블록 안에서 에러가 발생해도, catch에서 에러를 처리하기 때문에 스크립트는 중단되지 않는다.

try catch는 오직 유효한 코드에서 발생하는 에러만 처리할 수 있다. 이런 에러를 런타임 에러 혹은 예외라고 부른다.

또, try catch는 동기적으로 동작한다. setTimeout처럼 스케줄 된 코드에서 발생한 예외는 try catch에서 잡아낼 수 없다. 아래 예시에서 setTimeout에 넘겨진 익명 함수는 엔진이 try catch를 떠난 다음에서야 실행된다.

try {
  setTimeout(function() {
    noSuchVariable; // 스크립트는 여기서 죽는다.
  }, 1000);
} catch (e) {
  alert( "작동 멈춤" );
}

따라서, 스케줄 된 함수 내부의 예외를 잡으려면 try catch를 반드시 함수 내부에 구현해야 한다.

setTimeout(function() {
  try {
    noSuchVariable; // 이제 try..catch에서 에러를 핸들링 할 수 있다.
  } catch {
    alert( "에러를 잡았습니다!" );
  }
}, 1000);

에러 객체

에러가 발생하면 자바스크립트는 에러 상세내용이 담긴 객체를 생성한다. 그 후, catch 블록에 해당 객체를 인수로 전달한다.

try {
  // ...
} catch(err) { // <-- '에러 객체', err 대신 다른 이름으로도 쓸 수 있음
  // ...
}

에러 객체는 아래의 주요 프로퍼티를 가진다.

  • name : 에러 이름
  • mesage : 에러 상세 내용을 담고 있는 메시지
  • stack : 현재 호출 스택. 에러를 유발한 중첩 호출들의 순서 정보를 가진 문자열로, 디버깅 목적으로 사용된다.
try {
  lalala; // 에러, 변수가 정의되지 않음!
} catch(err) {
  alert(err.name); // ReferenceError
  alert(err.message); // lalala is not defined
  alert(err.stack); // ReferenceError: lalala is not defined at ... (호출 스택)

  // 에러 전체를 보여줄 수도 있다.
  // 이때, 에러 객체는 "name: message" 형태의 문자열로 변환된다.
  alert(err); // ReferenceError: lalala is not defined
}

에러에 대한 자세한 정보가 필요하지 않으면, err 인수를 생략할 수도 있다.

try {
  // ...
} catch { // <-- (err) 없이 쓸 수 있음
  // ...
}

try catch를 실무에서 사용하는 방법

앞 서 JSON으로 인코딩된 값을 읽을 수 있도록 하는 JSON.parse(str) 메서드에 대해 배운 바 있다. 이 메서드는 주로 서버 등에서 네트워크를 통해 전달받은 데이터를 디코딩하는 데 사용한다.

이 때 잘못된 형식의 json이 들어온 경우, JSON.parse는 에러를 만들기 때문에 스크립트가 중단된다. 서버에서 전달받은 데이터가 잘못되어 스크립트가 죽는 경우, 사용자는 개발자 콘손을 열지 않는 이상 원인을 알 수 없다. 이를 처리하기 위해 try catch를 사용해보자.

let json = "{ bad json }";

try {

  let user = JSON.parse(json); // <-- 여기서 에러가 발생하므로
  alert( user.name ); // 이 코드는 동작하지 않는다.

} catch (e) {
  // 에러가 발생하면 제어 흐름이 catch 문으로 넘어온다.
  alert( "데이터에 에러가 있어 재요청을 시도합니다." );
  alert( e.name );
  alert( e.message );
}

위 예시에서는 에러가 발생했다는 것만 보여주기 위해 간단히 예외처리 했지만, catch 블록 안에서 새로운 네트워크 요청 보내기, 대안 제안하기, 에러 정보 로깅 등 다양한 일을 할 수 있다. 그냥 스크립트가 죽도록 놔두는 것보다 훨씬 나은 대응이다.

throw 연산자로 직접 에러를 만들어서 던지기

json이 문법적으로 잘못되진 않았지만, 스크립트 내에서 사용 중인 필수 프로퍼티 등을 가지고 있지 않다면 무슨 일이 발생할까?

let json = '{ "age": 30 }'; // 불완전한 데이터

try {

  let user = JSON.parse(json); // <-- 에러 없음
  alert( user.name ); // 에러 발생

} catch (e) {
  alert( "실행되지 않습니다." );
}

위 예시에서 JSON.parse는 정상적으로 실행되었지만 name 프로퍼티가 없기 때문에 에러가 발생한다. 이 때 throw 연산자를 사용해서 직접 에러 처리를 할 수 있다.

throw 연산자는 에러를 생성한다.

throw <error object>

이론적으로 숫자, 문자열 같은 원시형 자료를 포함한 어떤 것이든 에러 객체로 사용할 수 있다. 하지만 내장 에러와의 호환을 위해 되도록 에러 객체에 namemessage 프로퍼티를 넣어주는 것을 권장한다.

자바스크립트는 Error, SyntaxError, ReferenceError, TypeError 등의 표준 에러 객체에 관련한 생성자를 지원한다. 이 생성자들을 이용해서 에러 객체를 만들 수 있다.

let error = new Error(message);
// or
let error = new SyntaxError(message);
let error = new ReferenceError(message);
// ...

일반 객체가 아닌 내장 생성자를 사용해서 만든 내장 에러 객체의 name 프로퍼티는 생성자 이름과 동일한 값을 갖는다. 프로퍼티 message의 값은 인수에서 가져온다.

let error = new Error("이상한 일이 발생했습니다. o_O");

alert(error.name); // Error
alert(error.message); // 이상한 일이 발생했습니다. o_O

다시 원래 예시로 돌아가서 잘못된 데이터를 받았을 때, throw 연산자를 사용해서 에러를 던져보자.

let json = '{ "age": 30 }';	// 불완전한 데이터

try {
  let user = JSON.parse(json);
  
  if (!user.name) {
    throw new SyntaxError("불완전한 데이터: 이름 없음");	// 에러 생성 (*)
  }
  
  alert( user.name);
} catch(e) {
  alert( "JSON Error: " + e.message ); // JSON Error: 불완전한 데이터: 이름 없음

(*)로 표시한 줄에서 throw 연산자는 message를 이용해서 SyntxError를 생성한다. 에러 생성 방식은 자바스크립트가 자체적으로 에러를 생성하는 방식과 동일하다. 에러가 발생했으므로 try의 실행은 즉시 중단되고, 제어 흐름이 catch로 넘어간다.

에러 다시 던지기

위 예시에서는 불완전한 데이터를 try catch로 처리했다. 그런데 또 다른 예기치 않은 에러가 try 블록 안에서 발생할 수도 있다.

let json = '{ "age": 30 }'; // 불완전한 데이터

try {
  user = JSON.parse(json); // <-- user 앞에 let을 붙이는 걸 잊었다.

  // ...
} catch(err) {
  alert("JSON Error: " + err); // JSON Error: ReferenceError: user is not defined
  // (실제론 JSON Error가 아니다.)
}

에러는 어떤 상황에서도 발생할 수 있다. 위 예시에서는 불완전한 데이터를 다루려는 목적으로 try catch를 썼다. 그런데, catch는 원래 try 블록에서 발생한 모든 에러를 잡으려는 목적으로 만들어졌다. 그런데 위 예시에서는 에러 종류와 관계없이 "JSON Error" 메시지를 보여준다. 이렇게 에러 종류와 관계없이 동일한 방식으로 에러를 처리하는 것은 디버깅을 어렵게 만들기 때문에 좋지 않다.

이런 문제를 피하고자 다시 던지기(rethrowing) 기술을 사용한다. 즉, catch는 알고 있는 에러만 처리하고 나머지는 다시 던져야 한다.
1. catch가 모든 에러를 받는다.
2. catch 블록 안에서 에러 객체를 분석한다.
3. 에러 처리 방법을 알지 못하면 throw 구문을 실행한다.

에러를 다시 던져서 catch 블록에서는 SyntaxError만 처리되도록 해보자. 보통 에러 타입을 instanceof 명령어로 체크한다.

let json = '{ "age": 30 }'; // 불완전한 데이터
try {

  let user = JSON.parse(json);

  if (!user.name) {
    throw new SyntaxError("불완전한 데이터: 이름 없음");
  }

  blabla(); // 예상치 못한 에러

  alert( user.name );

} catch(e) {

  if (e instanceof SyntaxError) {
    alert( "JSON Error: " + e.message );
  } else {
    throw e; // 에러 다시 던지기 (*)
  }

}

catch 블록 안의 (*)로 표시한 줄에서 다시 던져진 에러는 try catch 밖으로 던져진다. 이 때 바깥에 try catch가 있다면 여기서 에러를 잡는다. 아니면 스크립트는 중단된다. 이렇게 하면 catch 블록에서는 어떻게 다룰지 알고 있는 에러만 처리하고, 알 수 없는 에러는 건너뛸 수 있다.

이제 try catch를 하나 더 만들어, 다시 던져진 예상치 못한 에러를 처리해 보자. 아래 예시에서 readDataSyntaxError만 처리할 수 있지만, 함수 바깥의 try catch에서는 예상치 못한 에러도 처리할 수 있게 된다.

function readData() {
  let json = '{ "age": 30 }';

  try {
    // ...
    blabla(); // 에러!
  } catch (e) {
    // ...
    if (!(e instanceof SyntaxError)) {
      throw e; // 알 수 없는 에러 다시 던지기
    }
  }
}

try {
  readData();
} catch (e) {
  alert( "External catch got: " + e ); // 에러를 잡음
}

try catch finally

finally 안의 코드는 다음과 같은 상황에서 실행된다.

  • 에러가 없는 경우. 즉, try 실행이 끝난 후
  • 에러가 있는 경우. 즉, catch 실행이 끝난 후

finally를 사용하면 try catch를 다음과 같이 확장할 수 있다.

try {
   ... 코드를 실행 ...
} catch(e) {
   ... 에러 핸들링 ...
} finally {
   ... 항상 실행 ...
}

finally 절은 무언가를 실행하고, 실행 결과(에러 여부)에 상관없이 어떤 코드를 실행하고 싶을 경우 사용된다.

아래 예시는 피보나치 함수의 연산 시간을 측정하는 함수이다. finally 절을 사용해서 에러가 발생했든 아니든 관계없이 연산 시간 측정을 할 수 있다.

let num = +prompt("양의 정수를 입력해주세요.", 35)

let diff, result;

function fib(n) {
  if (n < 0 || Math.trunc(n) != n) {
    throw new Error("음수나 정수가 아닌 값은 처리할 수 없습니다.");
  }
  return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}

let start = Date.now();

try {
  result = fib(num);
} catch (e) {
  result = 0;
} finally {
  diff = Date.now() - start;
}

alert(result || "에러 발생");

alert( `연산 시간: ${diff}ms` );

catch 절이 없는 try finally 구문도 사용할 수 있다. try finally 안에서는 에러를 처리하고 싶지 않지만, 시작한 프로세스가 마무리되었는지 확실히 하고 싶은 경우에 사용한다.

function func() {
  // 무언가를 측정하는 경우와 같이 끝맺음이 있어야 하는 프로세스
  try {
    // ...
  } finally {
    // 스크립트가 죽더라도 완료됨
  }
}

커스텀 에러와 에러 확장

개발을 하다 보면 자체 에러 클래스가 필요한 경우가 종종 생긴다.

예를 들어 네트워크 관련 작업 중 에러가 발생하면 HttpError, 데이터베이스 관련 작업 중 에러가 발생했다면 DbError 등을 사용하는 것이 직관적이기 때문이다.

직접 에러 클래스를 만든 경우, 이 에러들은 name, message, stack 프로퍼티 등을 지원해야 한다. 이 뿐만 아니라 다양한 프로퍼티를 지워할 수도 있다. 예를 들어 HttpError 클래스의 객체는 statusCode 프로퍼티를 만들고 404 등의 값을 가질 수 있다.

앞서 배운 바와 같이 throw의 인수에는 아무런 제약이 없기 때문에 커스텀 에러 클래스는 반드시 Error를 상속할 필요가 없다. 하지만, Error를 상속받아 커스텀 에러 클래스를 만들게 되면 obj instanceof Error를 사용해서 에러 객체를 식별할 수 있다는 장점이 생긴다. 이런 장점 때문에 맨땅에서 커스텀 에러 객체를 만드는 것보다 Error를 상속받아 에러 객체를 만드는 것이 낫다.

또, 애플리케이션 크기가 점점 커지면 HttpTimeoutErrorHttpError를 상속받는 것처럼 커스텀 에러 클래스들은 자연스레 계층 구조를 형성하게 된다.

에러 확장하기

사용자 데이터가 저장된 JSON을 읽는 함수 readUser(json)가 있다고 하자.

let json = `{ "name": "John", "age": 30 }`;

readUser 내부에서는 JSON.parse를 이용하게 된다. 따라서 잘못된 형식의 json이 들어오면 SyntaxError가 발생한다. 그런데 인수로 받은 데이터가 형식은 맞지만, 유효한 사용자가 아니면 어떡할까? 필수적인 name이나 age가 누럭되었을 수도 있다.

따라서, readUser는 JSON 형식의 데이터를 읽을 수 있을 뿐만 아니라, 데이터를 검증할 수도 있어야 한다. 그런데 이 때 발생하는 에러는 SyntaxError가 아니다. 지금부터 이 에러를 ValidationError라고 부르자. 이제 ValidationError를 위한 클래스를 만들어보자.

ValidationError 클래스에는 문제가 되는 필드 정보가 저장되어야 한다. 내장 클래스 Error를 상속받아 만들어 보자.

// 자바스크립트 자체 내장 에러 클래스 Error의 '슈도 코드'
class Error {
  constructor(message) {
    this.message = message;
    this.name = "Error"; // (name은 내장 에러 클래스마다 다르다.)
    this.stack = <call stack>;  // stack은 표준은 아니지만, 대다수 환경이 지원한다.
  }
}

class ValidationError extends Error {
  constructor(message) {
    super(message);	// (1)
    this.name = "ValidationError";	// (2)
  }
}

function test() {
  throw new ValidationError("에러 발생!");
}

try {
  test();
} catch(err) {
  alert(err.message); // 에러 발생!
  alert(err.name); // ValidationError
  alert(err.stack); // 각 행 번호가 있는 중첩된 호출들의 목록
}

(1)에서 부모 생성자를 호출하고 있다. 자바스크립트에서는 자식 생성자 안에서 super를 반드시 호출해야 한다. 또, 부모 생성자에서는 name 프로퍼티를 설정하기 때문에 (2) 에서 원하느 값으로 재설정한다.

이제, readUser 안에서 ValidationError를 사용해보자.

class ValidationError extends Error {
  constructor(message) {
    super(message);
    this.name = "ValidationError";
  }
}

// 사용법
function readUser(json) {
  let user = JSON.parse(json);

  if (!user.age) {
    throw new ValidationError("No field: age");
  }
  if (!user.name) {
    throw new ValidationError("No field: name");
  }

  return user;
}

// try..catch와 readUser를 함께 사용한 예시

try {
  let user = readUser('{ "age": 25 }');
} catch (err) {
  if (err instanceof ValidationError) {
    alert("Invalid data: " + err.message); // Invalid data: No field: name
  } else if (err instanceof SyntaxError) { // (*)
    alert("JSON Syntax Error: " + err.message);
  } else {
    throw err; // 알려지지 않은 에러는 재던지기 한다. (**)
  }
}

이제 try catch 블록에서 커스텀 에러 ValidationErrorJSON.parse에서 발생하는 내장 에러 SyntaxError 둘 다 처리할 수 있다. 이 과정에서 instanceof로 에러 유형을 확인했다(*). 에러 유형 확인은 instaceof 말고도 아래와 같이 err.name을 사용해도 가능하다.

// ...
// (err instanceof SyntaxError) 대신 사용 가능
} else if (err.name == "SyntaxError") { // (*)
// ...

그런데 에러 유형 확인은 err.name 보다는 instanceof를 사용하는 게 훨씬 좋다. ValidationError를 확장하여 새로운 에러를 만들수도 있는데, instanceof는 새로운 상속 클래스에서도 동작하기 때문이다.

또, catch에 알려지지 않을 에러가 있을 때 이 에러는 재 던지기 된다(**). 위 예시의 catch 블록에서는 유효성 검사와 문법 오류만 처리하고, 다른 종류의 에러는 밖으로 던져야 한다.

더 깊게 상속하기

앞 서 만든 ValidationError 클래스는 너무 포괄적이어서, 꼭 필요한 프로퍼티가 누락되거나 age에 문자열 값이 들어가는 것처럼 형식이 잘못된 경우를 처리할 수 없어 잘못될 확률이 있다. 따라서, 필수 프로퍼티가 없는 경우에 대응할 수 있도록 좀 더 구체적인 클래스 PropertyRequiredError를 만들어 보자. 해당 클래스에는 누락된 프로퍼티에 대한 추가 정보가 담겨야 한다.

class ValidationError extends Error {
  constructor(message) {
    super(message);
    this.name = "ValidationError";
  }
}

class PropertyRequiredError extends ValidationError {
  constructor(property) {
    super("No property: " + property);
    this.name = "PropertyRequiredError";
    this.property = property;
  }
}

// 사용법
function readUser(json) {
  let user = JSON.parse(json);

  if (!user.age) {
    throw new PropertyRequiredError("age");
  }
  if (!user.name) {
    throw new PropertyRequiredError("name");
  }

  return user;
}

// try..catch와 readUser를 함께 사용하면 다음과 같다.

try {
  let user = readUser('{ "age": 25 }');
} catch (err) {
  if (err instanceof ValidationError) {
    alert("Invalid data: " + err.message); // Invalid data: No property: name
    alert(err.name); // PropertyRequiredError
    alert(err.property); // name
  } else if (err instanceof SyntaxError) {
    alert("JSON Syntax Error: " + err.message);
  } else {
    throw err; // 알려지지 않은 에러는 재던지기 한다.
  }
}

여기서 주목할 점은 PropertyRequiredError 생성자 안에서 this.name을 수동으로 할당해 주었다. 그런데 이렇게 매번 커스텀 에러 클래스의 생성자 안에서 this.name을 수동으로 할당해 주는 것은 귀찮은 작업이다.

이런 번거로운 작업은 베이스가 되는 에러 클래스를 만들고 커스텀 에러들이 이 클래스를 상속받게 하면 피할 수 있다. 베이스 에러 생성자에 this.name = this.constructor.name을 추가하면 된다.

베이스가 되는 에러 클래스 MyError를 만들어 보자.

class MyError extends Error {
  constructor(message) {
    super(message);
    this.name = this.constructor.name;
  }
}

class ValidationError extends MyError { }

class PropertyRequiredError extends ValidationError {
  constructor(property) {
    super("No property: " + property);
    this.property = property;
  }
}

// 제대로 된 이름이 출력된다.
alert( new PropertyRequiredError("field").name ); // PropertyRequiredError

예외 감싸기

함수 readUser가 기능이 많아지면서 에러 종류가 많아지면 그 때마다 에러 종류에 따라 에러 처리 분기문을 매번 추가해야 하는가? 라는 의문이 생길 수 있다.

보통은 그렇지 않다. 실제로 우리가 필요로 하는 정보는 데이터를 읽을 때 에러가 발생했는지에 대한 여부이다. 왜 에러가 발생했는지와 자세한 설명은 대부분의 경우 필요하지 않다. 이런 에러 처리 기술은 예외 감싸기(wrapping exception)라고 부른다. 예외 감싸기는 다음과 같은 순서로 진행된다.
1. 데이터 읽기와 같은 포괄적인 에러를 대변하는 새로운 클래스 ReadError를 만든다.

  • 함수 readUser가 발생한 ValidationError, SyntaxError 등의 에러는 readUser 내부에서 잡고 이 때 ReadError를 생성한다.
  1. ReadError 객체의 cause 프로퍼티에는 실제 에러에 대한 참조가 저장된다.

이렇게 예외 감싸기를 사용하면 readUser를 호출하는 코드에서는 ReadError만 확인하면 된다. 데이터를 읽을 때 발생하는 에러 종류 전체를 확인하지 않아도 된다. 추가 정보가 필요한 경우에는 cause 프로퍼티를 확인하면 된다.

실제로 ReadError를 정의하고 이를 사용해보자.

class ReadError extends Error {
  constructor(message, cause) {
    super(message);
    this.cause = cause;
    this.name = 'ReadError';
  }
}

class ValidationError extends Error { /*...*/ }
class PropertyRequiredError extends ValidationError { /* ... */ }

function validateUser(user) {
  if (!user.age) {
    throw new PropertyRequiredError("age");
  }

  if (!user.name) {
    throw new PropertyRequiredError("name");
  }
}

function readUser(json) {
  let user;

  try {
    user = JSON.parse(json);
  } catch (err) {
    if (err instanceof SyntaxError) {
      throw new ReadError("Syntax Error", err);
    } else {
      throw err;
    }
  }

  try {
    validateUser(user);
  } catch (err) {
    if (err instanceof ValidationError) {
      throw new ReadError("Validation Error", err);
    } else {
      throw err;
    }
  }

}

try {
  readUser('{잘못된 형식의 json}');
} catch (e) {
  if (e instanceof ReadError) {
    alert(e);
    // Original error: SyntaxError: Unexpected token b in JSON at position 1
    alert("Original error: " + e.cause);
  } else {
    throw e;
  }
}

이제 readUser를 사용했을 때, Syntax 에러나 Validation 에러가 발생하면 해당 에러 자체를 다시 던지기 하지 않고, ReadError를 던지게 된다.

이렇게 되면, readUser를 호출하는 바깥 코드에서는 instanceof ReadError 딱 하나만 확인하면 된다. 에러 처리 분기문을 여러 개 만들 필요가 없다.

정리하자면 예외 감싸기는 종류별 에러를 좀 더 추상적인 에러 ReadError 등에 하나로 모아 처리하는 것을 뜻한다. 이런 기법은 객체 지향 프로그래밍에서 널리 쓰이는 패턴이다.


참고 문헌

https://ko.javascript.info/error-handling

profile
더깊이

0개의 댓글