gRPC 단건 릴레이는 정말 느렸을까?
Published: July 25, 2026
Minichat은 서버 여러 대로 나눠서 운영된다. 어떤 유저가 어느 서버에 붙어 있는지는 Redis가 알고 있고, 다른 서버에 있는 유저에게 메시지를 보내려면 서버 간에 gRPC로 전달해야 한다.
처음엔 이 릴레이를 수신자 한 명마다 gRPC 한 번씩 보내는 방식으로 짰다.
[단건 방식]
Server A → Server B [user1 (gRPC 1회)]Server A → Server B [user2 (gRPC 1회)]Server A → Server B [user3 (gRPC 1회)]유저 1,2,3 모두 같은 B서버에 있다고 가정.
원격에 있는 유저가 100명이면 gRPC 호출도 100번이다. 이걸 대상 서버 한 대당 한 번씩만 보내도록 바꿨다.
[벌크 방식]
Server A → Server B: [user1, user2, user3, ...] (gRPC 1회)두 방식의 차이를 부하 조건별로 재측정했다.
이전 측정을 폐기한 이유
섹션 제목: “이전 측정을 폐기한 이유”이 실험은 처음이 아니다. 예전에 한 번 A/B 테스트를 돌린 적이 있고, 결과는 이랬다.
| 벌크 | 단건 | |
|---|---|---|
| 전달률 | 100.0% | 21.9% |
| p95 지연 | 346ms | 1분 49초 |
| 처리량 | 18,634/s | 4,081/s |
전달률 21.9%는 성능이 떨어졌다는 의미가 아니라, 메시지 대부분이 아예 전달되지 않았다는 뜻이다. 따라서 p95 1분 49초 역시 도착한 일부 메시지만을 대상으로 계산된 값일 뿐, 단건 릴레이의 실제 성능을 나타낸다고 보기 어렵다.
이 상태에서는 성능 외의 요인이 결과에 크게 영향을 미쳐, 두 방식의 차이를 비교할 수 없다. 이전 측정을 폐기하고 실험을 다시 설계했다.
실험 재설계
섹션 제목: “실험 재설계”두 개의 변수
섹션 제목: “두 개의 변수”방 인원 수만 조절하면 두 가지가 동시에 변한다.
- 메시지 하나당 전달해야 할 수신자 수
- 방 안에서 초당 발생하는 메시지 수
두 변수가 함께 움직이면 결과가 나빠졌을 때 원인을 특정할 수 없다. 이번엔 방 안에 있는 사람 수와 실제로 말하는 사람 수를 별도 변수로 분리했다.
이 구성이면 예를 들어 방 인원 150명 중 30명만 말하고 나머지는 받기만 하는 조건을 만들 수 있다.
무엇을 측정할 것인가
섹션 제목: “무엇을 측정할 것인가”gRPC 호출 횟수만 세면 부분적인 그림만 나온다. 서버의 실제 작업량을 확인하려면 스레드풀에 들어가는 작업(Task) 단위로 봐야 한다.
여기서 작업(Task)은 스레드풀에 제출되는 Runnable 하나를 말한다.
메시지 하나가 들어오면 서버는 다음 일을 한다.
단건 방식
총 150개 Task 생성
메시지 1건 발생 ├── 로컬 전송 (50개 Task) : WebSocket → 유저 1~50명 각자 전송 └── 원격 전송 (100개 Task) : gRPC → Server B (유저 51) : gRPC → Server B (유저 52) : ... : gRPC → Server C (유저 150)벌크 방식
총 52개 Task 생성
메시지 1건 발생 ├── 로컬 전송 (50개 Task) : WebSocket → 유저 1~50명 각자 전송 └── 원격 전송 (2개 Task) : gRPC → Server B [유저 51~100 목록] : gRPC → Server C [유저 101~150 목록]방 인원 150명, 서버 3대 기준 메시지 1건당 작업 수는 단건 150개, 벌크 52개(로컬 WebSocket 50 + 원격 gRPC 2)다. 이 작업들은 전부 같은 스레드풀에서 처리된다. 벌크는 gRPC 호출 수뿐 아니라 스레드풀에 던지는 작업 수 자체를 줄인다.
따라서 이번 실험에서는 지연 시간뿐 아니라 작업 수와 스레드 풀 크기(executor_pool_size_threads)도 함께 관찰했다.
통제 변수
섹션 제목: “통제 변수”두 방식을 비교하는 동안 아래 값들은 고정했다.
| 항목 | 값 |
|---|---|
| 서버 인스턴스 | 3대 |
| 스레드풀 | core 30 / max 200 / queue 1000 |
| 부하 시간 | 송신 120초 + 수신 대기 60초 |
| Prometheus scrape | 1초 |
두 모드는 같은 실행 파일에서 설정 플래그로만 전환했다. 코드를 각각 띄워 재는 방식은 피했다.
재측정 결과
섹션 제목: “재측정 결과”전 10개 조건에서 전달률 100%, Kafka LAG 0이었다. k6가 보낸 건수, Kafka 오프셋 증가량, DB에 저장된 건수 세 값이 모두 일치했다.
축 A — 방 안에 있는 사람 수를 늘려가며
섹션 제목: “축 A — 방 안에 있는 사람 수를 늘려가며”| 방 인원 | p50 | p95 | max | 초당 작업 | 스레드 풀 크기 | |
|---|---|---|---|---|---|---|
| 30 | 단건 | 64ms | 107ms | 340ms | 890/s | 30 |
| 벌크 | 60ms | 105ms | 422ms | 360/s | 30 | |
| 90 | 단건 | 42ms | 81ms | 476ms | 2,600/s | 200 |
| 벌크 | 73ms | 111ms | 410ms | 950/s | 30 | |
| 150 | 단건 | 118ms | 167ms | 275ms | 4,400/s | 200 |
| 벌크 | 45ms | 80ms | 202ms | 1,550/s | 30 |
표의 ‘초당 작업’은 executor_completed_tasks_total 카운터에 rate(…[10s])를 적용한 값이다.
축 B — 방 인원 150명 고정, 실제로 말하는 사람 수를 늘려가며
섹션 제목: “축 B — 방 인원 150명 고정, 실제로 말하는 사람 수를 늘려가며”| 초당 메시지 | p50 | p95 | max | 초당 작업 | 스레드 풀 크기 | |
|---|---|---|---|---|---|---|
| 10 | 단건 | 36ms | 71ms | 219ms | 1,500/s | 170 |
| 벌크 | 41ms | 62ms | 208ms | 500/s | 30 | |
| 30 | 단건 | 118ms | 167ms | 275ms | 4,400/s | 200 |
| 벌크 | 45ms | 80ms | 202ms | 1,550/s | 30 | |
| 50 | 단건 | 181ms | 298ms | 662ms | 7,300/s | 200 |
| 벌크 | 99ms | 154ms | 205ms | 2,300/s | 30 |
표에서 눈에 띄는 점은 세 가지였다.
- 부하가 낮은 조건에서는 두 방식의 차이가 크지 않았다.
- 방 인원 90명에서는 단건의 p50이 더 낮게 측정됐다.
- 부하가 가장 큰 조건에서는 벌크의 지연 시간이 더 안정적으로 유지됐다.
이 세 가지를 순서대로 살펴봤다.
부하 크기와 지연 격차
섹션 제목: “부하 크기와 지연 격차”방 인원 30명·초당 30건 조건에서 두 방식의 p50은 각각 64ms와 60ms로 거의 같다. 방 인원 150명·초당 50건 조건에서는 181ms와 99ms로 수치에서 확실히 차이가 났다.
두 방식의 차이는 단순히 gRPC 호출 횟수에만 있지 않다. 단건 방식은 수신자마다 Task를 하나씩 생성하고, 벌크 방식은 서버마다 하나의 Task만 생성한다. 결국 같은 부하에서도 스레드풀에 들어가는 작업량이 달라진다.
이 차이가 실제로 스레드풀 동작에도 나타나는지 확인하기 위해 ThreadPoolTaskExecutor의 동작을 함께 살펴봤다.
1. 스레드가 30개 미만이면 → 스레드 생성2. 큐에 자리가 있으면 → 큐에 넣기 (최대 1000)3. 큐가 꽉 찼으면 → 스레드 생성 (최대 200)4. 그것도 넘치면 → 호출자가 직접 처리

