Javascript/Node.js

[Node.js] 2. 에러 처리 방법

Frankie 2021. 9. 10. 18:07

2.1 비동기 에러 처리시에는 async-await 혹은 promise를 사용하라

- 비동기 에러를 콜백 스타일로 처리하는 것은 지옥으로 가는 급행열차나 마찬가지다. 평판이 좋은 promise 라이브러리를 사용하거나 훨씬 작고 친숙한 코드 문법인 try-catch를 사용하게 해주는 async-await를 사용하는 것이다.

-> 그렇게 하지 않을 경우: Node.js의 function 콜백 스타일은 에러 처리와 일반 코드의 혼합, 코드의 과도한 중첩, 어색한 코딩 패턴 때문에 유지보수가 불가능한 코드로 가는 확실한 길이다.

 

예) promise를 사용해서 에러를 잡아내는 예제

doWork()
 .then(doWork)
 .then(doOtherWork)
 .then((result) => doWork)
 .catch((error) => {throw error;})
 .then(verify);

 

예) 안티 패턴 코드 예제 - 콜백 스타일 에러 처리

getData(someParameter, function(err, result) {
    if(err !== null) {
        // do something like calling the given callback function and pass the error
        getMoreData(a, function(err, result) {
            if(err !== null) {
                // do something like calling the given callback function and pass the error
                getMoreData(b, function(c) {
                    getMoreData(d, function(e) {
                        if(err !== null ) {
                            // you get the idea? 
                        }
                    })
                });
            }
        });
    }
});

2.2 내장된 Error 객체만 사용하라

- 많은 사람들이 문자열이나 사용자가 임의로 정의한 타입으로 에러를 던지곤 하는데, 이것은 에러 처리 로직과 모듈 사이의 상호운영성을 복잡하게 한다. 내가 promise를 거부하든, 예외를 던지든, 에러를 내던간에, 내장된 Error 객체만 이용하는 것이 균일성을 향상하고 정보의 손실을 방지해준다.

-> 그렇게 하지 않을 경우: 일부 컴포넌트를 호출할 때 어떤 에러의 타입이 반환될지 불확실해져서 적절한 에러 처리가 매우 어려워진다. 게다가 임의적인 타입으로 에러를 나타내는 것은 스택 정보와 같은 중요한 에러 관련 정보 손실을 일으킬 수 있다!

 

Node.js 내장 Error 객체를 사용하면 당신의 코드와 제 3자 라이브러리의 균일성을 유지할 수 있도록 도와주며, 또한 스택정보와 같은 중요한 정보도 보존할 수 있다. 예외가 발생할 때, 일반적으로 오류 이름이나 관련 HTTP 오류 코드 같은 상황별로 추가적인 속성으로 채우는 것이 좋은 방법이다.

 

예) 옳은 예시

// throwing an Error from typical function, whether sync or async
if(!productToAdd)
    throw new Error("How can I add new product when no value provided?");

// 'throwing' an Error from EventEmitter
const myEmitter = new MyEmitter();
myEmitter.emit('error', new Error('whoops!'));

// 'throwing' an Error from a Promise
const addProduct = async (productToAdd) => {
  try {
    const existingProduct = await DAL.getProduct(productToAdd.id);
    if (existingProduct !== null) {
      throw new Error("Product already exists!");
    }
  } catch (err) {
    // ...
  }
}

예) 좋지않은 패턴

// throwing a string lacks any stack trace information and other important data properties
if(!productToAdd)
    throw ("How can I add new product when no value provided?");

예) 훨씬 더 좋은 예시

// centralized error object that derives from Node’s Error
function AppError(name, httpCode, description, isOperational) {
    Error.call(this);
    Error.captureStackTrace(this);
    this.name = name;
    //...other properties assigned here
};

AppError.prototype = Object.create(Error.prototype);
AppError.prototype.constructor = AppError;

module.exports.AppError = AppError;

// client throwing an exception
if(user == null)
    throw new AppError(commonErrors.resourceNotFound, commonHTTPErrors.notFound, "further explanation", true)

2.3 동작상의 에러와 프로그래머 에러를 구분하라

- API에서 잘못된 입력을 받는 것과 같은 동작 상의 에러는 에러의 영향을 완전히 이해할 수 있고 신중하게 처리할 수 있는 알려진 경우를 의미한다.

- 반면에, 정의되지 않은 변수를 읽는 것과 같은 프로그래머 에러는 어플리케이션을 안정적으로 다시 시작하게 만드는 알 수 없는 코드 에러를 의미한다.

