grep

Backend

실시간 메시징 시스템 개발기 - “삽질 과정”

aiden.ahn카카오

2024년 12월 12일

원문에서 보기 ↗

실시간 메시징 시스템 개발 시리즈


들어가며

안녕하세요, 인터렉션플랫폼에서 알렉스 플랫폼 개발을 담당하고 있는 에이든입니다. 인터렉션플랫폼에서는 댓글, 투표, 리액션 등, 사용자가 직접 참여할 수 있는 기능을 제공하는 알렉스 플랫폼을 개발하고 있습니다.

최근 알렉스 플랫폼에는 실시간 메시징 시스템인 카리브가 새롭게 추가되었습니다. <실시간 메시징 시스템 개발기> 시리즈에서는 카리브를 개발하면서 고민했던 점을 아래의 세 가지 주제로 나누어서 설명하고자 합니다.

❶ 알렉스 플랫폼의 실시간 메시징 시스템이 변해온 과정

❷ 실시간 메시징 시스템 성능 테스트

❸ 성능 개선 포인트 및 레슨런

시리즈 중 첫 번째 글인 이 글에서는 알렉스 플랫폼의 실시간 메시징 시스템 발전 과정에 대해서 다룹니다. 카리브는 저희 플랫폼에서 Go 언어로 개발한 첫 시스템입니다. 이에 Go 언어를 처음 사용하면서 맞닥트린 어려움들과 실시간 메시징 시스템 개발 과정에서의 고민 및 해결 과정을 중심으로 설명하겠습니다.

카리브의 모태, 알렉스 라이브 서버

이번 공유의 주인공인 카리브는 팬아웃 형태의 실시간 메시징 시스템입니다. 처음에는 댓글의 실시간 반응을 전달하기 위한 목적으로 시작되었지만, 현재는 범용 메시징 시스템으로 발전시키고 있습니다.

사실 알렉스 플랫폼에는 이미 실시간 댓글을 위해 ‘라이브 서버’라는 메시징 서버가 있었습니다.

그 당시에도 실시간 기능은 도전적인 과제였고, 개발 경험을 카카오 테크 블로그에 시리즈로 포스팅을 하기도 했습니다.

그림 1. 실시간 기능이 적용된 댓글 화면

https://tech.kakao.com/2020/06/08/websocket-part1/

https://tech.kakao.com/2020/06/15/websocket-part2/

https://tech.kakao.com/2020/06/22/websocket-part3/

기존 라이브 서버는 서버당 10K 커넥션을 목표로 개발되었지만, 아래와 같은 한계가 있었습니다.

특히 높은 리소스 사용량은 갑작스러운 대규모 트래픽 상황에서 안정적인 서비스를 유지하기 어렵게 만들었습니다. 아래는 사회적 이슈로 트래픽이 급증했을 때 경험한 라이브 서버의 지표입니다.

그림 2. 대규모 트래픽으로 인한 라이브 서버 장애 당시 서버 지표

cpu:8 / mem: 8GB 스펙의 VM 장비 30대에 약 15만 접속자에게 초당 60개 이상의 메시지를 전송하는 상황입니다. 장비의 스펙이 낮지 않음에도 대당 약 5K 커넥션 수준에서 서비스가 정상적으로 이루어지지 않고 있었습니다.

이러한 한계를 극복하고 보다 범용적인 시스템에 대한 요구가 커지고 있어서 이를 대응하기 위한 새로운 시스템 구축이 필요해졌습니다.

새로운 시스템의 개선 정도를 정량적으로 비교하기 위해 먼저 기존 라이브 서버의 성능 테스트를 진행해 보았습니다. 위의 장애 상황을 기준으로 부하 상황을 아래와 같이 정의해 볼 수 있었습니다.

위와 같은 부하 상황을 가정하고 성능 테스트를 진행 했을때 아래와 같은 결과를 얻을 수 있었습니다.

커넥션메시지 수신량
1000정상
2000메시지 20~30% 유실
4000메시지 60~70% 유실

<표 1. 기존 라이브 서버 성능 측정>

약 1K 커넥션 정도까지만 정상적으로 각 커넥션에 메시지가 도달되었고 2K 커넥션부터는 각 커넥션에 전달되어야 할 메시지가 테스트 시간 내에 도달하지 못하는 것을 확인할 수 있었습니다. 따라서 약 1K 커넥션까지만 정상적인 성능이 보장된다고 판단할 수 있습니다.

