자동 확장 가능한 콘서트 티켓 예매 사이트 구축하기

이재홍 http://www.pyrasis.com 2014.03.24 ~ 2014.06.30

웹 서버 및 사이트 내용 둘러보기

웹 서버의 내용을 살펴보겠습니다. 먼저 Node.js의 express 모듈로 80번 포트에 웹 서버를 실행합니다. 그리고 express에 socket.io를 연결합니다. 다음 내용은 예제 코드에서 express, socket.io 부분만 요약하였습니다.

var express = require('express')
...
  , http = require('http')
...
  , app = express()
...
  , server = http.createServer(app)
  , io = require('socket.io').listen(server);
...
server.listen(80);

서버 기본 설정 및 정의입니다.

  • ElastiCache 캐시 노드 엔드포인트 주소(Redis), RDS DB 인스턴스 엔드포인트 주소(MySQL)와 연결 설정은 여러분들이 생성한 AWS 리소스의 정보를 입력합니다.
  • Publisher용 Redis 클라이언트와 Subscriber용 Redis 클라이언트를 따로 생성합니다.
  • 좌석 정보 테이블을 정의하고, 테이블을 생성합니다.
var redisEndpoint = {
  host: 'exampleticket.o5nouc.0001.apne1.cache.amazonaws.com',
  port: 6379
};
var rdsEndpoint = {
  host: 'exampleticket.cnlconsezo7y.ap-northeast-1.rds.amazonaws.com',
  port: 3306
};

// Redis Pub/Sub
var publisher = redis.createClient(redisEndpoint.port, redisEndpoint.host);
var subscriber = redis.createClient(redisEndpoint.port, redisEndpoint.host);

// MySQL DB 이름, 계정, 암호
var sequelize = new Sequelize('exampleticket', 'admin', 'adminpassword', {
  host: rdsEndpoint.host,
  port: rdsEndpoint.port,
  maxConcurrentQuries: 1024,
  logging: false
});

// MySQL DB 테이블 정의
var Seat = sequelize.define('Seat', {
  seatId: { type: Sequelize.STRING, allowNull: false, unique: true },
  actionType: { type: Sequelize.STRING, allowNull: false },
  userId: Sequelize.STRING
});

// MySQL DB 테이블 생성
sequelize.sync();

/, /index.html에 GET 메서드로 접속했을 때 index.html 파일을 출력합니다.

app.get(['/', '/index.html'], function (req, res) {
  fs.readFile('./index.html', function (err, data) {
    res.contentType('text/html');
    res.send(data);
  });
});

/seats에 GET 메서드로 접속했을 때 좌석 예약, 결제 상태를 출력합니다.

  • Sequelize 모듈로 MySQL에서 취소된 좌석을 제외한 좌석 정보를 가져온 뒤 배열 형태로 출력합니다.
  • CloudFront에서 좌석 예약, 결제 상태를 캐시하지 않도록 HTTP 헤더에 Cache-Control을 설정합니다. 이 부분을 설정하지 않으면 매번 고정된 내용을 가져오게 되므로 주의합니다.
// 좌석 예약, 결제 상태 출력
app.get('/seats', function (req, res) {
  Seat.findAll({
    where: { actionType: { ne: 'cancel' } }
  }).success(function (seats) {
    var data = [];
    seats.map(function (seat) { return seat.values; }).forEach(function (e) {
      seat = e.seatId.split('-');
      data.push({
        row: seat[0],
        col: seat[1],
        actionType: e.actionType,
        userId: e.userId
      });
    });
    res.header('Cache-Control', 'max-age=0, s-maxage=0, public');
    res.send(data);
  });
});

/ip에 GET 메서드로 접속했을 때 EC2 인스턴스의 IP 주소를 출력합니다.

  • 웹 브라우저에서 ELB 로드 밸런서를 통하지 않고, socket.io에 직접 접속할 수 있도록 합니다.
  • ELB 로드 밸런서를 경유하면 WebSocket 프로토콜의 Upgrade Handshake 동작이 실패하기 때문에 EC2 인스턴스의 socket.io에 직접 연결합니다.
  • 만약 ELB 로드 밸런서를 경유하여 socket.io에 연결하면 WebSocket 프로토콜을 사용할 수 없고, xhr-polling 또는 jsonp-polling 방식을 사용하게 됩니다. 그리고 60초마다 한번씩 연결이 끊어집니다.
  • ec2metadata 모듈을 사용하여 현재 EC2 인스턴스의 IP 주소를 얻어옵니다. 접속할 때마다 매번 IP 주소를 얻지 않도록 처리합니다.
  • CloudFront에서 IP 주소를 캐시하지 않도록 HTTP 헤더에 Cache-Control을 설정합니다. 이 부분을 설정하지 않으면 모든 사용자가 동일한 EC2 인스턴스에 접속하게 되고, 부하 분산이 되지 않으므로 주의합니다.
// socket.io에 접속할 IP 주소 전달
app.get('/ip', function (req, res) {
  res.header('Cache-Control', 'max-age=0, s-maxage=0, public');
  if (!ipAddress) {
    EC2Metadata.get(['public-ipv4'], function (err, data) {
      ipAddress = data.publicIpv4;
      res.send(ipAddress);
    });
  }
  else {
    res.send(ipAddress);
  }
});

