
실무에서 채팅 서버를 처음부터 오픈까지 개발할 기회가 생겨 제목의 기술 스택을 활용해 채팅 서버를 개발했었던 경험을 정리하기 위해 불필요한 내용은 최대한 배제하고 서버 구현과 테스트 그리고 클러스터링과 스케일 아웃 확장 구조까지 적용 해 보겠습니다. (그치만 왠지 길어 질 것만 같네요 ..)
서버, 클라이언트간의 저지연 양방향 통신을 제공해주는 라이브러리입니다.
기본적으로는 한 번 연결이 되면, 더 이상 서버, 클라이언트의 구분이 없이 이벤트 Producer, Listener의 입장이 되며 양방향으로 이벤트를 주고 받을 수 있습니다.
또한 Redis Pub/Sub, Stream, AMQP등을 이용한 Clustering도 쉽게 구현 할 수 있도록 지원해줘서 Socket통신에서 Scale-Out 구조 적용이 매우 간단합니다.(개인적으로 이게 가장 큰 장점이 아닌가 싶습니다.)
제 개발 환경과 사용하는 툴은 아래와 같습니다
OS: MacOS / AppliSilicon
Tool: IntelliJ
Node 버전은 22.11버전으로 진행하겠습니다.
순서는 아래와 같이 진행하겠습니다.
1. Express 프로젝트 생성 및 Typescript 적용
2. Socket.io 적용 및 연동
3. 테스트 페이지 생성 및 정상 작동 여부 테스트
4. AWS EC2에 배포 및 LoadBalancer 적용
5. EC2 AutoScaling 적용
6. 실제 Scale out 테스트
저는 인텔리제이에서 프로젝트를 생성하겠습니다.
![]() | ![]() |
|---|
프로젝트 생성이 완료되었을경우 TypeScript를 적용하겠습니다. 아래 명령어를 입력해 적용해주세요
# typescript 라이브러리 추가
npm install typescript @types/node @types/express ts-node --save-dev
# typescript 설정을 위한 tsconfig.json 생성
npx tsc --init
tsconfig.json이 생성되었다면 내용을 아래처럼 바꿔주세요
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"rootDir": "./",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
저는 최대한 간단하게 하기위해 이렇게 구성했지만, 좀 더 많은 옵션을 주려면 TypeScript 공식문서를 참조해주세요.
Node가 실행하는 파일은 TypeScript파일인 .ts가 아니라 JavaScript파일인 .js 파일이므로 우리가 개발할때는 .ts파일에 TypeScript 문법을 이용해 개발하고, 이 파일들을 TypeScript Compiler에게 .ts파일을 자바스크립트 문법으로 변환 하는 과정이 필요합니다.
근데 이걸 매번 명령어를 입력해서 터미널에 입력하기엔 너무 번거로우므로 이를 npm 커맨드화 시켜 사용하기 위해 package.json을 수정하겠습니다.
package.json의 scripts부분을 변경 해 주세요.
"scripts": {
"start": "node src/bin/www",
"build": "bash -c \"tsc && cp -r package.json .env.prod dist && cp -r src/views dist/src/views\"",
"dev": "nodemon src/bin/www.ts"
},

프로젝트의 각 모듈 구조를 아래처럼 변경하겠습니다.

다음으로 테스트 페이지를 만들겠습니다. views 폴더 내부에 만들어주시고, 파일명으로는 testPage.pug로 짓도록 하겠습니다.
// testPage.pug
html
head
title 테스트 페이지
body
h1 소켓 테스트 페이지입니다.
다음으로 testPage 페이지로 라우팅 시켜줄 라우팅 객체를 만들겠습니다. 이름은 testRouter.ts로 짓도록 하겠습니다.
// testRouter.ts
var express = require('express');
var router = express.Router();
import { RequestHandler } from 'express';
const getTestPage: RequestHandler = (req, res, next) => {
res.render('testPage', {});
};
// http://localhost:3000 으로 접속시 getTestPage에 등록된 라우팅 핸들러 실행
router.get('/', getTestPage)
module.exports = router;
다음으로 url endpoint "/" 로 접근시 testPage로 라우팅 시키기 위해 testRouter를 app.ts에 등록하겠습니다.
// 기존 app.use('/', index); 를 아래 코드로 변경해주세요.
app.use('/', testRouter);
그 후 아까 package.json에 등록한 script로 node를 실행시켜보겠습니다.
npm run dev
그 후 localhost:3000 으로 접근하면

테스트 페이지 렌더링이 잘 되는 것을 볼 수 있습니다.
터미널에 아래 커맨드를 입력 해 주세요
npm install socket.io
저는 HTTP Server와 Socket Server를 별도로 운영할 것 이므로 chat.ts에 소켓 서버 관련된 프로세스를 작성하도록 하겠습니다.
Socket서버가 Listening할 Port의 경우 저는 .env 파일에서 이를 관리하므로 process.env.SOCKET_PORT로 되어있는데요, 만약 사용하지 않으신다면 url을 직접 지정해주세요!

