저번 포스트에 이어서, SSE 구독 요청을 동시에 수행하는 부하 테스트를 진행합니다.
https://soberyl.tistory.com/34
[Test] 점진적 부하테스트: 10,000명 유저의 동시 SSE 구독 요청 - 설계 실패
지금 유지 보수하고 있는 이커머스 서비스에서는 고객이 로그인을 하면 동시에 SSE 구독을 요청하는 구조를 취하고 있습니다. 이 기능이 서비스가 커져감에 따라서 100, 1000으로 점진적으로 증가
soberyl.tistory.com
구독 요청을 제외한 모든 API 요청을 시나리오에서 제거해서, 오직 SSE 구독 API의 성능과 메모리 관리의 관점에서만 분석합니다.
시나리오 1: 1000명 유저 SSE 구독 요청


여기서 http_req_waiting = TTFB(Time To First Byte)입니다. 즉, 구독 요청을 하고, 첫 HTTP 200 OK 헤더를 보낼 때 까지 98ms가 걸렸다고 볼 수 있습니다. 오른쪽의 http_req_connecting은 TCP를 연결하는데 걸린 시간입니다. k6가 구독 요청을 보내고 이에 대한 SSE의 첫 답변을 받는데 걸린 시간은 아래와 같이 상정할 수 있습니다.
실질 대기 시간 = connecting (네트워크) + sending (요청 전송) + waiting (서버 처리)
요청 전송 시간은 GET 요청 데이터가 작으므로 거의 0ms 입니다. 따라서 SSE 연결을 하고 첫 응답을 하기까지 걸린 시간은 82 + 84 = 166ms입니다. 즉, 0.1초 이므로 문제가 없음을 확인할 수 있습니다.
시나리오 1: 10000명 유저 SSE 구독 요청
이제는 다음과 같이 시나리오를 진행해보겠습니다.
export const options = {
tags: {
application: 'notification-sse-load-test',
},
scenarios: {
sse_subscribe_test: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '1m', target: 1000 },
{ duration: '2m', target: 10000 },
],
gracefulStop: '120s',
},
},
thresholds: {
'sse_real_errors': ['rate<0.01'],
},
};
...
const subscribeRes = http.get(`${BASE_URL}/noti/subscribe`, {
headers: commonHeaders,
timeout: '60s'
});
...
테스트 결과

10000개를 요청하니, 요청의 62.33%가 실패했습니다. 여기서 분석을 위해서 각종 지표들을 살펴보겠습니다.

TTFB가 천정부지로 올라간 것을 볼수 있습니다. 60000ms, 즉 60s가 최초 응답시간까지 걸렸습니다. 너무 이상한 수치라고 생각됩니다. TCP 연결 시간은 300ms으로, 이전의 값인 84ms에 비해서 3.5배 가량 올라간 것을 확인했습니다.

File descriptor 수는 23개에서 8214개까지 밖에 올라가지 못했습니다. SSE 연결 수만큼 생긴다는 것을 고려하면 1809개 만큼이 모자란 것 같습니다.


Eden 영역의 Used: 현제 실제로 객체가 차지하고 있는 메모리의 양
Eden 영역의 Committed: JVM이 운영체제로부터 미리 할당 받아놓은 메모리의 크기
(Used <= Committed <= Max)
테스트가 시작하니 Used가 계속해서 올라가고, Commited는 처음에 많은 양을 할당한 것을 볼 수 있습니다.

스레드 수입니다. 최초에는 50개 정도까지만 받아들였다가, 테스트 종료 후에 갑자기 200개 초반대 까지 치솟았습니다.

Allocated: JVM 힙 메모리에 새로 만들어진 객체의 양
-> 여기서는 new SseEmitter()를 호출할 때마다 올라갈 것입니다.
Promoted: Old 영역으로 승격된 객체의 양
-> SseEmitter가 일정 시간이 지나도 사용되고 있다고 판단되면 Old 영역으로 이동할 것입니다.

