toggle menu

Express.js 라우팅 핸들러에 async/await 을 적용할 수 있을까?

2017. 2. 24. 10:03 Node.js

들어가며

지난 2017년 2월 22일, node.js 의 자바스크립트 엔진인 V8 이 5.5 버전으로 업그레이드되면서 특별한 옵션 없이도 바로 async/await 을 네이티브로 사용할 수 있게 되었다.

물론 이전 버전의 node.js 에서도 하모니 옵션을 활성화 시키면 async/await을 사용할 수 있었지만, 이제는 특별한 옵션 설정 없이도 곧바로 async/await 문법을 사용할 수 있게 된 것이다.

다만, 아직 Ignition 과 TurboFan 이 적용된건 아니라서 퍼포먼스는 V8 5.7버전이 도입되면 비약적으로 향상될 것으로 보인다. Ignition 과 TurboFan 은  V8의 새로운 인터프리터와 컴파일러 파이프라인으로 V8 블로그에 관련된 설명을 찾아볼 수 있다.




express.js 와 async/await

사실 node.js 진영에는 async/await 문법을 일찌감치 대응하고 정식으로 node.js 에서 async/await 을 지원하기만을 기다리고 있었던 koa.js 라는 웹프레임워크도 존재하고 있었다. 하지만 이미 대부분의 프로젝트는 express를 기반으로 하고 있고, 새로 시작하는 프로젝트가 아닌이상 async/await 을 사용해보기 위해 koa.js 로 프레임워크를 전환하는 것은 쉬운 일이 아니다. 또 얼핏보면 koa.js 가 express.js와 비슷해보이긴 하지만 실제로 사용해보면 express.js 와 꽤 많은 차이가 있음을 발견하게 된다.


그렇다면, express.js 에서 async/await 을 사용할 수는 없을까? 더 정확히 말해서 express.js 의 라우팅 핸들러에서 async/await 을 지원하게 만들수는 없을까? 지금부터 이 부분에 대해 차근차근 탐험(?)해 보자.




단순 무식한 async/await 적용

express.js 의 라우팅 핸들러는 기본적으로 아래와 같은 형태이다.

const { Router } = require('express'); const router = Router(); //라우팅 핸들러 router.get('/', (req, res) => { res.send('Hello World'); });


위의 예에선 루트 경로에 대해 request 와 response 를 파라메터 갖는 익명 함수가 라우팅 핸들러 함수다. 대부분의 동기적인 응답이라면 큰 문제가 없지만 사실 대부분 service 레이어를 두고 DB 입출력 등 비동기적인 처리를 한 이후에 응답을 하는 케이스가 많을 것이다.

이런 케이스를 예로 들기 위해 친숙한 setTimeout 을 등장시켜보자.

//3초 뒤에 'Hello World' 리턴 function test () { return new Promise(resolve => { setTimeout(() => resolve('Hello World'), 3000); }); } //라우팅 핸들러 router.get('/', (req, res) => { test().then(result => res.send(result)); });

앞서 예제와 동일하지만 차이점이 있다면 Promise 와 setTimeout 을 사용해서 3초 뒤에 응답하는 것 뿐이다. express.js 에서 비동기 처리를 위해 위와 같이 Promise를 활용하는 것은 흔하게 발견할 수 있는 패턴이다.


기본적으로 await 키워드는 Promise 에 대해서 사용할 수 있다. 따라서 위의 예제에서는 test 함수가 Promise를 리턴하기 때문에 await 할 수 있는 대상이 된다. 그런데 await 키워드는 반드시 async 함수 내에서만 사용 가능하므로 라우팅 핸들러 함수를 async 함수로 바꿔줄 필요가 있다.


그럼 단순 무식하게 async/await 을 라우팅 핸들러 함수에 적용해보자.

//3초 뒤에 'Hello World' 리턴 function test () { return new Promise(resolve => { setTimeout(() => resolve('Hello World'), 3000); }); } //라우팅 핸들러 router.get('/', async (req, res) => { const result = await test(); res.send(result); });

라우팅 핸들러 함수 앞에 async 키워드를 추가해서 익명 async 함수로 변경해주고, test 함수의 응답을 await 키워드를 사용해서 지연해서 받도록 변경했다.


과연 잘 동작할까?

동작한다! 그런데 뭔가 찜찜한 기분이 든다.




async/await 과 예외처리

