Node.js에서의 Error Handling 적용

류예린·2022년 8월 6일
11

1. throw로 에러 던져보기


[그림1] throw를 이용한 에러 흐름도

에러를 던지는 방법으로 throw 가 있다. 이는 개발자가 작성하는 모듈에서 발생가능한 에러 상황에서 던지게 되며 상위 계층이나 호출하는 곳에서 모듈의 에러를 감지할 수 있다. 아래는 에러를 던지는 기본적인 예시다.

✅ 동기함수에서 에러 던지기

// func.js
function someFunc(someParam) {
  if (!someParam) {
    throw new Error('someError');
  }

  // ...someFunc의 로직

  return someParam;
}

module.exports = { someFunc }

위 예시 코드에서 매개 변수 someParam이 특정한 값을 가지고 있지 않다면 에러를 던진다. someFunc이 someParam 값이 없을 때, 그리고 더 이상 함수를 진행할 필요가 없을 경우를 처리하고자 사용된다.

✅ 비동기함수에서 에러 던지기

// func.js

...someFunc

async function someAsyncFunc(someParam) {
  if (!someParam) {
    throw new Error('someError');
  }

  // ...someAsyncFunc의 로직

  return someParam;
}

module.exports = { someFunc, someAsyncFunc }

위 내용은 비동기 함수 내에서 throw 하는 코드다. 에러를 던질 때에는 위 동기방식의 함수와 큰 차이는 없습다. 다만, 비동기 함수의 throw는 Promise Rejection을 발생시키기 때문에 에러를 잡아내는 곳에서는 다른 방식을 이용해야 한다.

그리고 비동기 작업은 동기적인 에러 핸들링 방식으로 처리 할 수 없다. 만일 비동기 에러를 처리하지 않으면 unhandled promise rejection 으로 인해 프로그램 자체를 종료시킨다. 때문에 꼭 비동기적인 방법을 이용하여 해당 에러를 사전에 핸들링 해주어야 한다.

이를 위해서 활용가능한 두 가지 방법은 바로 (1) await 를 사용하여 try - catch 로 에러 핸들링을 하는 것과 (2) promise - catch의 기능을 이용하여 에러 핸들링을 하는 방법이 있다. Express 에서는 async wrapping 모듈로 비동기 함수를 처리할 수 있다.









2. try - catch 구문으로 에러 핸들링


[그림2] try - catch 구문을 사용 했을 때의 논리블록 모식도

라이브러리 혹은 개발자가 작성한 모듈에서 throw가 발생했다면 상위 모듈에서 해당 에러를 잡아낼 수 있다. 에러를 잡아낸 후 return 및 throw를 하지 않으면 로직은 계속 진행되며 멈추지 않는다.

✅ 동기 방식일 때

// caller.js

const { someFunc } = require('./func');

function caller() {
  const someValueWithParam = someFunc(1);
	console.log("someValue:", someValue1);
	// someValue: 1

	const someValueWithoutParam = someFunc();
	// Error: someError
	// 에러가 발생하였으므로 더 이상 실행되지 않는다.
	console.log('someValue', someValueWithoutParam);
}
caller();

// 최종적으로 콘솔에 보이는 것
someValue: 1

위 예시 코드는 someFunc을 import 하여 매개 인자에 값을 넣었을 때와 넣지 않았을 때 차이를 보여주는 코드다. 매개 인자에 값을 넣게 되면 someFunc의 로직에 의해 정상적인 값을 반환해주지만 매개 인자에 값을 넣지 않는다면 someFunc은 throw를 하게 된다. 그 때 에러가 발생하였기 때문에 더 이상 로직이 실행되지 않고 함수가 종료된다.

// caller.js

const { someFunc } = require('./func');

function caller() {
  const someValueWithParam = someFunc(1);
	console.log("someValue:", someValueWithParam);
	// someValue: 1

  try {
	  const someValueWithoutParam = someFunc();
		// 에러가 발생하였으므로 더 이상 실행되지 않는다.
		console.log('someValue', someValueWithoutParam);
  }
  catch(error) {
    console.log(error);
    // Error: someError
  }

  console.log('여기는 실행됩니다.');
}
caller();

// 최종적으로 콘솔에 보이는 것
someValue: 1
Error: someError
여기는 실행된다.

