Rev. 2.73

어떠한 데이터라도 받기만 하면 비주얼라이제이션하는 데에는 자신있다고 자부해 왔습니다. 그러나 며칠간 고민에 빠져들게 하는 과제가 하나 생겼습니다. 그것은 바로 '/dir1/dir2/file.name'과 같은 형식으로 반환되는 데이터들에 대한 디렉터리 구조를 표현하는 것이었습니다. 언뜻 보기에는 굉장히 쉬워보였습니다만, 막상 작업에 돌입해 보니 그리 만만한 프로그래밍이 아니구나라는 생각이 들어 그 해결과정을 이곳에 기록합니다. 사실, 예전에도 이와 유사한 고민을 앓았던 적이 있었는데 서버-사이드에서 처리해야 할 문제였으므로 다른 분에게 부탁하고 기억에서 지웠던 일이 고스라니 돌아온 상황이네요.(임대리 난 이렇게 해결했어!)

app.get('/files/:container?', function(req, res) {
  var swift = users[req.session.name]
    , container = req.params.container;

  if (!swift)
    return res.redirect('/login');

  swift.listObjects(container, function(err, result) {
    var objects = serialize(JSON.parse(result.body));
    if (req.xhr)
      res.partial('partial/objects', {
          container: container
        , objects: objects
      });
    else
      res.render('files', {
          layout: 'layout/main'
        , view: 'files'
        , container: container
        , objects:objects
      });
  });
});

위 코드는 Node.JSExpress 프레임웤을 이용하여 진행중인 OpenStack Object Storage(이하 Swift)의 웹 클라이언트 프로젝트에서 라우팅 룰의 일부분입니다. 컨테이너 이름을 URL로 받아 Swift에 질의하고 그 결과를 HTML로 응답하는 일을 합니다. 그리고 xhr요청을 구분하여 문서의 일부만 그릴 것인지 문서 전체를 그릴 것인지를 구분하는 조건이 있습니다. 이 때 Swift는 다음과 같은 JSON형식의 데이터를 돌려줍니다.(지면 관계상 이 처리과정과 무관한 hash, bytes, last_modified 속성들은 생략했습니다.)

[
  {"name": "README.chromium", "content_type": "application/octet-stream"},
  {"name": "buildinf.h", "content_type": "text/x-chdr"},
  {"name": "config", "content_type": "application/directory"},
  {"name": "config/k8", "content_type": "application/directory"},
  {"name": "config/k8/openssl", "content_type": "application/directory"},
  {"name": "config/k8/openssl/opensslconf.h", "content_type": "text/x-chdr"},
  {"name": "config/piii", "content_type": "application/directory"},
  {"name": "config/piii/openssl", "content_type": "application/directory"},
  {"name": "config/piii/openssl/opensslconf-posix.h", "content_type": "text/x-chdr"},
  {"name": "config/piii/openssl/opensslconf-win32.h", "content_type": "text/x-chdr"},
  {"name": "config/piii/openssl/opensslconf.h", "content_type": "text/x-chdr"},
  {"name": "openssl.gyp", "content_type": "application/octet-stream"},
  {"name": "patches", "content_type": "application/directory"},
  {"name": "patches/handshake_cutthrough.patch", "content_type": "text/x-diff"},
  {"name": "patches/missing_stddef.patch", "content_type": "text/x-diff"},
  {"name": "patches/next_proto_neg.patch", "content_type": "text/x-diff"},
  {"name": "patches/posix_c_source.patch", "content_type": "text/x-diff"},
  {"name": "patches/snap_start.patch", "content_type": "text/x-diff"}
]

데이터(오브젝트)들을 잘 살펴보면 "name"속성에는 일반적으로 파일명이 있지만 경로만 있거나 경로명을 포함한 파일명도 있습니다. 그리고 컨텐츠-타입으로 구분할 수 있도록 되어있습니다. 이것은 오브젝트들이 계층구조를 가질수 있게하는 Swift의 Pseudo-Hierarchical Folders/Directories API입니다. 이렇게 수신된 데이터들은 serialize 함수에 의해 최초로 가공됩니다.