지금까지는 항상 정상적으로 동작하는 비동기 함수를 사용해서 굉장히 이상적인 케이스를 테스트해 보았다. 그런데 실제 운영 환경에서는 항상 예상하지 못한 오류가 발생하기 마련이다.


아래와 같이 async 함수가 아닌 경우에는 express.js 의 에러 처리 미들웨어가 오류를 잘 인지해서 오류 페이지를 정상적으로 출력해준다.

function err () {
   throw new Error('에러 발생');
}

//라우팅 핸들러
router.get('/', (req, res) => {
    const result = err();
    res.send(result);
});


이번에는 async/await 을 사용하는 상황을 생각해보자. 아래와 같이 호출한 async 함수 내에서 예상치 못한 오류가 발생한다면 어떻게 될까?

async function err () {
   throw new Error('에러 발생');
}

//라우팅 핸들러
router.get('/', async (req, res) => {
    const result = await err();
    res.send(result);
});

async 함수 안에서 예상치 못한 에러가 발생하는 건 충분히 운영 환경에서 발생 가능한 시나리오이다. 실행해보면 어떻게 될까? express.js 의 든든한 에러 처리 미들웨어가 우연히 발생한 에러를 안전하게 처리해줄 수 있을까?

하지만 기대와는 달리 아래와 같이 node.js 는 Unhandled promise rejection 를 출력하고 express.js 는 에러 발생을 인식하지 못한채 응답만 지연되게 된다.

(node:5819) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): Error
(node:5819) DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

오래 전에는 Unhandled promise rejection 이 그렇게 큰 문제가 되지 않았다. 하지만 위의 경고 문구를 잘 살펴보면, Unhandled promise rejection 은 곧 deprecated 될 예정이고 그 이후엔 이렇게 다뤄지지 않은 promise rejection 이 발생할 경우 무려 node.js 가 종료되는 사태가 벌어질 것이라고 경고하고 있다.


이대로라면 express.js 에서 async/await 을 사용하는건 사실상 포기해야 할지도 모른다. 도대체 express.js 가 오류를 잡지 못하는 이유는 무엇일까? 힌트는 node.js 가 출력해준 에러 메시지에 있다. 


우리는 단순무식하게 express.js 의 라우팅 핸들러 함수를 async 함수로 변경했다. 그런데 async 함수는 기본적으로 Promise 를 리턴해주는 구조이다. 그렇다는건 그 안에서 발생한 오류를 단순하게 바깥으로 던져서 잡는게 아니라 Promise 의 catch 함수를 통해 처리해주어야 한다는 의미이다. 즉 express.js 가 라우팅 핸들러 함수가 리턴한 reject 된 promise 를 적절하게 처리해주지 않고 있어서 발생하는 문제라는 얘기다.




express.js 에서의 오류 처리

express.js 에서는 라우팅 핸들러 함수에서 오류가 발생한 경우, 내부적으로 미들웨어의 세번째 파라메터인 next 콜백함수에 error를 전달해서 마지막 에러 처리 미들웨어에서 받아서 적절하게 처리할 수 있도록 하고 있다.

좀더 자세히 express.js 를 들여다보면, 내부적으로 layer 라는 객체를 두고 여기에서 주입받은 핸들러 함수를 실행하고 있고 핸들러 함수가 던지는 오류도 여기에서 처리가 되고 있다.

/**
 * Handle the request for the layer.
 *
 * @param {Request} req
 * @param {Response} res
 * @param {function} next
 * @api private
 */

Layer.prototype.handle_request = function handle(req, res, next) {
  var fn = this.handle;

  if (fn.length > 3) {
    // not a standard request handler
    return next();
  }

  try {
    fn(req, res, next);
  } catch (err) {
    next(err);
  }
};

소스의 마지막 부분의 try/catch 문이 오류 처리를 하는 부분이다. 바로 이 부분이 핵심이다. 우리가 주입해준 라우팅 핸들러 함수는 async  함수이기 때문에 일반 함수 내의 try/catch 내에서는 적절하게 예외 처리를 해줄 수가 없다.


아래와 같이 async 함수에서 발생한 오류는 일반 함수 내에서는 try/catch로 잡을 수 없기 때문이다.

async function err () {
   throw new Error('오류 발생');
}

(function () {
    try {
        err();
    }
    catch (e) {
        console.log('에러', e);
    }
}) ();
// Uncaught (in promise) Error


