본문 바로가기
프로그래밍/Backend

[Backend] 그런 에러 핸들링 아키텍처로 괜찮은가

by 개발 까마귀 2022. 1. 25.
반응형

그런 에러 핸들링 아키텍처로 괜찮은가

안녕하세요. 개발 까마귀입니다.

오늘 알려드릴거는 "Error Handling" 입니다. 

Error Handling?

클라이언트에서 요청을 보냈는데 예상치 못하게 서버에서 에러가 터져서 pending 상태가 되거나

아니면 서버가 죽거나 등 사용자를 떠나가게 하는 일들입니다. 서버에서는 에러가 터지면 치명적인 에러가 아닌 이상

클라이언트에 에러 응답을 보내야겠죠 그러면 에러 핸들링이란거는 그저 에러를 클라이언트에 잘 보내는거를 말할까요?

어느정도 맞는 말이지만 완벽하게 맞지는 않습니다. 에러라는 거는 어찌보면 보안과도 관련된 데이터이기 때문에 이러한 에러를 잘 핸들링하여 클라이언트에 잘 보내야 하며 에러 같은 경우에는 로그를 남겨 그 로그를 추적해서 문제를 해결 하거나 서비스 상태가 테스트, 배포 버전에 따라서 줘야되는 에러 데이터도 다릅니다. 이러한 것들이 에러 핸들링이라고 볼 수 있습니다. 이러한 에러 핸들링을 빠르게 에러 이슈를 해결 할 수 있습니다. 즉 디버깅이 쉬워지는거죠

그럼 어떻게 해야하나?  

제가 예시로 드는 에러 핸들링은 Nodejs입니다. 일단 Nodejs든 다른 언어든 에러핸들링에 프로세스는 비슷할겁니다.

controller에서 바로 db를 access를 하는게 아닌 비즈니스 로직을 분리해서 비즈니스 로직이 db를 access 하는 방식 즉 레이어드 아키텍처로 갑니다. 뭐 바로 controller가 db를 access를 할 수 있지만 이렇게 되면 controller 로직 이랑 비즈니스 로직이 같은 로직에 존재하기 때문에 유지보수가 더 힘들어집니다. 

에러핸들링 프로세스 및 규칙

우선 절대 비즈니스 로직과 db 쪽은 에러를 처리하면 안됩니다. 그저 에러를 던져야 합니다.

기본적으로 비즈니스 로직과 db는 controller에 종속되었습니다. 그러니 비즈니스로직과 db 로직은 에러를 던지고 controller는 그 에러를 에러 미들웨어에 넘기므로서 에러를 처리하는 방식입니다.

실습

프로젝트 구조

config: 각 db 서버에 대한 정보가 들어 있습니다. 

controller: router callback에 직접 controller 로직을 짜는게 아닙니다. router의 목적은 데이터를 받고 그 데이터를 다음 장치로 보내는 역할입니다. 그 다음 장치가 controller 이며 로직을 분리하는게 좋습니다.

middleware: 애플리케이션 미들웨어 및 오류 미들웨어에 관한 함수들이 모여 있습니다.

routes: endpoint 주소에 정의된 router가 있습니다.

services: 비즈니스 로직이 있습니다.

utils: 한곳에서 종속된게 아닌 여러 로직에서 사용되는 파일들이 담겨져있습니다.

 

이러한 폴더구조로 간단하게 회원가입을 하는 로직을 구현해 봅시다.

 

index.js

const express = require("express");
const indexRouter = require("./routes/index");
const { notFound, serverError } = require("./middleware/error");
const app = express();

app.use(express.urlencoded({ extended: true }));
app.use(express.json());

indexRouter(app);

app.use(notFound);
app.use(serverError);

app.listen(8081, () => console.log("8081 server start"));

 

routes/

 

index.js

const userAPI = require("./user");

module.exports = (app) => {
  app.get("/", (req, res) => res.send("hello world"));
  app.use("/api/user", userAPI);
};

user.js

const express = require("express");
const router = express.Router();
const userController = require("../controller/user");

router.post("/sign-up", userController.signUp);

module.exports = router;

 

controller/user.js

const ctx = require("../context");
const bcrypt = require("bcrypt");

const usController = module.exports;

usController.signUp = async function (req, res, next) {
  const { user_email, user_password } = req.body;

  try {
    const hashPassword = bcrypt.hashSync(user_password, 10);
    await ctx.userSvc.createUser(user_email, hashPassword);

    ctx.response.init(201, 200, "success");

    return res.json(ctx.response.makeSuccessResponse([]));
  } catch (err) {
    console.log("User Error:", err);
    return next(err);
  }
};

router 까지는 아마 이해를 하셨을겁니다. 이제부터 중요한 로직들 입니다.

