ECMAScript(JavaScript)의 Object와 Property

JavaScript(이하 ECMAScript)를 처음 접하면 프로토타입 개념이 혼란을 일으키곤 하는데, Object가 어떻게 생성되고 프로퍼티를 어떻게 읽고 쓰는지만 확실히 기억해두면 혼란을 피할 수 있다.

Object 생성하기 (( ECMA-262 13.2.2 ))

ECMAScript에서 오브젝트란 프로퍼티들의 정렬되지 않은 컬렉션이다. 오브젝트는 생성자를 통해 만들어진다. Foo()라는 생성자가 있다면 다음의 방법으로 만들 수 있다.

x = new Foo();

이 때 생성자가 prototype이란 이름의 프로퍼티를 갖고 있다면 그 프로퍼티의 값이 생성되는 오브젝트의 프로토타입이 된다.

Foo.prototype = new Number();
x = new Foo();

new Number() 오브젝트는 x의 프로토타입이므로, x 오브젝트에 어떤 property를 추가하지 않았더라도 그 property가 new Number() 오브젝트에 존재한다면 x 오브젝트의 프로퍼티처럼 읽게 된다. 다음 절을 읽어보자.

프로퍼티 읽기/쓰기

ECMASCript에서 오브젝트의 프로퍼티의 읽기/쓰기는 다음과 같이 동작한다.

읽기

프로퍼티를 읽는 내부 메소드를 GET이라고 한다. GET은 다음과 같이 동작한다. (( ECMA-262 8.12.3 ))

어떤 오브젝트에 대해 GET P를 하면,

  1. 이름이 P인 프로퍼티가 있으면 그 프로퍼티의 값을 반환
  2. 없으면 [[Prototype]] 내부 메소드로 프로토타입을 가져오고 null이 아니라면 [[Get]] (P) 를 수행, null이면 undefined 반환.

쓰기

프로젝트를 쓰는 내부 메소드를 PUT이라고 하는데, PUT은 프로퍼티 존재 유무 확인하고 그런 거 없이 그냥 현재 오브젝트에 바로 값을 쓴다. 쓸 수 없다면 예외를 던진다. (( ECMA-262 8.12.5 ))

광고

구글 캘린더에 자연어로 이벤트 추가하는 자바스크립트 애플리케이션 만들기

구글캘린더에 자연어로 이벤트를 추가하는 아주 간단한 웹 애플리케이션을 만들었다.

‘굳이’ 서버사이드 스크립트를 전혀 사용하지 않고 만들었는데, 첫째는 서버에 부담을 주기 싫었고 둘째는 웹서버가 없어도 로컬에서 웹브라우저만으로 동작할 수 있게 하고 싶었기 때문이다.

여기에서 테스트해볼 수 있다. 소스코드도 거기서 참조하고 있는 자바스크립트 파일들이 전부이다.

만들기

OAuth 인증

요게 가장 난감하다. OAuth 인증과정에서 구글 서버하고 내 서버가 서로 통신을 해야 하기 때문이다. 이걸 순수하게 클라이언트 사이드 스크립트로만 처리하려면 약간 지저분해지는 것은 피하기 어렵다.

우선 여기서 웹애플리케이션용 클라이언트 아이디를 받급받자.

client id를 얻었다면 이걸로 구글 서버에 access token을 달라고 요청하는 코드를 작성한다. redirect_uri 를 줘야 하는데, 이건 window.location에서 얻어온다. 요청하는 것도 window.location을 이용한다.

var redirect_uri =
    'https://' + window.location.host + window.location.pathname;

window.location = "https://accounts.google.com/o/oauth2/auth" +
    "?response_type=token" +
    "&client_id=" + client_id +
    "&redirect_uri=" + redirect_uri +
    "&scope=https://www.googleapis.com/auth/calendar" +
    "&state=profile";

인증이 되면 구글의 인증 서버가 redirect_uri로 요청을 보낸다. access token이 query에 붙어서 오므로 이것을 뜯어내어 사용한다. window.location.hash.substring(1) 로 가져올 수 있다.

var params = {};
var queryString = window.location.hash.substring(1);
var regex = /([^&=]+)=([^&]*)/g;
var m;

while (m = regex.exec(queryString)) {
    params[decodeURIComponent(m[1])] = decodeURIComponent(m[2]);
}

access_token = params.access_token;
window.location.hash = "";

주소창에 access token이 떡 노출된다. 찝찝하니까 window.location.hash = ""; 로 지워버렸다. 깔끔하지 못하지만 ‘굳이’ 서버를 거치지 않고 싶으니 별 수 없다.

위의 스크립트는 최우선으로 실행되도록 한다. 왜냐면 저 인증과정에서 구글의 인증 페이지로 이동하게 되므로 다른 작업은 해봤자 무의미한 시간 및 트래픽 낭비이기 때문이다.

