아키텍처와 벤치마크
숫자 읽는 법: 이 문서에는 성격이 다른 처리량 둘이 나온다. 서로 곱하거나 나누지 않는다.
- 노드당 운영 기준 — 약 6,000 프레임/s. AWS EC2 t3.medium에서 노드를 0.2 vCPU로 묶어 잰 값이다(노드 vCPU 1개당 약 3만). 운영과 비슷한 환경에서 노드 하나가 내는 용량이고, 아래 「결과 1」이다.
- 개발 머신 합성 부하 — 약 15만 프레임/s. 코어가 많은 빠른 개발 머신 한 대(Apple M5 Pro, 18코어)에서 서버·PostgreSQL·Redis·부하 생성기를 함께 돌려 잰 값이다. 한 대에서 만든 합성 부하라 운영 수치가 아니고, 아래 「별도 측정」이다.
두 값이 20배 넘게 다른 이유는 노드가 쓸 수 있는 CPU 몫이다. 앞의 것은 노드에 0.2 vCPU만 주었고, 뒤의 것은 18코어를 서버와 부하 생성기가 나눠 썼다. 같은 엔진이라도 CPU를 얼마나 주느냐에 따라 숫자가 이만큼 달라진다.
노드를 하나 더하면 같은 용량이 그대로 더해진다. 노드 0.2 vCPU가 초당 약 6,000 프레임을 내보내고(노드 vCPU 1개당 약 3만), 노드 1·2·3대에서 정확히 1배·2배·3배가 나왔다. 발행(메시지 저장)은 PostgreSQL 하나를 거치고, 그 PostgreSQL은 코어 1개당 초당 약 1,300건의 발행을 받는다(1코어 값은 외삽). 아래에 구조, 결과 표 둘, 개발 머신 합성 부하 표 하나, 잰 방법, 못 잰 것까지 모두 적는다.
이 숫자들은 용량 계획이 아니다. 결과 1·2는 일부러 작게 묶은 노드로 "용량이 어떻게 늘어나는가"를 본 시험이라, 여기 적힌 절대값을 그대로 가져다 서비스 규모를 계산하지 말고 아래 「한계」를 먼저 읽는다.
구조
Browsers / apps (WebSocket)
| | |
+----v---+ +---v----+ +--v-----+
| Node 1 | | Node 2 | | Node N | connections are spread over nodes
+----+---+ +---+----+ +--+-----+
| ^ | ^ | ^
publish| | | | | | every message is delivered
v | v | v | through Redis, even to the same node
+--------+------+--+------+--+---+
| Redis pub/sub |
+--------------------------------+
^
| one write per publish
+----+--------------+
| PostgreSQL | per-room sequence number + message row
+-------------------+- 연결은 노드가 나눠 가진다. 브라우저는 WebSocket으로 노드 하나에 붙는다. 노드를 더하면 연결이 그만큼 더 들어온다.
- 전달은 Redis pub/sub을 거친다. 한 메시지가 발행되면 모든 노드가 받아 자기 소켓에 내려 보낸다. 발행한 노드도 똑같이 Redis를 거쳐 받는다. 그래서 같은 노드에 앉은 수신자와 다른 노드에 앉은 수신자의 경로가 같다.
- 발행은 PostgreSQL에 한 번 쓴다. 방마다 순서 번호(
seq)를 발급하고 메시지를 저장한다. 발행 한 건이 PostgreSQL에 거는 일은 방 조회 한 번과 트랜잭션 한 번(번호 올리기 + 메시지 추가)이다.
노드는 늘리면 되지만 PostgreSQL은 하나다. 그래서 두 가지가 따로 늘고 따로 막힌다: 전달(노드와 Redis)과 발행(PostgreSQL). 아래 두 표가 그 둘이다.
결과 1 — 전달: 노드를 더하면 처리량이 그대로 더해진다
방 하나에 연결 300개, 발행자 30명. 메시지 하나가 299개 프레임이 되므로 발행 20건/s는 약 6,000 프레임/s다. "유지"는 달성한 발행률이 제안한 발행률과 같고, 전달이 100%이고, 느린 소비자로 끊긴 연결이 0인 최고 수준이다.
| 노드 | 노드당 상한 | 최대 유지 발행/s | 전달 프레임/s | 1대 대비 | 프레임당 CPU |
|---|---|---|---|---|---|
| 1 | 0.2 vCPU, 96 MB | 20 | 약 5,900 | 1.0배 | 33 µs |
| 2 | 0.2 vCPU, 96 MB | 40 | 약 12,000 | 2.0배 | 32 µs |
| 3 | 0.2 vCPU, 96 MB | 60 | 약 18,000 | 3.0배 | 32 µs |
각 칸은 2회 측정의 평균을 반올림한 값이고, 2회의 처리량 차이는 4% 안이었다.
- 프레임당 CPU가 노드 수와 상관없이 32–33 µs다. 노드를 더해도 노드마다 얹히는 일(Redis 수신, 메시지당 한 번의 디코딩)이 팬아웃 옆에서 보이지 않는다. 1 ÷ 33 µs, 즉 노드 vCPU 1개당 약 3만 프레임/s가 여기서 나온다.
- 노드 사이 전달은 공짜에 가깝다. 같은 노드의 수신자와 다른 노드의 수신자 사이 p99 지연 차이가 몇 ms였다(3대, 발행 45/s에서 109 ms 대 105 ms).
- 한 단계만 올려도 무너진다. 각 노드가 CPU 상한에 붙은 뒤에는 지연이 초 단위로 튄다(예: 1대 25/s에서 p99 4.8초). 병목은 매번 노드 CPU였고, 시험 생성기·Redis·PostgreSQL은 상한 아래였다.
- 여유 있는 부하는 빠르다. 상한의 75%인 45/s를 3대에 주면 p50 20 ms, p99 107 ms. 단, 이 지연은 아래 「한계」의 이유로 부풀려진 값이라 노드 수끼리 비교할 때만 쓴다.
결과 2 — 발행: 상한은 PostgreSQL 코어가 정한다
방 50–1000개, 방마다 연결 2개(발행자 1, 수신자 1). 방이 많고 발행이 많은 쪽이다. 발행 한 건이 프레임 하나라 팬아웃이 거의 없고, 일은 발행 경로 자체다.
| 노드 | PostgreSQL 상한 | 최대 유지 발행/s | 먼저 막힌 것 |
|---|---|---|---|
| 1 | 0.3 코어 | 약 175 | 노드 CPU |
| 2 | 0.3 코어 | 약 345 | 노드 CPU (PostgreSQL과 거의 동시) |
| 3 | 0.15 코어 | 약 180 | PostgreSQL |
| 3 | 0.3 코어 | 약 390 | PostgreSQL |
| 3 | 0.6 코어 | 약 500 | 노드 CPU |
| 3, 웹훅 켠 앱 | 0.3 코어 | 약 270 | PostgreSQL |
- PostgreSQL 코어 1개당 약 1,300 발행/s. 발행 한 건에 PostgreSQL CPU가 0.7–0.8 ms 든다. 0.15코어는 180 발행/s에서(코어당 약 1,200), 0.3코어는 약 390에서(코어당 약 1,300) 막혔다. 1코어를 직접 채워 본 적은 없다. 이 서버에서는 노드 3대(0.6 vCPU)가 PostgreSQL 0.35코어어치 일밖에 못 만든다. 1코어 값은 위 두 점과 0.6코어 구간의 발행당 CPU에서 낸 외삽이고, 상한을 2배로 올렸을 때 처리량이 2.1배가 되는 것까지만 확인했다.
- 엔진이 PostgreSQL을 낭비하지 않는다. 엔진 없이 같은 문장 셋(방 조회, 번호 올리기, 메시지 추가)을 pgbench로 같은 0.3코어에 걸면 초당 약 405 트랜잭션이 천장이고, 엔진 경로(약 390)가 그 96%다. 병목은 엔진의 군더더기가 아니라 PostgreSQL이 해야 하는 일의 양이다.
- 방 수는 무릎이 아니다. 방이 50개든 1,000개든, 방당 연결이 2개든 10개든 상한이 같았다(366–400 발행/s). 락 대기는 풀 16 실험을 빼고 0이었다. 커넥션 풀도 아니다: 풀을 키우면(노드당 4 → 16) 오히려 10–15% 나빠졌다. 0.3코어에 백엔드가 몰려 서로 기다리기 때문이다.
- 노드를 더해도 PostgreSQL 상한은 못 넘는다. 3대부터는 노드를 더해도 약 390 위로 올라가지 않고, PostgreSQL에 코어를 주면 상한이 다시 노드(약 500)로 간다. 발행이 많은 서비스에서 용량을 정하는 쪽은 노드 수가 아니라 PostgreSQL이다.
- 웹훅을 켠 앱은 발행당 PostgreSQL 몫이 약 40% 늘어난다(발행마다 전달 기록을 한 줄 더 쓰므로, 이 행은 1회 측정). 그리고 한 앱의 웹훅 전달은 초당 25–60건에서 포화되어, 그보다 빨리 발행하면 전달이 뒤로 밀린다.
- 노드 메모리는 연결 하나당 약 100–125 KB이다. 256 MB 상한에서 연결 2,000개를 열자 노드가 메모리 부족으로 죽었다.
별도 측정 — 개발 머신 한 대의 합성 부하
운영 환경이 아니라 노드 하나가 CPU를 넉넉히 쓸 때 어디까지 가는가를 본 시험이다. 2026년 9월에 개발 머신 한 대(Apple M5 Pro, 18코어)에서 서버·PostgreSQL·Redis·부하 생성기를 함께 띄우고 쟀다. 노드 1개, 채팅방 1개, 30초 런이고, 방 하나에 접속자를 400명까지 모았다. 부하 생성기가 서버보다 먼저 포화되어서 모든 값이 하한이다.
| 동시 접속 | 발행 메시지/s | 전달 프레임/s | 클라이언트 p99 | 끊긴 접속 |
|---|---|---|---|---|
| 20 | 25 | 472 | 5 ms | 0 |
| 50 | 100 | 4,883 | 18 ms | 0 |
| 100 | 200 | 19,730 | 25 ms | 0 |
| 400 | 100 | 39,614 | 55 ms | 0 |
| 400 | 400 | 157,873 | 52 ms | 0 |
| 400 | 800 (한계 지점) | 162,413 | 4.8 s | 84 / 400 |
- 약 15만 프레임/s는 위 표의 다섯째 줄이다. 발행 400건/s × 방 인원 400명 ≈ 16만 프레임/s가 전달되고, 메시지 99%가 모두에게 닿기까지 52 ms, 이 부하에서 끊긴 연결은 0이다.
- 발행 800건/s에서 무너진다. 지연이 초 단위로 튀고 느린 접속 84개를 끊었다. 이 위가 이 조건에서 노드 하나의 한계다.
- 프레임/s = 방 인원 × 초당 발행 메시지. 표는 400명이 한 방에 있는 극단이다. 20명짜리 방 800개가 초당 1건씩 쓰면 1만 6천 프레임/s로 셋째 줄 수준이다.
- 운영 인스턴스의 수치가 아니다. 수평 확장 없이 노드 하나로 쟀고, 서버·부하 생성기·DB가 같은 머신의 코어를 나눠 썼다. 용량을 계산할 때는 위 「결과 1」·「결과 2」를 쓴다.
어떻게 쟀나
위 「결과 1」·「결과 2」를 잰 방법이다. 개발 머신 시험의 조건은 「별도 측정」에 적었다.
- 하드웨어: AWS EC2 t3.medium(2 vCPU) 한 대. 다른 서비스가 같이 도는 서버다. 시험 몫은 3노드 기준 약 1–1.1 vCPU, 박스 전체 CPU는 57–86%였다.
- "작은 기계 N대"를 흉내 냈다. 같은 서버에 노드 프로세스를 여러 개 띄우면 서로 서버의 CPU를 나눠 먹어 "여러 대로 돈다"만 확인된다. 그래서 노드마다 똑같은 고정 CPU·메모리 상한(cgroup:
CPUQuota=20%,MemoryMax=96M)을 걸어, 노드 하나가 쓸 수 있는 몫을 같게 했다. 일이 노드 사이로 실제로 나뉘면 처리량이 노드 수에 비례해 늘어야 한다. - PostgreSQL 17과 Redis 7은 시험 전용으로 따로 띄웠다(docker, PostgreSQL
--cpus=0.3 --memory=192m, Redis--cpus=0.15). 운영 서비스의 데이터베이스에는 붙지 않았다. 호스트 포트를 열지 않고 컨테이너 주소로 붙었다(포트를 열면 프록시가 트래픽을 한 번 더 복사해 박스 CPU가 부풀었다). - 지연은 받는 쪽 시계로 쟀다. 발행한 클라이언트가 아니라 다른 클라이언트가 프레임을 받기까지의 시간이다. 부하 생성기는 Go로 쓴 WebSocket 클라이언트이고, 가장 센 부하(18,000 프레임/s)에서 0.33코어·28 MB를 썼다.
- 한 번에 25초: 워밍업 5초 + 측정 20초. 대부분의 칸을 2회 쟀고, 1회뿐인 칸은 따로 적었다.
- 서버 쪽 숫자를 함께 기록했다. 노드 CPU, PostgreSQL CPU와 스로틀, 연결 상태와 대기 이벤트, Redis CPU. 그래야 "어디가 막았나"를 말할 수 있다.
- 같은 방·같은 유저로 다시 돌리면 중복 방지 때문에 서버가 저장을 건너뛰고 ack만 돌려준다. 그래서 런마다 식별자를 새로 넣었다.
한계 — 이 숫자가 말하지 않는 것
- 지연은 부풀려져 있다. cgroup CPU 상한은 처리량(CPU 시간)은 맞게 주지만 지연을 늘린다. 20% 상한은 10 ms마다 2 ms만 돌게 하므로 2 ms가 넘는 일(300소켓 팬아웃 한 번)은 벽시계로 최대 5배 길어진다. 실제로 이 크기의 작은 서버를 쓴다면 지연은 이보다 낫다. 지연 숫자는 노드 수끼리 비교할 때만 믿는다. 상한에 딱 붙은 지점에서는 지연이 런마다 크게 달랐다(예: 같은 설정의 p50이 167 ms와 908 ms).
- PostgreSQL이 하나라서, 발행이 많은 부하는 PostgreSQL에서 먼저 막힌다. 노드를 더해도 풀리지 않는다. 위 표의 PostgreSQL 코어당 약 1,300은 이 시험의 PostgreSQL 설정에서 나온 값이고, 코어 1개 이상은 외삽이다.
- 방 하나에 발행이 몰리는 경우는 재지 않았다. 시험은 방을 흩었다. 한 방의 순서 번호는 그 방의 행 잠금 안에서 발급되므로, 방 하나가 받는 발행의 상한은 트랜잭션 시간이 따로 정한다. 인기 방송 하나에 채팅이 쏟아지는 경우가 이쪽이다.
- Redis 하나의 한계도 재지 않았다. 메시지마다 노드 수만큼 pub/sub 전달이 늘어난다. 3노드·초당 70건에서 Redis는 0.05코어라 멀었지만, 노드 수십 대에 초당 수천 건은 따로 재야 한다.
- 시험 PostgreSQL은 운영과 설정이 다르다. 공유 버퍼 32 MB, 컨테이너 디스크, 통계 확장 켬. 운영 데이터베이스는 이보다 크고 같은 서버의 다른 데이터베이스와 CPU를 나눈다.
- 진짜 다른 기계 사이의 네트워크는 없다. 이 시험은 루프백과 도커 브리지다. 같은 가용 영역이면 홉마다 0.1–1 ms와 네트워크 카드·전송량 비용이 더해진다.
- 다른 서비스가 같이 도는 서버다. 시험 중 같은 서버의 다른 서비스 응답은 변하지 않았지만, 깨끗한 전용 서버의 숫자는 아니다.
- 그 밖에 안 본 것: 연결 수가 아주 클 때, 재접속이 한꺼번에 몰릴 때, 데이터가 많이 쌓였을 때(시험의 메시지는 최대 수십만 행), 20초보다 긴 시간, 읽기·접속 상태 갱신과 섞인 부하.
앱마다 두는 기본 한도(한도)는 한 앱의 폭주가 다른 앱을 느리게 하지 않도록 두는 안전 장치이고, 위 시험의 용량 숫자와는 별개다.
재현
시험 코드와 스크립트는 엔진 저장소에 있고 그 저장소는 비공개라 링크를 걸 수 없다. 대신 같은 시험을 직접 차릴 수 있게 방법을 적는다.
- 서버 한 대에 노드를 N개 띄우되 노드마다 같은 상한을 건다. systemd라면
systemd-run -p CPUQuota=20% -p MemoryMax=96M …. - 시험 전용 PostgreSQL과 Redis를 따로 띄운다.
docker run --cpus=0.3 --memory=192m …, Redis는--cpus=0.15. 호스트 포트를 열지 말고 컨테이너 주소로 붙인다. 운영 데이터베이스에 붙지 않는다. - 앱을 하나 만들고 발행 속도·연결 수 한도를 시험 값보다 높게 올린다. 한도가 기본값이면 한도가 먼저 막는다.
- WebSocket 부하 생성기로 연결을 노드에 돌아가며 붙인다. 전달 시험은 방 하나·연결 300·발행자 30, 발행 시험은 방 N개·방마다 연결 2개(두 연결은 서로 다른 노드). 런마다 새 식별자를 넣고, 지연은 다른 클라이언트가 받는 시각으로 잰다. 연결이 수백 개이고 프레임이 초당 수만 개라면 부하 생성기를 JS로 쓰면 생성기가 먼저 포화되니 Go 같은 쪽이 낫다.
- 워밍업 5초 + 측정 20초, 레벨을 올려 가며 "제안 발행률 = 달성 발행률, 전달 100%, 느린 소비자 0"이 깨지는 지점을 찾는다. 같은 때에 노드 CPU, PostgreSQL CPU와 스로틀(
docker stats또는 cgroup 통계),pg_stat_activity를 1초마다 남겨 무엇이 막았는지 본다. - 서버가 다른 일도 한다면 시험 전에 CPU 크레딧과 남은 메모리를 보고, 박스 CPU가 85%를 넘으면 멈춘다.