우선 controller는 비즈니스 로직과 1:1 매칭이 아닙니다. 즉 user controller는 user service와 1:1 매칭만 될수는 없습니다. user service는 user controller에만 종속되는게 아닌 모든 controller에 종속이 될 수 있는거죠 이러한 행위를 해줄 수 있게 해주는게 context입니다.

 

context.js

const UserSvc = require("./services/UserSvc");
const MakeRes = require("./utils/MakeRes");

function context() {
  const makeRes = new MakeRes();
  const userSvc = new UserSvc(makeRes);

  return {
    response: makeRes,
    userSvc,
  };
}

module.exports = context();

여기서는 각종 비즈니스 로직들을 모아서 관리합니다. 그리고 비즈니스 로직에서 에러를 던지기 위해서 인스턴스화된 MakeRes 객체를 넘겨줍니다. 먼저 MakeRes 소스를 보기 전 controller를 다시 봅니다.

 

const ctx = require("../context");
const bcrypt = require("bcrypt");

const usController = module.exports;

usController.signUp = async function (req, res, next) {
  const { user_email, user_password } = req.body;

  try {
    const hashPassword = bcrypt.hashSync(user_password, 10);
    await ctx.userSvc.createUser(user_email, hashPassword);

    ctx.response.init(201, 200, "success");

    return res.json(ctx.response.makeSuccessResponse([]));
  } catch (err) {
    console.log("User Error:", err);
    return next(err);
  }
};

controller는 간단합니다. controller에서 db를 바로 access 하는게 아닌 context를 통해 원하는 비즈니스 로직으로 db에 access 하는 방식입니다. 그러기 위해서는 service에서 정의된 데이터를 controller에서 던져 줍니다. 여기서 주의해야할점은 req, res 객체를 통째로 비즈니스 로직에 절대 보내면 안됩니다. 이러한 점은 그냥 SQL에서 SELECT를 할 때 모든 컬럼을 "*"을 사용해서 불러온다는거랑 다르지 않습니다. 아니 더 심각합니다. controller 로직이 service 로직에 데이터를 넘기는 코드만봐도 무슨 데이터를 넘기는지 알수있어야합니다. 하지만 req, res 객체로 통째로 넘겨버리면 데이터를 무엇을 사용하는지 알기위해 서비스로직까지 넘어가야하며 유지보수에 치명적인 짓입니다. 그래서 controller에서는 데이터를 넘긴 후 비즈니스 로직이 정상적으로 처리가 되었으면 성공 응답을 보내게 되는데 여기서도 controller내 로직에서 무조건 한번만 응답 코드가 적혀있어야합니다. 성공 응답이 controller 내에서만 존재한다는거를 단번에 알 수 있으며 이러한 로직은 코드의 가독성을 높여주면 성공 응답이 한번이라는거를 알기 때문에 응답을 2번이상 보낼시에 에러에 대한 걱정을 지울수 있습니다. 성공 응답 코드는 맨 아래에 있다는 것 또한 코드의 가독성을 많이 높혀줍니다. 즉 일관성이 있다는거죠

 

services/userSvc.js

const userRepo = require("../model/userRepo");

class UserSvc {
  constructor(response) {
    this.response = response;
  }

  /**
   *
   * @param {String} email
   * @param {String} hashPassword
   */
  async createUser(email, hashPassword) {
    const emailLength = await userRepo.checkEmail([email]);

    if (emailLength.data[0].emailCount) {
      this.response.init(409, 409, "email duplicate");
      throw this.response.makeErrorResponse({}, "Email duplicate");
    }

    const newUser = await userRepo.createUser([email, hashPassword]);

    if (!newUser.data.affectedRows) {
      this.response.init(500, 500, "User SignUp Error");
      throw this.response.makeErrorResponse({}, "signUp user insert Error");
    }
  }
}

module.exports = UserSvc;

service 로직 또한 간단합니다. db에 필요한 데이터를 받은 후 db access를 한 후 그 db 결과에 대한 에러 로직을 작성 후 에러가 발생 시 controller에 그 에러를 던져서 처리하게 만듭니다. 여기서 "this.response"는 context에서 인자로 넘긴 인스턴스화된 MakeRes 객체를 말합니다.  이 로직을 보시고 대충 눈치를 채셨겠지만 성공 응답을 제외한 모든 응답은 에러로 처리합니다. 하지만 이 생각은 어디까지나 저의 생각입니다. 이거에 대한 자세한 설명은 MakeRes 소스코드 로직 부분에서 얘기하겠습니다. 그 전에 model 로직을 봅시다.

 

model/

 

conn.js

const mysql = require("mysql");
const { local, dev, prod } = require("../config/db");
const MakeRes = require("../utils/MakeRes");