Errors
중반까지는 잘 요청이 진행 되다가, 중 후반부터 EOF 에러가 나왔습니다.
WARN[0194] Request Failed error="Get \"http://ip:8080/api/noti/subscribe\": EOF"
후반 부에서 부터 dial i/o timeout 에러가 같이 나오기 시작했습니다.
WARN[0194] Request Failed error="Get \"http://ip:8080/api/noti/subscribe\": dial: i/o timeout"
분석
먼저 이유가 가장 명확해 보이는 Error 로그부터 분석해보겠습니다.
[Error] Request Failed: Get "address": EOF
EOF(End Of File)는 원래는 파일을 다 읽었다는 뜻이지만, 네트워크에서는 상대방, 즉 k6 입장에서는 서버가 소켓 연결을 강제로 닫았다(FIN/RST)는 뜻입니다. k6가 응답을 받을 때까지 기다리고 있었는데 갑자기 연결이 끊겨서 에러를 던지는 것입니다.
가설 (1)
파일 디스크립터 한도에 도달해서, 더 이상 JVM에서 열 수 있는 파일이 없으면 연결을 끊기 때문에 EOF가 발생했을 것입니다. 따라서 열 수 있는 파일의 개수의 한도를 늘리면 해결될 것입니다.
저는 지금 도커에서 이미지 형태로 spring boot를 띄워놓았습니다. 여기서 확인한 결과는 다음과 같습니다.
begroom@serverui-MacBookPro ~ % docker exec -it begroom-was cat /proc/1/limits | grep "Max open files"
Max open files 1048576 1048576 files
여기서 이미 1만개를 넘었기 때문에(1048576개, 1MB) 이 때문에 발생한 것이라고 보기에는 힘들 것 같습니다.
[Error] Request Failed: Get "address": dial: i/o timeout
dial, 즉 TCP 연결의 첫 단계인 3-Way Handshake 중 SYN을 시도했다는 뜻입니다. 여기서 서버가 SYN-ACK 응답이 없어서 발생한 에러입니다. 이 에러의 발생 과정을 흐름 순서대로 정리 해보겠습니다.
- SYN Backlog에서 리눅스 커널이 먼저 쌓아놓고 톰캣에게 넘겨줄 준비를 합니다.
- net.core.somaxconn 값을 확인하면 해당 큐의 사이즈를 확인할 수 있음.
- SYN Backlog에서 대기하던 요청이 톰캣에게 넘겨지기 직전에 쌓아놓는 톰캣 전용 대기실인 Accept Queue에 쌓입니다.
- 순차적으로 요청이 처리됩니다.
여기서 max-connections(톰캣 수용의 한도)가 8192(기본 설정 값)에 도달하면, 새로 들어오는 요청 1800개는 accept queue에 쌓입니다.
-> accept queue의 기본 설정 사이즈는 100입니다. 이것도 꽉 차게되면 handshake 중인 syn backlog에 정체가 발생합니다.
-> 리눅스 커널 설정인 net.core.somaxconn 한도까지 다 차버리면 모든 요청 대기공간이 사라집니다.
가설 (2)
리눅스 커널 somaxconn(Socket Maximum Connections) 값을 포함한 대기열의 사이즈가 주어진 요청의 양보다 작아서 처리를 못해서 dial: i/o timeout이 발생하는 것입니다.
begroom@serverui-MacBookPro ~ % docker exec -it begroom-was sysctl net.core.somaxconn
net.core.somaxconn = 4096
위는 명령어로 확인해본 somaxconn입니다. 8192(기본 수용 가능 값) + 100(톰캣 Accept queue) + 4096 (net.core.somaxconn) = 12388이므로 위 흐름에 따르면 정상적으로 연결이 되었었어야 했습니다. 또한 애플리켕션에서 설정한 backlog 값이 아무리 커도 커널의 somaxconn 수치보다 클 수 없습니다. 따라서 대기 큐 사이즈 때문에 발생한 문제는 아닐 것입니다.
[K6 Result] checks_succeeded: 37.66% (8292 out of 22018)
최초의 부하 테스트 결과화면을 보게되면 checks_succeeded가 8292/22018입니다. 8292 값에 주목할 필요가 있습니다.
이는 8192 + 100 == max-connections + accept count 와 정확하게 일치합니다.
12,388명 까지는 버텨야 한다고 가설을 세웠지만 실제로는 8292, 즉 톰캣의 한도까지만 버틴 것입니다. 그렇다면 k6가 정의하는 Success의 정의를 알아야 합니다.
K6에서 산출하는 성공의 기준은 TCP 연결을 맺는 것 뿐만 아니라 HTTP 200 OK 응답 헤더를 받아야 합니다.
톰캣은 max-connections가 꽉 차면, 내부 스레드가 사용 가능해질 때까지 새로운 요청을 accpet()해서 안으로 들여보내지 않습니다. 즉 somaxconn에 있는 나머지 1708개의 요청은 TCP Handshake는 끝났지만 톰캣이 받아주지 않아서 SYN Backlog에서 대기하다가 timeout이 일어나서 에러를 뱉고 요청이 끝나는 것입니다.
톰캣이 max-connections에 도달하면 Acceptor 스레드가 accept()를 멈춥니다. 이로 인해 OS의 Accept Queue에 쌓인 연결들이 배출되지 못하고 쌓이다가 큐가 가득 차게 되고(Accept Queue Full), 결국 뒤따라오던 요청들이 SYN Backlog에서도 밀려나며 타임아웃(Timeout)이 발생합니다.
아래는 이러한 것들이 진행되는 예시입니다. 아래의 사진에서 Poller Event의 사이즈가 8192인 것이 아니라, Poller 자체에 저장하는 공간이 있어서 그곳이 8192로 기본 값을 가지는 것이고, Poller Event는 단지 Accpetor와 Poller 사이의 버퍼입니다. 사실상 이곳은 무제한의 크기인 셈이고, Poller가 매우 빠른 속도로 polling하기 때문에 이곳에 요청이 쌓일 일은 거의 없습니다.
또한 Accpet queue와 SYN backlog의 차이를 명확하게 해야합니다. Accept queue는 TCP 연결이 완료된 요청이 대기하는 곳으로, OS 설정의 somaxconn과 application.yml 설정의 accept-count 중 작은 값으로 결정됩니다. SYN Backlog는 연결이 안된 상태의 요청들이 대기하고 있는 곳입니다.