//chat.ts
import { Server } from 'socket.io';
import {ChatEventHandler} from "./chat/ChatEventHandler";
// socket server setup
// 저는 소켓 서버가 리스닝할 포트를 .env 파일에서 이를 관리하므로 아래와 같이 process.env.SOCKET_PORT로 구성했는데요, 사용하지 않으신다면 Number(~~~) 부분은 지우셔도 됩니다.
const chatSocketServer = new Server(Number(process.env.SOCKET_PORT || '5500'), {});
const chatEventHandler = new ChatEventHandler();
// 인증 관련 핸들러 등록
chatEventHandler.setAuthorizeHandler(chatSocketServer);
module.exports = chatSocketServer;

// ChatInterchange - 채팅 관련 이벤트 핸들러 등록 클래스
import { Server } from "socket.io";
export class ChatEventHandler {
// 소켓 연결 전 인증 관련된 프로세스를 처리하는 미들웨어 등록
public setAuthorizeHandler(socketServer: Server): void {
socketServer.use((socket, next) => {
const token: string = socket.handshake.auth.token;
// 원래 암호화를 해야하지만 편의상 PlainText로 token을 사용합니다
if (token == 'token-for-client') {
next();
} else {
next(new Error('Not authorized'));
}
})
}
}
이제 실제 애플리케이션을 실행시키는 파일인 www.ts 파일에 chat.ts 관련 임포트를 진행하겠습니다.
//www.ts
const chatSocketServer = require('../chat');
이후 npm run dev로 node를 실행시켜 정상적으로 서버 실행이 되는지부터 확인하겠습니다.