function serialize(objects) {
  var folders = []
    , files = [];

  for (var i = 0; i < objects.length; i++) {
    var object = objects[i]
      , depth = object.name.split('/');

    if (object.content_type == 'application/directory' || depth.length > 1) {
      object.pseudo_path = object.name;
      object.name = depth[depth.length - 1];

      for (var j = 0; j < depth.length; j++) {
        object.parent = depth[j - 1] || null;
        if (!folders[j]) folders[j] = {};
        if (!folders[j][depth[j]]) folders[j][depth[j]] = object;
      }
    } else
      files.push(object);
  }

  folders = hierarchy(folders);
  if (!folders.length) folders = [];

  return folders.concat(files);
}

오브젝트들의 반복문에서는 크게 파일과 폴더를 구분합니다. 폴더는 슬래시('/')를 구분자로 나누고 폴더의 깊이를 알아내어 배열을 만듭니다. 이 배열의 값으로는 key-value 형식으로 오브젝트를 담아둡니다. 이 때 오브젝트에는 약간의 변화가 발생하는데 부모(상위 디렉터리)는 누구인지, 그리고 name속성은 디스플레이에서 사용될 값으로 교체하고 새로운 pseudo_path 속성에 실제 name값을 백업합니다. 이것으로 1차 가공을 완료했습니다.

[
  // depth 1
  {
    "config": {name: "config", content_type: "application/directory", pseudo_path: "config", parent: null},
    "patches": {name: "patches", content_type: "application/directory", pseudo_path: "patches", parent: null}
  },
  
  // depth 2
  {
    "k8": {name: "k8", content_type: "application/directory", pseudo_path: "config/k8", parent: "config"},
    "piii": {name: "piii", content_type: "application/directory", pseudo_path: "config/piii", parent: "config"},
    "handshake_cutthrough.patch": {name: "handshake_cutthrough.patch", content_type: "text/x-diff", pseudo_path: "patches/handshake_cutthrough.patch", parent: "patches"},
    "missing_stddef.patch": {name: "missing_stddef.patch", content_type: "text/x-diff", pseudo_path: "patches/missing_stddef.patch", parent: "patches"},
    "next_proto_neg.patch": {name: "next_proto_neg.patch", content_type: "text/x-diff", pseudo_path: "patches/next_proto_neg.patch", parent: "patches"},
    "posix_c_source.patch": {name: "posix_c_source.patch", content_type: "text/x-diff", pseudo_path: "patches/posix_c_source.patch", parent: "patches"},
    "snap_start.patch": {name: "snap_start.patch", content_type: "text/x-diff", pseudo_path: "patches/snap_start.patch", parent: "patches"}
  },
  
  // depth 3
  {
    "openssl": {name: "openssl", content_type: "application/directory", pseudo_path: "config/k8/openssl", parent: "k8"}
  },
  
  // depth 4
  {
    "opensslconf.h": {name: "opensslconf.h", content_type: "text/x-chdr", pseudo_path: "config/k8/openssl/opensslconf.h", parent: "openssl"},
    "opensslconf-posix.h": {name: "opensslconf-posix.h", content_type: "text/x-chdr", pseudo_path: "config/piii/openssl/opensslconf-posix.h", parent: "openssl"},
    "opensslconf-win32.h": {name: "opensslconf-win32.h", content_type: "text/x-chdr", pseudo_path: "config/piii/openssl/opensslconf-win32.h", parent: "openssl"}
  }
]

위와 같이 1차적으로 가공을 마친 폴더 데이터를 hierarchy함수에서 2차 가공을 진행합니다. 2차 가공에서는 배열에 속해있는 오브젝트들을 계층구조로 만드는 일을 수행합니다. 배열의 뒤쪽에서부터 순차적으로 처리하며, 부모(parent)가 누구냐라는 근거로 자식들(childs) 배열을 만들어 냅니다. hierarchy함수는 디렉터리의 깊이 단위로 반복해서 자신을 호출하는 재귀함수이며 마지막 처리시점(부모가 더이상 존재하지 않는 시점)에서 완전이 가공된 데이터를 돌려줍니다.

function hierarchy(arr, childs) {
  var item = arr.pop()
    , parents = {};

  for (var name in item) {
    var parent = item[name].parent || '_root_';
    if (!parents[parent]) parents[parent] = [];
    if (!item[name].childs && childs)
      for (var key in childs)
        if (key == name)
          item[name].childs = childs[key];

    parents[parent].push(item[name]);
    delete item[name].parent;
  }

  if (arr.length) parents = hierarchy(arr, parents);
  return parents['_root_'] || parents;
}