그럼 여기서부터는 HTML 페이지 및 자바스크립트 파일들을 다 로딩한다음 작업을 수행해도 된다. jquery도 마음껏 쓸 수 있다.

access token은 사용하기 전에 반드시 validation을 해야 한다. 이제 jquery를 써서 비동기로 요청을 보내자. validation에 성공하면 callback 함수를 호출해서 다음 할 일을 진행하도록 하자.

var validateAccessToken = function(callback) {
  var url =
    'https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=' +
    access_token;

  $.get(url, function(data) {
    callback();
  }).error(function() {
    notifyError('access token is invalid');
  });
}

validation까지 끝났다면 모든 준비는 완료되었다. 이제 진짜 하고 싶은 일을 할 수 있다.

이벤트 입력받아 자연어 처리하기

사용자의 캘린더에 이벤트를 추가하려면, 우선 사용자로부터 이벤트를 입력받아야 한다.

내가 생각하기에 가장 쉬운 이벤트 추가 방법은 그냥 자연어로 입력받는 것이다. 이렇게:

party with you@mail.com at my house on Friday 7pm

제대로 된 자연어 처리 기능을 넣는 것은 너무 어려우니 아주 단순하게 처리해보자.

내가 택한 방식은, 일단 조사 단위로 자르고 각 구절을 파싱하는 것이다. ‘with’, ‘at’, ‘on’, ‘in’ 기준으로 자른다.

var phrases = [];
var prepositions = ['with', 'at', 'on', 'in'];

for (var i in prepositions) {
  var name = prepositions[i];
  var matched = source.match(new RegExp('\b' + name + '\b'));
  if (matched) {
    phrases.push({name: name, index: matched.index});
  }
}

이렇게 잘라내면, ‘party’, ‘you@mail.com’, ‘my house’, ‘Friday 7pm’을 얻을 수 있다.

그리고 파싱을 하면 되는데… 사실 파싱이라고 해봤자, 이벤트 요약(party), 참석자(you@mail.com), 장소(my house)는 굳이 파싱할 필요가 없다. 그대로 이벤트에 넣어주면 된다.

일시(Friday 7pm)만 파싱해주면 되는데, 이건 Sugar 라는 훌륭한 라이브러리가 있으므로 그걸 그대로 사용했다.

date = Date.create(phrase);

그럼 이제 필요한 건 다 얻었으니, 이벤트를 만들어서 사용자의 캘린더에 넣어주면 된다.

구글 캘린더 API로 이벤트 추가하기

우선 캘린더를 얻어야 한다. 사용자에게 캘린더가 여러개 있을 수도 있는데, 그냥 여기선 첫번째 캘린더를 얻자.

var getTheFirstCalendar = function(callback) {
  url = 'https://www.googleapis.com/calendar/v3/users/me/calendarList?maxResults=1&minAccessRole=writer';
  $.ajax(url,
    {
      datatype: 'json',
      headers: {
        Authorization: 'Bearer ' + access_token
      },
      success: function(data) {
        if (typeof data === 'string') {
          data = JSON.parse(data);
        }
        callback(data.items[0].id);
      },
      error: function(jqXHR, textStatus, error) {
        notifyError('Failed to get a calendar: ' + error + 'from ' + url);
      }
    }
  );
}

여기서도 마찬가지로 성공했으면 다음 작업 진행을 위해 callback을 호출하게 했다.

캘린더까지 얻어왔다면 이벤트를 추가하면 된다. 아까 사용자로부터 입력받아 파싱까지 완료된 이벤트 데이터를 구글 캘린더 API에서 요구하는 형식에 맞게 맞춰주고 json으로 만들어 이벤트 추가 요청을 보낸다.

var addEvent = function(calendar_id) {
  if (data.date) {
    data.end = data.start = {
      dateTime: moment(data.date).format()
    }
  } else {
    data.start = data.end = {
      dateTime: moment().format()
    };
  }

  if (data.emails) {
    data.attendees = [];
    for (var i in data.emails) {
      data.attendees.push({
        email: data.emails[i],
        responseStatus: 'needsAction'
      });
    };
  }

  data.reminders = { useDefault: true };

  calendar_id = encodeURIComponent(calendar_id);
  url = 'https://www.googleapis.com/calendar/v3/calendars/' + calendar_id + '/events?sendNotifications=false';
  $.ajax(url,
    {
      contentType: 'application/json; charset=utf8',
      datatype: 'json',
      type: 'post',
      headers: {
        Authorization: 'Bearer ' + access_token
      },
      data: JSON.stringify(data),
      success: function(data) {
        notifySuccess('Created successfully. Click to go to the event.');
      },
      error: function(jqXHR, textStatus, error) {
        notifyError('Failed to create an event: ' + error + ' from ' + url);
      }
    }
  );
}