에러 없이 실행이 잘 되는 것을 확인했으니 이제 본격적으로 메시지를 주고 받는 이벤트를 등록해보겠습니다. chat.ts와 ChatInterchange.ts의 내용을 아래와 같이 변경합니다.
// chat.ts
import { Server } from 'socket.io';
import {ChatInterchange} from "./chat/ChatInterchange";
// socket server setup
// 저는 소켓 서버가 리스닝할 포트를 .env 파일에서 이를 관리하므로 아래와 같이 process.env.SOCKET_PORT로 구성했는데요, 사용하지 않으신다면 Number(~~~) 부분은 지우셔도 됩니다.
const chatSocketServer = new Server(Number(process.env.SOCKET_PORT || '5500'), {});
const chatEventHandler = new ChatInterchange();
// 인증 관련 핸들러 등록
chatEventHandler.setAuthorizeHandler(chatSocketServer);
chatEventHandler.setEventHandlers(chatSocketServer);
module.exports = chatSocketServer;
// ChatInterchange.ts
import {Server, Socket} from "socket.io";
import {LocalRoomHandler} from "./room/impl/LocalRoomHandler";
import {RoomHandler} from "./room/RoomHandler";
import {EntireMsg} from "./message/dto/MessageDto";
export class ChatInterchange {
private readonly roomHandler: RoomHandler;
constructor() {
this.roomHandler = new LocalRoomHandler();
}
public setAuthorizeHandler(socketServer: Server): void {
socketServer.use((socket, next) => {
// 클라이언트에서 받은 roomId로 해당 소켓을 입장시키기 위해 조회
const roomId = socket.handshake.query.roomId;
// 해당 소켓의 유저가 누구인지 알기위해 조회
const userId = socket.handshake.query.userId;
if (!roomId || !userId) {
next(new Error('roomId, userId는 필수 값 입니다.'));
}
// socket.data 객체에 데이터를 저장 (이벤트 핸들러에서 사용)
socket.data.roomId = roomId;
socket.data.userId = userId;
// 접속한 소켓이 클라이언트에서 접속한건지 여부 판단을 위한 토큰 조회
const token: string = socket.handshake.auth.token;
// 원래 암호화를 해야하지만, 테스트 목적으로 간단하게 PlainText로 판단합니다.
if (token == 'token-for-client') {
next();
} else {
// 인증 예외가 발생하면 커넥션 예외 발생
next(new Error('Not authorized'));
}
})
}
/**
* 이벤트 핸들러 등록
* @param socketServer 소켓서버
*/
public setEventHandlers(socketServer: Server): void {
// 소켓 연결 이벤트 발생시 각종 핸들러 등록
socketServer.on('connection', async (socket: Socket) => {
console.log(`socket connected. socket id = ${socket.id}`);
await this.joinRoom(socket);
await this.setEntireMsgHandler(socketServer, socket);
await this.disconnectHandler(socket);
})
}
/**
* 소켓 연결시 query에 담긴 roomId에 해당하는 방에 소켓을 입장시킴
* @param socket 소켓
*/
private async joinRoom(socket: Socket): Promise<void> {
const roomId = socket.data.roomId;
const userId = socket.data.userId;
// 해당 소켓을 roomId에 입장시킴
socket.join(roomId);
// userId로 현재 입장한 roomId를 얻기위해 저장
await this.roomHandler.saveRoomId(userId, roomId);
}
/**
* 클라이언트로부터 전체 메시지 전송 이벤트가 들어올 경우 이를 처리할 핸들러 등록
* @param socketServer 소켓 서버
* @param socket 소켓
*/
private async setEntireMsgHandler(socketServer: Server, socket: Socket): Promise<void> {
socket.on('entireMsg', async (dataJsonStr: string) => {
try {
console.log(dataJsonStr);
// Json 데이터 -> Object 변환
const objData = JSON.parse(dataJsonStr);
// msg 값 없으면 예외
if (!objData.msg) {
throw new Error("메시지는 필수 값 입니다.");
}
// 메시지를 보낸 유저의 id값으로 현재 접속중인 방 아이디 구하기
const roomId = await this.roomHandler.getRoomIdBy(socket.data.userId);
// 접속한 방 정보가 없으면 메시지 안보냄
if (!roomId) {
return;
}
// 클라이언트에 이벤트를 보낼 메시지 데이터 객체 생성
const data: EntireMsg = {
msg: objData.msg,
}
// 해당 방에 메시지 전체 전송
socketServer.in(roomId).emit('entireMsg', JSON.stringify(data))
} catch (error: any) {
console.error(`entireMsg 예외발생`);
await this.handleError(socket, error);
}
})
}
/**
* 예외 발생시 에러 이벤트를 처리하는 핸들러
* @param socket 소켓
* @param err 발생한 에러
*/
private async handleError(socket: Socket, err: any): Promise<void> {
if (!(err instanceof Error)) {
console.error('Error 타입의 매개 변수를 할당 해 주세요.');
}
socket.emit('error', err);
}
/**
* 소켓 연결이 끊어질 경우 이벤트 핸들러
* @param socket 소켓
*/
private async disconnectHandler(socket: Socket) {
socket.on('disconnect', (reason) => {
console.log(`socket disconnected. socket id = ${reason}`);
})
}
}
다음으로는 접속과 채팅 테스트를 위해 testPage.pug를 아래처럼 수정합니다.
// testPage.pug
html
head
title 테스트 페이지
body
h1 소켓 테스트 페이지입니다.
div
input#userId(type="text" name="userId" placeholder="유저 id 입력")
input#roomId(type="text" name="roomId" placeholder="채팅방 번호 입력")
button#connectionButton(onclick="roomIn()") 채팅방 입장
br
br
input#msg(type="text" name="msg" placeholder="메시지 입력")
button#sendMsgButton(onclick="sendMsg()") 메시지 전송
script(src="http://localhost:5500/socket.io/socket.io.js")
script.
const uri = 'http://localhost:5500'
const token = 'token-for-client2'
let socket = null
function roomIn(){
// 소켓이 이미 존재하면 더이상 처리 안함
if (socket) return;
// roomId 조회
const roomId = document.getElementById('roomId').value;
// userId 조회
const userId = document.getElementById('userId').value;
// socket 연결
socket = io(uri, {
transports: ["websocket"],
auth: {
token
},
query: {
roomId,
userId
}
});
let connectSuccess = true;
// 소켓 연결중 에러 이벤트 발생시
socket.on('connect_error', (err) => {
console.log(`socket connection error: ${err}`);
connectSuccess = false;
})
if (connectSuccess) {
console.log(`소켓연결완료`);
}
// 서버에서 entireMsg 이벤트가 오면 어떻게 처리할지에 대한 핸들러 정의
socket.on('entireMsg', async (jsonData) => {
const objData = JSON.parse(jsonData);
console.log(`entireMsg:${objData.msg}`);
})
}
// entireMsg 이벤트를 서버로 전송
function sendMsg() {
if (!socket) alert('소켓 연결을 먼저 해 주세요');
const msg = document.getElementById('msg').value;
const data = {
msg
}
socket.emit('entireMsg', JSON.stringify(data));
document.getElementById('msg').value = '';
}
이제 잘 되는지 확인해보겠습니다.
소켓 연결과 메시지 전송이 잘되는 것을 확인 할 수 있습니다. 그렇다면, 현재 userId=1 유저가 1번 채팅방에 입장해있는 상태인데, 여기서 userId=2 유저가 1번 채팅방에 입장해서 채팅을 해서 서로에게 채팅이 잘 전달 되는지 확인해봐야겠죠?

