현재 크로키에서는 서버용 웹 프레임워크로 Node.js 위에서 Express를 사용하고 있습니다.

이번 글에서는 어떤 이유로 Node.js를 사용하게 되었는지 설명하려고 합니다. 여러분들이 웹 프레임워크를 선택하시는 데 참고가 되었으면 합니다.


크로크닷컴을 설립한 것은 2012년 2월입니다. 회사를 차리면서 구상했던 것은 SNS 성격을 가진 서비스로 당연히 서버가 필요했습니다. 서버 프로그래밍은 2004년 무렵 Tomcat을 잠깐 써본 것이 전부였기 때문에 어떤 언어/프레임워크를 써야 하는지부터가 고민의 시작이었습니다.

몇 가지 후보군이 있었던 것으로 기억합니다.

첫 번째는 국내 웹에서 이미 많이 사용하고 있던 PHP입니다.

두 번째는 국내에서 또 많이 사용하는 ASP입니다.

세 번째는 대형 서비스에서 많이 사용하는 스프링입니다. 지금도 이력서를 받아보면 스프링을 사용/공부했다는 사람들이 종종 보입니다.

네 번째는 루비 온 레일즈입니다.

마지막이 Node.js입니다. 회사 설립 당시에 버전이 0.6이었는데, 국내에 책도 나오면서 이곳저곳에서 화제가 되었기에 살펴보았습니다.

그 외에 장고도 스타트업에서 많이 쓰이고 이력서에 장고를 사용했다는 사람이 심심치 않게 보이는데, 2012년 당시에 보면서도 그냥 지나쳤던 건지 널리 쓰이지 않았던 것인지 정확히 기억나지는 않지만, 후보로 고려하지 않았던 것 같습니다.


PHP는 언어로서 완성도가 떨어진다고 생각하고 있었기에 배제를 했습니다. 또한, 서버로는 당연히 리눅스를 생각했기에 ASP도 바로 배제되었습니다.

스프링은 조금 고민이 되었지만, Tomcat을 통한 경험이 좋지 않아서 컴파일해서 배포해야 하는 프레임워크를 후보에서 제외했습니다. (Tomcat과 스프링이 뭐가 다른 건지 잘 모르고, 10년 동안 많이 발전했을 수도 있지만, 워낙 거부감이 컸습니다.)

레일즈에 대해서는 지인에게 회의 중에 나온 내용을 바로바로 구현해가면서 확인해봤다는 무용담(?)을 들었던 적이 있고 2007년에 관련 책도 산 적이 있기에 (잘 보지는 않았지만), 일단 후보로 두었습니다.

Node.js는 당시에 여러 곳에서 빠르다는 이유로 주목받고 있었기에 레일즈와 두개를 주요 후보로 설정했습니다.


정확히 비교하자만 레일즈=Express 이고, 루비=Node.js 겠죠. 레일즈는 이미 많은 기능이 있었고, Express는 그에 비하면 단순한 기능만을 지원했습니다. 아마 저희가 웹 서비스를 만들려고 했다면 레일즈를 골랐을 수도 있습니다. 하지만 저희는 앱만 생각하고 있었기에 웹 프로토콜(HTTP) 요청만 처리할 수 있으면 됐습니다. 그리고 반대중적(?) 성격이 조금 있어서 레일즈가 제시하는 대로 프로그램을 만들어야 한다는 것이 썩 맘에 들지는 않았습니다.

그러나 무엇보다 Node.js를 선택한 결정적인 이유는 이벤트 주도 방식 때문입니다. 대학교에서 공부할 무렵부터 스레드와 락 개념을 별로 좋아하지 않았습니다. 스레드는 컨텐스트 전환에 불필요한 비용이 많이 든다고 생각했고, 데이터를 보호하기 위해서 락이 필요한데 문제 없이 작성하는 것이 어렵다고 봤습니다. 거기에 이벤트 주도 방식으로 단일 스레드에서 동작한다는 것이 제 취향에 맞았습니다.

Node.js를 선택하는데 당시 얘기가 많던 성능 문제는 크게 고려상황은 아니였습니다. 이벤트 주도 방식때문에 스레드 방식보다는 성능이 잘 나오는게 당연하다는 생각이 있었고, 다른 언어에서도 이벤트 주도 방식을 사용한다면 성능 차이는 그렇게 나지 않을 거라고 봤습니다. (비슷하게 작성한다면 Java 같은 컴파일 언어가 당연히 성능이 좋을 것이라 생각했습니다.) 실제로 Node.js가 아니더라도 이벤트 주도 방식을 쓰는 Netty난 Twisted 같은 것도 이미 존재했습니다. Node.js 가 뜬 이후에 나온 Vert.x 같은 것도 있고요.


