본문 바로가기
웹/BE

확장가능한 socket 서버

by sun__ 2021. 9. 24.

개요

밥식구 프로젝트에서 같은 식구에 포함된 인원이 새로운 밥약속을 생성하거나, 스와이프를 종료하거나 등등의 행동을 할 때 그 페이지를 보고있는 다른 인원에게 즉시 업데이트된 상태를 갱신해줘야 하는 필요가 생겼다. firebase realtime db나 aws lambda와 같은 선택지가 있었지만 우리 팀은 socket.io를 사용하기로 했다. 그리고 비용절감을 위해서 새로 소켓통신 전용 서버를 파지 않고, 기존 express server에 추가하는 식으로 구성했다. 또한, polling 방식을 사용하지 않고 오직 websocket방식만 사용했다.

 

기술스택: typescript, node.js, express, socket.io, redis, pm2, cluster

 

 

* polling 방식을 사용한다면 여기서 기술한 방법 외에 sticky session을 적용해야 함

 


구성

1. cluster

하나의 인스턴스에서 코어 개수만큼의 클러스터를 만들도록 했다.

https://pm2.keymetrics.io/docs/usage/cluster-mode/

pm2 cluster mode 

 

2. AWS auto scaling group

elastic beanstalk의 auto scaling group을 활용하여 여러 인스턴스를 띄우도록 했다.

 

3. redis adapter

redis의 pub/sub을 기반으로 만든 socket.io-redis를 사용해서 1,2환경에서도 정상적인 소켓통신이 될 수 있도록 했다.

 

4. Redis

안정성을 위해 AWS elasticache를 사용했다. 레디스기반으로 생성해서 엔드포인트를 host로 사용하면 된다. 인바운드 규칙에서 포트 6379 꼭 열어주자.

 


코드

0. 

socket과 무관한 요소들은 숨김

 

1. 파일구조.

sockets를 따로 파일로 빼서 기존 rest api 서버와 섞이지 않도록 했다.

.
├── Dockerfile
├── ecosystem.config.js
├── nodemon.json
├── package-lock.json
├── package.json
├── src
│   ├── app.ts
│   ├── server.ts
│   ├── common
│   ├── configs
│   ├── controllers
│   ├── dtos
│   ├── exceptions
│   ├── http
│   ├── interfaces
│   ├── logs
│   ├── middlewares
│   ├── prisma
│   ├── routes
│   ├── services
│   ├── tests
│   ├── utils
│   └── sockets ***
│   	├──index.socket.ts
│   	├──sikgoo.socket.ts
│   	└──yaksok.socket.ts
├── swagger
└── tsconfig.json

 

server.ts

import App from '@/app';
import IndexRoute from '@routes/index.route';

const app = new App([new IndexRoute()]);

app.listen();

 

app.ts

import express from 'express';
import { Routes } from '@/interfaces/app/routes.interface';
import { createServer } from 'http';
import { Server } from 'socket.io';
import IndexSocket from './sockets/index.socket';

class App {
  public app: express.Application;
  public port: string | number;
  public env: string;

  constructor(routes: Routes[]) {
    this.app = express();
    this.port = process.env.PORT;
    this.env = process.env.NODE_ENV;
    
    this.initializeRoutes(routes);
  }

  public listen() {
  	const server = createServer(this.app);
    const io = new Server(server, { transports: ['websocket'] });

    server.listen(this.port, () => {
      logger.info(`App listening on the port ${this.port}`);
    });

    new IndexSocket(io);
  }

  private initializeRoutes(routes: Routes[]) {
    routes.forEach(route => {
      this.app.use('/', route.router);
    });
  }
}

export default App;

 

 

sockets/index.socket.ts

namespace : 앱에서 보이는 뷰에 따라서 소켓의 namespace를 구분했다. rest api는 uri로 자원을 나타내고 동사를 사용하지 않지만, 소켓의 경우 이벤트를 나타나는데 규칙 같은 것을 찾기 힘들어서 이벤트엔 어떤 이벤트인지 동사를 사용해서 상세하게 적었다.

 

redis adapter: 여러 인스턴스에서 pub/sub의 방식으로 유효한 소켓 통신을 할 수 있도록 해준다. REDIS_URL엔 서비스중인 레디스의 url을 적어주면 된다. 필자는 aws elasticache를 하나 만들었다.

 

클래스 사용: https://www.npmjs.com/package/typescript-express-starter의 라우트 구성 패턴을 모방해서 클래스를 사용했다.