잘되네요. 이렇게 우리의 로컬PC에서 소켓서버를 열고, 클라이언트가 서버에 접속해서 메시지를 주고받는 것 까지 완료했습니다.
다음으로는 AWS EC2에 우리의 애플리케이션을 실행하고 스케일 아웃 확장이 가능하도록 Clustering과 AutoScaling을 추가하도록 하겠습니다.
클러스터링의 필요성은 스케일 아웃 구조를 적용하기위해 필요하다고 말씀드렸는데요, 왜 필요한지에 대해 간략히 설명드리자면



이때 ALB를 거쳐 데이터가 서버로 전달될때 각각의 상황은 어떨까요?
A서버의 roomId=1에는 처음 입장한 userId=1 유저의 소켓만 있으므로, userId=2 유저가 입장한 방정보에 대한 데이터가 없기때문에 서버 로직상 메시지가 전송되지 않습니다. (만약 서버 코드를 변경해 전송 되도록 한다면, userId=2 유저는 A서버의 roomId=1에 입장한적이 없으므로 userId=1 유저에게만 메시지가 전달됩니다.)
B서버의 roomId=1에는 두번째로 입장한 userId=2에게만 메시지가 전달됩니다.
뭔가 이상하죠?. 결국 A서버와 최초 연결된 사람은 A서버와 연결된 유저들끼리만 채팅이가능하고, B서버와 최초 연결된 사람은 B서버와 연결된 유저들끼리만 채팅이 가능합니다.
또 로드밸런서 특성상 A,B서버 어디로 데이터가 전송될지는 로드밸런서만 알고있다는 것도 문제가 됩니다.(물론 이것은 Sticky 옵션을 사용하면 해결됩니다.)
진짜 이런 문제가 발생하는지 확인하기 위해 A서버는 5500포트, B서버는 6500포트로 실행시킨다음 각각 유저가 접속해서 보내보도록 하겠습니다.

확인 결과 같은 roomId에 접속해서 메시지를 보냈지만, 서로 메시지를 주고 받지는 못하는 것을 볼 수 있습니다.
우리가 만드려는 서비스는 이런 기능을 갖고있는 것이 아닌, 어느 서버와 연결을 하더라도 자기 자신이 입장해 있는 채팅방에 메시지를 전송했을때 각 서버마다 동일한 채팅방에 입장해있는 유저들이 메시지를 전송받는것이므로 이것은 우리가 원하는 동작이 아닙니다.
위에서 말씀드렸듯 Socket.io 라이브러리는 Clustering을 굉장히 편하게 할 수 있도록 지원하는데요, 바로 Adapter라는 기능을 통해 지원합니다. 지원하는 어댑터의 종류는 꽤 많은데요, 자세한 내용은 공식문서를 확인해주세요.
Adapter가 하는 역할이 뭘까요? 아래 이미지를 한번 보겠습니다.

(출처: Socket.io 공식문서)
각 서버들의 Adapter가 연동된 Redis나 다른 미들웨어에 개발자가 신경쓰지 않아도 알아서 등록해뒀다가 우리가 이벤트를 특정 roomId 채팅방에 전송하면 미들웨어에 이를 전파해서 등록된 모든 서버에 메시지가 Broadcasting되어 이벤트를 수신할 수 있도록 해주는 역할입니다.
여기서 중요한건 개발자가 신경을 쓰지 않아도 알아서 등록, 해제등의 라이프사이클을 관리 해준다는 것인데요, 이게 핵심 포인트입니다.
저는 일시적인 연결끊김 복구등에 좀 더 이점이 있는 Redis의 Streams기능을 이용해 Adapter를 구성해보겠습니다.
순서는 아래와 같습니다.
먼저 Adapter 라이브러리를 추가하겠습니다. 아래 커맨드를 터미널에 입력해 추가해줍니다.
npm install @socket.io/redis-streams-adapter redis
다음으로 Redis Client와 Adapter를 적용하기위해 chat.ts의 내용을 아래처럼 변경합니다.
저는 Redis Client가 Redis서버에 접속할 url을 .env 파일에서 이를 관리하므로 아래와 같이 process.env.REDIS_URL로 구성했는데요, 사용하지 않으신다면 직접 url을 지정해주세요!