그럼에도 불구하고 Node.js를 선택한 것은 JavaScript라는 언어때문입니다.

프로토타입을 통한 상속이라던지, this 바인딩등 보편적인 언어들과 다른 특성들 때문에 익히기 어려웠기에 사실 그다지 JavaScript는 좋아하는 언어는 아니였습니다.

일반적인 언어라면 어떤 것을 사용해서도 모든 일을 할 수 있다고 생각하고 있지만, 언어의 표현력에 따라 난이도는 달라집니다. 그런면에서 JavaScript는 개념이 색다르거나 잘못되어서(변수 스코프같은 경우), 제대로 사용하지 못하고 실수하기 좋은 언어라는 생각이 있었습니다.

하지만 이벤트 주도 방식에 대해서는 큰 장점이 있었습니다. 태생이 JavaScript는 브라우저 언어였기에 스레드에 대한 개념이 원래 고려되어 있지 않습니다. (요새는 웹 워커 같은 것이 추가되고 있지만) 따라서 Node.js의 API는 모두 비동기적으로 동작하게 설계가 되어 있습니다. 반면 Java나 Python등의 언어에서는 이벤트 주도 방식으로 처리를 하려면, 원래 사용하던 API를 사용하면 안 되고 Java NIO나 Python asyncio 같은 것을 사용해야 합니다.

저는 이 차이가 제일 중요하다고 판단했습니다. 이벤트 주도 방식으로 서버를 작성하려면 블로킹 I/O를 사용하면 안 되는데, 언어/플랫폼 적으로 기본이 블로킹 I/O라면 의도치 않게 사용할 확률이 높다고 봤습니다.


결론적으로 서버에는 이벤트 주도 방식이 가장 좋다고 생각했는데, 언어/플랫폼 태생적으로 이벤트 주도 방식인 Node.js를 사용하는 것이 맞다고 판단했습니다.

JavaScript의 단점은 CoffeeScript를 사용하는 것으로 회피했습니다. 다만 프로젝트가 커질 수록 형검사에 대한 요구가 커져서 TypeScript로 이전하는 것을 계속 고려중입니다.

콜백의 어려움은 초반에는 async로, 현재는 Promise를 사용해서 피하고 있습니다. TypeScript로 이전한 후에는 async/await를 사용해볼 예정입니다.

스프링이나 레일즈, 장고등에 비해 구인에 어려움은 조금 있지만, 현재는 Node.js를 선택한 것에 만족하고 있습니다.

크로키에서는 로그를 JSON 문자열로 만들어 일자별(혹은 시간별)로 묶은 후 gzip으로 압축해서 저장하고 있습니다. 그런데 이미 만들어진 로그를 수정해야 하는 일이 생겼습니다.

예를 들면

{"date":"Fri Jan 15 2016 00:00:01 GMT+0000 (UTC)","path":"/foobar"}
{"date":"Fri Jan 15 2016 00:00:03 GMT+0000 (UTC)","path":"/croquis"}
{"date":"Fri Jan 15 2016 00:00:10 GMT+0000 (UTC)","path":"/awesome"}

였던 데이터를

{"date":"2016-01-15T00:00:01.000Z","path":"/foobar"}
{"date":"2016-01-15T00:00:03.000Z","path":"/croquis"}
{"date":"2016-01-15T00:00:10.000Z","path":"/awesome"}

처럼 바꿔야 했습니다.

로그 전체를 읽어와서 줄별로 변환하고 다시 기록하면 되는 일이지만 로그가 커서 잘 동작하지 않았습니다.

그래서 유닉스의 파이프 형태로 처리하기로 했습니다.

위의 작업을 하는 Node.js 프로그램은 다음과 같습니다.

const byline = require('byline');
const stream = byline(process.stdin);
stream.on('data', function (line) {
  const data = JSON.parse(line);
  data.date = new Date(data.date).toISOString();
  console.log(JSON.stringify(data));
});

byline은 스트림을 줄별로 처리할 수 있게 해주는 모듈입니다. 줄별로 들어온 JSON 문자열을 파싱하고 원하는 처리를 한 후 다시 쓰기만 하는 단순한 코드입니다. 이 프로그램을 유닉스 파이프라인에 넣으면 원하는 결과를 얻을 수 있습니다.