카리브 프로젝트 목표

기존 라이브 서버의 성능적, 기능적 한계를 개선하고 범용 메시징 시스템으로 발전시키기 위해 아래와 같은 단순한 목표를 가지고 카리브 프로젝트를 시작하게 되었습니다.

페이로드에 관심을 갖지 않음

범용 메시징 서비스를 지향하므로 메시지의 구조나 내용에 관심을 갖지 않고 페이로드는 단순한 스트링으로만 취급

단방향 메시지 흐름

publish는 API를 통해 호출하고 이후 메시지는 단방향으로 흘러가는 팬아웃 구조를 지향

스케일 아웃 가능한 구조

당연히 대규모 커넥션에 대응할 수 있도록 스케일 아웃이 가능한 구조를 지향

Java? Go?

이런 목표를 세운 뒤 새로운 시스템의 기술 스택을 정하는 과정에서 다시 Java 기반으로 갈 것인지 고민해 보게 되었습니다. 라이브 서버 개발 당시 저희 조직의 개발자들은 모두 Java 개발자였고, Spring에 친숙했습니다. 때문에 라이브 서버는 큰 이견없이 spring-boot-starter-websocket을 기반으로 개발되었지만 성능 튜닝 과정에서 어려움을 많이 겪기도 했습니다.

기존 라이브 서버의 성능 테스트를 진행할 때, Go 언어 기반 테스트 클라이언트를 작성해 보며 그 성능에 깊은 인상을 받았었던 것이 떠올랐고, 경량 스레드를 가진 Go 언어라면 더 적은 리소스로 대량의 커넥션에 대응할 수 있지 않을까 하는 단순한 생각으로 카리브 프로젝트의 언어가 Go 언어로 결정되었습니다.

Go project?

Go 프로젝트는 시작부터 쉽지 않았습니다. 개발 생태계가 상당히 성숙한 다른 언어들과 달리 Go 언어는 (물론 빠르게 성장 중이지만) 아직 생태계가 자리 잡혀 가는 상황이었습니다. 때문에 당시에 참고할 만한 Go 프로젝트 표준 레이아웃이 많지 않았습니다. 성숙한 Go 프로젝트들의 경우 웹 어플리케이션보다는 시스템 어플리케이션이나 라이브러리들이 다수여서 Go 프로젝트를 시작하기 위해 참고할 만한 좋은 레퍼런스를 찾기가 쉽지 않았습니다.

golang-standards의 레파지토리에는 나름대로 설명과 함께 프로젝트 레이아웃을 가이드하고 있지만 크게 도움은 안되었습니다. 전반적으로 Go 프로젝트는 java 프로젝트와는 달라야 하지 않나 정도의 느낌이었습니다. 그나마 Go 언어 기반인 iris 프레임워크의 예제들이 다양한 사례를 다루고 있어서 프로젝트를 구성할 때 도움이 되었습니다.

iris 프레임워크 예제

https://github.com/kataras/iris/tree/main/_examples

config는 어떻게 구성하지?

별거 아닌데 쓸데없이 오래 걸린 프로젝트 레이아웃을 잡고 나서 이제 config 구성을 시작하는데, 처음엔 큰 걱정이 없었습니다. Go 언어 생태계에서 config 관련해서는 viper라는 유명한 오픈소스가 있다는 걸 알고 있었기 때문입니다.

그래서 viper 기반으로 스프링의 application properties와 유사하게 페이즈별 config를 가져오는 작업을 진행했습니다. 아래와 같은 순서로 설정 값들을 조합하고 싶었으나 쉽게 되지 않았습니다. 😢

viper는 구조적으로 무조건 ‘플래그 → 환경변수 → 파일’의 순서로 조합 방식이 고정되어 있었습니다. 이 외에도 키를 강제로 소문자로 지정한다거나, 불필요하게 디펜던시들이 많다거나, 추상화가 좋지 않아 확장이 어렵다거나 하는 등의 다양한 문제점들이 이미 사용자들에게 알려져 있었습니다.

그러던 중, viper의 이러한 한계를 개선한 koanf라는 오픈소스를 발견했습니다. Provider들을 사용해서 파일이나, 환경변수, vault 등에서 설정을 로드해 병합하는 구조로 viper와 달리 매우 명시적이었습니다.