방 인원 150명 · 초당 50건 조건. 위쪽이 단건, 아래쪽이 벌크다. 두 그래프의 Y축 범위는 동일하게 고정했다.
[단건]
초당 Task 4,400개──► ThreadPool 확장──► Thread [200 / 200]──► 처리 성공단건은 방 인원 90명 이상 조건에서 예외 없이 스레드가 200개까지 늘어났다. 벌크는 10개 조건 전부에서 30개를 유지했다.
단건 역시 모든 메시지를 정상적으로 처리했다. 전달률은 100%였고 Kafka LAG도 발생하지 않았다.
다만 이를 위해 스레드풀을 최대 크기인 200개까지 확장해야 했다.
[벌크]
초당 Task 1,550개──► 확장 없음──► Thread [ 30 / 200]──► 처리 성공ThreadPoolTaskExecutor는 현재 설정(core 30, queue 1000)에서는 큐가 가득 차야 추가 스레드가 생성된다.
벌크 방식은 생성되는 Task 수 자체가 적었고, 측정 내내 스레드풀은 core 크기(30)를 유지했다. 스레드가 30개에 머물렀다는 건, 이 부하에서는 큐가 가득 찰 만큼 작업이 쌓이지 않았다는 뜻이다.
방 인원 90명 조건의 단건 우세
섹션 제목: “방 인원 90명 조건의 단건 우세”방 인원 90명 조건에서는 단건의 p50이 42ms, 벌크가 73ms로 단건이 수치상 더 나았다.
단건은 생성되는 Task 수가 많았고, 스레드풀은 최대 크기인 200개까지 확장됐다. 현재 스레드풀 설정에서는 큐가 포화된 이후에만 추가 스레드가 생성되므로, 이 부하에서는 큐가 포화 구간에 도달했음을 의미한다.
벌크는 애초에 Task 수가 적어 스레드를 늘릴 필요가 없었다. 30개의 스레드만으로도 충분히 처리 가능했지만, 병렬 처리 횟수 자체는 단건보다 낮았기 때문에 이 조건에서는 p50이 다소 높게 나타났다.