폼 제출(submit)

form submit을 ‘굳이’ 자바스크립트로 했다. form으로 submit 한 요청에 대한 응답을 처리할 수 있는 이벤트가 있고 거기에 핸들러를 등록할 수 있다면 안 해도 될 것 같은데, 그런건 못 찾았다.

$('#new-event').submit(function() {
  $('#submit_button').button('loading');
  getTheFirstCalendar(addEvent);
  return false;
});

기타 고려사항

HTTPS

OAuth는 HTTP, HTTPS 어떤 scheme으로도 사용할 수 있지만 HTTP를 사용했다간 access token이 암호화되지 않은 채로 사용자에게 전달될테니 그보다는 HTTPS를 사용하자. startssl에서 무료로 인증서를 발급받을 수 있다.

Free (( 아이콘을 얻은 사이트에 Free라고 되어있었다. 공짜라고 하려다가 Free는 공짜가 아니야! 라고 할까봐 )) 아이콘

무료로 쓸 수 있는 품질 좋은 아이콘 없나 찾아 헤매다가 glyphish라는 아이콘 셋을 찾았다. 200개는 무료, 400개는 25달러다.

브라우저 호환성

몽땅 클라이언트 사이드 자바스크립트로 동작하므로, 브라우저 호환성은 완전 꽝이다. 지금 내가 만든 이것도 꽤 많은 브라우저에서 안 돌아갈 것이다.

그러니 꼭 클라이언트 사이드에서 전체 기능이 동작할 필요가 없다면 굳이 이렇게 만들지 않는 것이 좋다.

node.js zlib 스트림의 문제

얼마전에, 회사일로 Git의 pack에서 object를 가져오는 기능을 node.js 기반으로 구현하다가 문제를 만났다.

pack은 대략 아래와 같은 포맷으로 구성되어있다. (엉성해서 죄송)

...hhzzzzzzzzzzzzhhzzzzzzzzzzzhhzzzzzzzzz...

(h는 헤더, z는 deflate로 압축된 영역)

pack파일은 git의 object들을 합쳐놓은 파일이므로, 크기가 굉장히 커질 수 있다. 저장소의 모든 object가 pack 파일 하나에 다 들어가는 경우도 얼마든지 있을 수 있다. 따라서 object하나만 얻으면 되는 상황에서 파일 전체를 읽는 것은 비효율적이므로, 원하는 부분만 정확히 읽어들일 필요가 있다.

근데 그게 참 쉽지 않은데, pack에서 object하나를 얻을 때, 어디서부터 읽어야 하는지는 쉽게 알 방법이 존재하지만(인덱스 파일이 있다) 어디까지 읽어야 하는지는 읽어보지 않고서는 모르기 때문이다. 파일을 읽고 압축을 풀어봐야 알 수 있다.

따라서 나는 이 문제를, ReadStream에서 파일을 읽고 InflateStream에게 넘겨준 뒤, 압축해제가 완료되면 파일 읽기를 중단하도록 하면 해결될 것으로 생각했다. 코드는 아래와 같다.

in = require('fs').createReadStream('pack-031a12679260de44c3e123e42d17e5f7803ab016.pack')
inflate = require('zlib').createInflate();
out = process.stdout;

in.pipe(inflate).pipe(out)

그러나 이 코드는 두 가지 이유때문에 의도대로 동작하지 않는다:

  1. InflateStream에서 압축해제가 완료되어도 아무런 이벤트가 발생하지 않는다. 그래서 끝났는지 알 길이 없다.
  2. 압축해제가 완료되었을 때 이벤트가 발생한다 해도, ReadStream에게 더 이상 파일을 읽을 필요가 없음을 알려줄 방법이 없다.

일단 첫번째 문제는 node.js의 zlib 구현에 문제가 있는 것이라 생각했다. 그래서 압축해제가 완료되면(Z_STREAM_END가 리턴되면) end 이벤트(아이작이 close가 아니라 end가 맞다고 조언해주었다)가 발생하도록 고치고 pull request를 보냈다.

두번째 문제는 깔끔한 해결책을 찾지 못했다. 그냥 inflate에서 end 이벤트가 발생하면 input.destory()를 호출해서 강제로 파일 읽기를 중단시켰다.

inflate.on('end', function() {
  input.destroy();
});

이 문제에 대해서는 Nodeconf 2012에서 Stream에 대한 주제로 발표를 했던 Yammer의 Marco Rogers에게도 도움을 요청한 상태이다. 뭔가 깔끔한 해결책이 나와주면 좋겠지만 그렇지 못한다면 그냥 이대로 해야할지도 모르겠다.