❓Raw Socket -> NioChannel
랜선(네트워크)을 타고 들어온 연결은 OS가 관리하는 File Descriptor에 불과합니다. 이를 자바의 메서드인
serverSocket.accept()가 실행되면 OS에게 연결된 객체 중 하나를 달라고 요구합니다. 그러면 자바의 SocketChannel이라는 객체가 생성됩니다.
톰캣은 이 SocketChannel을 그대로 쓰는게 아니라, NioChannel로 한번 더 감쌉니다. (캡슐화) 그리고 여기에 SSL 암호화 유무, 버퍼 크기 등의 추가 정보를 적어놓습니다.
이제 Accpetor는 NioChannel 객체를 Poller의 Queue에 넣습니다. Poller는 이 Poller Queue만 바라봅니다.
여기서 NIO는 Non blocking I/O라는 뜻으로, 여기서는 아무 데이터도 안 보내는 요청을 잡고 워커 스레드가 낭비되던 시간을 Poller가 멈추지 않고 Queue에 쌓인 내용을 주기적으로 확인하고 뿌림으로서 해결했다는 의미입니다.
요청을 하게되는 타임라인을 보면 이해가 쉽습니다.
[0.00초] TCP 연결 성공 (Accept): 문은 열렸으나 요청에는 아직 데이터가 없음
[0.01초] 데이터 전송 시작: 클라이언트가 데이터를 쏘기 시작함
[0.05초] 데이터 도착 완료 (OP_READ): 서버에 데이터가 다 쌓임
여기서 NIO가 데이터 도착이 완료되면 그때가 돼야 Poller 스레드가 worker를 깨워서 처리합니다. 즉, [0.00~0.04초]에는 서버가 이 요청을 신경쓰지 않고 다른 일을 처리할 수 있게되고, 이 시간을 쉬지 않고(Non Blocking) 다른 일을 할 수 있기 때문에 NIO Channel이라는 의미가 성립합니다.
한편 Acceptor가 Pause를 푸는 조건은 "요청이 완료되어 Poller 스레드에 빈 공간이 생기면" 이라고 생각할 수 있습니다.
더 정확하게 말해서는 LimitLatch(전체 연결 수) 클래스가 Pause와 Resume를 결정하게 됩니다.
org.apache.tomcat.embed
tomcat-embed-core-11.0.15.org.apache.tomcat.util.net.AbstractEndpoint의 코드 내용입니다.
public abstract class AbstractEndpoint<S, U> {
...
private int maxConnections = 8 * 1024; // 기본 최대 수용값 8192
private volatile LimitLatch connectionLimitLatch = null;
// maxConnections 수정 메서드
public void setMaxConnections(int maxCon) {
this.maxConnections = maxCon;
LimitLatch latch = this.connectionLimitLatch;
if (latch != null) {
// Update the latch that enforces this
if (maxCon == -1) {
releaseConnectionLatch();
} else {
latch.setLimit(maxCon);
}
} else if (maxCon > 0) {
initializeConnectionLatch();
}
}
// Acceptor 스레드가 OS에서 요청을 가져오기 직전에 반드시 실행
protected void countUpOrAwaitConnection() throws InterruptedException {
if (maxConnections == -1) { // maxConnections == -1 이면 connection 제한없음.
return;
}
LimitLatch latch = connectionLimitLatch;
if (latch != null) {
// 여기서 정원이 꽉찼다면 스레드가 pause 됨.
// 빈 자리가 생길 때 까지 acceptor를 붙잡아 둠.
latch.countUpOrAwait();
}
}
// 대기 중인 모든 스레드를 풀어주고 정원 제한 기능을 꺼버림
private void releaseConnectionLatch() {
LimitLatch latch = connectionLimitLatch;
if (latch != null) {
// 대기 중인 모든 Acceptor를 즉시 깨움.
latch.releaseAll();
}
connectionLimitLatch = null;
}
// 요청 처리가 완전히 끝나고 소켓을 닫을 때 호출
protected void countDownConnection() {
if (maxConnections == -1) {
return;
}
LimitLatch latch = this.connectionLimitLatch;
if (latch != null) {
latch.countDown();
}
}
...
}
public class LimitLatch {
...
private volatile boolean released = false;
// 자원을 획득하려고 할 때 실행됨
@Override
protected int tryAcquireShared(int ignored) {
long newCount = count.incrementAndGet(); // 현재 접속자 수를 올림
if (!released && newCount > limit) {
if (log.isDebugEnabled()) {
log.debug(sm.getString("limitLatch.exceeded", Long.valueOf(limit)));
}
// Limit exceeded
count.decrementAndGet(); // 초과되면 올렸던 카운트를 원복하고 -1을 리턴하여 poller에 못 들어가게 함
return -1;
} else {
return 1;
}
}
// 입장 시도 및 대기
public void countUpOrAwait() throws InterruptedException {
if (log.isTraceEnabled()) {
log.trace("Counting up[" + Thread.currentThread().getName() + "] latch=" + getCount());
}
// tryAcquireShared가 구현체! tryAcquireShared를 실행해보고 결과가 -1이면 Accept queue에서 대기
sync.acquireSharedInterruptibly(1);
}
// 퇴장 및 자리 반납
public long countDown() {
// tryReleaseShared가 구현체! 대기 중이던 스레드를 깨움.
sync.releaseShared(0);
long result = getCount();
if (log.isTraceEnabled()) {
log.trace("Counting down[" + Thread.currentThread().getName() + "] latch=" + result);
}
// 지금 남은 인원 수를 반환
return result;
}
...
}
코드의 내용을 보면, count 값이 max값을 넘기면 pause 되도록 구성되어 있습니다. 또한 자원이 생길 때 까지 기다리는 구조이기 때문에 자원이 생기면, 즉 count 값이 줄어들면 바로 요청이 들어올수 있는 구조로 보입니다.
가설 (3)
지금 병목현상이 생긴 부분은 SSE 구독입니다. SSE는 한번 연결을 맺고나면 지정된 timeout 시간까지는 연결을 계속 붙잡고 있으므로, 8192개가 모두 연결을 물어놓고 안 놓습니다. 안 놓는 동안 SYN Backlog에 있던 요청들이 시간이 다 돼서 Timeout을 뱉는 것입니다. 따라서 애초에 이 만큼의 SSE 구독 연결을 유지하고 싶으면 연결할 수 있는 사이즈 자체를 늘려야합니다.
가설 (3) 개선 시도
일반 REST API에 대한 톰캣의 max-connections와 accpet-count 설정
- max-connections
- 예상 RPS * 평균 응답 시간 * 2
- 초당 1000명을 처리하고 응답이 0.1초 걸리는 상황
- 1000 * 0.1 * 2 = 200
- accept-count
- 기본값 또는 max-connections의 10%
- 처리가 매우 빠른데 길게 줄을 선다는 것은 서버가 죽었을 확률이 매우 높은 상황이므로 빨리 거절하는게 나을 수 있음.
장기 체류형 API에 대한 톰캣의 max-connections와 accpet-count 설정
- max-connections
- 목표 동시 접속자 수 * (1.2 ~ 1.5)
- 재접속이나 네트워크 불안정으로 인해 순간적으로 연결이 겹칠수도 있어서 20%의 여유 공간을 잡아놔야 함.
- accept-count
- max-connections * (0.1 ~ 0.5)
- 서버를 배포하고 재시작하면 끊어졌던 동시 사용 유저의 커넥션이 한꺼번에 재접속 요청을 시도함.
-> 동시에 모든 유저를 Accpetor가 다 들여보낼 수 없음.
-> Accept-count를 높여서 완충 작용을 유도함.
따라서 application.yml 파일의 내용을 다음과 같이 수정합니다.
...
server:
servlet:
context-path: /api
tomcat:
mbeanregistry:
enabled: true
max-connections: 13000 // 추가!
...
이제 문제없이 결과를 받아낼 것입니다.
가설 (3) 개선 결과



