카카오 「실시간 메시징 시스템 개발기」(3편) 리뷰 — Redis에 몰린 부하를 서버로 옮기고, 그 서버를 pprof로 세 번 깎은 이야기
원문(3편, 모두 2024-12-12 kakao tech, 인터랙션플랫폼): Part 1. 삽질 과정(Aiden.ahn) · Part 2. 성능 테스트 설계와 분석 · Part 3. 성능 개선 레슨런(George.5)
한 줄 요약
댓글·투표·리액션을 담당하는 알렉스 플랫폼의 실시간 서버(Spring WebSocket, Java)는 서버당 1K 커넥션에서 메시지가 새기 시작했다. Go로 다시 만든 카리브 v1은 Redis Streams를 폴링하는 구조라 2배 이상 좋아졌지만 Redis에 모든 부하가 몰렸고, v2는 Redis를 전역 브로커로만 남기고 채널별 분배를 서버가 맡게 바꿨다. 그 대가로 서버가 병목이 됐고, pprof로 마샬링 중복·JSON 라이브러리·고루틴 스케줄링을 차례로 고쳐 2코어 4GB에서 한 채널 6K 커넥션 × 초당 100건(수신 기준 60만 msg/s)을 유실 없이 돌리게 됐다. 2024 파리 올림픽 양궁 결승에서 단일 채널 48K 동접을 버텼다.
배경: 기존 라이브 서버의 한계
기존 서버는 서버당 10K를 목표로 했지만 커넥션 대비 자원 소모가 컸고 댓글 도메인과 강하게 결합돼 있었다. 사회 이슈로 트래픽이 튄 날, 8코어 8GB VM 30대가 약 15만 접속자에게 초당 60건 이상을 보내다 대당 5K에서 정상 동작을 못 했다. 정량 비교를 위해 그 상황을 부하 조건으로 정의해(6코어 12GB, 한 채널에 전부 접속, 10초마다 재접속, 초당 100건, 500B) 재 보니 1K는 정상, 2K는 20~30% 유실, 4K는 60~70% 유실이었다.
새 시스템의 목표는 셋. 페이로드는 문자열로만 취급(범용), publish는 API로 받고 이후 단방향 팬아웃, 스케일 아웃. 언어는 성능 테스트용 클라이언트를 Go로 써 보고 받은 인상 때문에 Go가 됐다.
Part 1: 삽질과 결정들
프로젝트 뼈대. Go에는 웹 애플리케이션 표준 레이아웃 레퍼런스가 드물었고, golang-standards는 별 도움이 안 됐으며 iris 프레임워크 예제가 그나마 유용했다. 설정은 viper가 “플래그 → 환경변수 → 파일” 순서가 고정이고 키를 소문자로 강제하는 등 알려진 문제가 있어 koanf(Provider로 파일·환경변수·Vault를 명시적으로 병합)를 골랐다.
프로토콜. 롱폴링·웹소켓·SSE를 다시 정리했다. 단방향이니 SSE가 자연스럽다. 그런데 IE11 지원이 필요했고, 조사 중 유튜브 채팅과 PubNub이 폴링을 쓴다는 것을 알게 되면서 롱폴링의 장점을 다시 봤다. Ajax만 되면 되는 호환성, 메시지를 자연스럽게 모아 내려주는 효과, 그리고 로드 밸런싱. 소켓 서버는 커넥션이 오래 유지되므로 한계에 닿아 서버를 추가해도 기존 커넥션이 안 옮겨 가지만, 롱폴링은 짧게 반복 연결이라 새 서버로 즉시 분산된다. 그래서 SSE도 롱폴링처럼 주기적으로 재연결하고 메시지를 모아 보내는 틀로 잡았다.
브로커. 재연결 사이 메시지가 유실되지 않게 잠시 보관하고 원하는 위치부터 읽어야 하니 Redis pub/sub(push)이 아니라 Redis 5.0의 Streams(타임스탬프 ID, 특정 지점 이후 조회)를 썼다.
v1 결과와 Singleflight. 롱폴링과 SSE 모두 2K까지 정상, 4K에서 약 40% 유실. 기존보다 2배 이상이지만 Redis CPU가 4K에서 75%를 넘었다. 수천 커넥션이 주기적으로 XRANGE를 치니 당연했다. Go의 singleflight(groupcache에서 파생된 200줄짜리 패키지, 같은 키의 동시 요청을 하나로 합쳐 응답을 공유)를 적용하자 10K 커넥션에서 Redis CPU 2% 미만. v1은 6코어 12GB에서 5K 이상을 처리했고, PM 15대 + VM 48대 클러스터를 VM 33대로 줄였다.
v1의 문제와 v2. 테스트가 “한 채널에 극단적으로 몰리는” 장애 상황 위주였는데, 실제 운영은 채널이 다양해 Singleflight가 덜 먹히고 피크에 Redis CPU 40%였다. 발행량이 적어도 커넥션이 많으면 Redis에 부하를 주는 구조였다. v2는 Redis를 최상위 분류만 하는 전역 브로커로 제한하고 채널별 분배(로컬 브로커)는 카리브 서버가 맡는다. pull에서 push로 바뀌면서 롱폴링은 접고 사내 요청이 있던 웹소켓을 추가했다. 웹소켓은 네트워크 단절을 서버·클라이언트 모두 즉시 못 알아채므로 ping/pong을 직접 다뤘고, 항상 보내지 않고 메시지가 오래 없을 때만 보내 I/O를 줄였다. 메시지 포맷은 SSE를 참고한 JSON(id, appId, channel, payload, retry)이다. retry는 재연결 전 대기 시간으로, 클라이언트마다 랜덤 값을 줘 장애·배포로 전부 끊겼을 때 동시 재접속(thundering herd)을 막는다.
v2 초기 결과는 양면적이었다. 1,000채널에 10K 커넥션을 분산한 테스트에서 Redis CPU 15% → 1% 미만, 서버 CPU 100% → 75%. 그런데 한 채널 집중 테스트에서는 1K 커넥션에서 15% 유실. v1이 같은 조건에서 5K 이상 버텼으니 1/5 이하다. 부하를 서버로 옮겼으니 예상은 했지만 너무 낮았다.
Part 2: 성능을 어떻게 잴 것인가
이 편의 가치는 “실시간 메시징의 TPS는 무엇인가”라는 질문이다. 일반 API는 요청-응답이 트랜잭션이지만, 구독 API는 오래 유지되는 커넥션을 만들 뿐이다. 구독 API만 재서 100K TPS를 얻어 봤자 실제로는 서버당 1K 구독자에서 죽을 수 있다. 구독과 발행은 팬아웃으로 묶여 있다. 그래서 시나리오를 한 문장으로 고정했다.
“N명의 구독자가 1개 채널을 구독할 때, 초당 M개의 메시지를 처리할 수 있는가?”
다른 변수(장비, 세션 유지 시간, 채널 수, 메시지 크기)도 영향이 있지만 N과 M에 비해 작거나 운영 시나리오와 안 맞아 고정했다. 발행 클라이언트는 publish API가 Redis에 넣는 것이 전부라 Redis에 직접 쓰되, 초당 100건을 10ms마다 1건으로 고르게 낸다. 구독 클라이언트는 여러 프로세스(컨테이너)로 가상 유저를 띄우고, “초당 100건 × 60초 = 6,000건을 각 구독자가 다 받았는가”를 판정 기준으로 삼는다. CPU·메모리보다 사용자가 메시지를 받았는가가 최우선이다. 지표는 cAdvisor로 컨테이너 단위로 본다.
N, M을 올리다 CPU 100%에 닿으면 “한계”라는 것 외에 정보가 없다. 팀원들이 각자 병목을 추측해 고쳤는데, 좋아질수록 진짜 병목 찾기가 어려워졌다. 그래서 프로파일링으로 갔다. Go의 net/http/pprof는 import 한 줄로 엔드포인트가 생기고, CPU 프로파일은 샘플링(주기적으로 콜스택 스냅샷)이다. 원문은 미니 pub/sub 예제로 flat(그 함수 자체가 쓴 시간), cum(하위 호출 포함 누계), list(소스 줄별), graphviz 그래프를 차근차근 보여 준다. 예제에서 5·3·1초 바쁜 함수가 4.21·2.67·0.89초로 잡히는 것은 샘플링이라 정상이라는 대목이 친절하다.
Part 3: 세 번 깎기
pprof가 가리킨 것은 I/O였다.
네트워크 I/O 모으기. socketify·µWebSockets에서 본 “코르크” 개념을 따라갔다. TCP의 Nagle은 ACK를 기다려 모아 보내고, TCP_NODELAY는 바로 보내며, TCP_CORK는 사용자가 직접 막았다 열어 모아 보낸다. 50ms 주기로 코르크를 열어 봤더니 setsockopt 시스템 콜이 CPU를 잡아먹어 2K 구독자에서 25% 유실. 대신 웹소켓 라이브러리 gobwas의 애플리케이션 레벨 프레임 버퍼링을 쓰니 더 나았다. io_uring도 검토했지만 Go 런타임 쪽에서 고루틴과 링 버퍼 접근 조정이 걱정된다는 논의 단계라 보류했다.
중복 마샬링 제거: CPU 25% 감소. 커넥션마다 같은 메시지를 JSON으로 마샬링하고 있었다. Redis에서 메시지를 받는 Delivery에서 한 번만 마샬링해 결과를 실어 보내게 바꿨다.
JSON 라이브러리 교체: 유실률 2.5% 개선. 표준 encoding/json을 go-json으로.
스케줄링 확률 증가: 유실률 60% 감소. Delivery가 Redis에서 메시지를 받을 때마다 채널로 보내고 콜백을 실행하는 흐름이었다. “Delivery 고루틴이 예상보다 적게 스케줄링되는 것 아닐까”라는 가설을 세우고, 메시지를 모아 채널로 보내고 콜백은 별도 고루틴으로 띄우게 바꿨다. 교훈은 Go 스케줄러가 훌륭하지만 특정 고루틴을 우선하거나 상주시키고 싶을 때는 아쉽다는 것.
Heap Escape. 채널에 포인터를 넘기면 힙으로 escape하니 값으로 바꿔 봤는데 오히려 포인터가 더 나았다. 복사 비용이 더 컸다. escape를 피하는 것이 항상 이득은 아니다. Zero Allocation(벤치마크 allocs/op=0)도 학습했지만 카리브 코드에서는 적용점을 못 찾았다. 슬라이스는 크기가 정해지고 64KB 이하면 스택, 함수가 반환하면 힙, append는 capacity를 2배씩 늘리니 미리 잡아 두라는 정리가 있다.
숫자
최종(2코어 4GB VM, 한 채널, 60초 유지, 500B):
| 조건 | v1 SSE | v2 SSE | v2 WebSocket |
|---|---|---|---|
| 5K 커넥션, 50 msg/s 유실률 | 14% | 0 | 0 |
| 서버 메모리 | 300MB | 200MB | 350MB |
| Redis CPU | 0.4% | 0.2% | 0.3% |
| 발행량 | 커넥션 | 유실 | CPU | 메모리 |
|---|---|---|---|---|
| 100 msg/s | 3,000 | 0 | 100% | 225MB |
| 100 msg/s | 6,000 | 0 | 100% | 448MB |
8K에서는 약 5% 지연 도달이지만 서비스 가능. 장비는 v1의 1/3(6코어 12GB → 2코어 4GB). 실전 최고치는 2024-08-04 22:06 파리 올림픽 남자 양궁 개인 결승, 단일 채널 약 48K 동접, 분당 수백 건.
읽고 남는 질문
- v2에서 “전역 브로커는 최상위 분류만”이라는데, Redis 스트림이 여전히 하나인지 서버 그룹별로 나뉘는지, 서버가 늘면 Redis 구독 팬아웃이 어떻게 되는지가 없다.
- 카리브 서버 CPU가 100%인 상태에서의 결과라, 그 상태가 지속될 때 지연 분포(p99)가 어떤지가 있어야 “유실 0”의 의미가 완결된다.
- Go 표준 net.Conn 기반은 연결당 고루틴 하나 이상이라 메모리가 빠르게 는다고 했는데, 448MB/6K면 커넥션당 약 75KB다. 이것을 줄이기 위해 gnet 같은 이벤트 루프 기반을 검토했는지 궁금하다.
한 줄로 가져가기
팬아웃 시스템의 성능은 “N 구독자 × M 발행”으로 재야 하고, 부하를 브로커에서 서버로 옮기면 서버가 병목이 되므로 그때부터는 추측이 아니라 프로파일러가 고칠 곳을 정해야 한다. 그리고 escape를 피하는 최적화조차 재 보기 전엔 모른다.
댓글
아직 댓글이 없습니다