- 책 또는 웹사이트의 내용을 복제하여 다른 곳에 게시하는 것을 금지합니다.
- 책 또는 웹사이트의 내용을 발췌, 요약하여 강의 자료, 발표 자료, 블로그 포스팅 등으로 만드는 것을 금지합니다.
자동 확장 가능한 콘서트 티켓 예매 사이트 구축하기
이재홍 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 공유는 허용합니다.