위 예시 코드는 try-catch를 써서 에러가 발생하면 해당 에러를 잡아두도록 프로그래밍 되어 있다. try 안에 에러를 검출해 낼 코드를 작성하고 catch에 에러를 검출했을 시에 관한 작업을 작성한다. 이후 try - catch 문을 제외한 부분은 다시 정상적으로 실행된다.

try-catch 이외에 try-catch-finally로도 작성할 수 있다. finally 블록에 있는 구문은 try 혹은 catch 이후에 무조건적으로 한번은 실행되는 영역이다.

✅ 비동기 방식일 때

다음은 비동기 함수를 호출 하였을 때이다. 비동기 방식에서 에러 핸들링은 동기 방식과는 다르다.

// caller.js

const { someAsyncFunc } = require('./func');

function caller() {
  try {
    someAsyncFunc();
  }
  catch(error) {
    console.log(error);
  }
}
caller();

// 최종적으로 콘솔에 보이는 것
Unhandled Promise Rejection: Error: someError

위 예시 코드에서 비동기 함수 에러는 잡히지 않는다. 최종적으로 unhandled 에러, 즉 처리되지 않는 에러로 잡히게 되는데 그 이유는 이벤트 루프, 태스크 큐와 관련된 내용과 연관되어 있어 추후 심화 학습이 요구된다. 이 처리되지 않은 에러를 핸들링 하기 위해 특수한 장치를 두어야 하는데 두 가지 방법이 있습니다. 첫번째는 await를 사용하는 방법, 두번째는 promise - catch를 사용하는 방법입니다.

✔️ await 방식

// caller.js

const { someAsyncFunc } = require('./func');

async function caller() {
  console.log('첫번째 콘솔');
  try {
    await someAsyncFunc();
  }
  catch(error) {
    console.log(error);
    // Error: someError
  }
  console.log('두번째 콘솔');
}
caller();

// 최종적으로 콘솔에 보이는 것
첫번째 콘솔
Error: someError
두번째 콘솔

await를 사용하면 동기방식에서 사용했던 방법대로 try - catch 구문을 사용할 수 있다. 다만 하위 모듈 { someAsyncFunc } 에 await 를 걸어주기 위해 상위 모듈 ‘caller( )’ 또한 async 함수로 만들어 주어야 한다. 만약 caller() 함수를 비동기로 만들고 싶지 않으면 promise, catch 방식을 사용한다.

✔️ promise - catch 방식

// caller.js
const { someAsyncFunc } = require('./func');

function caller() {
  console.log('첫번째 콘솔');
  someAsyncFunc().catch((error) => {
    console.log(error);
    // Error: someError
  });
  console.log('두번째 콘솔');
}
caller();

// 최종적으로 콘솔에 보이는 것
첫번째 콘솔
두번째 콘솔
Error: someError

promise-catch는 위 ‘someAsyncFunc()’ 함수의 리턴값이 그러 하듯이 (async function은 늘 promise 를 리턴) promise를 리턴 받는 상황에서 사용할 수 있다. 이는 await 를 사용하지 않아 caller를 동기 함수로 유지할 수 있다. 하지만 비동기 함수는 비동기적으로 로직 처리 및 에러 처리를 하기 때문에 동기적으로 작동하지 않는다. 그래서 위 콘솔과 같이 동기적인 작업들이 먼저 출력이 되고 그 뒤에 비동기 작업들이 출력된다.









3. Express 미들웨어로 에러 핸들링


Express에서의 에러는 하나의 미들웨어에서 처리할 수 있게끔 만들 수 있다.

// app.js

const express = require('express');
const { someFunc, someAsyncFunc } = require('./func');

const app = express();

app.get('/someFunc', (req, res) => {
  const { someQuery } = req.query;

  const someValue = someFunc(someQuery);

  res.json({ result: someValue });
});

app.get('/someAsyncFunc', async (req, res) => {
  const { someQuery } = req.query;

  const someValue = await someAsyncFunc(someQuery);

  res.json({ result: someValue });
});

app.listen(3000);

위 예시 코드는 someFunc을 호출하기 위한 라우터로 someQuery라는 쿼리를 받아와서 someFunc을 호출하여 결과를 사용자에게 보여준다. 기본적으로 Express 는 자동으로 에러를 처리한다.