// koanf 인스턴스 생성
var k = koanf.New(".")

// yaml 파일에서 설정 로드
k.Load(file.Provider("./config.yaml"), yaml.Parser())

// 환경변수 설정 로드 (FOO_BAR=123 -> foo.bar=123)
k.Load(env.Provider("", "_", nil), nil)

함께 제공되는 Vault Provider를 통해 vault 연동도 쉽게 처리 가능했습니다.

k.Load(vault.Provider(vault.Config{
  Address: "Vault 주소",
  Token:   "Vault 토큰",
  Path:    "시크릿 경로",
  Delim:   ".",
  Timeout: 30 * time.Second,
}), nil)

Go 언어로 프로젝트를 진행한다면 매우 추천하고 싶은 config 라이브러리입니다. 👍

실시간 프로토콜

프로젝트 구성을 어느 정도 마친 뒤, 이어서 새로운 실시간 메시징 시스템에서 어떤 실시간 프로토콜들을 지원할 지에 대해 논의를 시작했습니다. 이를 위해 고전적인 롱 폴링(Long Polling)부터, 웹소켓과 최근 도입된 SSE(Server-Sent Events) 등에 대해 다시 정리해 보며 지원할 프로토콜에 대해 결정하기로 했습니다.

롱폴링(Long Polling)

롱 폴링은 가장 구현이 간단한 오래된 방식입니다. 일반적인 HTTP 요청에 대해 이벤트가 발생할 때까지 최대한 서버에서 응답을 지연하는 단순한 아이디어입니다. 구현이 쉽고 레거시 디바이스에서도 사용할 수 있는 장점이 있지만, 반복적인 요청으로 인한 오버헤드로 성능이 비교적 높지 않은 단점이 있어 현재는 잘 사용되지 않습니다.

웹소켓 (Websocket)

웹소켓은 클라이언트와 서버 간에 텍스트 및 바이너리의 양방향 스트리밍을 지원하는 브라우저에서 원시 네트워크 소켓에 가장 가까운 API로, 실시간 메시징 프로토콜 중 가장 널리 알려져 있습니다. 브라우저가 간단한 API 뒤에 있는 모든 복잡성을 추상화해서 네트워크 레벨의 문제들은 해결해 주지만, 웹소켓 연결 이후 프로토콜은 사용자에게 열려있기 때문에 무척 자유로운 동시에 필요한 프로토콜에 대해 고민하고 설계하거나 적합한 프로토콜을 찾아 적용해야 합니다.

HTTP 프로토콜에서는 기본적으로 제공되는 상태 관리, 압축, 캐싱 등이 없기 때문에 이런 부분들에 대해 고려해야 합니다. 하지만 IE 10부터 지원될 정도로 거의 모든 브라우저에서 사용가능한 큰 장점이 있고 거의 모든 언어에서 신뢰할 만한 라이브러리가 많다는 장점이 있습니다.

SSE(Server-Sent Events)

기존 라이브 서버는 웹소켓 기반이었지만, 카리브는 단방향 메시지 흐름을 지향했기 때문에 지원할 실시간 프로토콜 후보로 처음부터 SSE를 고려했습니다. SSE는 서버에서 클라이언트로의 한 방향으로만 메시지 전달이 가능하기 때문에 단순한 팬아웃 형태의 메시지 스트리밍에 매우 적합한 기술입니다. 또, SSE는 IE를 제외한 모던 브라우저에서 지원되고 있고 브라우저에서 사용할 때에는 EventSource 객체를 통해 쉽게 연결 관리를 할 수 있는 장점이 있습니다. HTTP 프로토콜 위에서 동작하기 때문에 HTTP에서 제공하는 상태 관리, 압축 등의 장점을 공유할 수 있습니다. 무엇보다 서버 쪽에서의 구현도 매우 간단합니다.

롱폴링?!

당시 새로운 시스템이 지원할 브라우저는 IE11을 포함한 모던 브라우저였습니다. IE11 지원을 위해서는 롱폴링 또는 웹소켓 지원을 반드시 고려해야 했습니다. 관련된 리서치를 이어나가던 중, 유튜브 채팅은 폴링 방식을 사용한다는 의외의 사실을 알게 되었습니다.