function getConnType() {
  let connDBType = {};

  // server type에 맞게 db 연결
  if (process.env.NODE_ENV === "prod") {
    connDBType = prod;
  } else if (process.env.NODE_ENV === "dev") {
    connDBType = dev;
  } else {
    connDBType = local;
  }

  return connDBType;
}

function connDB() {
  const connDBType = getConnType();
  const conn = mysql.createConnection(connDBType);

  conn.connect();

  return conn;
}

/**
 *
 * @param {Object} conn
 */
function closeConnDB(conn) {
  conn.end();
}

/**
 *
 * @param {String} sql
 * @param {Array} params
 * @returns {Object}
 */
async function query(sql, params) {
  const makRes = new MakeRes();
  const conn = connDB();

  const startTransaction = () => {
    return new Promise((res, rej) => {
      conn.beginTransaction((err) => {
        if (err) rej(err);
        else res();
      });
    });
  };

  const getQueryData = () => {
    return new Promise((res, rej) => {
      conn.query(sql, params, (err, rows) => {
        if (err) rej(err);
        else res(rows);
      });
    });
  };

  try {
    await startTransaction();
    const queryData = await getQueryData(conn, sql, params);
    conn.commit();

    return {
      // expandability...
      data: queryData,
    };
  } catch (err) {
    conn.rollback();

    makRes.init(500, 666, "query Error");
    throw makRes.makeErrorResponse(err, "DB Query Error");
  } finally {
    closeConnDB(conn);
  }
}

// TODO...
function poolQuery() {}

module.exports = {
  query,
  poolQuery,
};

userRepo.js

const db = require("./conn");
const userRepo = module.exports;

userRepo.checkEmail = async function (params) {
  const sql =
    "SELECT COUNT(user_email) as emailCount FROM user WHERE user_email = ?";

  return db.query(sql, params);
};

userRepo.createUser = async function (params) {
  const sql = "INSERT INTO user(user_email, user_password) VALUES(?, ?)";

  return db.query(sql, params);
};

실질적인 DB access는 model이 합니다. 여기서는 sql을 작성해서 위 conn.js에서 query를 통해 원하는 데이터 결과를 비즈니스 로직에 반환합니다. conn.js 또한 자세히 보면 에러가 날시 에러를 던져 버립니다. 여기서도 처음에 말했듯이 비즈니스 로직과 db 로직은 무조건 에러를 던지기만 할 뿐 그 에러를 자체적으로 응답을 한다거나 하면 안됩니다. 무조건 에러처리는 controller에 던지고 controller는 그 에러를 에러 미들웨어에 던져 처리를 하면 됩니다.

 

utils/MakeRes.js

class MakeRes {
  constructor() {}

  /**
   * http status
   * @param {Number} httpStatus
   * err code(애플리케이션 err code)
   * @param {Number} code
   * custom status
   * @param {Number} status
   * @param {String} message
   */
  init(httpStatus, code, message) {
    this.httpStatus = httpStatus;
    this.code = code;
    this.message = message;
  }

  /**
   *
   * @param {Array<Object> || Array} data
   * @returns
   */
  makeSuccessResponse(data = []) {
    return {
      code: this.code,
      message: this.message,
      data,
    };
  }

  /**
   *
   * @param {Object} err
   * 서버 애플리케이션에 보이는 error name
   * 디버깅 쉽게하기 위한 목적
   * @param {String} name
   * @returns
   */
  makeErrorResponse(err = {}, name = "Syntax Error") {
    const error = new Error();

    error["httpStatus"] = this.httpStatus;
    error["code"] = this.code;
    error["err"] =
      typeof err === "object" && err["stack"] ? (err = String(err)) : err;
    error.message = this.message;
    error.name = name;

    return error;
  }
}

module.exports = MakeRes;

이 로직이 이번 강의에 핵심이라 할 수 있죠 우선 왜 커스텀 에러를 만들었냐에 대해서 답변을 드려야겠네요.

답은 그냥 "디버깅을 편하게 하기 위해서"입니다. 개발자한테 구별할 수 있는 객체를 생성하면 에러가 나도 에러 로그만 보면 각 로직에 대한 에러 구분만 잘 해놓으면 디버깅이 쉬워집니다. 예를 들어 저 code 라는 객체에 user 로직에 id 중복 에러는 2000으로 정의를 해놨고 그 에러가 나서 디버깅을 하기 위해 로그를 보니 2000번 에러가 찍혔다거나 아니면 클라이언트에서 code 2000번 에러가 났다고 말을 한다거나 등 디버깅이 매우 빨라질 수 있죠 뭐 code 같은 경우는 애플리케이션 레벨에 status이기 때문에 따로 code 문서를 만들겠지만 그 이슈는 에러 핸들링 로직과 상관이 없죠 그리고 더 커스텀을 해서 errorStatus라는거를 만든 후 error log에 남아야되는 에러들은 "ERROR"로 그렇지 않은 애들은 "WARRING"로 구분해서 핸들링을 할 수 있게 구분 값을 추가할 수 있습니다. 지금 현재 이 로직은 에러를 생성하거나 성공 응답에 대한 로직만 있습니다. 이거를 보고 에러 핸들링라고 할 수는 없죠, 이 로직은 에러핸들링을 하기 위한 "에러 설정" 부분이라고 생각하면 쉽습니다. 제가 에러 핸들링은 뭐라고했죠? 서버 상태(테스트, 배포 버전 등)에 따라 상세 에러를 던지단거나 던지지 않다거나 로그에 저장을 한다거나 안한다거나 등 이러한게 에러핸들링입니다. 그러면 에러 핸들링 로직은 어디서 처리 할까요?

 