import { Server } from 'socket.io';
import SikgooSocket from './sikgoo.socket';
import YaksokSocket from './yaksok.socket';
import { createAdapter } from 'socket.io-redis';
import { RedisClient } from 'redis';

class IndexSocket {
  constructor(io: Server) {
    this.initalizeAdapter(io);
    this.initializeSocket(io);
  }
  /**
   * @summary redis adapter 연결.
   */
  private initalizeAdapter(io: Server) {
    const pubClient = new RedisClient({ host: process.env.REDIS_URL, port: 6379 });
    const subClient = pubClient.duplicate();
    io.adapter(createAdapter({ pubClient, subClient }));
  }

  /**
   * @summary socket 연결
   */
  private initializeSocket(io: Server) {
    new YaksokSocket(io.of('/yaksok'));
    new SikgooSocket(io.of('/sikgoo'));

    //socket error handling
    io.of('/').adapter.on('error', function (err) {
      console.log(err);
    });
  }
}

export default IndexSocket;

 

sockets/sikgoo.socket.ts

socket.io의 room을 활용해서 유효한 유저들만 소켓통신하도록 했다.

import { Namespace, Socket } from 'socket.io';

/**
 * @summary 식구 네임스페이스. 밥약속 생성, 식구초대 소켓처리
 */
class SikgooSocket {
  constructor(sikgooNamespace: Namespace) {
    this.initializeSocket(sikgooNamespace);
  }

  private initializeSocket(sikgooNamespace: Namespace) {
    sikgooNamespace.on('connection', (socket: Socket) => {
      console.log('a user connected in sikgooNamespace');

      socket.on('disconnect', () => {
        console.log('a user disconnected sikgooNamespace');
      });

      //식구화면이 업데이트되는걸 확인하고 싶은 경우
      socket.on('join sikgoo', sikgooId => {
        console.log('식구화면이 업데이트되는걸 확인하고 싶은 사람 등록');
        socket.join(`Sikgoo${sikgooId}`);
      });

      //누군가 밥약속을 새로 생성한 경우
      socket.on('make yaksok', sikgooId => {
        console.log(`a user made a yaksok Sikgoo${sikgooId}`);
        //room, event순
        sikgooNamespace.to(`Sikgoo${sikgooId}`).emit('update yaksok list');
      });

      //식구에 새로운 멤버가 추가된 경우
      socket.on('add sikgoo member', sikgooId => {
        console.log(`a member added in sikgoo ${sikgooId}`);
        sikgooNamespace.to(`Sikgoo${sikgooId}`).emit('update sikgoo member list');
      });
    });
  }
}

export default SikgooSocket;

 

클라이언트가 사용할 코드 예시.

react의 경우 useEffect와 같이 렌더링 시 실행되는 훅에서 사용할 때 극히 주의해야한다. 

import {io} from 'socket.io-client'

//yaksok 네임스페이스의 소켓
const socket = io('BACKURL/yaksok', {transports:['websocket'], upgrade:false, forceNew: true});

socket.on("disconnect", (reason) => {
  if (reason === "io server disconnect") {
    // the disconnection was initiated by the server, you need to reconnect manually
    socket.connect();
  }
  // else the socket will automatically try to reconnect
});

//스와이프 끝낸 경우
socket.emit('finish swipe', sikgoohistoryId);

//최종 식당 고른 경우
socket.emit('decide store', sikgoohistoryId);

//스와이프 참여하는 경우
socket.emit('start swipe', sikgoohistoryId);

//progress, 최종식당 업데이트 받아야 함.
socket.emit('join yaksok', sikgoohistoryId);

//누군가 스와이프를 끝내서 결과를 업데이트 하는 경우
socket.on('update progress', ()={//progress update})

//최종식당이 결정돼서 결과를 업데이트 하는 경우
socket.on('update store decision', ()={//update store decision})

 

 

ecosystem.config.js

pm2를 사용해서 cluster모드로 cpu코어 개수만큼의 클러스트를 구동하도록 했다.

module.exports = {
  apps: [
    {
      name: 'app1',
      script: 'dist/server.js',
      exec_mode: 'cluster',
      instances: 0,
      env: {
        NODE_ENV: 'development',
      },
    },
  ],
};

 

Dockerfile

도커를 통해 배포함. 도커위에서 pm2 사용시 pm2-runtime 커맨드 사용해야 함

FROM node:14.14.0-alpine3.12

COPY . ./app

WORKDIR /app

RUN npm install
RUN npm install -g pm2
RUN npm run build

EXPOSE 3000

CMD ["pm2-runtime", "start", "ecosystem.config.js"]