그림 3. PubNub 이 롱 폴링을 선택한 것에 대한 설명. 출처: PubNub 사이트, https://www.pubnub.com/guides/what-are-websockets-and-when-should-you-use-them/

또 관심 있게 살펴보고 있었던 유사 서비스인 PubNub에서도 의외로 웹소켓이 아니라 C로 작성된 효율적인 롱폴링 기술을 개발해서 사용하고 있다는 내용을 읽게 되며 기존 상식에 큰 혼란이 오기 시작했습니다. 그래서 다시 긍정적인 마인드로 폴링 방식의 장점에 대해 정리하고 고민해 보았고 아래와 같은 장점들을 추려낼 수 있었습니다.

소켓 서버의 스케일 아웃

소켓 서버는 그 특성상 일반적인 API 와는 달리 한 번 맺은 커넥션이 오랫동안 지속되게 됩니다.

그림 4. 소켓 서버에 신규 서버가 투입되었을 때

위의 이미지는 기존 소켓 서버들이 부하 한계치인 2500 커넥션에 이르러서 부하를 분산하기 위해 신규 서버를 투입하는 상황을 그리고 있습니다. 부하 분산을 위해 신규 서버를 투입했지만 그 효과는 즉각 발생하지 않습니다. 소켓 서버의 특성상 기존의 커넥션이 끊어지지 않고 계속 유지되기 때문입니다.

하지만, 롱폴링의 경우 일반적인 API 서버와 동일하게 커넥션이 연결이 짧게 반복되기 때문에, 신규 서버가 투입됨과 동시에 거의 즉각적으로 자연스럽게 부하가 분산되는 장점이 있습니다. 물론 짧게 주기적으로 재연결하면 롱폴링이 아니어도 어느 정도 부하가 분산되는 효과를 얻을 수 있습니다.

그림 5. 일반적인 API 서버에 신규 서버가 투입되었을 때

또 약간의 지연이 발생하는 롱폴링의 특성을 역으로 이용해서, 접속자가 동일한 채널에 몰릴 경우 전달해야 할 메시지를 특정 구간으로 묶어서 내려준다면 트래픽이 커질 때 오히려 부하에 유리한 부분도 있겠다는 판단도 들었습니다.

아무튼 이쯤 되자, 꼭 롱폴링은 아니더라도 주기적으로 재연결을 강제하고 메시지들을 모아서 내려주는 방식이 장점이 클 수도 있겠다는 생각을 하게 되었습니다. 그래서 SSE와 함께 롱폴링도 지원하게 되었고, 내부적으로 SSE도 롱폴링과 마찬가지로 주기적으로 재연결을 하고 메시지를 모아 보내주는 형태로 틀을 잡기 시작했습니다.

pub/sub? stream?

새로운 실시간 메시징 시스템은 팬아웃 형태의 대규모 메시지 전파에 관심이 있고, 메시지의 저장과 보관에는 관심이 없었기 때문에 이전 라이브 서버와 마찬가지로 메시지 브로커 역할로 레디스를 우선적으로 고려했습니다. 하지만 앞서 이야기한 것과 같이, 주기적으로 재연결이 발생하도록 강제하고 메시지를 모아서 내려주는 방식은 데이터를 push 하는 레디스의 pub/sub 기능으로 달성이 어려웠습니다.

연결이 주기적으로 끊어지기 때문에 재연결 사이에 메시지가 유실되지 않도록 잠시 보관될 필요가 있었고, 쌓여있는 메시지 중에서 원하는 위치부터 가져올 수 있어야 했습니다. 다행히 레디스에는 이러한 요구 조건에 완전히 부합하는 레디스 스트림이라는 자료구조가 5.0부터 지원되고 있었습니다.

그림 6. Redis Streams 에 대한 설명. 출처: Redis 사이트, https://redis.com/blog/getting-started-with-redis-streams-and-java/

타임스탬프 형식으로 메시지가 쌓이고 특정 타임스탬프 이후의 메시지를 쉽게 가져올 수 있기 때문에 앞서 결정한 우리 시스템의 구조에 매우 적합하다고 판단했고, 레디스 스트림을 중심으로 메시지를 쌓고 전파하는 구조로 개발을 시작하게 되었습니다.

성능 테스트

