1. 개요
마케팅 푸시나 오픈 이벤트처럼 접속이 순간적으로 몰리는 서비스를 운영하다 보면, 평소엔 멀쩡하던 Tomcat이 몇 초 동안 클라이언트에 Connection refused를 뿌리는 장면을 만난다. 서버는 죽지 않았고 CPU도 여유가 있는데 일부 요청만 거부된다. 로그를 아무리 뒤져도 catalina 쪽엔 흔적이 없어서 더 헷갈린다.
결론부터 말하자면, 이 거부는 Tomcat이 내는 게 아니라 운영체제의 소켓 큐가 넘쳐서 생기는 경우가 많다. Tomcat의 커넥터는 요청을 받아들이는 길목에 세 개의 경계값을 둔다. OS가 쌓아두는 대기열 크기(acceptCount), Tomcat이 동시에 물고 있는 연결 수(maxConnections), 실제로 요청을 처리하는 스레드 수(maxThreads)다. 이 셋의 관계를 이해하면 거부가 어느 지점에서 터지는지 바로 짚을 수 있다.
이 글은 Apache Tomcat 11.0과 9.0의 기본 HTTP NIO 커넥터를 기준으로 한다. 값과 동작은 공식 Configuration Reference를 따랐고, 버전에 따라 기본값이 다를 수 있으니 실제 환경에서는 각자의 문서를 확인하기 바란다. OS 예시는 Linux 기준이다.
2. 요청이 거부되기까지의 세 단계
클라이언트 TCP 연결 하나가 애플리케이션 코드에 도달하기까지, 요청은 서로 다른 세 개의 관문을 지난다. 각 관문에 위에서 말한 경계값이 하나씩 걸려 있다.
2-1. OS 수락 큐와 acceptCount
TCP 3-way 핸드셰이크가 끝난 연결은 곧바로 애플리케이션에 전달되지 않는다. 커널이 수락 큐(accept queue)라는 대기열에 넣어두고, 서버 프로세스가 accept()를 호출해 하나씩 꺼내 가기를 기다린다. 이 큐의 최대 길이를 정하는 값이 acceptCount다. Tomcat은 리스닝 소켓을 열 때 이 값을 listen()의 backlog 인자로 넘긴다.
Tomcat의 acceptCount 기본값은 100이다. 문서 설명은 이렇다. "maxConnections에 도달했을 때 들어오는 연결 요청을 담는, 운영체제가 제공하는 큐의 최대 길이. 이 큐가 가득 차면 운영체제는 추가 연결을 거부하거나 타임아웃시킬 수 있다."
즉 수락 큐가 가득 찬 상태에서 새 연결이 들어오면 커널이 그 연결을 거절한다. 이때 클라이언트가 받는 것이 바로 ECONNREFUSED, 자바로 치면 java.net.ConnectException: Connection refused다. 프로토콜에 따라서는 거부 대신 패킷을 무시해 재전송을 유도하기도 하는데, 그러면 클라이언트 쪽에선 연결 타임아웃으로 나타날 수 있다.
2-2. 동시 연결 상한 maxConnections
서버 프로세스가 큐에서 연결을 꺼내 받아들이면, 그 연결은 Tomcat이 동시에 관리하는 연결 집합에 들어간다. 이 집합의 상한이 maxConnections다. NIO/NIO2 커넥터 기본값은 8192이다.
Tomcat의 Acceptor 스레드는 열려 있는 연결 수가 maxConnections에 닿으면 더 이상 accept()를 호출하지 않고 멈춘다. 그러면 새 연결은 꺼내지지 못한 채 2-1의 OS 수락 큐에 쌓이기 시작하고, 그 큐마저 acceptCount만큼 차면 그때부터 거부가 발생한다. 문서 표현으로는 "이 수에 도달하면 서버는 연결을 하나 더 받아들이기는 하지만 처리하지는 않는다"이다. NIO/NIO2에서 값을 -1로 두면 이 상한 자체를 끄고 연결을 세지 않는다.
2-3. 처리 스레드 maxThreads
받아들여진 연결에서 실제 HTTP 요청이 도착하면, 커넥터의 스레드풀이 작업 스레드 하나를 꺼내 요청을 처리한다. 이 풀의 최대 크기가 maxThreads, 기본값 200이다. 최소 유지 스레드인 minSpareThreads는 기본 10이다.
여기서 자주 오해가 생긴다. maxThreads는 "동시에 처리 중인 요청"의 상한이지 "동시에 열려 있는 연결"의 상한이 아니다. NIO 커넥터는 연결과 스레드를 1:1로 묶지 않기 때문에, 요청을 보내지 않고 가만히 연결만 유지하는 keep-alive 연결 수천 개를 200개 스레드로 감당할 수 있다. 그래서 maxConnections(8192)가 maxThreads(200)보다 훨씬 큰 것이다.
3. 기본값 한눈에 정리
Tomcat 11.0과 9.0의 HTTP 커넥터 기본값은 다음과 같다.
acceptCount: 100 (OS 수락 큐 길이)maxConnections: 8192 (NIO/NIO2 동시 연결 상한)maxThreads: 200 (요청 처리 스레드 상한)minSpareThreads: 10connectionTimeout: 문서 기본 60000ms, 다만 배포본 server.xml은 20000ms로 설정해 둔다keepAliveTimeout: 미지정 시connectionTimeout값을 따른다maxKeepAliveRequests: 100
순간 폭주에서 거부가 터지는 가장 흔한 조합은 "maxConnections는 넉넉한데 acceptCount가 100으로 작아서, 처리 지연으로 Acceptor가 잠깐 밀리는 순간 OS 큐가 바로 가득 차는" 경우다.
4. 거부가 어디서 터지는지 진단하기
catalina 로그만 보면 원인을 못 찾는다. 이 거부는 커널 레벨에서 일어나므로 Tomcat은 그 연결을 받아보지도 못하고, 따라서 catalina.out에 아무것도 남지 않는다. 이 점이 진단을 어렵게 만드는 첫 번째 함정이다.
대신 리스닝 소켓의 큐 상태를 커널에서 직접 본다. ss의 -lnt 옵션으로 리스닝 소켓을 조회하면, Recv-Q는 현재 수락 큐에 쌓여 아직 accept()되지 않은 연결 수, Send-Q는 그 큐의 최대치(실효 backlog)를 뜻한다.
$ ss -lnt 'sport = :8080' State Recv-Q Send-Q Local Address:Port Peer Address:Port LISTEN 101 100 0.0.0.0:8080 0.0.0.0:*
Recv-Q가 Send-Q에 바짝 붙거나 넘어선 순간이 바로 큐가 가득 차 거부가 일어나는 지점이다. 위 예시처럼 Send-Q가 100이면 실효 backlog가 100이라는 뜻이고, 이는 acceptCount와 OS 상한 중 작은 값이 적용된 결과다.
두 번째 함정이 여기 있다. Linux는 listen()에 넘긴 backlog가 net.core.somaxconn보다 크면 조용히 그 값으로 깎는다. acceptCount를 아무리 키워도 somaxconn이 작으면 거기서 막힌다.
$ sysctl net.core.somaxconn net.core.somaxconn = 4096
somaxconn 기본값은 커널 5.4부터 4096, 그 이전은 128이다. 오래된 배포판을 그대로 쓰는 서버라면 128에 묶여 있을 수 있으니 먼저 이 값을 확인한다. 스레드풀이 포화됐는지는 JMX의 ThreadPool MBean에서 currentThreadsBusy를 보면 된다. 이 값이 maxThreads에 닿아 있다면 거부의 원인이 큐가 아니라 처리 지연일 수 있다.
5. 커넥터와 커널을 함께 튜닝하기
튜닝은 두 군데를 같이 손봐야 효과가 난다. Tomcat의 server.xml과 커널의 somaxconn이다. 한쪽만 올리면 다른 쪽에서 막힌다.
5-1. server.xml 변경 전후
배포본 기본 커넥터는 대개 이렇게 생겼다.
<!-- 변경 전: 기본값 (acceptCount 100) -->
<Connector port="8080" protocol="org.apache.coyote.http11.Http11NioProtocol"
connectionTimeout="20000"
redirectPort="8443" />순간 폭주를 흡수하려면 OS 큐를 넓히고 스레드풀과 연결 상한을 명시한다.
<!-- 변경 후: 폭주 흡수용 -->
<Connector port="8080" protocol="org.apache.coyote.http11.Http11NioProtocol"
connectionTimeout="20000"
acceptCount="1024"
maxConnections="10000"
maxThreads="400"
minSpareThreads="50"
redirectPort="8443" />acceptCount를 1024로 넓히면 Acceptor가 잠깐 밀려도 그 사이 들어온 연결을 거부하지 않고 담아둔다. 폭주의 성격이 "짧게 몰렸다 빠지는" 형태라면 큐를 키우는 이 조정이 가장 직접적인 효과를 낸다.
다만 maxThreads는 무작정 키우면 안 된다. 요청 하나가 DB 응답을 수백 ms씩 기다리는 서비스라면 스레드를 늘리는 게 처리량에 도움이 되지만, CPU 바운드 작업이라면 스레드만 늘려도 컨텍스트 스위칭 비용만 커진다. 스레드를 늘리기 전에 느린 쿼리나 외부 호출부터 손보는 것이 먼저다.
5-2. somaxconn 올리기
acceptCount=1024를 설정했어도 somaxconn이 128이면 실효 backlog는 128이다. 커널 값을 함께 올린다.
# 즉시 적용 $ sudo sysctl -w net.core.somaxconn=4096 # 재부팅 후에도 유지 $ echo 'net.core.somaxconn = 4096' | sudo tee /etc/sysctl.d/99-tomcat.conf $ sudo sysctl --system
적용 뒤 Tomcat을 재시작하고 다시 ss -lnt로 Send-Q가 acceptCount만큼 올라왔는지 확인한다. 리스닝 소켓의 backlog는 소켓을 열 때 결정되므로, somaxconn을 바꾼 뒤에는 반드시 Tomcat을 다시 띄워야 반영된다.
5-3. keep-alive가 연결을 잡아먹을 때
거부가 OS 큐 때문이 아니라 maxConnections 자체가 차서 생기는 경우도 있다. keep-alive 연결이 오래 살아남아 연결 슬롯을 점유하는 상황이다. 이럴 땐 keepAliveTimeout을 짧게 잡거나 maxKeepAliveRequests를 조절해 유휴 연결을 빨리 정리한다. 프록시(Nginx, ALB)가 앞단에 있고 거기서 백엔드 연결을 재사용한다면, 프록시의 keep-alive 설정과 Tomcat의 설정을 함께 보는 것이 맞다.
6. 권장 설정
순간 트래픽이 몰리는 서비스라면 다음 순서로 접근한다. 먼저 ss -lnt로 거부가 OS 수락 큐(Recv-Q가 Send-Q에 붙음)에서 나는지, 아니면 스레드풀 포화(currentThreadsBusy가 maxThreads에 붙음)에서 나는지를 먼저 가른다. 큐 문제라면 acceptCount와 somaxconn을 함께 1024~4096 범위로 올린다. 처리 지연 문제라면 스레드를 늘리기 전에 느린 처리부터 줄인다. maxConnections는 keep-alive 연결 수를 넉넉히 덮도록 기본값 8192를 유지하거나 소폭 올린다. 세 값은 "OS 큐 < 동시 연결 < 그중 처리 스레드" 순으로 자리를 잡고 있다는 것만 기억하면, 다음에 거부가 터져도 어느 관문을 볼지 바로 안다.