import { Server } from 'socket.io';
import {ChatInterchange} from "./chat/ChatInterchange";
import { createClient } from "redis";
import { createAdapter } from "@socket.io/redis-streams-adapter";
// Redis client 생성 (Redis Client는 한 채널의 Stream을 열면 다른 용도로는 못 쓰기 때문에 채팅서버에서만 사용하는 고유 클라이언트로 생성합니다)
// 저는 Redis Client가 Redis서버에 접속할 url을 .env 파일에서 이를 관리하므로 아래와 같이 process.env.REDIS_URL로 구성했는데요, 사용하지 않으신다면 직접 url을 지정해주시면 됩니다.
const redisClient = createClient({url: process.env.REDIS_URL});
const createSocketServer = async () => {
try {
// redis 접속
const connectedRedis = await redisClient.connect();
console.log(`redis 접속완료 url:${connectedRedis.options?.url}`)
// socket server setup
const chatSocketServer = new Server(Number(process.env.SOCKET_PORT || '5000'), {
adapter: createAdapter(redisClient)
});
// 이벤트 핸들러 등록 객체 생성
const chatEventHandler = new ChatInterchange();
// 어댑터 이벤트 핸들러 등록
chatEventHandler.setAdapterEvents(chatSocketServer);
// 인증 관련 핸들러 등록
chatEventHandler.setAuthorizeHandler(chatSocketServer);
// 다른 각종 이벤트 핸들러 등록
chatEventHandler.setEventHandlers(chatSocketServer);
}catch (error) {
console.error(`socket server 생성중 예외 발생. ${error}`);
}
}
createSocketServer();
단지 Adapter와 미들웨어를 추가해 소켓서버를 생성할때 어댑터를 등록해주는 것만으로 클러스터링이 끝나게 됩니다. 엄청 간단하죠?
다음으로 Adapter가 잘 동작하는지를 보기위해 ChatInterchange.setAdapterEvents() 메소드를 추가해 각 이벤트별로 핸들러를 등록합니다.
여기서 핸들러를 등록하는 이벤트는 모두 Adapter가 관리하는 이벤트로 각 상황에 따라 이벤트가 발생하게 됩니다.

/**
* Adapter의 라이프사이클 이벤트 핸들러 등록
* @param socketServer 소켓서버
*/
public setAdapterEvents(socketServer: Server): void {
// 해당 소켓 서버의 모든 Path(endPoint)로 접근하는 소켓이 방에 처음 입장하여 방이 생성된 경우 핸들러 등록
socketServer.of("/").adapter.on('create-room', (roomId) => {
console.log(`room ${roomId} was created`);
});
// 해당 소켓 서버의 모든 Path(endPoint)로 접근하는 소켓이 방에 입장할때 핸들러 등록
socketServer.of("/").adapter.on('join-room', (roomId, socketId) => {
console.log(`socketId:${socketId} has joined room ${roomId}`);
});
// 해당 소켓 서버의 모든 Path(endPoint)로 접근하는 소켓이 방에 퇴장할때 핸들러 등록
socketServer.of("/").adapter.on('leave-room', (roomId, socketId) => {
console.log(`socketId:${socketId} has leaved room ${roomId}`);
});
// 해당 소켓 서버의 모든 Path(endPoint)로 접근하는 소켓이 방에서 퇴장하면서 방에 입장한 소켓이 없는 경우 방을 삭제할때 핸들러 등록
socketServer.of("/").adapter.on('delete-room', (roomId) => {
console.log(`room ${roomId} was deleted`);
});
}
자 이제 클러스터링이 잘 되었는지 확인해볼까요? 먼저 각기 다른 포트를 리스닝하는 서버 2대를 만들겠습니다.
A서버 포트정보 = http: 3000, socket: 5500
B서버 포트정보= http: 3300, socket: 6500
그 다음 A서버에 userId=1, B서버에 userId=2 유저를 roomId=1 채팅방에 입장시킨 뒤 메시지를 보내보면

서로 다른서버와 연결이 되었음에도 불구하고 동일한 roomId 채팅방에 입장해 메시지를 주고 받을 수 있게 되었습니다.
서버에서 어댑터는 어떻게 동작되는지 보겠습니다.

우선 소켓이 생성되고 연결된 다음 1번 방을 만든다음, 해당 소켓을 1번 방에 입장시키는것을 볼 수 있습니다.

소켓 연결이 끊어지면 위와 같이 작동하는 것을 볼 수 있습니다.
이렇게 간단한 방법으로 클러스터링을 적용했는데요, 다음은 AWS EC2를 이용해 배포 및 ScaleOut까지 적용해보겠습니다.
EC2 배포시 우리가 구성할 서버 아키텍쳐는 다음과 같습니다.

EC2 - A, B, Redis 서버 까지 인스턴스 3대를 구성해주세요.
블로그 포스팅을 참고해주세요!
생성 전 EC2 - Redis서버에 적용할 보안그룹을 생성해서 적용하겠습니다.