실제 구현이 어느 정도 마무리된 후 성능 테스트를 통해 기존 라이브 서버와 비교해 얼마나 개선이 되었는지 확인하는 과정을 가졌습니다.

성능 테스트에서 고려한 조건은 아래와 같습니다. 성능 테스트 조건에 대한 고민 등 성능 테스트에 대한 자세한 내용은 이후 이어지는 글에서 다룰 예정입니다.

지표기준지표 설명
커넥션1K 단위서버당 커넥션 수. 얼마나 많은 사용자들이 동시에 접속할 수 있는지
각 커넥션 유지 시간10초얼마나 자주 사용자(클라이언트)가 진입/이탈하는가?
메시지 수100건/초한 채널에 초당 발행되는 메시지 수
채널 수1얼마나 다양한 채널에서 메시지가 발생하는가? (한 채널에 커넥션이 집중될수록 부하 증가)
테스트 시간60초부하가 지속되는 시간

<표 2. 성능 테스트 조건>

서버는 6 Core / 12GB 사양의 VM이었고, 메시지를 수신하는 클라이언트들은 2 Core / 4GB 사양의 VM 5 대에서 진행했습니다.

커넥션기존 서버카리브 롱폴링카리브 SSE
1000정상정상정상
2000메시지 20~30% 유실정상정상
4000-약 40% 유실약 40% 유실

<표 3. 카리브 v1 성능 테스트 결과>

성능 테스트 결과, 롱폴링 방식은 SSE 보다 다소 낮지만 유사한 성능을 보여주었고 4K 커넥션부터 오류가 발생했지만 두 방식 모두 2K 커넥션까지 잘 동작했습니다. 마치 첫 테스트에서 기존 라이브 서버 1K 커넥션보다 적어도 2배 이상의 성능 향상을 이룬 것 같았습니다.

하지만 다른 지표들을 자세히 살펴본 결과, 전반적으로 레디스의 CPU 사용량이 우려스러울 정도로 높았습니다. 4K 커넥션을 테스트할 때에는 레디스의 CPU 사용량이 무려 75%를 넘어가기도 했습니다.

그림 7. 카리브 v1 성능 테스트 상황에서의 Redis CPU 지표

단순 지표로는 기존 라이브서버보다 성능이 나아졌다고 보였지만, 실상은 레디스로 모든 부하가 집중되어 오히려 대규모 트래픽을 감당하기 어려운 상황이 되었습니다. 당연하게도 대량의 커넥션이 주기적으로 레디스에 xrange 커맨드를 요청하므로 필연적으로 레디스 부하가 커질 수밖에 없었습니다.

Singleflight

따라서 동일한 요청을 서버 상에서 모아서 요청할 수 있다면 레디스에 가해지는 부하를 거의 대부분 해소할 수 있다는 판단을 했습니다.

Go 언어에는 이런 목적에 부합해 동일한 요청을 모아서 한 번만 요청할 수 있도록 해주는 Singleflight라는 패키지가 이미 존재합니다.

그림 8. SingleFlight 적용 시 호출 흐름도

memcached를 만든 Brad Fitzpatrick이 Google에서 Go 언어 팀에 있을 때 memcached를 대체하는 groupcache를 Go 언어로 만들면서 파생된 패키지입니다. 불과 200 라인에 불과한 간단한 로직으로, 동일한 키로 비동기 함수를 실행할 경우, 기존 요청이 있으면 새 요청을 보내지 않고 기존 요청의 응답을 공유합니다.

Singleflight 적용 결과 10K 커넥션에서도 레디스의 CPU 사용량은 불과 2% 미만까지 줄어드는 결과를 보여주었습니다.

그림 9. SingleFlight 적용 후 Redis CPU 지표

적용 전에는 4K 커넥션에서 CPU 사용량이 무려 75%를 넘어섰던 것과 매우 대조적이었습니다. 동일한 비동기 요청에 대해 모아서 처리해 주는 Singleflight는 적용이 매우 간단하면서도 효과는 높은 개선 포인트였습니다.

카리브 v1 오픈

성능 테스트를 통해 기존 라이브 서버보다 일반적인 상황과 대규모 유입 상황에서도 모두 나은 성능을 보인다고 판단하고 기존 라이브 서버를 대체해서 카리브 v1 서비스를 오픈했습니다.