-> 그렇게 하지 않을 경우: 에러가 날 때마다 어플리케이션을 다시 시작할 수도 있지만, 알 수 없는 이슈가 났는데 어플리케이션을 그대로 두는 것은 예측 불가능한 반응을 일으킬 수 있다.

 

두 가지 에러 유형을 구별하면 앱 다운타임을 최소화하고 심각한 버그를 방지하는데 도움이 될 것이다.

- 동작 오류는 발생한 일과 영향에 대해 어떤 상황인지 말한다(예: 연결 문제로 인한 일부 HTTP 서비스 쿼리 실패) -> 비교적 다루기 쉽다.

- 프로그래머 에러는 어디에서 에러가 발생했는지 왜 발생했는지 모르는 경우(이것은 정의되지 않은 값이나 메모리 누수되는 데이터베이스 연결 풀을 읽으려는 코드일 수 있다.) -> 다시 시작하는 것보다 더 좋은 방법은 없다.

 

예) 동작 오류

// marking an error object as operational 
const myError = new Error("How can I add new product when no value provided?");
myError.isOperational = true;

// or if you're using some centralized error factory (see other examples at the bullet "Use only the built-in Error object")
class AppError {
  constructor (commonType, description, isOperational) {
    Error.call(this);
    Error.captureStackTrace(this);
    this.commonType = commonType;
    this.description = description;
    this.isOperational = isOperational;
  }
};

throw new AppError(errorManagement.commonErrors.InvalidInput, "Describe here what happened", true);

2.4 에러를 Express 미들웨어에서 처리하지 말고 한군데에서 집중적으로 처리해라

- 관리자에게 메일을 보내거나 로깅을 하는 것과 같은 에러 처리는 에러가 발생할 때 모든 엔드포인트(예: Express 미들웨어, cron 작업, 단위 테스트 등)가 호출하는 에러전용 중앙집중 객체로 캡슐화 되어야 한다.

-> 그렇게 하지 않을 경우: 한 곳에서 에러를 처리하지 않는 것은 코드 중복과 부적절한 에러처리로 이어진다.

 

- 에러 처리를 위한 전용 객체가 없으면 잘못된 처리로 인해 중요한 에러가 숨어있을 가능성이 더 커진다. 예를 들어, 에러 처리 객체는 (Sentry, Rollbar, 또는 Raygun)와 같은 모니터링 프로그램에 이벤트를 보내 에러를 가시적으로 만드는 역할을 한다.

- Express와 같은 대부분의 웹 프레임워크는 미들웨어 메커니즘 에러처리를 제공한다. 일반적인 에러 처리 흐름은 다음과 같다

1. 일부 모듈이 에러를 던진다.

2. API 라우터가 에러를 잡는다.

3. 에러를 에러 검출을 담당하는 미들웨어(예: Express, KOA)에 전달한다.

4. 중앙 에러 처리기가 호출된다.

5. 미들웨어는 이 에러가 신뢰할 수 없는 에러인지(작동하지 않음) 알려 앱을 정상적으로 재시작 할 수 있다.

Express 미들웨어 내에서 오류를 처리하는 것이 일반적이지만 잘못된 관습이다 - 이렇게 하면 웹 이외의 인터페이스에 발생하는 에러를 해결할 수 없다.

 

예) 일반적인 에러 흐름

// DAL layer, we don't handle errors here
DB.addDocument(newCustomer, (error, result) => {
  if (error)
    throw new Error("Great error explanation comes here", other useful parameters)
});

// API route code, we catch both sync and async errors and forward to the middleware
try {
  customerService.addNew(req.body).then((result) => {
    res.status(200).json(result);
  }).catch((error) => {
    next(error)
  });
}
catch (error) {
  next(error);
}

// Error handling middleware, we delegate the handling to the centralized error handler
app.use(async (err, req, res, next) => {
  const isOperationalError = await errorHandler.handleError(err);
  if (!isOperationalError) {
    next(err);
  }
});

예) 전용 객체에서 에러 처리

module.exports.handler = new errorHandler();

function errorHandler() {
  this.handleError = async function(err) {
    await logger.logError(err);
    await sendMailToAdminIfCritical;
    await saveInOpsQueueIfCritical;
    await determineIfOperationalError;
  };
}

예) 좋지않은 패턴: 미들웨어에서 에러 처리