호출한 async 함수에서 발생한 오류를 잡기 위한 가장 간단한 방법은 아래와 같이 또다른 async 함수 내에서 실행하는 방법이다. async 함수 안에서 호출된 또다른  async 함수는 try/catch 로 예외 처리가 가능하다.

async function err () {
   throw new Error('오류 발생');
}

(async function () {
    try {
        await err();
    }
    catch (e) {
        console.log('예외 처리');
    }
}) ();
//'예외 처리' 출력


위와같이 async 함수 내에서 실행하는 방법 외에도 다른 방법도 있다. 함수가 리턴한 값을 확인해서 Promise 인 경우 Promise의 catch로  promise rejection 된 값을 처리해주는 방법이다.


그런데 실제로 express.js 의 async-route-handlers 브랜치를 살펴보면 이런식으로 예외처리를 하려던 흔적이 발견된다.

/**
 * Handle the request for the layer.
 *
 * @param {Request} req
 * @param {Response} res
 * @param {function} next
 * @api private
 */

Layer.prototype.handle_request = function handle(req, res, next) {
  var fn = this.handle;

  if (fn.length > 3) {
    // not a standard request handler
    return next();
  }

  try {
    var maybe_promise = fn(req, res, next);
    if (maybe_promise && maybe_promise.catch && typeof maybe_promise.catch === 'function') {
      maybe_promise.catch(next);
    }
  } catch (err) {
    next(err);
  }
};

소스를 살펴보면 단순하게 try/catch만 해주는게 아니라, 라우팅 핸들러 함수의 리턴값을 받아서 catch 함수가 존재하는지 체크하고, 있으면 next 콜백 함수와 연결하는 것이다.


express.js github의 이슈를 살펴보면 라우팅 핸들러 함수가 promise를 리턴하는 문제에 관련된 논의도 진행되고 있는데, 결론만 이야기하자면 Express 5 부터는 라우팅 관련된 부분을 Router 라는 모듈로 분리했기 때문에 더이상 express.js 에서 이부분을 다루지 않고 Router 모듈에서 다루어야 한다며 책임을 미뤄놓은 상태다. 물론 Router 모듈의 github 에서도 이 부분에 대한 논의가 이어지고 있지만 아직 적극적으로 적용하거나 결론을 내진 않고 있다.


이런 상태로는 Express 5 에서도 async 라우팅 핸들러를 사용하기는 어려워 보인다. 방법이 없는걸까?

(express 5 alpha 6 버전부터 이 부분에 대한 처리가 추가될 수도 있을 것 같다.)




데코레이터 패턴

앞에서 살펴보았던 것처럼 Express 에서 async 라우팅 핸들러를 사용하려면 Express 에서 제공해주는 방법으로는 어려운 상황이다. 하지만 약간의 추가적인 작업을 통해 async 라우팅 핸들러를 사용할 수 있는 방법이 두 가지가 존재한다.

그 중에서 먼저 데코레이터 패턴을 활용하는 방법을 살펴보자.


앞서 살펴봤던 것처럼 async 함수가 던지는 오류를 catch 하려면 간단하게 async 함수 안에서 실행하고 그 안에서 발생한 오류를 적절하게 처리해주면 된다. 이 아이디어을 활용해서 async 라우팅 핸들러를 주입받아 이런 작업을 추가한 새로운 라우팅 핸들러를 리턴하도록 하면 어떨까? 아래 코드를 살펴보자.

fn => async (req, res, next) => await fn(req, res, next).catch(next);

arrow function 이 연속으로 이어져 있어서 해석하기 어려울 수 있기 때문에 좀더 풀어서 표현하면 아래와 같다.

function (fn) {
    return async function (req, res, next) {
        await fn(req, res, next).catch(next);
    }
}

라우팅 핸들러 함수를 주입받아서 async 함수를 리턴한다. async 함수 안에서 주입받은 async 라우팅 핸들러 함수를 실행하고 실행된 async 라우팅 핸들러함수에서 promise를 리턴하면 catch 메서드로 잡아서 next 콜백으로 전달하는 형태이다.


이 랩핑 함수를 실제 라우팅 핸들러에 적용해보면 아래와 같다.

const doAsync = fn => async (req, res, next) => await fn(req, res, next).catch(next);

async function err () {
   throw new Error('에러 발생');
}