socket.io에서 웹 브라우저가 보내는 action 이벤트를 처리합니다.

  • 클라이언트에서 요청한 좌석 정보를 MySQL에서 가져온 뒤 정보가 없을 때, 유저 ID가 같을 때, 좌석 상태가 취소일 때 MySQL에 새로운 좌석 정보를 업데이트(save 함수)합니다.
  • MySQL에 새로운 좌석 정보 업데이트가 끝나면 Redis의 seat 채널에 메시지를 보냅니다(publish 함수).
  • 메시지 내용은 JSON 형태로 된 새로운 좌석 정보입니다.
// 좌석 예약, 결제 처리
io.sockets.on('connection', function (socket) {
  socket.on('action', function (data) {
    Seat.find({
      where: { seatId: data.row + '-' + data.col }
    }).success(function (seat) {
      if (seat == null ||
          seat.userId == data.userId ||
          seat.actionType == 'cancel') {
        
        if (seat == null)
          seat = Seat.build();
        seat.seatId = data.row + '-' + data.col;
        seat.userId = data.userId;
        seat.actionType = data.actionType;
        seat.save().success(function () {
          publisher.publish('seat', JSON.stringify(data));
        });
      }
    });
  });
});

Redis의 seat 채널의 메시지를 받습니다(subscribe 함수).

  • 메시지가 올 때마다 socket.io에 연결된 모든 클라이언트에 새로운 좌석 정보를 보냅니다.
// 실시간으로 좌석 상태 개신
subscriber.subscribe('seat');
subscriber.on('message', function (channel, message) {
  io.sockets.emit('result', JSON.parse(message));
});

Node.js와 console.log 함수
Node.js에서 특정 동작 때마다 매번 console.log 함수로 로그를 출력하면 성능이 매우 떨어집니다. 따라서 동시 접속자 수가 적어지므로 주의해야 합니다.

console.log 함수는 꼭 필요한 정보나 에러가 발생했을 때만 사용합니다.

이번에는 웹 브라우저에 표시될 사이트 내용을 살펴보겠습니다.

트래픽을 줄이기 위해 CDN의 jQuery, Bootstrap CSS와 JavaScript를 사용합니다. socket.io에 접속하기 위해서는 반드시 /socket.io/socket.io.js를 사용해야 합니다.

<head>
  <title>ExampleTicket</title>
  <link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css">
  
  <script src="//ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
  <script src="//netdna.bootstrapcdn.com/bootstrap/3.1.1/js/bootstrap.min.js"></script>
  <script src="/socket.io/socket.io.js"></script>
</head>

prompt 함수로 ID를 입력받습니다. 시연이 목적이므로 로그인 과정을 간략화하였습니다.

var userId = prompt('ID를 입력하세요', '');

처음 페이지가 열렸을 때 /seats에 접속하여 좌석 정보를 가져온 뒤 좌석의 색상을 변경합니다.

$.getJSON('/seats', function (data) {
  $.each(data, function (i, e) {
    updateSeat(e);
  });
});

처음 페이지가 열렸을 때 /ip에 접속하여 IP 주소를 가져온 뒤 EC2 인스턴스의 socket.io에 직접 접속합니다.

  • 좌석을 클릭하면 socket.io로 서버에 action 이벤트를 보내서 예약(reserve) 상태로 만듭니다.
  • 서버에서 받은 result 이벤트 내용에서 userId가 같고, 예약 상태일 때 결제 창을 출력합니다(좌석을 클릭한 상태에서만 결제 창을 출력해야 하기 때문에 currentSeat을 검사합니다).
  • 결제 버튼을 클릭하면 socket.io로 서버에 action 이벤트를 보내서 결제(pay) 상태로 만듭니다.
  • 취소 버튼을 클릭하면 socket.io로 서버에 action 이벤트를 보내서 취소(cancel) 상태로 만듭니다.
  • 서버에서 result 이벤트를 받은 뒤 좌석의 상태에 따라 색상을 변경합니다.
$.get('/ip', function (ip) {
  socket = io.connect(ip);
  socket.on('connect', function () {
    $('.seat').click(function () {
      if ($(this).hasClass('btn-warning') || $(this).hasClass('btn-success'))
        return;

      currentSeat = $(this);
      action($(this), 'reserve');
    });

    $('#pay').click(function () {
      action(currentSeat, 'pay');
      currentSeat = null;
    });

    $('#cancel').click(function () {
      action(currentSeat, 'cancel');
      currentSeat = null;
    });

    socket.on('result', function (data) {
      if (currentSeat && data.userId == userId && data.actionType == 'reserve')
        openDialog(currentSeat);
      updateSeat(data);
    });
  });
});

좌석은 반복되는 코드를 피하기 위해 코드를 작성하여 그렸습니다.


저작권 안내

이 웹사이트에 게시된 모든 글의 무단 복제 및 도용을 금지합니다.
  • 블로그, 게시판 등에 퍼가는 것을 금지합니다.
  • 비공개 포스트에 퍼가는 것을 금지합니다.
  • 글 내용, 그림을 발췌 및 요약하는 것을 금지합니다.
  • 링크 및 SNS 공유는 허용합니다.