hierarchy함수에 의해 2차적으로 가공된 데이터는 다음과 같습니다. 드디어 궁극적으로 원했던 계층형 데이터 컬렉션이 완성되었습니다. serialize함수는 최종 폴더 데이터와 파일 데이터와 병합하여 돌려줍니다.

[{
  name: "config",
  content_type: "application/directory",
  pseudo_path: "config"
  childs: [{
    name: "k8",
    content_type: "application/directory",
    pseudo_path: "config/k8"
    childs: [{
      name: "openssl",
      content_type: "application/directory",
      pseudo_path: "config/k8/openssl"
      childs: [{
        name: "opensslconf-win32.h",
        content_type: "text/x-chdr",
        pseudo_path: "config/piii/openssl/opensslconf-win32.h"
      }, {
        name: "opensslconf-posix.h",
        content_type: "text/x-chdr",
        pseudo_path: "config/piii/openssl/opensslconf-posix.h"
      }, {
        name: "opensslconf.h",
        content_type: "text/x-chdr",
        pseudo_path: "config/k8/openssl/opensslconf.h"
      }]
    }]
  }, {
    name: "piii",
    content_type: "application/directory",
    pseudo_path: "config/piii"
  }]
}, {
  name: "patches",
  content_type: "application/directory",
  pseudo_path: "patches",
  childs: [{
    name: "snap_start.patch",
    content_type: "text/x-diff",
    pseudo_path: "patches/snap_start.patch"
  }, {
    name: "posix_c_source.patch",
    content_type: "text/x-diff",
    pseudo_path: "patches/posix_c_source.patch"
  }, {
    name: "next_proto_neg.patch",
    content_type: "text/x-diff",
    pseudo_path: "patches/next_proto_neg.patch"
  }, {
    name: "missing_stddef.patch",
    content_type: "text/x-diff",
    pseudo_path: "patches/missing_stddef.patch"
  }, {
    name: "handshake_cutthrough.patch",
    content_type: "text/x-diff",
    pseudo_path: "patches/handshake_cutthrough.patch"
  }]
}]

이제 이 데이터를 뷰-파셜에 그냥 컬렉션으로 전달해 주기만 하면 됩니다. 다음은 컨텐츠 영역에 해당하는 내용을 담은 뷰 파일입니다. 참고로 템프릿 엔진으로는 EJS가 사용되고 있습니다.

<ul class="section objects" id="objects">
  <li class="header">
    <span class="name active"><a href="#refresh">File Name <em>asc</em></a></span>
    <span class="when"><a href="#refresh">Modified <em>asc</em></a></span>
    <span class="size"><a href="#refresh">Size <em>asc</em></a></span>
  </li>

  <%- partial('partial/object', objects) %>

  <li class="actions">
    <a class="button upload">Upload</a>
    <em class="message">Drop files here to upload</em>
    <a class="button action" href="#refresh">Refresh</a>
    <div class="progress" style="display: none;">
      <span id="bar" class="bar" style="width: 0%;"></span>
      <span id="state" class="state"></span>
    </div>
  </li>
</ul>

그리고 컬렉션 단위로 그려질 뷰 파셜입니다. 여기에서 하나의 조문이 있는데 만약에 자식이 있다면 뷰 자신에게 자신을 다시 파셜하라는 명령을 수행합니다.

<li class="object">
  <p>
    <span class="name">
      <a href="<%= object.content_type == 'application/directory' ? '#open' : '/store/' + container + '/' + (object.pseudo_path || object.name) %>" target="_blank"><%- object.name %></a>
    </span>
    <span class="when"><%- object.when %></span>
    <span class="size"><%- object.size %></span>
  </p>
  <% if (object.childs) { %>
  <ul class="tree">
    <%- partial('partial/object', object.childs) %>
  </ul>
  <% } %>
</li>

이리하여 얻어진 결과물은 다음과 같습니다.

tree.png

Comments

요즘 OpenStack Object Storage(Swift)를 가지고 놀고있습니다. Swift는 아마존(Amazon)의 S3 서비스와 유사한 오픈소스화 된 오브젝트 스토리지 서비스이며 최근 주목받는 클라우드 솔루션입니다. 두드러진 특징은 ReSTful API를 제공한다는 것입니다. 그래서 웹서버를 통하지 않고 클라이언트(예를 들면 웹브라우저 또는 모바일 애플리케이션)에서 직접 접근하여 관리할 수 있는 인터페이스를 제공합니다. Authentication(인증)과 Container/Object로 이루어진 간단한 계층 구조를 가지며 동일한 URI에 GET, POST, PUT, DELETE 등의 HTTP 메서드를 이용하여 행동을 구분하고, 요청 헤더(Request Header)를 조작하여 인증을 처리합니다. Swift에 대한 소개는 이즘에서 마치고 본론으로 들어가겠습니다.