//라우팅 핸들러
router.get('/', doAsync(async (req, res) => {
    const result = await err();
    res.send(result);
}));

앞서 express.js 가 제대로 async 라우팅 핸들러 함수가 리턴하는 오류를 잡지 못해서 Unhandled promise rejection 오류가 났던 코드 그대로이다. 여기에 단지 방금 설명한 랩핑함수 doAsync 로 async 라우팅 핸들러 함수를 감싸주었을 뿐이다.


실행해보면.. 이제는 정상적으로 express.js 에서 async 라우팅 핸들러에서 발생한 오류를 잡아서 express.js 의 오류 처리 미들웨어에서 처리하는 것을 볼 수 있다. 한번 랩핑해주는 것이 조금 신경쓰일 수 있지만, 기존 로직을 크게 변경하지 않고 express.js 에서 async/await 을 적용해 비동기 처리를 보다 직관적으로 만들어줄 수 있게 됐다.


핸들러 함수가 async 함수가 아닐 때엔 catch 메서드가 없을 수도 있으니 랩핑 함수의 로직을 좀더 안전하게 try/catch 로 아래와 같이 바꿔줄 수 있다.

function (fn) {
    return async function (req, res, next) {
        try {
            await fn(req, res, next);
        } catch (err) {
            next(err);
        }
    }
}


이왕 이렇게 만들어진 랩핑함수를 모두가 같이 쓸 수 있도록 npm 에 모듈로 등록해서 공개하려고 했는데, 나름 신선한 아이디어라고 생각했으나 역시 참 세상엔 비슷한 생각을 하는 사람들이 많았나보다. 이런 아이디어를 구현한 모듈을 npm 에서 찾아보면 꽤 여러개가 나온다.


express-async-wrap

express-wrap-async


위의 두 모듈 모두 앞서 설명한 방식을 거의 동일하게 구현하고 있다.




또다른 방법

위의 랩핑 함수를 사용하는 방법 외에, 한 가지 방법이 더 존재한다. 런타임 상에서 생성한 express 인스턴스와 router 인스턴스의 라우팅 메서드 자체를 수정해버리는 것이다.

사실 express 내부적으로는 하나의 핸들러 함수 처리 로직으로 get, post, put, all 등 수많은 http method 메소드 함수를 처리해주는데, 반대로 async 라우팅 핸들러를 지원하게 만들기 위해 수많은 라우팅 메서드 함수들을 하나하나 다 async 함수를 처리해줄수 있도록 변경해주어야 한다.

뭔가 비효율적인 것 같지만 이렇게 처리해주면 대신 앞서 랩핑 함수를 사용하는 것과 달리 그냥 바로 라우팅 핸들러 함수에 async 함수를 사용해도 되어 아래와 같이 좀더 소스가 깔끔해보일 수 있다. 물론 내부적으로는 각각 위의 랩핑 함수 구조를 갖게 될 것이다.


const express = require('express');
const asyncify = require('express-asyncify');

const app = asyncify(express());

// ...

app.get('/', async (req, res) => {
    const posts = await Post.findAll();
    res.render('index', {posts});
});

이런식으로 직접 express 인스턴스 자체를 수정해주는 모듈로는 위의 예제소스에서 사용한 express-asyncify 외에도 asyncify-express 가 있다. 이름만 앞뒤로 바꾼 느낌처럼 동작도 같다.




마치며

지금까지 express.js 에서 async 라우팅 핸들러를 사용하기 위한 탐험(?)을 했다. express.js 자체에서 유연하게 지원해주면 좋겠지만, 그렇지 못하기 때문에 데코레이터 패턴을 활용하는 방법과 express 인스턴스의 라우팅 핸들러 전체를 직접 수정해주는 방법 두 가지에 대해 설명했다.


지금하고 있는 프로젝트에는 데코레이터 패턴을 활용하는 방법을 사용했다. express 인스턴스의 라우팅 핸들러를 다 수정해주는게 좀 비효율적으로 느껴지기도 했고, 향후에 express 의 변경에 취약할 수도 있을 것 같아서였다. 결과적으로 합리적인 선택이었다고 생각한다.


새로운 node.js 를 설치했고 express.js 를 사용하고 있다면, 한번 async 라우팅 핸들러 함수를 써보는건 어떨까?


Node.js 관련 포스팅 더보기