middleware/error.js

module.exports = {
  notFound: (req, res) => {
    res.status(404).send("404 NOT FOUND");
  },
  serverError: (err, req, res, next) => {
    const result = {
      code: err.code || 500,
      message: err.message || "500 server Error",
      error: {},
    };

    if (err.err || err) result.error = err.err || err;
    if (process.env.NODE_ENV === "prod") delete result.error;

    res.status(err.httpStatus || 500);

    return res.json(result);
  },
};

controller에서 error를 next에 담아서 넘겨버리기 때문에 무조건적으로 error middleware로 넘어오게 됩니다.

지금 이 로직은 간단합니다. 왜냐하면 지금 저희 서비스 규모에 처리할 로직이 많이 없습니다. 하지만 기본적인 로직은 다 비슷비슷합니다. 여기서 로직을 더 추가한다거나 등 하는거죠, 이 로직은 저희가 커스텀 에러를 통해 새로운 에러 객체를 만들었습니다. 그 에러 객체를 여기 에러핸들링에서는 서버상태(개발, 배포 버전 등)에 따라 응답할 에러를 컨트롤 하고 있습니다. prod 일 때는 error라는 객체를 없앱니다. 일단 제 프로젝트에서는 error라는 객체는 라이브러리가 던지는 에러를 담기 때문에 가끔식 제가 무슨 라이브러리 모듈을 쓰고있는지 등 추측할 수 있는 에러나 DB에 에러가 담기기 때문에 클라이언트에 전달 하지 않습니다. 하지만 개발버전이나 테스트 버전 일 때는 그데로 내보냅니다. 왜냐하면 테스트 버전이나 개발 버전일 때는 빠르게 에러를 잡아야하기 때문이죠 ㅎㅎ 

테스트

이제 API 테스트를 해 볼까요? 저는 테스트를 Postman으로 했습니다. 

네 성공적으로 DB에 insert 됐습니다. 근데 저희 서비스 로직이 어떻게됐죠? 똑같은 이메일이 있으면 에러가 나도록 했죠? 잘 날까요?

네 완벽하게 잘 동작합니다. 지금은 npm run dev로 서버를 실행 해서 error 객체가 나오네요.

 

저는 cross-env 모듈을 통해서 배포버전과 개발버전을 설정했습니다.

  "scripts": {
    "dev": "cross-env NODE_ENV=dev nodemon index.js",
    "start": "cross-env NODE_ENV=prod node index.js"
  },

npm start를 한 후 똑같이 요청을 보내면은 위 와같이 에러 객체가 삭제 후 응답이 오게 됩니다.

정리  

에러핸들링이라해서 거창한거는 아닙니다. 저희는 각 로직마다 디버깅을 하기 쉽게 에러 객체를 생성을 했고 그 에러 객체를 각 상황과 에러 객체에 상태에 따라 컨트롤하는게 에러핸들링입니다. 이 아키텍처에서 핵심은 어떻게 에러를 처리했냐보다는 에러 처리에 흐름입니다. 어느 로직에서 에러 처리를 했고 에러 객체를 생성할 때 어떠한 구분으로 에러처리를 할건지 암시 등 이 핵심입니다. 처리는 쉽습니다. 하지만 그 처리를 어떻게 빠르게 파악해서 에러를 처리 및 유지보수를 쉽게 할거냐이죠, 거창하게 에러 핸들링 아키텍처라고 했지만 그냥 레이어드 아키텍처에 에러핸들링 로직만 자연스럽게 추가한거 뿐입니다. 

 

위의 소스코드는 https://github.com/dlwngh9088-lee/errorHandling 에 있습니다. 

혹시나 잘못된 정보나 궁금하신거 있으면 댓글이나 위 링크 issue를 통해서 질문주시면 될거같습니다.

 

GitHub - dlwngh9088-lee/errorHandling

Contribute to dlwngh9088-lee/errorHandling development by creating an account on GitHub.

github.com

감사합니다.

 

참조

 

https://softwareontheroad.com/ideal-nodejs-project-structure/

반응형

댓글