일단 Node.JS를 이용하여 실험용으로 Swift기반 클라우드 데이터 스토어 클라이언트를 개발하고 있습니다. 클라이언트에서 직접 접근하는 방법과 웹서버를 통하는 두가지 방법 중 후자를 선행하기로 한 것입니다. 전자의 경우 인증 계정과 키를 클라이언트가 관리하게 되는데 웹이라는 특성상 보안 취약점이 발생할 수 있다는 위험성이 있으며, 만약, 클라이언트가 웹브라우저라면 헤더를 조작하는 수단이 천상 Ajax로 날리는 수 밖에 없는데 응답이 바이너리여서 뭔가 엄청난 시련이 다가올 것 같은 기운이 엄습했기 때문이었죠. 기가-바이트급 Base64 스트링을 자바스크립트로 처리한다고 생각해 보세요.(헐~) 후자는 파일을 송/수신하는 과정에 웹서버를 거치기 때문에 불필요한 I/O가 발생하여 성능이 떨어질 것을 감안하고 작업에 착수했습니만, 그 예상은 하루만에 뒤집혔습니다. Node.JS에서 파일 전송을 매우 효율적으로 처리할 수 있는 방법을 발견하여 이곳에 공유합니다.

다음 자바스크립트 코드는 서버-사이드에서 작동하는 코드로 HTTPS 프로토콜을 사용하는 Swift 서비스의 REST에 접근하도록 만들어진 공통 요청 함수입니다. 이 함수는 swift라 명명한 모듈에 포함되어 있습니다. 첫 번째 인자에서 HTTPS 요청에 필요한 옵션들을, 두 번째 인자는 HTTPS 요청에 대한 결과를 돌려주는 콜백을, 세 번째 인자는 생략 가능한 응답 객체입니다.

function request(options, callback, pipe) {
  options = extend({
      host: this.options.host
    , port: this.options.port
    , path: '/auth/v1.0'
    , method: 'GET'
    , headers: {
        'X-Auth-Token': this.token
      , 'X-Storage-Token': this.token
    }
  }, options);

  options.headers['User-Agent'] = 'Node.JS Swift API Client';
  options.path = encodeURI(options.path);

  var client = https.request(options, function(res) {
    var buffers = [];
    if (pipe) {
      pipe.header('Content-Length', res.headers['content-length']);
      pipe.header('Content-Type', res.headers['content-type']);
    }

    res.on('data', function(buffer) {
      if (pipe) pipe.write(buffer);
      else buffers.push(buffer);
    });

    res.on('end', function(err){
      buffers.length && clog.info(res.statusCode, res.headers);
      res.body = buffers.join('');
      callback && callback(null, res);
    });
  });

  client.on('error', function(err) {
    callback && callback(err);
    client.end(err);
  });

  client.end();
}

소스가 전문이 아니기 때문에 세 번째 인자로 받은 응답 객체에만 집중하겠습니다. 이 객체는 Node.JS에서 구축한 HTTP 웹서버의 응답 객체를 그대로 전달한 것입니다. 아래의 코드는 Express를 이용하여 구성한 라우팅 룰들 중에서 클라이언트의 파일 다운로드 요청을 처리하는 부분입니다.

var swift = new Swift({
    user: 'auth-user'
  , pass: 'auth-key'
  , host: 'auth.api.yourcloud.com'
  , port: 443
});

...

app.get('/store/:container/:object', function(req, res){
  var container = req.params.container
    , object = req.params.object;

  request.call(swift, {
    path: '/v1.0/' + swift.account + '/' + container + '/' + object
  }, function(result, headers) {

    ...

    res.end()
  }, res);
});

