-
Error HandlingServer/Node.js 2022. 11. 20. 23:13
1. 에러 핸들링의 목적과 의의
1.1 [why] 에러 핸들링 목적
- 소프트웨어는 에러와 예외가 늘 발생하여 정상적인 사용 흐름이 막히게 된다. 에러가 발생하는 원인은 개발자의 실수도 있지만 사용자의 실행 환경, 사용자의 특성 등 다양하기 때문이다.
- 그리고 에러가 발생하면 서버는 작동을 멈춘다. 그래서 개발자는 사용자가 서비스를 이용하는 동안 발생할 수 있는 에러를 예측하여 사용자의 사용 흐름이 막히지 않도록 유도해야 한다. 이런 과정을 에러 핸들링이라고 한다.
- 하지만 모든 에러를 개발자가 예측할 수는 없다. 따라서 예외 상황에 소프트웨어가 갑자기 종료되기보다는 에러가 발생했음을 알리고 서비스가 곧 정상 작동할 것임을 안내하는 것이 좋다.
- 이러한 에러 핸들링은 사용자로 하여금 서비스가 다시 안정화 될 것이라는 믿음을 주고, 서비스 이용자의 이탈을 방지하기 위한 수단으로도 사용된다.
1.2 [for what] 에러 핸들링 의의
에러 핸들링은 엄밀히 말해, 에러 핸들링(Error Handling)과 예외 핸들링(Exception Handling) 두 가지 종류로 구분된다.
에러는 컴퓨터가 내지만 예외는 개발자가 의도적으로 발생시키는 것이다.
"개발자가 의도적으로 발생시키는 예외상황"은 컴퓨터가 코드를 실행하는 과정에서 더 이상 진행할 수 없어서 발생한 "에러"가 아니다.
언어가 실행되는 과정에는 문제가 없지만 개발자와 개발팀이 판단하기에 정상적으로 작동되면 안되는, 정상적인 상황이 아닌 과정을 의미한다. 예를 들면 회원 가입 상황에서의 아래와 같은 규칙이 정해져 있다고 가정하면,
- 회원 가입에 사용되는 이메일에는 @가 들어가야 한다.
- 비밀번호를 10자 이상 지정하도록 한다.
- 회원가입에 사용되는 계정은 중복될 수 없다.
유저가 위 규칙을 지키지 않고 회원가입을 진행한다면, 이는 개발자의 의도와는 다른 방향으로 서비스를 이용하는 "예외 상황"에 해당한다. 하지만 컴퓨터 입장에서는 코드를 실행하는 과정에서 문제가 되지않기 때문에 정상작동한다. 따라서 의도하지 않은 예외 상황의 발생을 대비해 개발자는 예상되는 예외 상황들을 나열하여 잘못된 방법으로 사용자가 서비스를 이용하는 것을 막기 위해 예외처리를 진행한다.
- 회원 가입에 사용되는 이메일에는 @가 들어가지 않으면 에러 반환
- 비밀번호를 10자 이상 지정하지 않으면 에러 반환
- 기 가입 계정으로 가입을 시도하면 에러 반환
2. throw와 try-catch
try { const { email, password } = req.body if (!email.includes('@')) { const error = new Error('EMAIL_INVALID') error.statusCode = 400 throw error } if (password.length < 10) { const error = new Error('PASSWORD_INVALID') error.statusCode = 400 throw error } ... } catch (err) { console.log(err) // 어떠한 형태의 에러가 발생하든, 에러를 콘솔에 찍어서 개발자에게 보여줍니다. return res.json({ }
프로그램 언어에서 예외 상황에 에러를 발생시키기 위해선 throw 키워드를 써야한다.
그리고 어떤 예외 상황인지, 어떤 에러인지 설명하기 위해 '메시지'가 필수로 들어간다.
예외 상황이 발생하면 진행 작업을 중단시키고 발생한 에러를 상위 모듈에서 처리할 수 있도록, 상위 모듈로 에러를 던진다.
그래서 그 에러가 발생하면 상위 모듈에서 에러를 잡아(catch)서 에러에 맞는 액션을 취한다.
이처럼 잠재적인 에러가 발생할 가능성이 있는 코드를 작성할 때는 try-catch를 사용한다.
즉, 에러를 throw 할 수 있는 코드를 try 구문 내에 넣어두고 뒤에 catch구문을 작성하여 try내부에서 던져진 error를 catch에서 잡게 한다. 그래서 catch블록에는 에러를 잡게 되면 어떤 행동을 취할지를 정하여 코드를 넣어두면 된다.
또한, catch 블록 내부에서는 또 다시 에러를 throw할 수 있는데, 이러한 상황에서는 더욱 상위의 모듈로 에러를 던지게 된다.
물론 그 상위 모듈은 에러를 catch하여 handling 할 수 있는 구조여야 한다.
3. 에러 핸들링에서의 미들웨어
3.1 [WHAT] 미들웨어란?
에러 핸들링은 반복적인 작업이다. 서로 다른 기능이어도 같은 에러를 잡아야 할 수 있다.
예를 들어, 앞서 언급한 '회원가입 규칙'은 회원가입 기능에 포함되는 동시에 로그인 기능에도 포함된다.
이 경우 같은 규칙을 서로 다른 두 기능에 중복으로 기입해야 하고, 그렇다면 코드도 중복으로 계속 기입해야 하기때문에,
이러한 공통된 작업을 모듈화하여 사용하는 개념 가운데 하나가 미들웨어이다.
Express의 미들웨어는 컨트롤러와 컨트롤러를 이어주는 또 다른 컨트롤러의 한 종류이다. 즉, 컨트롤러 사이에 위치하여 컨트롤러 진입 전 공통적으로 처리해야 하는 작업들을 처리하는 기능을 한다. 물론 중복되는 기능들이라고 모두 미들웨어로 생성하는 것은 아니지만 에러핸들링은 미들웨어로 사용하는 것을 추천한다고 한다. 미들웨어를 사용하면 여러 경우에 발생하는 에러 핸들링을 하나로 모을 수 있는 장점이 있기 때문이다.
3.2. [WHY] 에러 핸들링에서 미들웨어의 중요성
각 레이어 계층에서 각자의 에러를 처리하면 에러처리를 하는 계층이 너무 많아진다. 그래서 유지보수가 어려워지기 때문에 한 군데로 모아서 처리하는 것이 좋다. 예외 상황이 발생했을 때, Error를 바로 response로 반환하지 않고 throw 한다. 즉 "에러를 던진다"고 표현하는데 던져진 에러는 에러처리 미들웨어 한군데서 "잡아서" 처리한다.
또한, 에러 핸들링을 미들웨어로 모듈화 하는 것은 특정 기능을 분리하는 것이므로 관심사의 분리(SOC-Sepreration Of Concern)에 해당한다.
4. [HOW] Error Handling 적용하기
// func.js // 동기 함수에서 에러 던지기 function someFunc(someParam) { if (!someParam) { throw new Error('someError'); } // ...someFunc의 로직 return someParam; } module.exports = { someFunc } // 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 모듈로 비동기 함수를 처리할 수 있다.
4.1 try-catch
라이브러리 혹은 개발자가 작성한 모듈에서 throw가 발생했다면 상위 모듈에서 해당 에러를 잡아낼 수 있다. 에러를 잡아낸 후 return 및 throw를 하지 않으면 로직을 계속 진행되며 멈추지 않는다.
4.1.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 -------------------------------------------------------- // 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 여기는 실행됩니다.
동기방식일때의 로직은 쉽게 이해가 된다.
4.1.2. 비동기 방식일 때
// 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를 사용하는 방법이다.
// caller.js - await 방식 const { someAsyncFunc } = require('./func'); async function caller() { console.log('첫번째 콘솔'); try { await someAsyncFunc(); } catch(error) { console.log(error); // Error: someError } console.log('두번째 콘솔'); } caller(); // 최종적으로 콘솔에 보이는 것 첫번째 콘솔 Error: someError 두번째 콘솔 ---------------------------------------------------- // caller.js - promise - catch 방식 const { someAsyncFunc } = require('./func'); function caller() { console.log('첫번째 콘솔'); someAsyncFunc().catch((error) => { console.log(error); // Error: someError }); console.log('두번째 콘솔'); } caller(); // 최종적으로 콘솔에 보이는 것 첫번째 콘솔 두번째 콘솔 Error: someError
await를 사용하면 동기방식에서 사용했던 방법대로 try - catch 구문을 사용할 수 있다. 다만 하위 모듈에 await를 걸어주기 위해 상위 모듈을 async 함수로 만들어주어야 한다. 만약 상위 모듈을 비동기로 만들고 싶지 않으면 promise, catch방식을 사용한다.
promise-catch는 하위 모듈이 비동기여서 promise객체를 리턴 받는 상황에서 사용할 수 있다. 하지만, 하위모듈은 그대로 비동기 함수이기 때문에, 비동기적으로 로직 및 에러가 처리 된다. 즉, 동기적인 작업이 먼저 출력 되고 그 뒤에 에러가 출력된다.
4.2 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’를 통해 에러 핸들링 미들웨어로 넘어가게 됩니다.
비동기 함수에 try-catch 및 await를 반복적으로 작성하게 되는데 그것을 자동으로 비동기함수에 넣어주는 모듈이라고 생각하면 된다.
'Server > Node.js' 카테고리의 다른 글
[HOW] Node.js 모듈 시스템 (0) 2022.10.30 [WHY] Node.js 기반으로 API 서버를 구축하면 좋은 이유 (0) 2022.10.30 [WHAT] Node.js란 무엇인가? (0) 2022.10.30