제가 설정했던 K6의 시나리오 세팅이 sse의 특성에 맞는 코드가 아니었기 때문에 0%가 나왔습니다. 타임 아웃을 20s로 걸어놨었는데, 잘 연결됐지만 반환되는 200 값이 SSE는 인식이 안되기 때문에 이를 처리하지 못하고 타임아웃의 거의 근처 값에서 에러를 뱉고 나온 것 입니다. 이를 다른 지표와 보면 서버는 정상적이라고 확인할 수 있습니다.

테스트는 33분부터 시작했습니다. 여기서 http_req_waiting은 요청을 하고, 최초의 응답을 받을 때 까지 걸린 시간인데, 이 응답은 어떠한 형태의 응답이라도 모두 포함입니다. TTFB, 즉 Time To First Byte를 의미합니다. 이 시간을 보게되면 매우 적게 나타난 것을 볼수 있고, 아무리 늦어도 1초보다 약간 넘게 나오는 정도로 그칩니다.

히카리CP도 Active가 순간적으로 3까지밖에 안 올라간 것을 보아서 준수한 DB I/O 시간임을 짐작할 수 있습니다.

최초의 상태보다 힙이 20%가량 높아졌습니다. 1만명의 유저도 좀 간당간당해 보여서, heap의 최적화가 중요할 것 같습니다. 너무 커진다 싶으면 추가적인 주기억장치를 고려할 것 같습니다.
❓왜 톰캣은 굳이 기본값을 8192 + 100 구조로 설정해놨는데?
accept-count는 왜 고작 100인가?
가망 없는 요청은 기다리게 해서 시간낭비 시키기 말고 빨리 빨리 돌려보내자!
만약 대기열을 1만개로 늘려좋으면 기다리는 요청은 클라이언트 측의 타임아웃이 발생함. 기본적으로 타임아웃 시간은 30초인데,
클라이언트는 30초라는 시간을 낭비했고, 서버도 연결을 유지하느라 메모리를 낭비했습니다.
-> 애초에 기다릴수 있는 요청 수를 짧게 제한하고, 나머지를 거절하는 것이 이득입니다.
왜 8192에서 멈출까?
NIO(Non-blocking)라고 해도 소켓 객체, 버퍼, SSL 핸드셰이크 정보 등을 메모리에 저장해놓아야 합니다.
만약 제한없이 다 받아버리면 JVM에서 Heap 메모리가 터져서 OOM이 터지고 서버가 죽습니다.
연결 1개당 메모리 비용을 계산하면 다음과 같습니다.
- 커널 영역 (TCP 소켓 버퍼)
- Read Buffer (tcp_rmem): 최소 크키 4KB
- Write Buffer (tcp_wmem): 서버가 데이터를 보낼 때 사용 (8~16KB)
- 메타데이터: FD 등 관리정보 (2KB)
- JVM 힙 영역 (톰캣과 스프링이 이 연결을 관리하기 위해 만드는 객체)
- 톰캣 객체: NioChannel, NioSocketWrapper, PollerEvent 등 캡슐화에 필요한 객체 (1~2KB)
- 입출력 버퍼 (AppBuffer): 톰캣이 내부적으로 사용하는 AppReadBuffer, AppWriteBuffer이 있는데, NIO는 이걸 필요할 때만 할당하거나 공유. (16KB)
- 요청에 대응하는 Spring 객체: 내부 큐, 새로 선언한 객체 등 (SseEmitter...) (1KB)
이에 따라 계산하면 40KB (연결 한 개당 필요한 메모리) * 8192 = 320MB
이때 최소 사양의 컴퓨터를 기준으로는 힙 메모리가 1GB밖에 없습니다. 따라서
300MB(운영체제+스프링 부트) + 320MB = 620MB 이므로 열약한 완경에서도 OOM으로 죽지 않고 잘 돌아가는 안전장치인 것입니다.
참고 문헌
https://mangkyu.tistory.com/422
[SpringBoot] 멀티 스레드 기반으로 다중 요청을 처리하는 톰캣(Tomcat)의 구조와 동작 방식
1. 멀티 스레드 기반으로 다중 요청을 처리하는 톰캣(Tomcat)의 구조와 동작 방식[ 웹 애플리케이션 서버(WAS, Web Application Server)과 톰캣 ]스프링 MVC 프레임워크는 자바 엔터프라이즈 개발을 편리하
mangkyu.tistory.com
'Test' 카테고리의 다른 글
| [Test] 점진적 부하테스트: 10,000명 유저의 알림 전송 요청 - JPA (0) | 2026.02.06 |
|---|---|
| [Test] 점진적 부하테스트: 30,000명 유저의 SSE 구독 요청 - TCP (0) | 2026.02.04 |
| [Test] 500명 유저의 동시 로그인 요청 처리 (0) | 2026.02.02 |
| [Test] 점진적 부하테스트: 10,000명 유저의 동시 SSE 구독 요청 - 삽질 (0) | 2026.02.02 |
| [Test] Prometheus, InfluxDB 하이브리드 부하 테스트 구조 설계 (0) | 2026.01.29 |