Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

Cluster모듈에서 Socket.IO 사용하기

얼마전에 메일로 질문을 받았습니다. 뭐 질문내용은 약간 다른 부분이었지만 질문에 대한 내용을 테스트해보느라고 Socket.IO를 Node.js의 기본모듈인 Cluster와 함께 사용해보았습니다. 제가 실제로 프로덕션레벨로 서비스를 해본 것도 아니고 Node.js는 학습으로만 해왔기 때문에 각 모듈의 기능을 파악했지만 이렇게 함께 사용해 보진 않았었습니다. 책에서도 Cluster모듈만의 사용법만을 다뤘었네요.

Firejune님이 작성하신 Cluster를 이용한 Node.JS의 멀티-코어 서버 관리를 관련된 내용이 약간 나옵니다. 물론 지금은(Node.js v0.6.0 이상) Node.js에 Cluster 모듈이 기본모듈로 추가되었습니다. 서버는 보통 Stateless상태가 좋지만 Socket.IO는 그 특성상 많은 정보를 stateful하게 관리해야 하므로 내부적으로 MemoryStore를 사용해서 상태를 관리하고 있습니다. 이는 Socket.IO를 사용할 때 의식조차 못할 정도로 내부에서 처리가 됩니다. 하지만 스케일-아웃을 위해서 Cluster모듈을 사용하면 상황이 달라집니다. 다음은 간단히 작성한 Cluster와 Socket.IO를 함께 사용한 예제입니다.


// app.js
var express = require('express')
  , routes = require('./routes')
  , cluster = require('cluster')
  , sio = require('socket.io');

var numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
  for (var i = 0; i < numCPUs; i++) {
    cluster.fork();
  }
} else {
  var app = module.exports = express.createServer();

  // Configuration

  app.configure(function(){
    app.set('views', __dirname + '/views');
    app.set('view engine', 'jade');
    app.use(express.bodyParser());
    app.use(express.methodOverride());
    app.use(app.router);
    app.use(express.static(__dirname + '/public'));
  });

  app.configure('development', function(){
    app.use(express.errorHandler({ dumpExceptions: true, showStack: true }));
  });

  app.configure('production', function(){
    app.use(express.errorHandler());
  });

  // Routes

  app.get('/', routes.index);

  app.listen(3000);

  io = sio.listen(app);

  io.configure(function(){
    io.set('log level', 1);
    io.set('transports', [
        'websocket'
      , 'flashsocket'
      , 'htmlfile'
      , 'xhr-polling'
      , 'jsonp-polling'
      ]);
  });

  io.on('connection', function(socket) {
    console.log('connected by process #' + process.env.NODE_WORKER_ID);
    socket.on('message', function(msg) {
      console.log('message processed by process #' + process.env.NODE_WORKER_ID);
      socket.send(msg);
      socket.broadcast.send(msg);
    });
  });
}

express로 기본 생성한 템플릿코드에 Socket.IO를 연결하고 Cluster 모듈을 이용해서 워커에서 동작하도록 작성한 것입니다. Socket.IO와 Cluster의 사용법을 보신적이 있다면 그다지 어렵지 않은 코드이므로 기능 설명은 생략합니다.


// views/layout.jade
!!! 5
html
  head
    meta(http-equiv='Content-Type', content='text/html; charset=UTF-8')
    title Socket.IO Test
    link(rel='stylesheet', href='/stylesheets/style.css')
    script(src='http://code.jquery.com/jquery-1.7.2.min.js')
    script(src='/socket.io/socket.io.js')
  body!= body

layout.jade파일은 HTML5로 변경하고 jquery와 socket.io.js를 불러왔습니다.


// views/index.jade
form
  input(type='text', style='width:400px;')#message
  input(type='submit', value='전송')
script
  $(function() {
    var socket = io.connect();
    socket.on('connect', function() {
      printMessage('connected'); 
    });
    socket.on('message', function(msg) {
      printMessage(msg); 
    });

    $('form').submit(function(e) {
      e.preventDefault();
      socket.send($('#message').val());
      $('#message').val('');
    });

    var contents = $('#contents');
    printMessage = function(msg) {
      contents.append($('<p>').text(msg));
      contents.scrollTop(contents.height());
    }
  });

index.jade는 기존의 코드는 지우고 채팅처럼 간단히 메시지를 표시할 DIV 영역과 input박스를 만들어서 페이지 접속시 Socket.IO 서버에 연결하고 메시지를 주고받을 수 있도록 하였습니다. 이 페이지에 접속한 사람들간에 메세지를 주고 받습니다.

Cluster를 사용하면 보통 Master프로세스를 띄우고 CPU 갯수만큼 워커 프로세스를 실행한 뒤 각 워커프로세스가 실제 웹서버같은 어플리케이션을 실행하게 됩니다. 이는 프로세스가 분리된 것이므로 서로간에 상태공유같은 것을 하지 않고 당연히 MemoryStore도 각 프로세스마다 생성되므로 서로간에 공유가 되지 않습니다. 즉, 워커프로세스를 2개 띄운 경우 처음 접속은 1번 프로세스가 처리했지만 메시지 통신을 했을 때는 2번 프로세스가 받았을 경우 2번 프로세스에는 접속정보가 없으므로 다시 연결을 시도하게 되고 이러한 상황이 계속해서 반복됩니다. 이러한 상황은 다음 화면에서 볼 수 있습니다.(접속했을 때와 메시지를 받았을 때 처리하는 워커프로세스의 번호를 표시하도록 했습니다. 제 맥이 듀얼코어이므로 2개의 워커로 동작합니다.)

