Express error middleware & Custom error module

2022.01.06
11분
댓글

express.png


⛔️ Custom Error


개발을 하다 보면 자체 여러 클래스가 필요한 경우가 종종 생긴다.

네트워크 관련 작업 중 에러가 발생했다면 HttpError,

데이터베이스 관련 작업 중 에러가 발생했다면 DbError,

검색 관련 작업 중 에러가 발생했다면 NotFoundError

등등 직접 연관된 에러를 발생시키는게 직관적이기 때문이다.


나는 API 서버를 제작 중이여서 HttpResponseError 위주로 작성 했다.

직접 에러 클래스를 만든 경우, 이 에러들은 statusCode, message, stack처럼 여러 프로퍼티를 지원하게 할 수 있다.

물론 이외의 프로퍼티도 지원 하게 만들 수 있다.


JavaScript에서 throw의 인수에 아무런 제약이 없기 때문에 커스텀 에러 클래스는 반드시 Error를 상속할 필요가 없다.

하지만 Error를 상속받아 커스텀 에러 클래스를 만들게 되면 obj instanceof Error를 사용해 여러 객체를 식별 할 수 있다는 장점이 있다.

그래서 Error를 상속해서 커스텀 Error 클래스를 만들자


🛠 Custom Error 제작


modules/apiError.ts

export default class ApiError extends Error {
    statusCode: number;
    isFatal: boolean;

    constructor(statusCode: number, message: string, option?: { stack?: string; isFatal: boolean}) {
        super(message);
        this.statuscode = statusCode;

        if (option) {
            this.isFatal = option.isFatal === undefined ? false : option.isFatal;
            if (option.stack) {
                this.stack = option.stack;
            } else {
                Error.captureStackTrace(this, this.constructor);
            }
        } else {
            this.isFatal = false;
            Error.captureStackTrace(this, this.constructor);
        }
    }
}

Api 서버에서 사용하는 Api Custom Error이다


인자로 statusCode, message를 받아오고 옵션으로 stack, isFatal을 받아온다

stack은 stackTrace의 반환값이고 isFatal은 심각한 오류일 경우 로깅 & 슬랙 알림을 위해 넣었다


message프로퍼티가 부모 생성자에서 설정되게 하기 위해 super를 호출해준다

그리고 statusCode 또한 부모 생성자에 설정해준다.

그 뒤 필요에 의해 option값이 들어오면 그 값에 대해서도 부모 생성자에 설정해준다.



🚫 Error Middleware


Express로 개발을 하다 만약 실 서비스에서 생각지 못한 오류로 서버가 종료되면 어떡하지? 라는 생각이 들었던 적 있을것 이다.


이런 걱정을 할 수 밖에 없는 이유가 Node.js 자체가 단일 스레드 플랫폼이다 보니 오류가 날 경우 서비스가 그대로 죽어버리는 문제가 있다.

그렇다고 모든 오류에 관해 try ... catch문으로 막으면 코드가 보기 좋지 않을 뿐더러 단위/통합 테스트를 빡세게 한다해도 한계가 있기 때문이다.

사실 Node.js는 시스템적으로 non-blocking I/O를 지원하지 않는 호출이 있는 경우, 이를 비동기 처리 하기 위해 내부의 Thread pool(libio)을 별도로 이용해 처리한다고 해서 정확히 말하면 싱글 스레드는 아니다.


❓ 그래서 어떻게 해야하나


서비스 상 어디서 발생할 지 모르는 에러를 일일히 막는다는 것은 매우 비효율적이며 보기에도 별로 좋지 않고 그렇게 할 필요도 없다.

왜냐하면 오류가 난 상황에서 Express 미들웨어를 이용하면 손쉽게 처리가 가능하기 때문이다.

Middleware 란?

Client에게 요청이 오고 그 요청에 관한 응답을 보내는 중간에 거쳐가는 함수이다.

Middleware 함수는 req(요청)과 res(응답), 그리고 어플리케이션 요청-응답 사이클 중간에서 필요한 처리를 해주는 함수를 뜻한다.

모든 로직이 끝난 후 next를 호출하며 미들웨어가 순차적으로 처리되며 모든 미들웨어가 처리되었다면 응답 로직으로 넘어간다.


🚧 Error Middleware 작성


modules/error.ts

export const errorConverter = (err: any, req: Request, res: Response, next: NextFunction) => {
    let error = err;
    if (!(err instanceof ApiError)) {
        const statusCode = error.statusCode || httpStatus.INTERNAL_SERVER_ERROR;
        const message = error.message || httpStatus[statusCode];
        error = new ApiError(statusCode, message, err.stack);
    }
    next(error);
};

