TIL로 작성했던 에러 핸들링 방식을 그대로 차용했다.
//connectDB
let db;
const connectDB = async () => {
try {
if (!db) {
client = new MongoClient(uri, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
await client.connect();
db = client.db(dbName);
}
return db;
} catch (e) {
throw new DatabaseError(`DB연결에 실패했습니다: ${e.message}`);
}
};
//Service
async function getDocumentById(id) {
const db = await connectDB(); //connectDB내부에서 try-catch로 에러 핸들링중
try {
const document = await db.collection(COLLECTION_NAME).findOne(
{ _id: +id },
{
projection: { _id: 0 },
}
);
if (!document)
throw new NotFoundError(`${id}번째 문서를 찾을 수 없습니다!`);
return document;
} catch (e) {
throw new DatabaseError(`Failed to get document: ${e.message}`);
}
}
//Controller
async function getDocumentController(req, res) {
const documentId = req.params.id;
let httpStatus = 200;
let result;
try {
result = await getDocumentById(documentId);
console.log(result);
} catch (e) {
const { status, message } = handleErrors(e);
result = { message };
httpStatus = status;
}
console.log(JSON.stringify(result));
res.writeHead(httpStatus);
return res.end(JSON.stringify(result));
}
Service Layer에서 던진 에러를 Controller계층에서 handleErrors()
함수를 이용해 적절하게 처리한다.
나름 그럴듯한 에러 핸들링이 완성되었다.
그러나 개선할 점이 있다.
async function getDocumentController(req, res) {
const documentId = req.params.id;
let httpStatus = 200;
let result;
try {
result = await getDocumentById(documentId);
} catch (e) {
const { status, message } = handleErrors(e);
result = { message };
httpStatus = status;
}
console.log(JSON.stringify(result));
res.writeHead(httpStatus);
return res.end(JSON.stringify(result));
}
async function getDocumentListController(req, res) {
let httpStatus = 200;
let result;
result = await getDocuments();
res.writeHead(httpStatus);
return res.end(JSON.stringify(result));
}
async function createDocumentController(req, res) {
const { parent, title } = req.body;
let httpStatus = 201; // Created
let result;
try {
result = await createDocument({ parentId: +parent });
} catch (e) {
const { status, message } = handleErrors(e);
result = { message };
httpStatus = status;
}
res.writeHead(httpStatus);
return res.end(JSON.stringify(result));
}
//...update, delete controllers
다양한 컨트롤러에서 try-catch
를 이용한 로직이 반복된다. 따라서 컨트롤러의 에러 핸들링을 공통적으로 처리하는 방법이 있으면 좋겠다.
동일한 로직(에러 발생시 특정한 코드와 답 전송, try-catch)이 반복된다는 점에서 미들웨어가 떠오른다.
그러나 비동기 함수의 에러를 어떻게 처리할지 바로 감이 잡히지 않는다.
비동기함수 try-catch를 외부에서 그냥 사용하면 에러를 잡을 수 없다.
async function fetchData() {
throw new Error("데이터를 가져오는 중 에러 발생");
}
try {
fetchData();
} catch (error) {
console.log("에러 잡힘:", error.message);
}
try
블록은 fetchData
가 호출된 직후 종료되고, async
함수 내부 로직은 마이크로 태스크 큐에서 비동기적으로 처리된다,
따라서 try-catch
절은 이 에러를 잡을 수 없다.
만약 비동기함수의 에러를 처리하려면
async function fetchData() {
try{
throw new Error("데이터를 가져오는 중 에러 발생");
} catch(error){
console.log("에러 잡힘:", error.message);
}
}
fetchData();
이처럼 비동기 함수 내부에서 try-catch
를 사용해 자체적으로 처리하거나
async function fetchData() {
throw new Error("데이터를 가져오는 중 에러 발생");
}
try {
await fetchData();
} catch (error) {
console.log("에러 잡힘:", error.message);
}
await
키워드를 이용하여 Promise
가 settled
(이행or거부)될 때 까지 try
코드블록 실행을 잠시 멈추면 된다.
이러한 방식은 비동기적으로 발생하는 에러를 동기적으로 다루는 것 처럼 처리할 수 있게된다.
그렇다면 제작해볼 에러처리 미들웨어를 한 번 도식화 해보자.
거대한 try-catch
절이 미들웨어로 존재해서, catch
문에서 에러를 잡으면 에러 미들웨어를 실행하게 한다.
이러한 일이 가능할까?
위에 설명했던 내용에 따르면, try-catch
내부에서 await
키워드를 사용해야만 에러를 처리할 수 있다.
컨트롤러의 실행을 한번 더 await
으로 기다린 뒤 처리하는 게 옳은 걸까?
고민을 계속 해보아도 명확한 답이 나오질 않아 express의 에러 핸들링 미들웨어를 참고해보았다.
app.use((err, req, res, next) => {
console.log(error.stack);
res.status(500).send('error!');
})
위처럼 인수가 4개인 미들웨어(함수)를 전달시 express
는 자체적으로 에러 핸들링 미들웨어로 판단하여 에러 발생시 해당 미들웨어를 호출한다.
그렇다면 먼저 미들웨어 실행에 대한 로직 수정이 필요하다.
현재 미들웨어 로직은 다음과 같다.
const executeMiddlewares = (middlewares, req, res, done) => {
let index = 0;
const next = (err) => {
if (err) {
return done(err);
}
if (index >= middlewares.length) {
return done();
}
const middleware = middlewares[index++];
middleware(req, res, next);
};
next();
};
//...
return {
use: (middleware) => {
middlewares.push(middleware);
return this;
},
handleRequest:(req,res) => {
//....
executeMiddlewares(middlewares, req, res, (err) => {
if (err) {
res.writeHead(500, { "Content-Type": "text/plain" });
res.end("Internal Server Error");
return;
}
// 정규식으로 매칭한 라우트에 매핑된 api를 호출
api(req, res);
});
//...
}
단순히 use
메서드로 등록된 미들웨어를 호출하고있다. 이를 express
에서 에러 미들웨어를 인지하듯 바꿔보자.
const executeRouteHandlers = (middlewares, req, res, api) => {
let middlewareIndex = 0;
let errorMiddlewareIndex = 0;
const next = (err) => {
if (err) {
const errorMiddleware = errorMiddlewares[errorMiddlewareIndex++];
if (!errorMiddleware) {
return defaultErrorHandler(err, req, res);
}
return errorMiddleware(err, req, res, next);
}
if (middlewareIndex >= middlewares.length) {
return api(req, res, next);
}
const middleware = middlewares[middlewareIndex++];
middleware(req, res, next);
};
next();
};
//...
return{
use: (middleware) => {
if (middleware.length === 3) {
middlewares.push(middleware);
}
if (middleware.length === 4) {
errorMiddlewares.push(middleware);
}
return this;
},
//...
handleRequest:(req,res) => {
//...
executeRouteHandlers(middlewares, req, res, api);
}
먼저 에러 미들웨어와 단순한 미들웨어를 나눠 저장한다. 이때 함수가 기대하는 인수의 갯수를 뜻하는 function.length
값을 사용한다.
실제로 들어오는 인수와는 상관이 없다.
자세한 내용은 MDN 문서 Function.prototype.length로!
두 배열의 인덱스는 이전처럼 클로져를 이용해 미들웨어를 실행중인 컨텍스트내에서 기억하게 한다.
next()
를 호출시 인수에 값이 들어있다면 무조건 오류로 취급하게 한다.
조건문에 따라 오류라면 에러 미들웨어를 실행하게하고, 오류가 아니면 보통 미들웨어 => api순으로 실행한다.
이제 해당 로직을 활용해보자
이전 도식화처럼 거대한 try-catch
가 컨트롤러 계층을 감싸고있으면 좋을 것 같다.
//컨트롤러를 감싸는 try-catch 미들웨어 상상도
app.use((req,res,next) => {
try{
?
}catch(e){
next(e);// 에러 핸들링 미들웨어 호출!
}
})
이렇게 컨트롤러를 감싸서 try
블록 내 에러가 발생한다면 catch
블록에서 next(err)
를 호출하여 에러 핸들링 미들웨어로 넘어가게 만드는 게 목표다.
그러나 미들웨어의 try
블록에서 모든 컨트롤러의 제어권을 가지기는 어렵다.
미들웨어는 기본적으로 요청과 응답의 중개자 역할을 할 뿐, 컨트롤러 계층의 실행 제어권을 갖고있지 않다.
따라서 미들웨어에서 처리하는 것이 아닌, 고차함수를 이용하여 컨트롤러 계층을 처리해야한다.
const wrappedTryCatch = (controller) => async (req, res, next) => {
try {
await controller(res, req, next);
} catch (err) {
next(err);
}
};
위 고차함수를 컨트롤러에 씌운다.
이렇게 컨트롤러에 씌우고 의도적으로 에러를 던져본다.
async function getDocumentListController(req, res) {
let httpStatus = 200;
let result;
result = await getDocuments();
throw new Error("테스트용 에러~!");
res.writeHead(httpStatus);
return res.end(JSON.stringify(result));
}
에러 처리용 미들웨어는 다음과 같다.
const errorHandler = (err, req, res, next) => {
const { status, message } = handleErrors(err); //에러에서 상태와 메시지를 뽑아서 클라이언트에게 전송함
res.writeHead(status);
res.end(JSON.stringify({ message }));
};
에러가 잘 캐치되는지 확인하기위해 uncaughtException
을 이용한다.
process.on("uncaughtException", (err) => {
console.error("Uncaught Exception:", err);
// 서버가 죽지 않도록 기본적인 대응
// 로그 남기거나 복구 로직 추가 가능
});
해당 api와 통신하게되면, 에러가 발생하고 서버가 잘 살아있는 모습을 볼 수 있다.
클라이언트측으로도 잘 넘어온다.
혹시모르니 HOC없이도 서버가 살아남을 수 있는지 확인해보자. 고차함수를 제거하고 해당 api에 요청해본다.
당연히 Uncaught Excpetion이 발생한다.
클라이언트측은 무한대기 상태가 된다.
이렇게 HOC를 이용해서 컨트롤러 계층의 에러를 공통적으로 관리할 수 있게 되었다.
try-catch
가 들어간 고차함수wrappedTryCatch
를 조금 더 간단히 표현할 수 있다.
try-catch
와 async-await
을 사용했던건 Promise
객체가 끝나길 기다려야 했기 때문이다.
다시말해 Promise
객체가 reject됐을때 catch
블록을 실행하기 위함이다.
그렇다면 Promise
의 인스턴스 메서드인 Promise.prototype.catch()
를 이용할 수도 있다. 자세한 설명은 MDN 문서
const wrapAsync = (controller) => (req, res, next) => controller(req, res, next).catch(next);
//.catch(next)는 .catch((err) => next(err))와 동일하다.
try-catch
와 async-await
을 제거할 수 있게되었다.
어떻게 사용할 지는 개발자의 자유 아닐까? 아직까지는 차이점을 잘 모르겠다.
(혹시 잘 아시는분이 계시다면 댓글로 알려주세요!😅)
서버측에서 에러처리를 잘 하고 클라이언트측에서 응답을 받았을때 조금 당황했다.
import { API_END_POINT } from "../env.js";
export const request = async (url, options = {}) => {
try {
const res = await fetch(
`${API_END_POINT}${url.startsWith("/") ? url : `/${url}`}`,
{
headers: {
"Content-Type": "application/json",
},
...options,
}
);
if (res.ok) {
return await res.json();
}
} catch (e) {
console.log(`api불러오다가 오류 캐치 : ${e}`);
}
};
위와같이 에러의 로그를 볼 수 있게 catch
블록에 콘솔을 남겨두었는데, 콘솔자체 에러 빼고 아무런 메시지가 뜨질 않았다.
해당 오류를 타고 들어가보니 fetch
에서 내뿜은 오류였다.
나는 분명 http상태를 500
으로 주었는데, 어째서 클라이언트측 try-catch
블록에 잡히지 않은걸까? 어째서 fetch
메서드는 에러를 저렇게 전달하는걸까?
The fetch() method takes one mandatory argument, the path to the resource you want to fetch. It returns a Promise that resolves to the Response to that request — as soon as the server responds with headers — even if the server response is an HTTP error status. You can also optionally pass in an init options object as the second argument (see Request).
MDN 문서
fetch
API는 네트워크 요청을 보내고 서버가 응답을 반환하면, Response
객체를 반환하는 Promise
객체를 리턴한다.
이후 HTTP코드에 상관없이 요청-응답이 성공했다면 fetch
는 해당 응답을 resolve
해버린다.
그러니까, fetch
의 Promise
상태는 네트워크의 요청(왔다갔다)이 성공적으로 끝났다는 의미일뿐 서버측에서 임의로 오류라고 응답해준 결과와 전혀 상관이 없다!
아뿔싸!😫
그래서 보통 반환값중 성공여부를 체크하는 response.ok
를 이용해 데이터를 리턴하던 거였구나?
참고로 response.ok
는 http 상태코드가 200~299
인지 알려주는 간단한 논리값이다.
import { API_END_POINT } from "../env.js";
export const request = async (url, options = {}) => {
try {
const res = await fetch(
`${API_END_POINT}${url.startsWith("/") ? url : `/${url}`}`,
{
headers: {
"Content-Type": "application/json",
},
...options,
}
);
let result;
result = await res.json();
if (res.ok) {
return result;
}
console.log(
`서버로부터 응답 오류가 발생했습니다.code: ${res.status}, message: ${result.message}`
);
} catch (e) {
console.log(`서버와의 통신 장애 발생 : ${e}`);
}
};
try
블록에서 서버가 임의로 설정한 오류를잡아본다.
콘솔에 잘 찍히는 모습을 볼 수 있다.