레디스의 'Pub/Sub'
Hammer • Backend Engineer
채널톡 실시간 채팅 서버 개선 여정은 채널톡이 실시간 채팅 서버를 어떻게 관리하고 최근 개선을 위해 어떤 실험들을 했는지 소개하고 있습니다. 특히, 1편은 채널톡의 채팅 서버의 구조와 레디스의 Pub/Sub
에 대해 주로 이야기 합니다. 후속 편에서는 NATS.io 와 Redis Partitioning 등을 다룹니다.
채널톡은 채팅 상담을 솔루션으로써 7만 개 이상의 고객사에서 발생하는 데이터를 처리하고 있습니다.
대표적인 예로는 유저의 온라인 여부, 채팅 세션 관리 등이 있는데요, 이와 같은 형식의 데이터는 서비스 품질에 큰 영향을 미치기 때문에 관리하는 기술도 매우 중요해질 수밖에 없습니다.
특히 해당 데이터는 실시간으로 클라이언트와 서버가 주고받아야 하기에 채널톡은 Socket.IO 프레임워크를 사용하고 있습니다.
Socket.IO is a library that enables real-time, bidirectional and event-based communication between the browser and the server.
일명 ch-socket-io
(앞으로는 편하게 소켓 서버로 부르겠습니다.)는 Socket.IO 를 이용해 만들어졌으며, 현재도 수십 대에 소켓 서버가 구동되고 있습니다.
그림 1, 채널톡 소켓 서버의 대략적인 구조
위는 저희 소켓 서버에 대략적인 구조를 나타내는 그림입니다. 차례대로 설명해보자면,
레디스는 소켓 서버 간 세션 정보를 공유하기 위해 사용됩니다.
예를 들면, 접속한 소켓 서버가 서로 다른 클라이언트 간의 통신을 하려고 할 때 레디스 Pub/Sub
을 이용
하여 데이터를 전달합니다.
이를 위해 실제 레디스에 소켓 서버가 붙어 모든 데이터를 전송합니다.
소켓 서버는 실제 클라이언트와 통신하는 주체이며, 레디스에 붙어 여러 패킷을 관리하는 서버가 됩니다.
레디스와 통신하기 위해 사용하는 Adapter
는 Socket.IO 공식 레포에 있는 socket.io-redis-adpater
를 사용했습니다.
클라이언트는 소켓 서버에 붙어 동작합니다. 실시간으로 변경된 데이터를 전달 받거나, 유저의 온라인 여부 파악을 위해 heartbeat 를 전송합니다.
There is also a heartbeat mechanism which checks that the connection between the server and the client is still up and running:
저희 구조의 특이한 점으로는 일반적으로 소켓 서버의 클라이언트로서 메인 서버(채팅 세션 및 메세지 관리)가 존재하지 않고 레디스에 직접 붙어 PUBLISH
를 한다는 점입니다.
이에는 몇 가지 이유가 있는데,
메인 서버 → 레디스 인스턴스로의 단방향 적인 데이터 흐름만 존재
만약 소켓 서버 클라이언트로 존재한다면, 레디스 커넥션보다 관리가 어려운 웹 소켓 커넥션 관리 및 쓰레드 풀 운영을 해야함.
이러한 이유로 메인 서버는 레디스에게 직접 데이터를 전송합니다.
채널톡은 꾸준히 성장 중이며 현재도 많은 트래픽을 관리 중에 있는데요, 11월 업데이트였던 마케팅 기능으로 인해 한 번에 수 많은 메세지(한 번에 최대 300K)를 전송하는 경우가 잦아졌습니다.
그림 2, 6개월간의 레디스 트래픽 추이
계속해서 요청이 증가하는 상황에 더해, 순간적으로 발생하는 큰 트래픽은 레디스에게 더욱 큰 부하를 주게 되었고, 이는 예기치 않게 메인서버 로직에도 영향을 주었습니다.
그림 3, 실제 메인 서버에서 문제가 되었던 레디스 Requests
긴 대기 시간은 서버의 스레드 자원을 계속해서 잡아먹게 되고 전반적인 성능에도 영향을 미칠 수 있습니다. 메인 서버는 채팅 세션 및 메세지 관리 외에도 여러 작업을 수행하기 때문에 한 시가 급하게 이를 해결해야 했죠.
처음으로 가장 쉽게 생각할 수 있는 방법은 단순히 레디스 인스턴스의 성능을 올리는 방식이었습니다.
실제로, 현재 채널톡이 사용하고 있는 레디스 인스턴스의 경우 꽤나 스케일 업 할 수 있는 옵션이 많아 당장에도 올릴 수 있었습니다. 하지만, 스케일 업을 진행한다고 해서 반드시 문제가 해결되지는 않는 건 자명했습니다. 또한, 앞서 보여드린 그래프의 추이를 본다면 얼마 안 가 다시 문제가 생길 수 있음을 시사했죠.
스케일 업을 통해 해결하는 건 최후의 보루이자 저희가 선택할 수 있는 방법이 하나도 없는 경우에 유효한 방법이었습니다. 따라서, 문제 원인 파악 및 기존 아키텍처의 수정을 우선적으로 고려하게 되었죠.
Redis clustering 혹은 NATS.io 도입
Redis Partitioning
위 세 가지 방법은 실제 저희가 부하를 분산 혹은 줄이기 위해 진행했던 실험이었습니다. 당연하게도 모든 가설이 들어맞고, 실험이 성공하진 않았습니다. 여러 시행착오를 겪었고, 이는 채널톡 실시간 채팅 서버 개선 여정이 시리즈로 기획된 이유기도 하죠. 특히, 이번 포스트에서는 redis-adpater
의 비효율성을 가정하고 진행했던 1. socket.io-redis-adapter 수정 실험에 대해 소개해 보고자 합니다.
저희가 처음 예상했던 부하의 원인 중 하나는, socket.io-redis-adpater
의 비효율성이었습니다.
채널톡의 소켓 서버는 레디스와의 통신을 Socket.IO 공식 레포에 공개된 socket.io-redis-adapter
를 이용하고 있었는데요, 실제 소켓 서버가 모든 이벤트에 대해서 레디스에게 broadcast(PUBLISH)
하는 과정은 N 개에 클라이언트가 M 개의 메세지를 받아야 하는 경우 O(MN) 만큼의 부하를 레디스에게 주었습니다.
위 코드에서 주목해야할 부분은 PUBLISH
를 진행할 channel
입니다. 기존에는 모든 소켓 서버가 channel*
를 PSUBSCRIBE
하고 있어 메세지를 전부 발신했지만, PSUBSCRIBE
를 특정 channel
의 Room
에 진행한다면, 모든 소켓 서버에 전달하던 메세지를 필요한 서버에게만 전달하도록 최소화할 수 있겠죠.
저희는 위와 같은 가설을 기반으로 실험을 진행하게 되었습니다.
실험 방식은 실제 프로덕션 환경과 비슷하게 진행하되, 고정된 소켓 서버 대수에 일정 이상의 부하를 주도록 실험을 진행했습니다.
저희가 개선한 redis-adapter
가 기존에 방식보다 레디스에 부하를 덜 줄 것이라 예상했고 클라이언트에게 특정 주기마다 요청을 보내게 했습니다.
고정된 소켓 서버 4대와 레디스 인스턴스(m4.xlarge
) 하나는 고정되어 있다.
클라이언트 당 커넥션 및 클라이언트 인스턴스 수를 조절해 부하 테스트를 진행.
그림 4, 테스트 환경
과연, 저희가 추측했던 가설은 실제로 맞았을까요? 필요한 소켓 서버에만 레디스가 메세지를 보내주도록 하는 게 효과가 있었을까요?
앞서 언급했듯이 아니었습니다. 오히려 수정된 redis-adapter
가 레디스에게 더욱 많은 부하를 주었고, 기존에 사용했던 성능에도 못 미치는 결과를 보여줬죠.
그림 5, 기존 어댑터(좌)와 수정된 어댑터(우)의 EngineCPUUtilization metrics
실제로 기존 어댑터(왼쪽)가 더 좋은 성능을 보였고, 수정된 어댑터(오른쪽)는 EngineCPUUtilization
가 100% 에 도달하는 모습을 보여주고 있습니다.
저희가
EngineCPUUtilization
수치에 집중한 이유는 해당 AWS 포스트에서 찾아볼 수 있습니다.
그렇다면 왜 그럴까요? 어떤 이유로 인해 레디스의 부하를 줄이기는커녕 더 키우는 방향이 되었을까요?
어떤 방식으로 인해 오히려 부하가 커졌는지 알기 위해선 레디스가 어떻게 Pub/Sub
시스템을 관리하는지 알아보아야 합니다. 정확히는 수정한 어댑터가 어떻게 레디스의 Pub/Sub
구조와 맞물려 부하를 더 크게 만들었는지 알아야 합니다.
레디스 내부에서는 server
와 client
로 추상화된 구조체를 이용합니다.
서버와 클라이언트는 각각 pubsub_channels
, pubsub_patterns
라는 자료구조를 들고 있으며, 클라이언트에서 publish
시 서버에서는 실제로 위 자료구조들을 순회하여 조건에 맞는 다른 클라이언트에게 메세지를 보냅니다.
...
/* Send to clients listening for that channel */
de = dictFind(server.pubsub_channels,channel);
if (de) {
list *list = dictGetVal(de);
listNode *ln;
listIter li;
listRewind(list,&li);
while ((ln = listNext(&li)) != NULL) {
client *c = ln->value;
addReplyPubsubMessage(c,channel,message);
receivers++;
}
}
/* Send to clients listening to matching channels */
di = dictGetIterator(server.pubsub_patterns_dict);
if (di) {
channel = getDecodedObject(channel);
while((de = dictNext(di)) != NULL) {
robj *pattern = dictGetKey(de);
list *clients = dictGetVal(de);
if (!stringmatchlen((char*)pattern->ptr,
sdslen(pattern->ptr),
(char*)channel->ptr,
sdslen(channel->ptr),0)) continue;
listRewind(clients,&li);
while ((ln = listNext(&li)) != NULL) {
client *c = listNodeValue(ln);
addReplyPubsubPatMessage(c,pattern,channel,message);
receivers++;
}
}
decrRefCount(channel);
dictReleaseIterator(di);
}
...
출처, https://github.com/redis/redis/blob/4930d19e70c391750479951022e207e19111eb55/src/pubsub.c#L288
그림 6, Redis 의 PUBLISH 과정
server.pubsub_channels
의 실제 타입은 Map<ChannelName, Set<Client>>
입니다.
특정 채널 이름을 기준으로 클라이언트 리스트를 찾아 이를 순회하며 메세지를 보내는 형식으로 되어 있습니다.
그에 반해 pubsub_patterns
는 LinkedList<Pair<PatternStr, Client>>
인데, 해당 타입에서도 유추할 수 있듯이 패턴 문자열과 클라이언트 쌍을 통해 고유성(Uniqueness)을 보장합니다. 다시 말해, 레디스는 PSUBSCRIBE
에 대해 특정 패턴 문자열 별로 클라이언트를 묶는 방식을 사용하지 않고 있습니다.
이러한 이유로 PSUBSCRIBE
를 통해 등록된 클라이언트가 많을 경우, 또한 등록된 패턴 문자열이 다양할 경우 레디스는 그만큼의 부하를 그대로 받게 됩니다.
Time complexity: O(N+M) where N is the number of clients subscribed to the receiving channel and M is the total number of subscribed patterns (by any client).
실제로, PUBLISH
는 위와 같은 레디스의 구조로 인해 O(N + M) 의 시간 복잡도를 가집니다.
개선된 어댑터에서는 특정 Room
으로만 PUBLISH
를 하기 위해 변경된 점이 추가적으로 있었는데, 바로 PSUBSCRIBE
를 동적으로 진행하는 부분이었습니다.
이는 분명히 PUBLISH
의 성능 저하를 야기할 수 있는 코드이기에 결국엔 소켓 서버에는 부하를 줄일지언정 레디스에 부하를 더 키우는 꼴이 되었죠.
이번 실험의 주 목적은 레디스의 부하를 줄이는 것이었습니다. 하지만, 오히려 더 늘었던 것에는 레디스 Pub/Sub
시스템 자체가 많은 PSUBSCRIBE
를 감당하기 어려운 구조로 되어있기 때문이었죠.
그렇다고 개선된 어댑터가 모든 곳에서 성능 저하를 보여준 건 아니었습니다. 레디스에 부하를 더 줄지언정 소켓 서버가 필요한 메세지만을 전달받을 수 있도록 하여 오히려 소켓 서버의 부하는 줄일 수 있었습니다.
이번 실험의 핵심은 레디스의 부하를 줄이는 것이기에 가설 자체는 틀렸지만, 소켓 서버와 레디스는 서로 상충관계에 놓여 있기에 소켓 서버에 더 많은 부하를 주는 방식이 필요하다면 충분히 고려해 볼 만한 방법이 아닐지 싶습니다.
[1] https://making.pusher.com/redis-pubsub-under-the-hood/
[2] https://github.com/redis/redis/blob/6.2/src/pubsub.c
[3] https://socket.io/docs/v4/redis-adapter/
8만 개 이상의 고객사에서 발생하는 데이터를 관리하는 채널톡 백엔드팀, 이 도전적인 과제를 함께 해결하고 싶다면? 채널톡 백엔드팀에 지원하세요!
We Make a Future Classic Product
채널팀과 함께 성장하고 싶은 분을 기다립니다