[JavaScript] 3-8. 예외처리 (JavaScript Exception Handling)

예외처리 (JavaScript Exception Handling)

JavaScript 에는 코드 실행 중에 예기치 못한 에러가 발생했을 때,
이로부터 코드의 실행 흐름을 복구할 수 있는 기능이 내장되어 있습니다.
이런 기능을 일러 예외 처리(exception handling)라고 합니다.


동기식 코드에서의 예외 처리

JavaScript 코드에서 발생할 수 있는 에러에는 다양한 것들이 있습니다.
문법 에러와 같이 프로그래머의 실수로 인해 에러가 발생하는 경우도 있지만,
네트워크 에러와 같이 코드와는 무관한 이유로 발생하는 에러도 있습니다.

new Array(-1); // RangeError: Invalid array length
console.log(foo); // ReferenceError: foo is not defined
fetch("https://nonexistent-domain.nowhere"); // TypeError: Failed to fetch

코드 실행 중에 에러가 발생하면, 코드의 실행이 중단되어 그 시점에 실행 중이었던 작업을 완료할 수 없게 됩니다.
JavaScript 는 이로부터 코드의 실행 흐름을 원상복구할 수 있는 기능을 제공하며,
try…catch…finally 구문을 사용하면 에러가 나더라도 코드의 실행을 지속할 수 있습니다.

try {
  console.log("에러가 나기 직전까지의 코드는 잘 실행됩니다.");
  new Array(-1); // RangeError: Invalid array length
  console.log("에러가 난 이후의 코드는 실행되지 않습니다.");
} catch (e) {
  console.log("코드의 실행 흐름이 catch 블록으로 옮겨집니다.");
  alert(`다음과 같은 에러가 발생했습니다: ${e.name}: ${e.message}`);
}

에러가 났을 때 원상복구를 시도할 코드를 try 블록 내부에 작성하면,
에러가 발생했을 때 코드의 실행 흐름이 try 블록에서 catch 블록으로 옮겨갑니다.
이 때, catch 블록 안에서는 에러에 대한 정보를 담고 있는 객체(위 코드의 e)를 사용할 수 있습니다.

try {
  console.log("에러가 나기 직전까지의 코드는 잘 실행됩니다.");
  // new Array(-1); // RangeError: Invalid array length
  console.log(foo);
} catch (e) {
  if (e.name === "RangeError") {
    alert(`다음과 같은 에러가 발생했습니다: ${e.name}: ${e.message}`);
  } else if (e.name === "ReferenceError") {
    alert(`다음과 같은 에러가 발생했습니다. ${e.name}: ${e.message}`);
  }
}

// 에러도 객체이기 떄문에 위처럼 정보를 사용하려면 사용할 수 있습니다.

try 블록은 예외 처리를 위해서만 쓰이는 것은 아닙니다. try 블록 바로 뒤에 finally 블록이 오면,
finally 블록에 있는 코드는 try 블록 안에서의 에러 발생 여부와 관계 없이 무조건 실행됩니다.
심지어 try 블록 내에서 return, break, continue 등으로 인해 코드의 실행 흐름이 즉시 이동될 때에도 마찬가지입니다.

for (let i of [1, 2, 3]) {
  try {
    if (i === 3) {
      break;
    }
  } finally {
    console.log(`현재 i의 값: ${i}`);
  }
}

// finally는 제대로 작동해도, 에러가 발생해도 무조건 실행된다는 점 기억하기!

finally 블록은 catch 블록과도 같이 사용됩니다. 이 때 코드의 실행 순서를 정리해 보면 다음과 같습니다.

try {
  console.log("try");
  new Array(-1); // RangeError: Invalid array length
} catch (e) {
  console.log("catch");
} finally {
  console.log("finally");
}



직접 에러 발생시키기

Error 생성자와 throw 구문을 사용해서 프로그래머가 직접 에러를 발생시킬 수 있습니다.

const even = parseInt(prompt("짝수를 입력하세요"));
if (even % 2 !== 0) {
  throw new Error("짝수가 아닙니다.");
}

// new Error()는 JS 내장 생성자로 에러 객체를 생성할 수 있습니다.
try {
  const even = parseInt(prompt("2 이상의 짝수를 입력하세요"));
  if (even < 2) {
    throw new NotPositiveError("양수가 아닙니다.");
  }
  if (even % 2 !== 0) {
    throw new EvenError("짝수가 아닙니다.", even);
  }
} catch (e) {
  if (e instanceof NotPositiveError) {
    console.log(`양수를 입력하셔야 합니다.`);
  } else if (e instanceof EvenError) {
    console.log(`잘못된 입력입니다: ${e.even}`);
  }
}

위처럼 간혹 프로그램을 작성하면서 에러의 종류를 구분해야 하거나 에러 객체에 기능을 추가해야 할 필요가 있습니다.
이런 경우에는 Error 를 상속받는 클래스를 만들어서, throw 구문에서 이 클래스를 대신 사용할 수 있습니다.