// middleware handling the error directly, who will handle Cron jobs and testing errors?
app.use((err, req, res, next) => {
  logger.logError(err);
  if (err.severity == errors.high) {
    mailer.sendMail(configuration.adminMail, 'Critical error occured', err);
  }
  if (!err.isOperational) {
    next(err);
  }
});

2.5 Swagger를 이용해 API 에러를 문서화하라

- API를 호출자들에게 어떤 에러가 돌아올 수 있는지 미리 알려주어서 에러를 충돌없이 신중하게 처리할 수 있게 해주어라. RESTful API 같은 경우엔 Swagger 같은 API 문서화 프레임워크를 통해 이루어진다. GraphQL의 경우엔 개요(shema)와 주석을 활용할 수 있다.

-> 그렇게 하지 않을 경우: API 클라이언트는 알 수 없는 에러로 인한 충돌 후에 재시작을 결정할 수도 있을 것이다.

 

REST API는 HTTP 상태 코드를 사용하여 결과를 반환한다. API 사용자는 API 스키마 뿐만 아니라 잠재적인 오류에 대해서도 알고 있어야 한다. 그러면 호출자가 오류를 포착하고 재치 있게 처리할 수 있다.(예: API 문서에는 고객 이름이 이미 존재할 때 HTTP 상태 409가 반환된다고 미리 명시되어 있을 수 있으므로(API가 새 사용자를 등록한다고 가정) 호출자가 주어진 상황에 대해 그에 따라 최상의 UX를 렌더링할 수 있다.

Swagger는 온라인에서 쉽게 문서를 작성할 수 있는 도구 생태계를 제공하는 API 문서의 스키마를 정의하는 표준이다.

 

GraphQL 오류 예

# id가 유효하지 않기 때문에 실패해야 합니다.
{
  movie(id: "1ZmlsbXM6MQ==") {
    title
  }
}
{
  "errors": [
    {
      "message": "Nenhuma entrada no cache local para https://swapi.co/api/films/.../",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "film"
      ]
    }
  ],
  "data": {
    "film": null
  }
}

유용한 도구: Swagger 온라인 문서 작성기

2.6 낯선 이가 들어오면 프로세스를 적절하게 중단하라

- 알 수 없는 에러(프로그래머 에러)가 발생하면 어플리케이션의 건강상태가 불확실해진다. 일반적인 방법은 Forever나 PM2 같은 '재시작'도구로 프로세스를 다시 시작하는 것이다.

-> 그렇게 하지 않을 경우: 익숙치 않은 예외가 발생하면 일부 객체가 오류 상태(예: 전역적으로 사용되지만 내부 오류로 인해 이벤트를 더 이상 내보내지 않는 event emitter)라서 향후의 모든 요청을 실패시키거나 마친것처럼 작동할 수 있다.

 

- 코드 내 에러 핸들러 객체는 에러가 발생했을 때 처리 방법을 결정하는 역할을 한다. 작동 에러인 경우 로그 파일에 쓰는 것으로 충분할 수 있다. 오류가 익숙하지 않은 경우 상황이 봊갑해진다. 즉, 일부 구성 요소에 결함이 있고 향후 모든 요청이 실패할 수 있다. 이 때는 프로세스를 종료하고 재시작 도구를 사용하여 깨끗한 상태로 다시 시작한다.

 

예) 충돌 여부 결정

// Assuming developers mark known operational errors with error.isOperational=true, read best practice #3
process.on('uncaughtException', function(error) {
  errorManagement.handler.handleError(error);
  if(!errorManagement.handler.isTrustedError(error))
  process.exit(1)
});

// centralized error handler encapsulates error-handling related logic
function errorHandler() {
  this.handleError = function (error) {
    return logger.logError(err)
      .then(sendMailToAdminIfCritical)
      .then(saveInOpsQueueIfCritical)
      .then(determineIfOperationalError);
  }

  this.isTrustedError = function (error) {
    return error.isOperational;
  }
}

2.7 에러 확인을 용이하게 해주는 안정된 로거를 사용하라

- Winston, Bunyan 혹은 Log4J와 같이 자리를 잡은 로깅 도구들은 에러를 발견하고 이해하는 속도를 높여준다.(console.log를  안 써도 된다.)

-> 그렇게 하지 않을 경우: 로그 검색 도구나 제대로 된 로그 뷰어 없이 console.log를 훑어보거나 복잡하게 꼬인 텍스트 파일을 일일이 읽어 보는 것은 이해를 어렵게 할 수 있다.

 

충분히 발달한 로거를 사용하여 오류 가시성을 높인다

