| 항목 | 값 |
|---|---|
| Framework | Spring Boot 4.0.2 (Tomcat 11) |
| JDK | Azul Zulu 21.0.9 |
| OS | macOS Darwin 24.6.0 |
| DB | MySQL 8.0 (Docker) |
| 측정 방식 | JVM 내부 (Runtime.getRuntime(), Thread.activeCount()) |
| 동시 연결 수 | 100 |
측정 방법: 각 프로토콜 테스트 전에
System.gc()를 2회 호출하고 500ms 대기하여 baseline을 확보한 뒤, 100개의 동시 연결을 생성하고 2초 후 측정했다. 테스트 간에도 GC + 2초 대기를 두어 이전 테스트의 영향을 최소화했다.
| 프로토콜 | Baseline | 부하 중 | 정리 후 | Delta (증가량) | 연결 당 메모리 |
|---|---|---|---|---|---|
| HTTP | 34.2 MB | 39.8 MB | 31.5 MB | +5.6 MB | 57.4 KB |
| SSE (SseEmitter) | 31.9 MB | 56.0 MB | 40.7 MB | +24.1 MB | 247.2 KB |
| SSE (Flux) | 40.5 MB | 71.8 MB | 41.3 MB | +31.4 MB | 321.1 KB |
| WebSocket (STOMP) | 40.6 MB | 61.1 MB | 46.6 MB | +20.5 MB | 209.7 KB |
- HTTP: 연결 당 57 KB로 가장 가벼움. 요청-응답 후 즉시 자원 반환되므로 정리 후 baseline 이하로 회복.
- SSE (SseEmitter): 연결 당 247 KB. 각 연결마다
SseEmitter객체 + 별도 가상 스레드가 생성되어 버퍼를 점유. - SSE (Flux): 연결 당 321 KB로 가장 높음. Reactor의
Flux.interval()스케줄러,ServerSentEvent빌더 객체, 내부 연산자 체인이 모두 힙에 올라감. - WebSocket: 연결 당 210 KB. 세션 객체 + STOMP 프레임 버퍼가 연결 동안 유지되지만, Flux 방식보다는 가벼움.
| 프로토콜 | Baseline | 정리 후 | 회수율 |
|---|---|---|---|
| HTTP | 34.2 MB | 31.5 MB | 완전 회수 (오히려 감소) |
| SSE (SseEmitter) | 31.9 MB | 40.7 MB | 부분 회수 (약 +9 MB 잔여) |
| SSE (Flux) | 40.5 MB | 41.3 MB | 거의 완전 회수 |
| WebSocket | 40.6 MB | 46.6 MB | 부분 회수 (약 +6 MB 잔여) |
| 프로토콜 | Baseline | 부하 중 | Delta | 특징 |
|---|---|---|---|---|
| HTTP | 33 | 46 | +13 | Tomcat 스레드풀에서 할당 후 반환 |
| SSE (SseEmitter) | 46 | 62 | +16 | 연결 당 가상 스레드 1개 생성 |
| SSE (Flux) | 74 | 86 | +12 | Reactor 스케줄러 스레드 공유 (소수) |
| WebSocket | 86 | 103 | +17 | 연결 당 I/O 스레드 할당 |
- HTTP: Tomcat 기본 스레드풀(200개)에서 빌려쓰고 돌려줌. 순간적 동시 요청에도 13개 정도만 사용.
- SSE (SseEmitter):
Thread.startVirtualThread()로 가상 스레드를 사용해 OS 스레드 점유는 적음. +16은 캐리어 스레드 수준. - SSE (Flux): Reactor의
parallel()스케줄러가 CPU 코어 수 만큼만 스레드를 만들어 공유. 100개 연결에도 +12. - WebSocket: 각 연결이 I/O 스레드를 점유. +17로 가장 많지만, 양방향 통신이 가능한 대가.
# 요청 (약 80 bytes)
GET /api/hello HTTP/1.1
Host: localhost:8080
User-Agent: curl/8.7.1
Accept: */*
# 응답 (약 130 bytes + 14 bytes body)
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 14
Date: Fri, 30 Jan 2026 13:09:10 GMT
Hello, Spring!
특징: 매 요청마다 ~80 bytes 요청 헤더 + ~130 bytes 응답 헤더 반복. 데이터 14 bytes 전송에 헤더 오버헤드가 210 bytes.
# 최초 요청 (약 80 bytes) — HTTP와 동일
GET /api/sse/stream HTTP/1.1
Host: localhost:8080
User-Agent: curl/8.7.1
Accept: */*
# 최초 응답 헤더 (약 100 bytes) — 1회만 전송
HTTP/1.1 200
Content-Type: text/event-stream
Transfer-Encoding: chunked
Date: Fri, 30 Jan 2026 13:14:11 GMT
# 이후 데이터 프레임 (약 62 bytes/이벤트) — 헤더 없이 반복
id:1
event:time
data:Server time: 2026-01-30T22:14:11.779986
id:2
event:time
data:Server time: 2026-01-30T22:14:12.785223
...
특징:
- 최초 연결 시에만 HTTP 헤더 교환 (~180 bytes)
- 이후 이벤트는
id: + event: + data:\n\n텍스트만 전송 (~62 bytes/이벤트) - HTTP로 동일 데이터를 10번 요청하면: 10 x (80+130+62) = 2,720 bytes
- SSE로 10개 이벤트 전송하면: 180 + 10 x 62 = 800 bytes (약 70% 절약)
# 핸드셰이크 요청 (약 220 bytes)
GET /ws/websocket HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
# 핸드셰이크 응답 (약 130 bytes) — 101 Switching Protocols
HTTP/1.1 101
Upgrade: websocket
Connection: upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Date: Fri, 30 Jan 2026 13:18:17 GMT
# 이후 데이터 프레임 (2~6 bytes 프레임 헤더 + payload)
# 예: "Hello" (5 bytes payload) → 총 7 bytes (2 byte header + 5 byte data)
특징:
- 핸드셰이크 1회: ~350 bytes (HTTP보다 큼 — 추가 헤더 필요)
- 이후 프레임: 2~6 bytes 헤더 + payload (HTTP 헤더 ~210 bytes 대비 극소)
- 양방향 가능: 클라이언트→서버도 동일한 경량 프레임
- 10회 양방향 메시지: 350 + 20 x 7 = 490 bytes
- HTTP로 동일 작업: 20 x 272 = 5,440 bytes (약 91% 절약)
| HTTP | SSE | WebSocket | |
|---|---|---|---|
| 연결 방식 | 요청마다 새로 (또는 Keep-Alive) | 단방향 지속 연결 | 양방향 지속 연결 |
| 연결 당 메모리 | 57 KB | 247~321 KB | 210 KB |
| 스레드 증가 | +13 (풀 공유) | +12~16 | +17 |
| 데이터 프레임 오버헤드 | ~210 bytes/요청 | ~0 bytes (텍스트 스트림) | 2~6 bytes |
| 방향 | 클라이언트 → 서버 | 서버 → 클라이언트 | 양방향 |
| 자원 회수 | 즉시 | 연결 종료 시 | 연결 종료 시 |
| 재연결 | 불필요 | EventSource 자동 재연결 | 직접 구현 필요 |
- 단발성 요청/응답 (REST API, CRUD)
- 클라이언트가 필요할 때만 데이터 요청
- 연결 유지 비용을 감당할 수 없는 대규모 서비스
- 서버 → 클라이언트 단방향 실시간 데이터 (알림, 주가, 로그 스트리밍)
- HTTP/2 환경에서 멀티플렉싱 활용 가능
- 자동 재연결이 필요한 경우 (EventSource API 내장)
- SseEmitter vs Flux:
- SseEmitter: 전통적 서블릿 환경, 직관적 코드
- Flux: 리액티브 환경, 스레드 효율적이지만 메모리 오버헤드 더 큼
- 양방향 실시간 통신 (채팅, 게임, 협업 편집)
- 메시지 빈도가 높아 HTTP 헤더 오버헤드가 부담인 경우
- 가장 낮은 데이터 프레임 오버헤드 (2~6 bytes)
벤치마크 엔드포인트:
GET /api/benchmark/all?connections=100 # 전체 프로토콜 순차 테스트
GET /api/benchmark/http?connections=100 # HTTP만
GET /api/benchmark/sse-emitter?connections=100
GET /api/benchmark/sse-flux?connections=100
GET /api/benchmark/websocket?connections=100
측정 원리:
// 1. GC 2회 → baseline 측정
System.gc(); Thread.sleep(500); System.gc();
long baseMem = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
int baseThreads = Thread.activeCount();
// 2. 동시 연결 N개 생성 → 2초 대기
// 3. 부하 중 측정
long duringMem = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
// 4. 연결 정리 → GC → 정리 후 측정이 벤치마크는 JVM 내부에서 직접 측정하여 외부 프로세스 영향을 최소화했다. 단,
System.gc()는 JVM에 대한 힌트일 뿐 보장이 아니므로, 수치는 상대적 비교 목적으로 참고해야 한다.