$ gunzip -c original/01.data.gz | node convert.js | gzip > converted/01.data.gz

보너스

이 작업을 순수히 Node.js만 가지고도 할 수 있습니다.

const fs = require('fs');
const stream = require('stream');
const zlib = require('zlib');
const byline = require('byline');

class Convert extends stream.Transform {
  constructor(options) {
    super(options);
  }
  _transform(line, encoding, callback) {
    const data = JSON.parse(line);
    data.date = new Date(data.date).toISOString();
    callback(null, JSON.stringify(data)+'\n');
  }
}

fs.createReadStream('original/01.data.gz')
  .pipe(zlib.createGunzip())
  .pipe(byline())
  .pipe(new Convert())
  .pipe(zlib.createGzip())
  .pipe(fs.createWriteStream('converted/01.data.gz'));

하지만 파이프의 특성상 하나의 프로그램이 하나의 작업만 할 수록 응용하기가 편해집니다. 예를 들어 파일이 로컬에 있는게 아니고 S3에 있다면 유닉스 파이프로는 다음과 같이 바꾸면 됩니다.

$ aws s3 cp s3://mybucket/stream.txt - | gunzip | node convert.js | gzip > converted/01.data.gz

Node.js로도 작성할 수 있지만 훨씬 많은 코드를 작성해야겠죠.

이제 GitHub 웹훅을 처리할 수 있게 됐습니다. 이전 글에서 생성한 URL을 GitHub에 설정해줍니다.

GitHub 저장소의 Settings 탭에 가면 Webhooks 메뉴가 있습니다. 여기서 웹훅을 추가할 수 있습니다.

Add webhook

아래 부분에서 웹훅을 통해 받을 이벤트를 설정할 수 있습니다. 다른 이벤트는 이미 받고 있으므로 여기서는 Gollum(GitHub 위키 엔진) 이벤트만 체크해줍니다.

Select events

위키에 변경을 가하면 ‘Recent Deliveries’ 섹션에 그 내용이 보여집니다. Lambda 함수가 GitHub 위키 이벤트를 의도한대로 처리하는지 테스트하기 위해 이 내용을 사용할 있습니다. AWS Lambda 편집화면에서 Actions -> Configure test event 에서 입력할 수 있습니다.

다음은 최종 Lambda 코드입니다.

'use strict';

const AWS = require('aws-sdk');
const url = require('url');
const https = require('https');

let hookUrl;

function sendMessage(message) {
  const body = JSON.stringify(message);
  const options = url.parse(hookUrl);
  options.method = 'POST';
  options.headers = {
    'Content-Type': 'application/json',
    'Content-Length': Buffer.byteLength(body)
  };
  return new Promise((resolve) => {
    const req = https.request(options, (res) => {
      res.on('end', () => {
        resolve({
          statusCode: res.statusCode
        });
      });
    });
    req.write(body);
    req.end();
  });
}

function decryptHookUrl() {
  if (hookUrl) {
    return Promise.resolve(hookUrl);
  } else {
    const kms = new AWS.KMS();
    return new Promise((resolve, reject) => {
      const blob = new Buffer(process.env.encryptedHookUrl, 'base64');
      kms.decrypt({ CiphertextBlob: blob }, (error, data) => {
        if (error) {
          return reject(error);
        }
        hookUrl = data.Plaintext.toString('ascii');
        resolve(hookUrl);
      });
    });
  }
}

function processGithubPayload(payload) {
  const page = payload.pages[0];
  const repo = payload.repository.full_name;
  const page_link = page.html_url + '|' + page.title;
  const sender_link = payload.sender.html_url + '|' + payload.sender.login;
  return {
    username: 'github wiki',
    text: `[${repo}] <${page_link}> is ${page.action} by <${sender_link}>`
  }
}

exports.handler = (event, context, callback) => {
  decryptHookUrl()
  .then(() => {
    const message = processGithubPayload(event);
    message.channel = '#auto-github';
    sendMessage(message)
    .then((response) => {
      callback(null, 'success');
    });
  }).catch((error) => {
    callback(error);
  });
};

이제 위키를 수정하고 슬랙에 메시지가 표시되는 것을 확인하면 끝입니다. 수정내역이나 커밋 메시지를 표시해주면 좋은데 아쉽게도 해당 데이터는 없는 것 같습니다.