방 인원 90명 조건. 위쪽 단건은 스레드가 200개까지 확장됐고, 아래쪽 벌크는 30개를 유지했다.
방 인원 150명 조건에서는 스레드가 이미 최대치(200개)에 도달해 더 이상 병렬성을 늘릴 수 없었다. 이후 증가한 Task는 즉시 병렬 처리되지 못했고, 그 결과 p50도 함께 상승했다.
방 인원 30명 조건의 단건 p50(64ms)이 90명(42ms)보다 높은 것도 같은 이유다. 30명 구간은 스레드 30개로 처리되지만, 90명 구간은 스레드가 확장되어 더 많은 병렬 처리가 가능했다.
Tail Latency
섹션 제목: “Tail Latency”측정 전 가설은 다음과 같았다. 벌크는 하나의 gRPC 호출 안에서 수신자를 순서대로 처리하므로, 그 서버에 속한 마지막 유저는 앞선 전송이 모두 끝나기를 기다린다. 이 순차 처리가 max 지연을 악화시킬 수 있다고 보았다.
그러나 측정 결과는 가설과 다른 방향으로 나왔다.
| 조건 | 단건 max | 벌크 max |
|---|---|---|
| 방 150명 · 초당 30건 | 275ms | 202ms |
| 방 150명 · 초당 50건 | 662ms | 205ms |
방 150명·초당 50건은 벌크의 순차 처리 대기 시간이 가장 길어지는 조건이다. 그럼에도 벌크의 max가 단건보다 낮았다.
벌크는 하나의 Task 안에서 순차적으로 50명을 처리하지만, Task 자체는 매우 적다. 반면 단건은 초당 수천 개의 Task를 동시에 스레드풀에 등록하면서 Task 등록, 스케줄링, 컨텍스트 스위칭이 계속 발생한다.
이번 부하에서는 이 오버헤드가 순차 처리 비용보다 더 크게 작용했고, 그 결과 max latency도 벌크가 더 낮게 측정됐다.
측정을 왜곡한 요소들
섹션 제목: “측정을 왜곡한 요소들”성능 실험에서 측정하려는 성능이 아닌 다른 요인이 결과에 섞이면 그 숫자는 의미를 잃는다. 이번 실험에서 걸러낸 요소는 다음과 같다.
| 요소 | 결과에 미친 영향 |
|---|---|
| 로그 레벨이 DEBUG | 같은 조건 p50 66ms → 36ms |
| Micrometer 히스토그램 오류 | 부하 상승 시 지표 수집 실패 |
| 리셋 스크립트가 Kafka 파티션 수를 변경 | 조건 간 통제 실패 |
DEBUG 레벨의 로그가 초당 수천 줄씩 기록되면 그 자체가 부하가 된다. 첫 번째 조건을 측정한 뒤 로그 레벨을 WARN으로 변경하여 재측정했고, p50이 66ms에서 36ms로 낮아졌다.
Micrometer의 히스토그램 계산은 부하가 일정 수준을 넘으면 예외를 발생시켰고, 그 결과 /actuator/prometheus 응답이 500으로 나왔다. 이번 실험에서 필요한 지표는 히스토그램이 아니었으므로 관련 설정을 비활성화했다.
리셋 스크립트가 Kafka 토픽을 삭제하면서 파티션 수가 기본값(1개)으로 재생성되는 문제가 있었다. 조건마다 파티션 수가 달라지면 동일한 부하를 비교할 수 없으므로, 토픽은 유지하고 DB만 초기화하도록 수정했다.
이런 요소들을 제거한 뒤 모든 조건을 다시 측정했다.
왜 큐 대기 최고값을 비교 지표로 쓰지 않았을까
섹션 제목: “왜 큐 대기 최고값을 비교 지표로 쓰지 않았을까”executor_queued_tasks(작업 대기 큐 크기)는 해석에 주의가 필요했다. 예를 들어 단건 방식에서는 방 인원 150명 조건의 최고값이 179였지만, 오히려 부하가 더 낮은 조건에서 398이 관측됐다.

