WebSocket과 Redis를 이용한 실시간 통신 구현기

JeongYong·2024년 7월 24일
1

실시간 통신 기술 선택 과정

현재 프로젝트에서 핵심 기능이라고 하면 함께 운동하기 기능이다.
함께 운동하기는 여러 사람이 달리기, 수영, 사이클 운동을 함께 할 수 있고,
각 유저는 참가한 모든 유저의 실시간 정보를 볼 수 있다.
그러면 어떤 실시간 통신 기술을 선택해야 할지가 첫 번째 의문으로 다가왔다.

1) SocketIO
2) Polling

결론부터 말하자면 우리는 SocketIO를 사용하기로 결정했다.
먼저 선택한 근거를 나열하자면
폴링은 일정한 시간 간격으로 서버에 HTTP 요청을 보내기 때문에 불필요한 데이터 전송이 생기고,
헤더 오버헤드라는 단점이 존재했다.
또한 새로운 데이터가 있어도 다음 폴링 주기까지 기다려야한다는 점에서 실시간 성이 떨어진다고 판단했다.
하지만 SocketIO는 전통적인 HTTP 기반 통신의 한계를 극복하기 위해 나온 기술인 만큼
폴링의 문제를 상당 부분 해소했다.
구체적으로 보자면 소켓IO는 클라이언트와 서버 간에 지속적인 TCP 연결을 유지하며 이 연결은 HTTP 프로토콜을 업그레이드하여 WebSocket 연결로 전환된다.
그로인해 헤더가 경량화되고, 헤더 오버헤드라는 문제를 해결한다.
특히 실시간 통신에 있어서 클라이언트와 서버 간에 연결을 유지하므로 실시간으로 양뱡향 통신을 가능하게 한다.

이러한 근거를 토대로 우리는 SocketIO를 사용하기로 했다.

그런데 여기서 또 문제점이 있었다. iOS에서 라이브러리가 금지되었다. 그럼 순수 WebSocket 프로토콜만을 사용할 수 있다는 의미였고, 어쩔 수 없이 호환성을 위해서 백엔드에서도 WebSocket을 사용해야 했다.

결국 우리는 WebSocket으로 최종 결정 했고, 나는 오히려 더 좋다고 생각했다. 더 구현하기 여렵기도 하고, 한번도 해보지 않아서 도전해 보고 싶었다.

image

WebSocket으로 구현했을 때 다양한 문제점

WebSocket은 웹 표준에 기반한 기본적인 실시간 통신 기능만을 제공해준다. 즉 필요한 모든 기능은 직접 구현해서 적용해 줘야한다.

구현해야 될 기능은 크게 다음과 같다.
1. 함께 운동하는 유저를 그룹화 해 줘야 한다.
2. 그룹화한 유저들에게 BroadCast 해 줘야 한다.

이미 위와 같은 기능은 Socket.IO에 존재하기 때문에 공식 문서를 참조해서 구현 했다.

첫 번째는 Socket.IO에 Room이라는 개념을 이용했다.
Room은 Map<string, Set> 자료 구조로 구현 되어 있다.
이를 이용하면 간단하게 같은 RoomID로 유저를 그룹화해 줄 수 있다.

두 번째는 Room을 구현하면 간단하다.
Room에 있는 모든 유저에게 send()를 해주면 BroadCast할 수 있다.

그런데 문제점은 WebSocket Server나 WebSocket을 Nest에서 직접 관리하고 주입 해주기 때문에
내가 커스텀한 클래스를 주입하려면 Nest, WebSocket 모듈을 수정해야 했다.
오류날 확률이 크기 때문에 이미 생성된 인스턴스를 확장할 수 있는 동적 프로퍼티 할당을 적용했다.
이 방식을 통해 서버, 소켓 인스턴스를 런타임 시 유연하게 확장할 수 있는 구조를 마련했다.
또한 할당 코드를 묶어서 관리하기 위해 할당 용도로만 사용되는 클래스를 생성해줬다.
예를 들면 다음과 같이 new ExtensionWebSocketServer(Nest에서 생성한 서버 객체);
서버 인스턴스를 넣어주면 동적 프로퍼티 할당을 통해서 확장된다.