채널에 초당 100개의 이벤트가 발생할 때 기존 라이브 서버의 경우 1K 커넥션이 한계였으나, 카리브 v1의 경우 5K 커넥션 이상 가능해졌습니다. (6 Core / 12GB 사양의 VM)

PM 15대, VM 48대로 구성되었던 기존 라이브서버 클러스터를 절반 이하인 VM 33대의 클러스터 구성으로 대체하면서도 더 많은 커넥션을 처리할 수 있게 되었습니다.

전반적으로 성공적인 오픈으로 보였지만, 시간이 지남에 따라 카리브 v1의 문제점이 드러나기 시작했습니다. 성능 테스트가 기존 라이브 서버 장애 상황을 염두에 두고 설계된 까닭에 한 채널에 극단적으로 다수가 모이는 상황 위주로 검증을 진행했으나, 실제 다양한 채널이 분포된 운영 환경에서는 레디스의 부하가 여전히 높았습니다.

그림 10. 카리브 v1 실서비스 오픈 후 Redis CPU 지표

접속자가 많은 피크 타임에 레디스 서버의 CPU 사용률이 40% 에 육박하는 것을 확인할 수 있습니다. 물론 Singleflight 가 적용되어 한 채널에 대규모 접속이 발생하더라도 큰 장애로 이어지지 않을 수 있겠지만 레디스에 모든 부하가 집중되는 구조였기 때문에 스케일 아웃을 고려할 때 레디스에 집중된 부하를 각 카리브 서버로 옮겨와야 할 필요성이 커졌습니다.

카리브 v2 프로젝트

앞서 살펴본 것처럼 카리브 v1 은 기존 라이브 서버보다 훨씬 나은 성능을 보였지만, 다양한 채널로 트래픽이 많아질수록 레디스에 모든 부하가 집중되는 문제점이 존재했습니다.

그림 11. 카리브 v1 문제점

이러한 문제점을 해결하기 위해 각 커넥션마다 레디스에서 직접 채널별 메시지를 가져오는(pull) 방식을 버리고 레디스의 역할을 최소화하기로 했습니다. 주기적으로 메시지를 가져올 경우 실시간성은 떨어지는 대신 성능상 이점이 있다고 판단했었지만, 실제로 효용은 크지 않았습니다. 심지어 메시지 발행량이 많지 않은 때에도 커넥션이 많은 경우 레디스에 부하를 주는 문제가 있었습니다.

그림 12. Redis 의 부하를 최소화하고 부하를 카리브 서버로 가져온 카리브 v2

레디스는 최상위 레벨까지만 분류하는 전역 브로커로 역할을 제한하고 나머지 채널별로 메시지를 나누어주는 로컬 브로커 역할은 카리브 서버가 감당하도록 개선 방향을 정리했습니다. 큰 부하를 카리브 서버가 감당하게 되지만 카리브 서버는 스케일 아웃이 용이하기 때문에 대규모 트래픽에 대응하기 더 낫다고 판단했습니다.

웹소켓 지원 추가

또 카리브 내부 메시지 전파 구조를 pull 이 아닌 push 방식으로 전환하며 실효성이 줄어든 롱폴링 지원을 중단하고 사내에서 지원 요청이 있었던 웹소켓을 추가적으로 지원하게 되었습니다.

기존에 이미 지원 중이던 Server Sent Event는 HTTP 프로토콜 기반으로 커넥션 유지와 커넥션 관련 오류 처리에 대해 안정적인 규격이 이미 마련되어 있어 매우 다루기 쉬웠지만, 웹소켓은 앞서 설명한 것과 같이 많은 부분에 대한 규격이 열려 있었기 때문에 커넥션 유지와 연결 오류 등을 직접 처리해주어야 했습니다.

❶ 서버에서 웹소켓 연결을 강제로 닫은 경우 ➡ ✅ 클라이언트에서 감지 가능

❷ 클라이언트에서 웹소켓 연결을 강제로 닫은 경우 ➡ ✅ 서버에서 감지 가능

❸ 네트워크 이슈로 연결이 끊어진 경우 ➡ 🚫 서버/클라이언트 모두 즉각적으로 감지 불가