- 우리는 모두 console.log를 좋아하지만 분명히 Winston 또는 Pino와 같은 평판이 좋고 지속적인 로거는 진지한 프로젝트에 필수다.(일련의 사례와 도구는 오류에 대해 훨씬 더 빠르게 추론하는데 도움이 된다.)

(1) 다양한 수준(디버그, 정보, 오류)을 사용하여 자주 로깅하고

(2) 로깅할 때 컨텍스트 정보를 JSON 객체로 제공한다.

(3) 로그 쿼리 API 또는 로그 뷰어 소프트웨어를 사용하여 로그를 보고 필터링한다.

(4) Splunk와 같은 운영 인텔리전스 도구를 사용하여 운영 팀을 위해 로그 설명을 노출하고 관리한다.

 

예) 작동 중인 Winston Logger

// your centralized logger object
var logger = new winston.Logger({
  level: 'info',
  transports: [
    new (winston.transports.Console)()
  ]
});

// custom code somewhere using the logger
logger.log('info', 'Test Log Message with some parameter %s', 'some parameter', { anything: 'This is metadata' });

예) 로그 폴더 쿼리

var options = {
  from: new Date - 24 * 60 * 60 * 1000,
  until: new Date,
  limit: 10,
  start: 0,
  order: 'desc',
  fields: ['message']
};


// Find items logged between today and yesterday.
winston.query(options, function (err, results) {
  // execute callback with results
});

2.8 선호하는 테스트 프레임워크로 에러 흐름을 테스트하라

- 전문적인 자동화 QA든 일반적인 수동 개발자 테스트든 당신의 코드가 긍정적인 상황에서 잘 동작할 뿐만 아니라 올바른 에러를 처리하고 반환하는지도 확실히 하라. Mocha & Chai와 같은 테스트 프레임워크를 쓰면 쉽게 처리할 수 있다.

-> 그렇게 하지 않을 경우: 자동이든 수동이든 테스트가 없다면 당신은 당신의 코드가 올바른 에러를 반환하는지 믿지 못할 것이다. 의미 있는 에러가 없다면 에러 처리도 없는 것이다.

 

예) Mocha & Chai를 사용하여 올바른 예외가 발생하는지 확인

describe("Facebook chat", () => {
  it("Notifies on new chat message", () => {
    var chatService = new chatService();
    chatService.participants = getDisconnectedParticipants();
    expect(chatService.sendMessage.bind({ message: "Hi" })).to.throw(ConnectionError);
  });
});

예) API가 올바른 HTTP 오류 코드를 반환하는지 확인

it("Creates new Facebook group", function (done) {
  var invalidGroupInfo = {};
  httpRequest({
    method: 'POST',
    uri: "facebook.com/api/groups",
    resolveWithFullResponse: true,
    body: invalidGroupInfo,
    json: true
  }).then((response) => {
    // if we were to execute the code in this block, no error was thrown in the operation above
  }).catch(function (response) {
    expect(400).to.equal(response.statusCode);
    done();
  });
});

2.9 APM 제품을 사용하여 에러와 다운타임을 확인하라

- 모니터링 및 성능 제품(APM)은 미리 알아서 코드베이스와 API를 측정하고 자동적으로 당신이 놓친 에러, 충돌, 느린 부분을 강조 표시해준다.

-> 그렇게 하지 않을 경우: API의 성능과 다운타임을 측정하기 위해 많은 노력을 들여야 할지도 모른다.

 

예외랑 에러는 다르다.

기존의 오류 처리는 예외가 있다고 가정하지만 어플리케이션 오류는 느린 코드 경로, API 다운타임, 계산 리소스 부족 등의 형태로 발생할 수 있다. APM 제품은 최소한의 설정으로 다양한 '묻힌' 문제를 사전에 감지할 수 있으므로 편리하다.

APM 제품의 공통 기능 중에는 예를 들어 HTTP API가 오류를 반환할 때 경고, API 응답 시간이 일부 임계값 아래로 떨어질 때 감지, 코드 스멜(코드 스멜은 컴퓨터 프로그래밍 코드에서 더 심오한 문제를 일으킬 가능성이 있는 프로그램 소스 코드의 특징) 감지, 서버 리소스 모니터링 가능, IT 메트릭(타임스탬프와 보통 한 두가지 숫자 값을 포함하는 이벤트)이 포함된 운영 인텔리전스 대시보드 및 다양한 기능이 있다. 대부분의 공급업체는 무료 요금제를 제공한다.

 

APM 제품은 3가지 주요 부문으로 구성된다.