오류가 났을 때 Custom Error로 변환시켜주는 Middleware


일단 오류가 났다고 가정하면 위 errorConverter 미들웨어가 실행이 될 것이다.

이 미들웨어는 위에 작성한 일반 ErrorCustom Error로 변환시키는 작업을 해준다.

그 뒤 다음 미들웨어로 error 객체를 넘겨준다.


modules/error.ts

export const errorHandler = (err: ApiError, req: Request, res: Response, next: NextFunction) => {
    let { statusCode, message, stack } = err;

    const response: { code: number; message: string; stack: string } = {
        code: statusCode,
        message,
        stack: undefined,
    };

    if (env.nodeEnv !== "production") {
        response.stack = stack;
    }

    if (statusCode === httpStatus.INTERNAL_SERVER_ERROR) {
        slack({ log: logger.error(stack), statusCode, stack, message });
    } else {
        logger.warn(stack);
    }

    res.status(statusCode).send(response);
};

Custom Error로 변환된 Error를 Client에 응답해 주는 미들웨어


일단 예상치 못한 오류 / 예상한 오류로 나눠 에러 핸들링을 하였고

예상치 못한 오류 INTERNAL SERVER ERROR인 경우 제일 높은 단계로 logging을 한 뒤 Slack Web Hook을 통해 Slack으로 오류를 전달해 주는 로직을 짜게 되었다.

심각한 오류가 뜨면 헐레벌떡 컴퓨터를 켜야한다

그 뒤 예상한 오류들은 모두 400번대 Https Status Code를 갖고 있어 로깅만 해준다.

보통 Client에서 잘못된 정보가 온 경우이다.

마지막으로 현재 statusCode, message, stack를 응답해 준다.


🛤 Error Middleware 적용


위처럼 Middleware를 작성 했다고 오류가 났을 때 미들웨어가 작동되지 않는다.

당연한 소리지만 연결 해줘야지 🤣


app.ts

import * as apiRouter from "@routes/index";
import { errorConverter, errorHandler } from "@modules/error";

...

// Api Router
app.use(apiRouter.path, apiRouter.router);

// Error Middleware
app.use(errorConverter);
app.use(errorHandler);

경로의 시작이'@' 인 이유는 절대경로를 지정했고 TypeScript 설정을 참조하자


일단 app.ts파일에 Error Middleware를 적용시켜준다.

이 때 무조건 Api Router 밑에 작성해 줘야 정상적으로 작동 한다.

이런다고 미들웨어가 작동하나??? 아니다 또 설정 해줘야 한다.


modules/error.ts

export const catchError = (fn: Function) => (req: Request, res: Response, next: NextFunction) => {
    Promise.resolve(fn(req, res, next)).catch((err) => next(err));
};

특정 코드에서 오류가 났을때 try ... catch방식으로 오류를 잡아 Error Middleware로 넘겨주는 함수이다.


routes/index.ts

import { Router } from "express";
import { getInfo } from "@controllers/index.ts";

export const path: string = "/";
export const router: Router = Router();

router.get('/', getInfo);

밑의 controllers/index.ts파일이 어떻게 불러와지는지 이해를 돕는 코드


controllers/index.ts

import { Request, Response, NextFunction } from "express";
import { catchError } from "@modules/error";
import ApiError from "@modules/apiError";
import httpStatus from "http-status";

export const getInfo = catchError((req: Request, res: Response) => {
    if ('1' === 1) {
        res.send("이게 맞냐?");
    } else {
        throw new ApiError(httpStatus.INTERNAL_SERVER_ERROR, "틀렸다");
    }
});

위 처럼 변수를 받아오는 곳에 catchError함수로 감싸준 코드를 작성하고 http://localhost:${port}/ URL에 GET 요청을 날려보자.

이 때 문자열 '1'은 숫자 1이 아니므로 new ApiErrorthrow될 것 이다.


그때 일반적인 상황에서 따로 try ... catch같은 에러 핸들링을 해주지 않았다면 서버는 즉시 종료 될 것이다.

하지만 현재 Error Middleware를 적용한 경우 Error객체가 throw된다 해도 서버가 종료되지 않고 다음 미들웨어로 넘어가 오류가 응답되기 때문에 예상치 못한 오류라도 걱정하지 않고 코드를 짤 수 있게 된다.


이런 방식으로 controller단에 미들웨어를 놔준다면 심각한 오류를 막을 수 있고 유지보수에도 도움이 많이 된다.



Express
Exception Handling

프로필 사진
Seongsu Kim
Backend Developer