443포트는 편의상 완전 오픈해두고, 6379포트는 redis로 클러스터링할 EC2 보안그룹들만 허용하겠습니다.
이제 세션매니저를 통해 접속해서 redis 서버를 설치하고, config 설정도 해보겠습니다. 아래 명령어를 순서대로 입력해주세요.
sudo su - ec2-user
sudo dnf install redis6 -y
sudo vim /etc/redis6/redis6.conf
이후 vim으로 열린 redis6.conf 파일에 아래 이미지 처럼 bind 값을 변경 해 주세요
저장 후 아래 커맨드를 입력해 redis 서버를 실행하고, 접속해봅니다.
sudo systemctl start redis6

현재 EC2 - A,B 서버는 Private subnet에 있기 때문에 우리가 이들 서버에 ssh접속이 불가능하기 때문에 직접적인 애플리케이션 배포가 불가능합니다.
이 상황에서 배포를 하기 위해선 아래와 같이 몇 가지 배포 방식이 있습니다.
저는 이번엔 간단하게 3번의 S3를 통한 배포로 구성 하도록 하겠습니다.
애플리케이션 배포는 아래 순서대로 진행하겠습니다.
노드는 nvm을 이용해 설치하도록 하겠습니다.
nvm 설치방법
nvm 설치를 완료하신뒤 아래 커맨드를 입력해 22.11버전을 사용해주세요
nvm install 22.11
nvm alias default 22.11
nvm list
node -v
아래 이미지처럼 콘솔에 나오면 정상적으로 설치 된 것 입니다.

이렇게 A,B서버에 모두 설치해주세요.
기존 서버 애플리케이션에선 Redis의 접속을 로컬 PC로 접속했지만 AWS EC2상에서는 EC2 - Redis 인스턴스의 Private IP 주소를 입력해야 접속이 가능하므로 수정해야합니다.

Redis가 설치된 인스턴스의 private ip를 복사한 뒤 chat.ts의 RedisClient를 생성하는 부분의 url을 변경해주세요!