398이 관측된 시각은 테스트 시작 직후다. 이후 구간에서는 0에 가깝게 유지된다.
언뜻 보면 낮은 부하에서 큐가 더 많이 쌓인 것처럼 보인다. 하지만 원인은 메시지 처리 부하가 아니라 테스트 시작 직후 150개의 WebSocket 연결이 한꺼번에 생성된 구간이었다.
실제 메시지 부하가 지속되는 구간에서는 큐 크기가 대부분 0에 가까웠다. 따라서 이 값은 시스템의 처리 한계를 보여주는 지표라기보다, 순간적인 접속 폭주를 반영한 값으로 보는 게 적절하다고 판단했다.
이번 실험에서는 큐 대기 최고값을 포화 여부의 판단 기준으로 사용하지 않았다. 대신 부하가 증가했을 때 스레드풀이 실제로 확장됐는지를 기준으로 비교했다.
마치며
섹션 제목: “마치며”이번 실험의 결론은 두 가지다.
첫째, 벌크 릴레이가 빨랐던 이유는 gRPC 호출을 줄였기 때문이 아니라, 스레드풀에 던지는 작업(Task) 자체를 크게 줄였기 때문이다. 덕분에 같은 부하에서도 스레드풀을 확장하지 않고 처리할 수 있었다.
둘째, 이번 실험을 하면서 가장 오래 걸린 건 성능을 재는 일이 아니라, 제대로 잴 수 있는 환경을 만드는 일이었다. 로그 설정 하나, 측정 설정 하나만 달라도 결과가 달라질 수 있다는 걸 다시 확인했다.