결과적으로 기존 모듈을 수정하지 않고 기존 인스턴스의 기능을 확장할 수 있었고, 유연성과 확장성, 유지보수성을 향상시킬 수 있었다.

이제 확장해준 WebSocket으로 BroadCast 해주면 방에 참여한 모든 유저의 정보를 실시간을 받아올 수 있게 됐다.

나는 여기서 끝난줄 알았다.

하지만 문제가 있었다. 원호님이 설계한 인프라는 분산 환경이였으며, 단일 서버를 염두하고 구현한 방식은 제대로 동작하지 않았다.(https://github.com/boostcampwm2023/iOS08-WeTri/wiki/WeTri-%EC%9D%B8%ED%94%84%EB%9D%BC,-%EB%B0%B0%ED%8F%AC-%EA%B5%AC%EC%B6%95%EA%B8%B0-%E2%80%90-6)

분산 환경에서 WebSocket

그럼 분산 환경에서 WebSocket을 사용했을 때 어떤 문제점이 있을까?

지금까지 구현된 건 그저 WebSocket 서버로부터 온 유저를 그룹화하고 그룹화된 유저에게 BraodCast가 가능하다.

그런데 분산 환경에서는 서버 인스턴스가 여러 개이다.

즉 여러 유저가 같은 Room이여도 서버 인스턴스가 다르다면, 해당 유저에게는 정보를 보낼 수가 없다.

Redis를 도입한 이유

image

우리는 Redis가 뭔지 몰랐다. 하지만 Redis에 대해서 찾아볼수록 사용해야될 근거는 충분했다.

  1. 높은 성능과 낮은 지연시간: Redis는 메모리 기반 데이터 스토리지이다. 그렇기 때문에 빠른 읽기/쓰기 작업을 지원한다.
    구현하고자 하는 기능은 실시간성을 중요시 여겼다. 그래서 빠른 읽기/쓰기는 정말 중요했다.
  2. 간단하고 유연한 데이터 구조: Redis는 문자열, 리스트, 세트, 해시 등 다양한 타입의 데이터를 저장할 수 있다.
  3. pub/sub 지원: 분산 환경에서 서버 간 실시간으로 메시지를 교환할 수 있게 해준다.

근데 문제가 있었다. 몰라도 너무 몰랐다.
특히 pub/sub!!

Redis pub/sub 이란?

스크린샷 2023-12-13 오후 12 51 38 일단 pub/sub이 뭔지 학습했다.

pub/sub은 간단하게 요약하면 채널을 구독한 모두에게 메시지를 발행하는 통신 방법이다.
일반적인 pub/sub은 발행자가 메시지를 보내면 메시지를 저장하고 구독자가 메시지를 가져오는 방식이라면
Redis pub/sub은 좀 다른게 메시지를 저장하지 않고 메시지를 비동기적으로 바로 던져버린다.

WebSocket을 이용했을 때 일반적인 pub/sub보다는 Redis pub/sub이 매우 적합하다고 생각했다. 왜냐하면
추가적인 네트워크 통신이 필요없기에 딜레이가 생기지 않기 때문이다. 이러한 특성 때문에 다른 메시지 브로커와는 다르게 메시지 지속성은 없지만 2초마다 이동 거리를 전송하기 때문에 간헐적인 데이터 손실은 큰 문제가 되지 않았다. 또한 마감 기한이 짧아 단순한 Redis pub/sub 메커니즘은 적합했다.

그럼 이제 모호한 개념을 다 제거했으니 구현할 일만 남았다.

구현하기전에 SocketIO는 분산 환경을 지원할까?? 라는 궁금증이 있었고 찾아보기 시작했다.

분산 환경에서 Socket.IO

Socket.IO 공식 문서를 보다가 다음과 같은 그림을 봤다.

image

어? 이거 내가 찾던 거잖아?

정확히 내가 구현하고자 하는 기능을 Socket.IO는 socket.io-redis adapter로 지원한다.

더 자세히 알아보니 내부에서 Redis pub/sub도 사용한다.

공식 문서를 보니 더 확실해졌다. 웹소켓에 Redis는 단짝이다.

분산 환경에서 WebSocket, Redis를 이용한 구현

자 그럼 구현해보자

처음 단일 서버를 염두하고 만든 기능은 사실 분산 환경이라 해서 갈아엎을 필요는 없었다. 그냥 구현한 기능에 분산 환경을 위한 로직을 추가하면 됐다. 초석을 잘 마련해 놓아서 구현이 어렵지 않았다.

구체적으로 보자면,

Room에 입장하는 join(RoomId)

Romm을 나가는 leave(RoomId)

Room에 브로드캐스트하는 to(RoomId).emit(data)

이 메소드들을 변경해줬다.

1. join(RoomId)을 했을 때 서버간 동기화를 위해 Redis에도 RoomId를 key로한 Set 자료구조를 생성해준다.그리고 RoomId를 이름으로한 채널을 구독해준다.

이제 서버 인스턴스가 달라도 Room에 참가한 모든 유저들을 Redis를 통해 알 수 있다.

여기서 문제점이 하나 있다. 매번 join() 할 때 마다 채널을 구독한다. 정확히 말하면 채널을 구독하는건 서버 인스턴스에 존재하는 Redis Client 객체이다. 이미 구독했는데 또 구독해? 어떤 오류를 내뿜을지 모른다.
그렇기 때문에 현재 로컬 Room에 처음에 입장할 때만 RoomID 채널을 구독해주는 식으로 변경해줬다.

2. leave(RoomId)를 했을 때 또한 서버간 동기화를 위해 Redis Room도 변경해준다.

여기서 RoomId 채널을 무조건 구독 취소해야할까??

아니다.
구독 취소해야할 상황은 명백하다.

내가 로컬 Room에 마지막으로 나간다면 더 이상 해당 서버 인스턴스에 Room에는 아무도 존재하지 않는다. 아무도 존재하지 않는데 Room에대한 메시지를 받는 것은 매우 비효율적이다. 당연히 구독 취소를 눌러야한다.

3. to(RoomId).emit(data)를 했을 때 RoomId 채널로 메시지를 발행한다.

그러면 구독한 모든 서버 인스턴스(정확히는 Redis Client 객체)가 메시지를 받게된다.

발행된 메시지를 처리해주는 함수도 구현해줘야 한다.

이 부분도 어렵지 않게 구현할 수 있다.
this.redisClient.on('message', (channel, message) => {}

Channel은 RoomID를 뜻하기 때문에 로컬에 RoomID 방에 message를 send 해주면 된다.

이제 방에 BroadCast를 했을 때 분산 환경이라도 방에 있는 모든 유저가 메시지를 받게 된다.

구현한 전체 구조

유저가 WebSocket server에 접속했을 때

스크린샷 2023-12-13 오후 1 28 55

유저가 Room에 BroadCast 했을 때

스크린샷 2023-12-13 오후 1 29 30

아쉬운점

시간 부족으로 인해서 재연결했을 때 복구하는 부분을 구현하지 못했다.
현재 서버 인스턴스 하나가 다운되면 그 서버에 접속한 유저의 기록은 날아간다.
이러한 문제를 해결해 주기 위해서 Redis에 room을 동기화 해줬지만, 제대로 활용하지 못해 아쉽다.

또한 하나의 Redis에 여러 서버가 의존하고 있다 보니 유저가 많아진다면, 부하가 심해지며, Redis가 다운되면 시스템이 마비된다. 이를 Redis cluster를 적용해서 무중단 서비스와 서버 부하를 분산시켜 해결할 수 있지만, 적용해 주지 못해 아쉽다.

Git

https://github.com/boostcampwm2023/iOS08-WeTri

래퍼런스

Socket.IO Room,
Socket.IO Redis Adapter,
REDIS의 PUB/SUB 기능 (채팅 / 구독 알림),
[Server] pub/sub 이란?

0개의 댓글