pattern subscribe 구조 개선을 통해 불필요한 트래픽을 줄인 경험
Asher • Backend Engineer Meet, Socket
안녕하세요 채널톡 백엔드 엔지니어 어셔입니다. 이번 글에서는 socket.io redis Adapter의 pattern subscribe 구조를 개선하여 불필요한 트래픽을 줄인 경험에 대해 공유하려고 합니다.
Socket.iO Adapter란, 다중화된 socket.io 서버가 redis나 mongoDB 같은 다른 백엔드와 통신하기 위한 모듈입니다.
Socket.io 내부에서는 default로 In-memory Adapter를 사용하고 있습니다. In-memory Adapter에서는 단순하게 rooms, sids Set을 만들어서 클라이언트의 join, leave 마다 각각 addAll()
del()
혹은 delAll()
메서드를 대응시켜 알맞은 socket을 추가 및 삭제하며 관리합니다.
실제로 그 내부를 들여다보면 생각보다 훨씬 간단하게 작성되어 있는것을 확인할 수 있습니다. 특정 소켓이 특정 rooms에 참여할 때 호출되는 addAll()
의 경우 단순히 for loop 를 통해 room을 순회하며 Set에 해당 socketId 를 추가해주고 이벤트를 발행하는 역할만 하고 있습니다.
채널톡에서는 초당 수천 개의 메시지가 전송됩니다. 메인 API 서버에서 각 소켓 인스턴스와 연결하여 메시지를 전송한다면 확장성이나 결합성 등 많은 문제가 있기 때문에 redis의 pub/sub 패턴을 통해 비동기로 메시지를 전달합니다.
여기에서 Redis의 Pub/Sub 메커니즘을 socket.io 의 broadcast로 전환해주는 역할을 하는 것이 바로 socket.io Redis Adpater 입니다.
즉, redis adapter는 default adapter를 상속받아 redis와 통신하기 위한 로직으로 override 되어 있습니다.
/**
* Broadcasts a packet.
*
* @param {Object} packet - packet to emit
* @param {Object} opts - options
*
* @public
*/
public broadcast(packet: any, opts: BroadcastOptions) {
packet.nsp = this.nsp.name;
const onlyLocal = opts && opts.flags && opts.flags.local;
if (!onlyLocal) {
const rawOpts = {
rooms: [...opts.rooms],
except: [...new Set(opts.except)],
flags: opts.flags,
};
const msg = this.parser.encode([this.uid, packet, rawOpts]);
let channel = this.channel;
if (opts.rooms && opts.rooms.size === 1) {
channel += opts.rooms.keys().next().value + "#";
}
debug("publishing message to channel %s", channel);
this.pubClient.publish(channel, msg);
}
super.broadcast(packet, opts);
}
가장 대표적으로 broadcast()
를 살펴보면, 자신을 제외한 다른 소켓 인스턴스에게도 메시지를 전파하기 위해 redis 클라이언트를 사용하여 publish 해주는 과정이 포함되어 있음을 알 수 있습니다.
기존에 저희가 사용하고 있던 adapter는 생성자에 다음과 같은 로직이 포함되어 있습니다.
기본적으로 인스턴스가 생성될 때 pSubscribe를 진행합니다. pSubscribe는 pattern subscribe의 약자로 해당 채널과 같은 패턴의 채널로 오는 메시지를 전부 받는다는 의미입니다.
Redis는 내부적으로 pattern subscribe 하고 있는 client에게 publish하기 위해 다음과 같은 코드를 가지고 있습니다.
pubsub_patterns dict에서 iteration을 통해 Key, Val 을 가져오고, 이는 각각 pattern과 이를 구독하고 있는 클라이언트 리스트입니다.
패턴이 매칭된다면, value였던 client리스트를 한번 더 순회하면서 받을 클라이언트를 선별합니다. 구독중인 패턴의 수를 N, 메시지를 받을 패턴의 구독 클라이언트 수(소켓 인스턴스)를 M 이라고 한다면 이러한 방식은 O(N+M) 의 시간 복잡도를 가질 수 있습니다.
당시 파티션 당 Redis 한개에 소켓 인스턴스가 20개 이상 붙어있었고, 메시지 하나를 publish하면 (알림을 받을 대상이 없더라도) 레디스는 최소 20개가 넘는 소켓 인스턴스를 순회하며 메시지를 전달해야 했습니다. redis, 소켓 모두 필요 없는 트래픽으로 인해 과부하가 걸리는 상황이었습니다.
실제 알림을 받을 클라이언트 접속 여부와 관계 없이 모든 인스턴스에 메시지를 발행하기 때문에 엄청나게 많은 트래픽이 낭비되고 있음
대부분의 경우에는 알림을 받을 유저가 실제로 접속 중이라고 하더라도 하나의 인스턴스에만 전달되면 되는 케이스일것으로 예상
subscribe/unsubscribe 요청보다, 패킷 자체가 무거운 메시지를 멀티 인스턴스를 순회하며 전달하는 비용이 훨씬 클 것임
이러한 상황에서 저희가 선택한 아이디어는 실제로 클라이언트가 연결되어 있을때만 해당 소켓 인스턴스가 메시지를 받도록 하는것 입니다.
이를 구현하기 위해서 add()
, del()
에서 아래와 같은 로직을 작성하면 됩니다.
In-memory Adapter 에서 관리하는 Set에 추가(혹은 삭제)
이미 구독하고 있는(혹은 더이상 구독할 필요가 없는) room 에 대해 redis subscribe(혹은 unsubscribe)
그리고 이를 아주 간단하게 코드로 표현하면 다음과 같습니다.
이 로직은 한가지 문제점을 안고 있는데, 바로 동시성 이슈입니다.
만약 클라이언트가 아주 짧은 시간 내에 연결/끊김 이 발생한다면, 다음과 같은 상황이 벌어질 수 있습니다.
NodeJS는 single thread 기반이기 때문에 Set에 대한 동시 접근 자체는 걱정할 필요가 없습니다. 그러나 Set에 추가/제거 하는 작업과는 다르게, redis subscribe 와 unsubscribe는 Async Non-blocking callback 기반으로 진행됩니다. 즉, Set이 가지고 있는 room 정보와, 실제 구독 여부의 정합성이 깨질 수 있습니다. 이렇게 되면 그 다음 join에서는 이미 구독하고 있는 room 에 또다시 구독 로직을 수행하게 되면서(정확히는 onMessage 핸들러를 두 번 등록하게 되면서) 같은 메시지가 두번 오는것 같은 현상을 겪을 수 있습니다.
이 반대의 경우는 구독하고 있지 않지만 set에는 존재함으로써, 영영 메시지를 받지 못하는 상황이 발생할 수도 있습니다. 이는 상당이 치명적인 문제기 때문에, 이에 대한 방어 로직을 작성할 필요가 있었습니다.
이렇게 작성하고 배포 후에 꽤나 큰 폭의 변화를 확인할 수 있었습니다.
먼저 CPU 사용률입니다. CPU는 redis에서 pattern subscribe에서 publish 위한 iteration을 제거했기에 줄어들었다고 생각할 수 있습니다. 평일 피크 기준 기존 43%에서 33%로 10%p 감소한 것을 알 수 있었습니다.
그리고 가장 큰 변화가 나타난 곳은 역시 ElastiCache의 송신 네트워크 바이트입니다. 기존 높을 때는 시간당 평균 6.7GB 까지 사용되고 있었는데요. 배포후 수요일 피크타임 기준 110MB 로 98% 가량이나 줄어든 것을 확인했습니다.
메모리 사용률의 경우 구독 정보를 더 많이 관리해야 하기 때문에 크게 증가했지만, 피크 기준 1.4%정도로 저희가 사용하고 있는 ElastiCache의 전체 메모리에 비해서는 유의미하지 않은 부분으로 판단됩니다.
[1] 채널톡 실시간 채팅 서버 개선 여정 - 1편 : 레디스의 'Pub/Sub'
[2] 채널톡 실시간 채팅 서버 개선 여정 - 3편 :파티셔닝
We Make a Future Classic Product
채널팀과 함께 성장하고 싶은 분을 기다립니다