이전 글 - 사용자가 방을 퇴장하는 경우에서 확인한 것처럼, Socket.IO의 Adapter는 '소켓 연결이 끊겼을 때 해당 방에 아무도 없다면' 방 삭제 이벤트를 발생시킵니다.
@WebSocketGateway()
@UseFilters(SocketCustomExceptionFilter)
export class RoomsGateway implements OnModuleInit, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
constructor(
private readonly roomsService: RoomsService,
private readonly clientsService: ClientsService,
) {}
onModuleInit() {
const adapter = this.server.of('/').adapter;
adapter.on('delete-room', (roomId) => {
this.roomsService.deleteRoom(roomId);
});
}
...
}
기존 코드에서는 이를 적극적으로 활용하여, '실제 방에 남아 있는 사용자가 없다면 방 정보를 제거'할 수 있도록 구성해 뒀습니다.
하지만, 해당 플로우에는 한가지 치명적인 단점이 있었습니다.
바로 '클라이언트 한명만 방에 입장해있을 때, 새로고침을 수행하면 방이 터지는 문제'가 발생했다는 것입니다.
새로고침을 수행하면 기존 소켓 커넥션이 끊기고, 새로운 소켓이 생성됩니다.
이 때 기존 소켓 커넥션이 끊기면서 해당 방의 퇴장 이벤트가 발생하고, 그 시점에서 방은 텅 비어있게 되므로 해당 방 정보가 제거됩니다.
이후 연결되는 소켓 커넥션은 '이미 제거된 방 정보'를 찾게 되니, 다시 시도해 달라는 메시지를 띄우게 됩니다.
Adapter의 이벤트를 그대로 사용하는 건 문제가 있다는 것을 알았으니, 이를 해결할 방법을 찾아야 했습니다.
그렇다고 Adpater 대신 다른 객체를 통해 연결정보를 유지하는 방식도 옳지 않았습니다. 이미 Socket.IO가 잘 해주고 있는데 말이죠.
그래서, Adapter의 'delete-room' 이벤트를 잠깐 연기시키는 객체를 만들기로 했습니다. 방 정보 삭제는 물론 진행되어야 겠지만, 해당 삭제를 잠시 기다려보다가 누군가 들어오면 취소, 시간이 흘러도 들어오지 않는다면 삭제가 진행되도록 구성했어요.
@Injectable()
export class LazyDeleteRoomEventOperator extends EventEmitter implements OnModuleInit {
private readonly lazyDeleteInfo = new Map<string, any>();
onModuleInit(): any {
this.on('lazy-delete-room', (roomId: string) => {
this.lazyDeleteInfo.set(roomId, setTimeout(() => this.lazyDelete(roomId), 5 * SECOND));
});
this.on('cancel-lazy-delete-room', (roomId: string) => {
clearTimeout(this.lazyDeleteInfo.get(roomId));
this.lazyDeleteInfo.delete(roomId);
});
}
lazyDelete(roomId: string) {
this.lazyDeleteInfo.delete(roomId);
this.emit('delete-room', roomId);
}
}
Socket.IO Adapter의 'delete-room' 이벤트가 발생할 경우 해당 객체의 'lazy-delete-room' 이벤트를 활성화시킵니다.
5초간 해당 이름으로 된 방에 입장하지 않을 경우(해당 방이 생성되지 않을 경우), 'delete-room' 이벤트를 발행하도록 합니다.
만약 누군가 입장하는 경우, 'delete-room' 이벤트 발행을 취소합니다.
Nest.js가 지닌 IoAdapter에 적용해봅시다(참고로 기존 IoAdapter를 확장한 객체를 사용 중이며, 세션 미들웨어를 추가한 이유는 사용자들의 기존 연결정보를 보존하기 위함입니다. 추후 여유가 된다면 토큰 방식으로 변경하려고 합니다).
export class CustomWebSocketAdapter extends IoAdapter {
private readonly sessionMiddleware: RequestHandler;
private readonly eventOperator: EventEmitter;
constructor(appOrHttpServer: any, sessionMiddleware: RequestHandler, eventOperator: EventEmitter) {
super(appOrHttpServer);
this.sessionMiddleware = sessionMiddleware;
this.eventOperator = eventOperator;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createIOServer(port: number, options?: ServerOptions & { namespace?: string; server?: any }): any {
const server = super.createIOServer(port, {
...options,
cors: {
origin: process.env.ORIGIN,
credentials: true
},
});
server.engine.use(this.sessionMiddleware);
const adapter = server.of('/').adapter;
adapter.on('delete-room', (roomId: string) => {
if (isValidUUID(roomId)) {
this.eventOperator.emit('lazy-delete-room', roomId);
}
});
adapter.on('create-room', (roomId: string) => {
if (isValidUUID(roomId)) {
this.eventOperator.emit('cancel-lazy-delete-room', roomId);
}
});
return server;
}
}
해당 IoAdapter는 Socket.IO의 Adapter가 'delete-room' 이벤트를 발행할 시 앞서 생성한 LazyDeleteRoomEventOperator
의 'lazy-delete-room' 이벤트를 발행하도록 구성했습니다. 또한 새로 방이 생성된다면 'cancel-lazy-delete-room' 이벤트를 발행하도록 구성했습니다.
@WebSocketGateway()
@UseFilters(SocketCustomExceptionFilter)
export class RoomsGateway implements OnModuleInit, OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
constructor(
private readonly roomsService: RoomsService,
private readonly clientsService: ClientsService,
private readonly lazyDeleteRoomEventOperator: LazyDeleteRoomEventOperator,
) {}
onModuleInit() {
this.lazyDeleteRoomEventOperator.on('delete-room', (roomId) => {
this.roomsService.deleteRoom(roomId);
});
}
...
}
기존 Gateway에서는 LazyDeleteRoomEventOperator
를 DI받고, 해당 객체의 'delete-room' 이벤트가 발생할 때 방 정보를 삭제하도록 했습니다.
새로고침했을 때 에러 화면이 출력되지 않는다는 점에서는 기쁩니다.
그러나, Nest.js를 잘 다루고 있는건지는..
현재 FE쪽 버그가 생겨 revert하기도 했고 Nest.js를 마치 스프링 다루듯 작성하다 보니, Nest.js가 Spring과 비교해서 어떤 차별점이 있는지, 모듈을 어떻게 다루어야 하는지 등을 많이 신경쓰지 못했던 것 같습니다. 오히려 너무 성급하게 Nest.js를 선택한 건 아니었나 하는 생각이 드네요.
그래도 Socket.IO의 구조를 파악한 덕분에, 해당 구성을 크게 건드리지 않는 범위에서 적절한 해결책을 작성했다는 점에서는 만족합니다. 물론 이 방법이 완전한 정답은 아니겠지만, 조금씩 개발하고 있는 현 상황에서는 나쁘지 않은 대처였다고 생각합니다.