BE_[Node] Error Handling 적용하기_11.16

송철진·2022년 11월 16일
0

요약

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

1. throw로 에러 던져보기

개발자가 작성하는 모듈에서 발생가능한 에러 상황에서 던져 상위 계층이나 호출하는 곳에서 모듈의 에러를 감지한다.

1-1. 동기함수에서 에러 던지기

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

  // ...someFunc의 로직

  return someParam;
}

module.exports = { someFunc }
  • 매개 변수 someParam이 특정한 값을 가지고 있지 않다면 에러를 던진다.
  • 뭘 처리할 때 사용할까?
    • someFunc이 someParam 값이 없을 때
    • 더 이상 함수를 진행할 필요가 없을 때

1-2. 비동기함수에서 에러 던지기

// 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 구문으로 에러 핸들링

라이브러리 혹은 개발자가 작성한 모듈에서 throw가 발생했다면 상위 모듈에서 해당 에러를 잡아낼 수 있습니다. 에러를 잡아낸 후 return 및 throw를 하지 않으면 로직은 계속 진행되며 멈추지 않습니다. 우선, 동기방식의 에러를 잡아내는 방법에 대해 알아보겠습니다.

2-1. 동기 방식일 때

// 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 하여 매개 인자에 값을 넣었을 때 vs 넣지 않았을 때
매개 인자에 값을 넣게 되면 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 이후에 무조건적으로 한번은 실행되는 영역.

2-2. 비동기 방식일 때

비동기 함수를 호출 하였을 때

// caller.js

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

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

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

비동기 함수 에러는 잡히지 않는다.
최종적으로 unhandled 에러 즉, 처리되지 않는 에러로 잡히게 되는데,
그 이유는 이벤트 루프, 태스크 큐와 관련된 내용과 연관되어 있다(추후 심화 학습)
이 처리되지 않은 에러를 핸들링 하는 방법
1. await를 사용하는 방법,
2. promise - catch를 사용하는 방법

2-2-1. 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 방식을 사용합니다.

2-2-2. 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’를 통해 에러 핸들링 미들웨어로 넘어가게 된다.

profile
검색하고 기록하며 학습하는 백엔드 개발자

0개의 댓글