여기서 만약 someQuery란에 매개 변수를 넣지 않고 api 를 호출한다면 someQuery는 undefiend로 결과값이 정해지고, 그로 인해 someFunc은 에러를 던지게 된다. 그렇게 된다면 하단에 작성되어 있는 res.json()은 실행되지 않고 Express 의 기본적인 에러 처리방법이 진행된다.

Express의 처리방법대로 맡긴다면 개발자는 사용자가 정확히 어떤 에러를 받는지 알 수 없기 때문에 정확한 디버깅이 어려워 진다. 이에 대응하여 개발자는 에러 핸들링 미들웨어를 별도로 두어 자신의 개발환경을 보다 더 최적화할 수 있다. 아래 코드에서 미들웨어를 추가한 코드다.

// app.js

const express = require('express');
const { someFunc, someAsyncFunc } = require('./func');

const app = express();

app.get('/someFunc', (req, res) => {
  const { someQuery } = req.query;

  const someValue = someFunc(someQuery);

  res.json({ result: someValue });
});

app.get('/someAsyncFunc', (req, res) => {
  const { someQuery } = req.query;

  const someValue = someAsyncFunc(someQuery);

  res.json({ result: someValue });
});

// error handling 미들웨어
app.use((err, req, res, next) => {
  if (err.message === 'someError') {
		res.status(400).json({ message: "someQuery notfound." });
    return;
  }

  res.status(500).json({ message: "internal server error" });
});

app.listen(3000);

미들웨어의 추가로 이제 라우터에서 던지는 에러를 하나로 통일하여 받을 수 있게 되었다. 위 코드 예시에서 가장 마지막에 적혀있는 error handling 미들웨어에서 보는 것과 같이 이제 사용자에게 어떤 에러가 갈지 예측할 수 있으며 이는 일관적인 인터페이스를 유지할 수 있게 만들어 준다. 하지만 이 방법으로 했을 경우 여전히 맹점이 있는데, 바로 비동기 모듈 에러는 잡지 못 한다는 점이다. 이는 또 다른 모듈인 async wrapping 을 작성 및 적용하여 해결 할 수 있다.

// async-wrap.js

function asyncWrap(asyncController) {
  return async (req, res, next) => {
		  try {
        await asyncController(req, res)
      }
      catch(error) {
        next(error);
      }
  };
}

module.exports = asyncWrap;

---------------------------------------------------------------

// app.js

const asyncWrap = require('./async-wrap');

app.get('/someAsyncFunc', asyncWrap(async (req, res) => {
  const { someQuery } = req.query;

  const someValue = await someAsyncFunc(someQuery);

  res.json({ result: someValue });
}));

이제 asyncWrap을 컨트롤러에 씌워 주게 된다면 비동기 컨트롤러에서 생기는 에러를 잡을 수 있게 된다. asyncWrap은 컨트롤러를 받아서 비동기 에러를 처리하는 새로운 컨트롤러를 만드는 모듈이다. 해당 에러는 ‘next’를 통해 에러 핸들링 미들웨어로 넘어가게 된다.

[그림3] 에러 핸들링 미들웨어가 적용된 Express application






4. 마무리


  • Node.js 백엔드 개발 환경에서 처리 할 수 있는 에러 핸들링에는 크게 세 가지 방법 (Throw, Try & Catch, Middleware) 이 존재한다.
  • Throw와 Try-Catch를 이용한 방식은 각각 동기, 비동기를 기준으로 문법을 달리 표현할 수 있다.
  • Middleware를 이용한 방식은 다양한 에러를 개발자가 의도한 특정 에러로 수렴하여 전달 할 수 있다는 장점이 있다. 비동기 에러 핸들링을 Middleware를 이용하여 처리 하려면 ‘async-wrap’ 과 같은 별도의 컨트롤러를 적용해야 한다.
  • 각각의 에러 핸들링 방식은 특정 한계점이 있으며, 서로 상호 보완하는 모습을 보인다.
profile
helloworld

1개의 댓글

comment-user-thumbnail
2023년 6월 27일

정말 잘 읽었습니다. 이번 주 내내 가려웠던 부분인데 시원하게 긁혔습니다!

답글 달기