예를 들어, 아래 MyError 클래스를 통해 에러 객체를 생성할 때 에러에 대한 상세 정보를 포함시키면,
catch 블록 안에서 원상복구를 위해 이 값을 활용할 수 있습니다

// 예외 처리에 있어서 에러 생성자를 만들고 싶을 때는 아래의 클래스 코드를 그대로 활용해야 합니다.
class MyError extends Error {
  constructor(value, ...params) {
    super(...params); // 부모 클래스로 부터 params 모두 상속
    this.value = value;
    this.name = "MyError";
  }
}

try {
  const even = parseInt(prompt("짝수를 입력하세요"));
  if (even % 2 !== 0) {
    throw new MyError(even, "짝수가 아닙니다.");
  }
} catch (e) {
  if (e instanceof MyError) {
    console.log(e.value);
  }
}



비동기식 코드에서의 예외 처리

비동기 콜백

비동기식으로 작동하는 콜백의 내부에서 발생한 에러는, 콜백 바깥에 있는 try 블록으로는 잡아낼 수 없습니다.

try {
  setTimeout(() => {
    throw new Error("에러!");
  });
} catch (e) {
  console.error(e);
}

JavaScript 엔진은 에러가 발생하는 순간 호출 스택을 되감는 과정을 거칩니다.
이 과정 중에 try 블록을 만나야 코드의 실행 흐름을 원상복구시킬 수 있습니다.
위 예제에서 setTimeout 에 넘겨진 콜백에서 에러가 발생하면,
호출 스택을 따라 올라가도 try 블록을 만나는 것이 아니므로,
코드의 실행 흐름이 catch 블록으로 옮겨지지 않는 것
입니다.

따라서, 위 예제의 try 블록을 비동기 콜백 내부에 작성해주어야 합니다.

setTimeout(() => {
  try {
    throw new Error("에러!");
  } catch (e) {
    console.error(e);
  }
});

이는 비동기만이 아니라 이벤트 리스너도 마찬가지입니다.
즉, 실행 시점이 동기와 다른 경우에는 모두 try 블록을 내부에 작성 해줘야 합니다.

const buttonEl = document.querySelector("button");

try {
  buttonEl.addEventListener("click", e => {
    try {
      new Array(-1);
      alert("버튼이 눌렸습니다.");
    } catch (e) {
      alert("이벤트 리스너 안에서 에러가 발생했습니다.");
    }
  });
} catch (e) {
  alert("에러가 발생했습니다."); // 출력되지 않음
}

try...catch 가 원하는 방식대로 동작하는지 테스트를 한 후 사용하는 것이 좋습니다.

Promise

Promise 객체는 세 가지 상태를 가질 수 있습니다.

Promise 객체가 rejected 상태가 되면, 이 Promise 에 대해서는 then 메소드에 첫 번째 인수로 넘겨준 콜백이 실행되지 않고,
두 번째 인수로 넘겨준 콜백이 대신 실행
됩니다. 그리고 이 콜백에는 에러 객체가 첫 번째 인수로 주어집니다.

const p = new Promise(resolve => {
  const even = parseInt(prompt("짝수를 입력하세요"));
  if (even % 2 !== 0) {
    throw new Error("짝수가 아닙니다.");
  } else {
    resolve(even);
  }
});

p.then(
  even => {
    return "짝수입니다.";
  },
  e => {
    return e.message;
  }
).then(alert);

혹은, catch 메소드를 통해 에러 처리 콜백을 지정해줄 수도 있습니다.

p.then(even => {
  return "짝수입니다.";
})
  .catch(e => {
    return e.message;
  })
  .then(alert);

만약, then 메소드의 연쇄 안에서 에러가 발생하면,
try…catch 구문과 유사하게 처음 만나는 에러 처리 콜백으로 코드의 실행 흐름이 건너뛰는 결과를 낳게 됩니다.

Promise.resolve()
  .then(() => {
    throw new Error("catch 메소드를 통해 예외 처리를 할 수 있습니다.");
  })
  .then(() => {
    console.log("이 코드는 실행되지 않습니다.");
  })
  .catch(e => {
    return e.message;
  })
  .then(console.log);

비동기 함수

앞에서 봤던 Promise 객체의 예외 처리 방식은,
일반적인 동기식 예외 처리 방식과 다르게 콜백을 사용하고 있어서 코드를 복잡하게 만드는 원인이 됩니다.

비동기 함수 내부에서는, rejected 상태가 된 Promise 객체를 동기식 예외 처리 방식과
동일하게 try…catch…finally 구문으로 처리할 수 있습니다.

async function func() {
  try {
    const res = await fetch("https://nonexistent-domain.nowhere");
  } catch (e) {
    console.log(e.message);
  }
}

func(); // 출력 결과: Failed to fetch

단, Promise 객체에 대해 await 구문을 사용하지 않는 경우,
에러가 발생해도 catch 블록으로 코드의 실행 흐름이 이동하지 않는다는 사실을 기억하세요.

async function func() {
  try {
    fetch("https://nonexistent-domain.nowhere");
  } catch (e) {
    console.log(e.message);
  }
}

func(); // 아무것도 출력되지 않습니다.