Cluster환경에서 Socket.IO사용시 접속이 제대로 안되는 문제

프로세스가 분리되어 있으므로 당연한 얘기입니다. 여러번 테스트를 해보았지만 연결후 메시지통신을 할 때 워커프로세스가 바뀌어서 다시 연결되는 경우도 있고 한번 접속된 워커로 계속 연결되는 경우도 있었습니다. 같은 워커로 접속되었을 경우에 같은 워커에 붙은 다른 사용자와는 대화할 수가 있었지만 다른 워커프로세스의 사용자에게는 메시지가 전달되지 않았습니다.(어떤 워커프로세스에서 실행되는지 확인하기 위해서 로그 메시지로 현재 프로세스 ID를 출력했습니다.) 위의 Firejune님이 얘기하신 것과 마찬가지로 Socket.IO 개발팀은 이 문제를 Redis를 이용해서 외부 스토어로 해결했습니다. 소스는 동일하지만 다음과 같이 store로 RedisStore를 사용하도록 설정합니다.


// app.js
...
io.configure(function(){
  io.set('log level', 1);
  io.set('transports', [
      'websocket'
    , 'flashsocket'
    , 'htmlfile'
    , 'xhr-polling'
    , 'jsonp-polling'
    ]);
  io.set('store', new sio.RedisStore);
});
...


달라진 부분은 io.set('store', new sio.RedisStore); 부분 뿐입니다. 이렇게 Socket.IO가 내부에서 Redis클라이언트 모듈을 포함하고 있기 때문에 이렇게만 설정하면 스토어로 Redis를 사용합니다. 물론 접근할 수 있는 Redis가 있어야 하면 아무런 값도 지정하지 않았으므로 기본값인 127.0.0.1:6379로 접근합니다. 이 정보로 Redis에 접속하지 못하면 다음과 같은 오류가 발생합니다.

Socket.IO 실행시 Redis 접속 오류가 발생한 화면

Redis를 스토어로 사용하면 다음과 같이 다른 워커에 사용자들이 접속했더라도 서로간에 메시지를 잘 주고 받을 수 있습니다.

Socket.IO가 RedisStore로 Cluster환경에서도 정상 동작하는 화면

Socket.IO 내부 소스를 보면 RedisStore는 다음과 같은 옵션을 사용할 수 있습니다.

  • nodeId (fn) : 이 노드를 유일하게 식별할 수 있는 id를 리턴한다
  • redis (fn) : redis 생성자로 기본값은 redis이다
  • redisPub (object) : pub redis client에 전달할 옵션
  • redisSub (object) : sub redis client에 전달할 옵션
  • redisClient (object) : 일반적인 redis client에 전달할 옵션
  • pack (fn) : 커스텀 패킹, 기본값은 메시지팩이 설지되어 있으면 메시지팩이고 그렇지 않으면 JSON이다
  • unpack (fn) : 커스텀 패킹, 기본값은 메시지팩이 설지되어 있으면 메시지팩이고 그렇지 않으면 JSON이다

기본설정대신 다른 Redis에 접속하려면 Redis 클라이언트 객체를 만들어서 전달해야 합니다. 총 3가지가 필요한데 Pub 클라이언트, Sub 클라이언트, 일반용도의 클라이언트 이렇게 세 가지입니다. 제가 Redis는 이번에 처음 써봐서 세부사항은 잘 모르는데 Pub/Sub 클라이언트는 Redis에서 지원하는 리얼타임용 노티피케이션의 역할을 하는 것으로 보입니다. 어떻게 사용하는 지에 대해서는 Socket.IO에 문서화가 거의 되어 있지 않아서(Socket.IO는 문서화가 좀 더 되어야 된다고 생각합니다.) RedisStore and rooms with Socket.IO를 참고했습니다. 임의의 Redis 클라이언트를 사용하려면 다음과 같이 설정합니다.


// app.js
...
  , sio = require('socket.io')
  , redis = require('socket.io/node_modules/redis');

var numCPUs = require('os').cpus().length;

var pub = redis.createClient(8888, "127.0.0.1");
var sub = redis.createClient(8888, "127.0.0.1");
var store = redis.createClient(8888, "127.0.0.1");

if (cluster.isMaster) {
  for (var i = 0; i < numCPUs; i++) {
    cluster.fork();
  }
} else {
  ...
  io.configure(function(){
    ...
    io.set('store', new sio.RedisStore({
        redis: redis
      , redisPub: pub
      , redisSub: sub
      , redisClient: store
    }));
  });
  ...

앞에서 얘기한대로 Pub 클라이언트, Sub 클라이언트, 일반용도의 클라이언트 세개의 Redis 클라이언트가 필요하기 때문에 먼저 redis 모듈을 불러옵니다. redis를 설치할 수 도 있지만 설치된 Socket.IO안에 이미 Redis모듈이 설치되어 있으므로 이를 불러옵니다. Redis서버가 로컬에 8888 포트로 실행되어 있다고 가정하면 위처럼 Redis클라이언트를 세개 생성합니다. 그리고 io.set('store')에서 옵션으로 각 클라이언트를 설정해서 전달해서 사용할 수 있습니다.
2012/03/28 01:57 2012/03/28 01:57