웹소켓에는 이러한 연결 감지를 위한 스펙이 존재합니다. 컨트롤 프레임인 ping/pong 프레임을 서버와 클라이언트가 끊임없이 주고받으면서 연결감지를 하는 방식입니다.

RFC 6455 - The WebSocket Protocol

일부 Go 언어용 웹소켓 라이브러리의 경우 이러한 ping/pong 프레임 수발신을 자동으로 해주기도 하지만, 성능 최적화를 고려해 이러한 컨트롤 프레임도 직접 다룰 필요가 있었습니다. 항상 ping/pong 프레임을 보내지 않고, 메시지 전송이 긴 시간 없는 경우에만 보내도록 구현해서 네트워크 I/O 를 줄이며 연결 감지를 수행하도록 했습니다.

또 웹소켓에는 연결 관리를 포함한 메시지 전송 포맷 등에 대한 다양한 프로토콜이 있지만, 표준이 있는 것은 아니어서 카리브에서는 SSE의 프로토콜을 참고해서 JSON 형태로 아래와 같이 정의해서 사용하게 되었습니다.

{
  "data": {
    "id": "1669790031759-0", // 메시지 아이디
    "appId" : "news-1234" // 어플리케이션 아이디
    "channel": "foo", // 채널
    "payload": "Hello George!" // 페이로드 데이터
  }
  "retry" : 1000 // 재시작 대기 시간 (ms)
}

Thundering herd problem

retry 라는 필드 역시 SSE의 프로토콜을 참조해서 추가된 필드입니다.

9.2 Server-sent events

처음 이 필드를 보았을 때, 어떤 역할을 하는지 이해하지 못해 그 필요성에 대해 잘 공감이 되지 않았습니다.

retry 는 연결이 끊어졌을 때 자동으로 다시 재연결(retry) 하기 전까지 대기할 시간을 나타내는 값입니다. 연결이 끊어지면 바로 재연결 하면 되지 않을까 하고 생각할 수도 있지만 여기에는 그럴만한 이유가 존재합니다. SSE 프로세싱 모델에 retry 값이 필요한 이유에 대해 간단하게 설명되어 있습니다.

장애/배포로 모든 연결이 끊어졌을 때, 만약 모두 동시에 재접속을 시도하게 되면 대량의 접속 요청으로 인해 문제가 발생할 수 있습니다. 이것은 Thundering herd problem으로 알려진 문제로, 재접속 대기 시간에 적당한 차이(jitter)를 줌으로써 해결할 수 있습니다.

즉, 이 retry 값을 접속하는 클라이언트마다 랜덤하게 다른 값을 줌으로써 연결이 의도치 않게 모두 끊어졌을 때 동시에 재접속을 시도하는 상황을 막을 수 있습니다.

v2 성능 테스트

카리브 v2 프로젝트의 핵심 목표는 레디스의 부하를 카리브 서버로 가져오는 것이므로, v1 서버에서 레디스 부하가 발생하는 테스트 환경을 구성하고 이 상황에서 v2 서버 및 레디스의 지표를 확인해 보았습니다.

카리브 v1카리브 v2
카리브 CPU100%75%
카리브 Memory600MB500MB
레디스 CPU15%1% 미만

<표 4. 1000개 채널에 10K 커넥션이 분산된 카리브 v2 초기 성능 테스트 결과>

성능 테스트 결과, 기존 v1 서버의 고부하 상황에서 레디스 CPU 사용량이 15% 에 달했으나, 동일한 상황에서 v2 서버의 경우 레디스 CPU 사용량이 1% 미만을 유지함으로써 1차적인 목표를 달성했음을 확인할 수 있었습니다. 그 외에도 카리브 서버 자체의 지표도 불필요한 내부 폴링 작업이 사라지면서 v1 서버보다 더 나아진 것을 확인할 수 있었습니다.

이번에는 기존 장애 상황과 같이 한 채널에 대량의 커넥션이 유입되는 상황에서 v2 서버의 지표를 확인해 보았습니다.

커넥션메시지 수신량카리브 CPU카리브 SSE
500정상75%40MB
1000메시지 15% 유실100%75MB

<표 5. 단일 채널에 대한 카리브 v2 초기 성능 테스트 결과>