Express로 구성된 웹서버(80 포트)는 클라이언트가 요청한 정보와 인스턴스로 생성된 인증정보를 조합하여 Swift 서버(443 포트)로 다시 요청합니다. 이 처리 과정은 마치 두 부분으로 나뉜듯 보이지만 비동기로 짬뽕이 된 단 하나의 사이클이라는 사실을 기억하세요. 자, 이제 request 함수에서 받는 세 번째 인자의 흐름을 유심히 살펴 봅시다. 세 번째 인자가 있는 경우 Swift 서버로 요청한 바이너리 데이터를 수신한 만큼의 버퍼가 실시간으로 app.get의 응답객체(res)에 전달됩니다. 풀어서 말하면, 스토리지 서버에서 지금 막 내려온 청크(Chunk) 데이터를 받는 즉시 그냥 클라이언트 응답에 막 싸질러 버리는 거에요. 이 방법으로 바이너리 데이터를 가장 효율적이면서도 신속하게 클라이언트에 전달할 수 있었습니다. 기가-바이트급 데이터를 다운로드했는데 클릭과 동시에 속 시원하게 다운로드 게이지가 차더군요.

Node.JS에 또 한번 감동먹는 순간이었습니다. 졸라 간단하지 않나요? 파일 서버에서 받은 바이너리를 메모리에 캐시하는 동안 버퍼가 쌓이면 다시 스트림으로 얻어낸 청크 데이터를 클라이언트로 내려주는 파이프라인 로직을 어거지로 구현한 하루 분량의 소스 코드를 스스로 삭제하면서 세상 참 허무하다는 생각을 했습니다. 여기에는 Swift를 예로 들었지만 웹서버에서 다른 스토리지 서버에 위치한 바이너리 데이터를 클라이언트에 전달할 때에도 유용하게 사용될 수 있을 것입니다. 그리고 업로드 또한 이와 유사한 로직으로 구현할 계획인데 잘 될지 모르겠네요.

Comments

크로스-브라우저 이슈를 가지각색으로 안고있는 스타일시트를 다루는 작업은 클라이언트-사이드 개발자들을 매우 피곤하게 합니다. 코드를 수정하고 여러 브라우저에서 확인하고 다시 수정하고 확인하는 테스트 기반 코딩이 이루어지는 것이 보통입니다. 이러한 노고를 잘 알고있는 Andrew Davey씨는 이와 같이 더딘 개발 프로세스를 확실하게 개선시켜주는 Vogue를 만들었습니다.

Vogue는 현재 작업중인 HTML 페이지에 로드되어있는 스타일시트를 자동으로 감지하고 감시합니다. 그리고 감시 대상이 된 파일에 변동사항이 발견되면 브라우저의 새로고침 없이 해당 파일만 갱신하여 즉시 화면에 반영해 줍니다. 활용예를 들자면, IE9, 파이어폭스 그리고 크롬에 작업중인 페이지를 열어놓고 자신이 선호하는 편집기에서 스타일시트를 코딩하고 저장합니다. 늘 그랬듯이 각 브라우저별로 변경 내용을 확인하려는 순간 헉! 화면에는 방금 코딩했던 내용이 "새로고침"을 하지도 않았는데 이미 반영되어 있는 마법과도 같은 일이 발생합니다.

이를 조금더 활용하자면, 메인 모니터에는 편집기를, 서브 모니터에는 브라우저들을 한눈에 보이게 배치하합니다. 편집기에서 스타일시트를 코딩하여 저장하는 순간 모든 브라우저들이 조금전의 변동내역을 즉시 반영하는 상황을 경험할 수 있습니다. 뻥 아녜요; 이 2분짜리 스크린캐스트를 확인해 보세요.

설치 및 사용방법은 간단합니다. Vogue는 Node.JS로 구축된 실행형 모듈이며, Socket.IO 모듈에 기반하고 있습니다. 다음과 같이 npm을 사용하여 전역으로 설치합니다.

# npm install vogue -g
vogue@0.4.2 /usr/local/lib/node_modules/vogue
├── parseopt@1.0.0-2
└── socket.io@0.7.11

이제 작업중인 프로젝트의 경로(스타일시트 경로와 일치하는)로 이동하여 Vogue를 실행합니다.

# cd ./my-project
# vogue
Watching directory: /var/www/my-project
Listening for clients: http://localhost:8001/

끝으로, 스타일시트가 반영된 HTML파일에 아래와 같은 코드를 추가합니다.

<script src="http://localhost:8001/vogue-client.js" type="text/javascript"></script>

위 스크립트 태그를 추가하는 것 대신에 북마크릿과 같은 형태로 사용해도 무방합니다. 자, 이제 모든 준비를 마쳤습니다. 브라우저들에 작업중인 페이지를 열고 스타일스트 작업을 시작하세요! 작업 능률이 팍팍 오르겠지요?

Comments