1. 웹 사이트 또는 API 모니터링 - HTTP 요청을 통해 가동 시간과 성능을 지속적으로 모니터링하는 외부 서비스. 몇 분안에 설정할 수 있다.(Pingdom, Uptime Robot, New Relic 등)

2. 코드 계측 - 느린 코드 감지, 예외 통계, 성능 모니터링 등과 같은 기능을 사용하기 위해 어플리케이션 내에 에이전트를 내장해야 하는 제품군이다.(New Relic, App Dynamics 등)

3. 운영 인텔리전스 대시보드 - 어플리케이션 성능을 쉽게 파악하는데 도움이 되는 메트릭 및 선별된 콘텐츠로 운영 팀을 촉지하는데 중점을 둔다. 여기에는 일반적으로 여러 정보 소스(어플리케이션 로그, DB 로그, 서버 로그 등)를 집계하고 사전 대시보드 디자인 작업이 포함된다.(Datadog, Splunk, Zabbix 등)

 

예) UpTimeRobot.com - 웹 사이트 모니터링 대시보드

예) AppDynamics.com - 코드 계측과 결합된 종단 간 모니터링

2.10 처리되지 않은 promise 거부(unhandled promise rejection)를 잡아라.

- promise 안에서 발생한 예외는 개발자가 명시적으로 처리하지 않는 한 삼켜져 버려지게 된다. process.uncaughtException 이벤트를 구독하고 있다고 해도 마찬가지다. process.unhandledRejection 이벤트를 등록해서 이것을 극복해라.

-> 그렇게 하지 않을 경우: 당신의 에러는 삼켜지고 어떤 흔적도 남기지 않을 것이다.

 

- 일반적으로 Node.js/Express 어플리케이션 코드는 .then 핸들러, 함수 콜백 또는 catch 블록 내에서든 promise 내에서 실행된다. 놀랍게도 개발자가 .catch 절을 추가하는 것을 기억하지 않는 한 이러한 위치에서 발생한 오류는 uncaughtException 이벤트 처리기에 의해 처리되지 않고 사라진다.

- 간단한 해결책은 각 promise 체인 호출 내에 .catch 절을 추가하는 것을 잊지 않고 중앙 집중식 에러 핸들러로 리디렉션하는 것이다. 그러나 개발자의 원칙에 따라 오류 처리 전략을 구축하는 것은 다소 취약하다. 따라서 process.on('unhandledRejection', callback)를 사용하는 것이 좋다. - 이는 로컬에서 처리되지 않은 경우 모든 약속 오류가 처리되도록 한다.

 

예) 이러한 오류는 에러 핸들러에 의해 잡히지 않는다(unhandledRejection 제외)

DAL.getUserById(1).then((johnSnow) => {
  // this error will just vanish
  if(johnSnow.isAlive == false)
      throw new Error('ahhhh');
});

예) 해결되지 않았거나 거부된 promise 잡기

process.on('unhandledRejection', (reason, p) => {
  // I just caught an unhandled promise rejection, since we already have fallback handler for unhandled errors (see below), let throw and let him handle that
  throw reason;
});
process.on('uncaughtException', (error) => {
  // I just received an error that was never handled, time to handle it and then decide whether a restart is needed
  errorManagement.handler.handleError(error);
  if (!errorManagement.handler.isTrustedError(error))
    process.exit(1);
});

2.11 전용 라이브러리를 이용해 인자값이 유효한지 검사하여 빠르게 실패하라

- 나중에 처리하기가 더 힘들어지는 지저분한 버그를 피하기 위해 Assert API 입력은 당신의 Express 모범사례가 되어야 한다. Joi와 같은 유용한 헬퍼 라이브러리를 사용하지 않는 이상 유효성 검사 코드는 일반적으로 손이 많이 간다.

-> 그렇게 하지 않을 경우: 예를 들어, 함수가 "Discount"라는 숫자를 받아야하는데 요청하는 사람이 넘겨주는 것을 깜빡하면 그 후에 코드는 Discount가 0인지 아닌지 체크한다. 그러면 사용자가 할인을 받게될 것이다. 이렇듯 지저분한 버그가 된다.

'Javascript > Node.js' 카테고리의 다른 글

프라미스(promise)  (0) 2021.09.13
[Node.js] 3. 코드 스타일  (0) 2021.09.13
1. 프로젝트 구조 설계  (0) 2021.09.10
[Objection.js] Eager Loading Method  (0) 2021.09.09
옵셔널 체이닝 - '?.'  (0) 2021.09.02