테스트 결과, 기존 v1 서버의 경우 같은 상황에서 5K 커넥션 이상까지도 정상 동작했지만, v2 서버의 경우 불과 1K 커넥션부터 테스트 시간 내 모든 메시지가 도달하지 못하며 1/5 이하의 성능을 보여주고 있었습니다. 카리브 v2 프로젝트의 핵심 목표가 부하를 카리브 서버로 옮겨오는 것이었기 때문에 성능 하락을 예상하긴 했지만 너무 낮은 수치였습니다.

성능 병목 분석과 개선

그래서 어떤 부분이 성능의 병목 지점인지 분석하고 개선을 진행했습니다.

실시간 메시징 서비스에서의 성능 테스트 설계, 분석 그리고 개선 과정은 이후 이어지는 글들에서 자세히 다루므로, 이 글에서는 분석을 통해 도출된 개선 결과를 먼저 공유해 보겠습니다

다양한 성능 개선 방안을 적용한 결과, 최종적으로 아래와 같은 성능 테스트 결과를 얻었습니다.

카리브 v1 SSE카리브 v2 SSE카리브 v2 웹소켓
유실율14%0%0%
카리브 CPU100%100%100%
카리브 Memory300MB200MB350MB
레디스 CPU0.4%0.2%0.3%

<표 6. 단일 채널에 대한 개선된 카리브 v2 성능 테스트 결과>

불과 1000 커넥션에서 메시지의 15% 가 유실되었던 기존 v2 와는 다르게, 5000 커넥션에서도 유실없이 성능을 유지하는 것을 확인할 수 있었습니다. 게다가 기존에는 cpu:6 / mem: 12GB 스펙의 장비를 모두 사용했었지만, 이번엔 1/3 수준인 cpu: 2 core / mem: 4GB 스펙으로 기존 v1 보다 더 나은 성능을 보여주는 것을 확인할 수 있었습니다.

메시지 발행량커넥션커넥션 유지시간메시지 유실율CPUMem
100 msg/s3000유지0%100%225MB
100 msg/s6000유지0%100%448MB

<표 7. 단일 채널에 대한 개선된 카리브 v2 성능 테스트 결과>

웹소켓에서 수신 기준 초당 100 x 6000 = 600,000 메시지를 유실없이 전달했고, 이보다 큰 8K 커넥션에서도 약 5% 정도 메시지가 지연되어 도달했지만 서비스 가능한 수준임을 확인할 수 있었습니다.

결과적으로 카리브 v2 프로젝트를 통해 레디스의 부하를 효과적으로 줄이고 성능은 적어도 이전 v1과 동일하거나 더 나은 수준으로 개선할 수 있었습니다.

성능 개선 그 후

카리브 v2 서비스를 오픈한 이후, 알렉스 라이브 서버의 장애 때와 같은 15만 동접이 일어나는 상황까지는 발생하지 않았지만 사회적 이슈 때마다 카리브 역시 높은 트래픽을 받으며 실제 서비스에서의 성능과 안정성도 확인해 볼 수 있었습니다.

그중 가장 트래픽이 높았던 때는 2024년 8월 4일 오후 10시 6분경 파리 올림픽 남자 양궁 개인 결승전 메달이 결정되는 순간이었습니다. 단일 채널에 약 48K의 동시 접속이 발생했고, 분당 수백 건의 메시지가 각 접속자에게 전달되었지만 큰 문제없이 트래픽을 감당할 수 있었습니다.

맺음말

이 글에서는 Go 언어를 처음 사용하면서 직면한 기술적 과제들과 실시간 메시징 시스템 개발 과정에서의 문제 해결 사례를 공유하였습니다. 프로젝트 멤버들 모두 Go 언어이라는 낯선 언어로 실시간 메시징 시스템이라는 낯선 시스템을 구축하는 과정에서 값진 학습 경험을 얻을 수 있었습니다.

여기에서는 Go 언어로 처음 프로젝트를 시작할 때 실수하고 배운 것들과 실시간 메시징 시스템을 만들어가는 과정에 대해 다루었지만, 이후 이어지는 글들에서는 이 과정에서 알게 된 좀 더 깊이 있는 내용들을 다루려고 합니다. 후속 글들을 통해서 저희가 얻은 경험이 독자 여러분께 유익한 참고 사례가 되기를 바랍니다.

긴 글을 읽어주셔서 감사합니다.

관련 글 목록


Written by Aiden.ahn

Edited by Marron.b