다음으로는 TypeScript Compiler로 .ts 파일을 컴파일해 .js 파일을 만들겠습니다. 아래 명령어를 입력해주세요.
npm run build
아래와 같은 폴더 구조가 생성되면 성공입니다. (만약 따로 설정을 해주시려면 package.json의 scripts의 build 부분을 수정해주시면 됩니다.
(.env.prod는 제가 env파일에 포트와 접속정보를 관리하고있어서 따로 추가한 것 입니다.)

이제 이것을 압축해서 AWS S3에 업로드하겠습니다. 방법은 AWS S3로 이동하셔서 업로드할 Bucket을 만드신 뒤 업로드 해주시면 됩니다.

이제 서버 A,B에 이 압축파일을 다운받겠습니다. 아래 명령어에서 버킷명을 수정하신다음 입력해주세요.
aws s3 cp s3://{bucket명}/{prefix}/dist.zip ./

이제 압축을 해제하겠습니다. 저는 unzip 명령어를 사용해 압축을 해제하겠습니다.
unzip ./dist.zip -d ./

이제 npm을 통해 실행에 필요한 라이브러리를 받고 실행 해 보겠습니다.
cd dist
npm i
npm start
(만약 Error: Cannot find module 'express' 와 같은 에러가 발생한다면 npm i express 를 입력해주세요.)
실행이 잘 되는걸 확인 하셨으면, PM2를 이용해 백그라운드로 배포를 진행하겠습니다.
아래 커맨드를 순서대로 실행 해 주세요.
npm i pm2 -g
{매개변수명}={값} pm2 start npm --name "chatServer" -- run start --prefix /home/ec2-user/dist
pm2 ls

다음으로는 우리의 로컬에서 소켓 연결을 하기 위해 로드밸런서 구성을 하겠습니다.
먼저 대상 그룹을 지정하겠습니다.

그 후 상태 검사 url을 변경 해 줍니다.

우리가 배포한 애플리케이션에는 socket server는 5500 포트를 리스닝하므로 로드밸런서에서 포트포워딩을 5500으로, http server는 3000 포트를 리스닝하므로 healthcheck는 3000포트로 하도록 설정 하겠습니다.
다음은 로드밸런서를 생성하겠습니다. Application Load Balancer를 선택한 뒤 아래 이미지와 같이 설정후 생성해주세요.

다음은 로드밸런서의 보안그룹 설정을 해줄텐데요, 편의상 http를 사용해 통신을 할 예정이므로 80포트를 오픈하겠습니다.

또, 로드밸런서의 상태 검사 트래픽을 인스턴스가 받을 수 있어야 하므로 인스턴스의 보안 그룹에 3000포트와 5500포트를 ALB 보안그룹을 가진 리소스에 한해 오픈하겠습니다

이후 위에서 생성한 대상 그룹의 대상이 아래와 같이 healthy가 나타났는지 확인 해 주세요.
만약 Unhealthy 상태라면 애플리케이션이 정상적으로 실행중인지, 상태검사 url과 포트 설정이 제대로 되었는지, 서버 보안그룹의 포트 오픈 설정 등의 설정을 확인해주세요.
자 이제 클러스터에 접속 해 보겠습니다. 먼저 로드밸런서의 dns를 복사해 testPage.pug의 아래 이미지처럼 uri 부분에 붙여넣어주세요.


다음으로 로컬에서 애플리케이션을 실행해 해당 소켓서버와 연결 한 뒤 메시지를 보내보겠습니다.

정상적으로 작동이 되는 것을 볼 수 있습니다. 만약 한 쪽만 메시지가 전송된다면 배포한 애플리케이션의 Redis연결이 정상적으로 되어있는지 확인해주세요.
이제 로드밸런서도 적용했으니 ScaleOut을 위한 AutoScaling을 적용하겠습니다.
먼저 인스턴스가 실행될때 서버 애플리케이션을 실행시킬 스크립트와 모니터링을 적용하겠습니다.
아래 스크립트를 shell파일로 작성해주세요. 저는 /home/ec2-user/test.sh 파일로작성했습니다.
#!bin/bash
PROCESS_NO=$(pgrep -f "node src/bin/www")
if [ -z "$PROCESS_NO" ]; then
su - ec2-user -c "{환경변수명}={값} pm2 start npm --name chatServer -- run start --prefix /home/ec2-user/dist && pm2 save --force"
fi
작성한 파일의 권한을 수정해주세요.
sudo chmod -R +x {shell 파일명}
다음으로 linux에서 cron대신 서비스와 타이머를 작성해 적용하겠습니다.
sudo vim /etc/systemd/system/monitoring.service
[Unit]
Description=Monitoring Script Service
[Service]
User=ec2-user
ExecStart=/{위에서 만든 shell파일의 경로}/{shell파일명}
sudo vim /etc/systemd/system/monitoring.timer
[Unit]
Description=Run Monitoring Script
[Timer]
OnBootSec=1min # 부팅후 서비스 실행 대기시간
OnCalendar=*-*-* *:*:00 # 서비스 반복 간격
AccuracySec=1s # 시간 단위
[Install]
WantedBy=timers.target
다음은 인스턴스가 시작할때 timer를 실행하도록 구성하겠습니다.
sudo systemctl enable monitoring.timer
sudo systemctl start monitoring.timer
다음으로 EC2 인스턴스 한 대의 AMI를 생성하겠습니다.


(AMI를 생성하실때는 인스턴스 재부팅을 해주시는게 좋습니다.)
잠시 후 AMI 생성이 완료된 뒤 시작 템플릿을 생성합니다.(서브넷은 시작 템플릿에 포함하지 않도록 해주세요)



다음으로 AutoScaling 그룹을 설정합니다.


Subnet은 제가 실행하는 인스턴스 유형인 t2.micro를 이용할 수 있는 AZ가 ap-northeast-2a 뿐이여서 한 곳으로 정했는데, 여러 AZ에서 시작할 수 있도록 설정하는 것이 좋습니다.

아까 만들었던 로드밸런서를 지정해주시고, 상태 확인부분은 현재 간단하게 테스트를 할 목적이므로 Off하겠습니다. 실제 운영 서버라면 켜두는게 좋습니다.

오토스케일링은 최소 2대 최대 3대로 지정해두고 평균 CPU 사용률을 기준으로 ScaleOut, ScaleIn 하도록 구성하겠습니다.
그러면 오토스케일링에 의해 2대의 새로운 인스턴스가 생성됩니다.
생성이 된 다음 위에서 등록한 monitoring.timer에 의해 서버 애플리케이션이 정상적으로 실행되어 로드밸런서의 healthcheck를 통해 정상적으로 트래픽 처리가 가능하도록까지 구성이 되는지 확인하겠습니다.

새로 생성되면서 정상 상태로 전환 까지 확인했습니다. 이제 기존에 만들었던 2대의 인스턴스는 더이상 필요 없으니 삭제하고, 오토스케일링 그룹의 인스턴스만 남겨두겠습니다.
이제 실제로 ScaleOut이 되는지 보기위해 간단하게 부하를 주도록 하겠습니다.
testPage.pug의 내용을 아래와 같이 변경해주세요
//script.
// const uri = 'ws://cicd-pipeline-713279685.ap-northeast-2.elb.amazonaws.com'
// const token = 'token-for-client'
// let socket = null
// function roomIn(){
// // 소켓이 이미 존재하면 더이상 처리 안함
// if (socket) return;
// // roomId 조회
// const roomId = document.getElementById('roomId').value;
// // userId 조회
// const userId = document.getElementById('userId').value;
// // socket 연결
// socket = io(uri, {
// transports: ["websocket"],
// auth: {
// token
// },
// query: {
// roomId,
// userId
// }
// });
// let connectSuccess = true;
// // 소켓 연결중 에러 이벤트 발생시
// socket.on('connect_error', (err) => {
// console.log(`socket connection error: ${err}`);
// connectSuccess = false;
// })
// if (connectSuccess) {
// console.log(`소켓연결완료`);
// }
// // 서버에서 entireMsg 이벤트가 오면 어떻게 처리할지에 대한 핸들러 정의
// socket.on('entireMsg', async (jsonData) => {
// const objData = JSON.parse(jsonData);
// console.log(`entireMsg:${objData.msg}`);
// })
// }
//
// // entireMsg 이벤트를 서버로 전송
// function sendMsg() {
// if (!socket) alert('소켓 연결을 먼저 해 주세요');
// const msg = document.getElementById('msg').value;
// const data = {
// msg
// }
// socket.emit('entireMsg', JSON.stringify(data));
// document.getElementById('msg').value = '';
// }
script.
const uri = 'ws://cicd-pipeline-713279685.ap-northeast-2.elb.amazonaws.com'
const token = 'token-for-client'
let sockets = []
function addSocket(){
for(let i = 0; i < 100; i++) {
console.log(`i=${i}`);
roomIn(i);
}
}
function roomIn(userId){
// 소켓이 10개가 넘어가면 더이상 추가 안함
if (sockets.length > 100) return;
// roomId 조회
const roomId = document.getElementById('roomId').value;
// socket 연결
socket = io(uri, {
transports: ["websocket"],
auth: {
token
},
query: {
roomId,
userId
}
});
let connectSuccess = true;
// 소켓 연결중 에러 이벤트 발생시
socket.on('connect_error', (err) => {
console.log(`socket connection error: ${err}`);
connectSuccess = false;
})
if (connectSuccess) {
console.log(`소켓연결완료. userId:${userId}`);
}
// 서버에서 entireMsg 이벤트가 오면 어떻게 처리할지에 대한 핸들러 정의
// socket.on('entireMsg', async (jsonData) => {
// const objData = JSON.parse(jsonData);
// console.log(`entireMsg:${objData.msg}`);
// });
sockets.push(socket);
}
// entireMsg 이벤트를 연결한 소켓들을 순회하면서 전송
function sendMsg() {
if (sockets.length < 1) alert('소켓 연결을 먼저 해 주세요');
const msg = document.getElementById('msg').value;
const data = {
msg
}
setInterval(() => repeatMsg(data), 500);
}
function repeatMsg(data) {
console.log(`msg발송`);
for (let socket of sockets) {
socket.emit('entireMsg', JSON.stringify(data));
document.getElementById('msg').value = '';
}
}
위 시나리오는 테스트 소켓 10개를 만들어 무한 반복 순회하면서 socket server로 메시지를 보내게 됩니다.
이제 testPage.pug로 이동하여 접속 -> 메시지 전송을 해보겠습니다. (AutoScaling이 실제로 걸릴때까지는 조금 시간이 필요하므로 오랫동안 로직을 유지시켜주세요)
조금 더 빠르게 하기 위해 동적 크기 조정을 변경하겠습니다.

조금 기다리면 우리가 만든 AutoScaling 그룹에서 용량 업데이트로 상태가 변경되며, 조금 기다리면 새로운 EC2 인스턴스가 실행됩니다.


이제 로드밸런서의 대상그룹의 healthcheck가 정상상태로 전환되는지 확인하면 되겠죠?

정상상태로 전환되는 것 까지 확인했습니다. 그리고 부하를 주던 페이지의 개발자도구 로그를 보면?

이상 없이 메시지가 계속해서 전송이되고 있습니다.
실무에서 신규 서비스 런칭을 진행하며 만들었던 채팅 서버 아키텍쳐를 정리해보았습니다.
간단하게 쓰려고해도 상당히 긴 내용이 되었네요.
만들면서 다시한 번 느끼는 거지만 socket.io의 Adapter를 활용해 편하게 클러스터링을 할 수 있는건 정말 아름답다고 생각합니다.
클러스터링을 제가 하나하나 개발했었다면 상당한 공수를 들였어야 했을겁니다.
다만 주의할 점은 node.js는 싱글스레드로 동작하기 때문에 병목을 줄이려 비동기처리를 적극적으로 사용하는 것이 좋습니다만, 이 경우 비동기 프로세스에서 예상치 못한 이슈들이 일어날 수 있기 때문에, 개발시 많은 주의가 필요하다는 점이죠.
다음에 새로운 경험 혹은 깨달음이 생긴다면 포스팅하겠습니다.
긴 글 읽어주셔서 감사합니다. 도움이 되셨으면 좋겠습니다.