힙은 남았는데 서비스가 멈췄다 · 풀이 마르면 서비스가 선다 · 이론
서비스는 가장 작은 풀에서 멈춘다
한 줄 요약
서비스는 가장 작은 풀에서 멈춘다. 요청 처리 스레드가 200개여도 DB 커넥션 풀이 10개이고 대기에 상한이 없으면, 느린 쿼리 하나가 190개의 스레드를 커넥션 앞에 세운다. 풀 크기와 대기 시간 상한은 짝으로 정해야 한다.
왜 이게 필요했나
앞 모듈의 사건은 잠금이었다. 현장에서 더 자주 보는 것은 풀 고갈이다. DB 가 잠깐 느려졌다. 커넥션 풀 10개가 전부 느린 쿼리에 붙들렸다. 다음 요청들은 커넥션을 무한정 기다린다. 요청 처리 스레드 200개가 그 대기에 전부 들어가면 헬스체크 요청조차 처리할 스레드가 없다. DB 가 1분 뒤 정상으로 돌아와도 밀린 요청이 다 빠지기까지 서비스는 죽어 있다. 로드밸런서는 그 사이 이 인스턴스를 빼고, 남은 인스턴스에 부하가 쏠려 같은 일이 번진다. 풀 크기 문제가 아니라 기다림에 상한이 없는 것이 문제다.
어떻게 동작하나
Tomcat 의 [HTTP 커넥터 문서](https://tomcat.apache.org/tomcat-10.1-doc/config/http.html)는 요청이 들어오는 길을 세 숫자로 설명한다. maxThreads — 요청 처리 스레드의 최대 수, 곧 동시에 처리할 수 있는 요청 수이며 기본 200. maxConnections — 서버가 받아서 처리 중으로 들고 있을 수 있는 연결 수; 이 수에 닿으면 연결은 받되 처리하지 않고 기다린다. acceptCount — maxConnections 까지 찼을 때 운영체제가 대기열에 쌓아 두는 연결 요청 수, 기본 100; 이 대기열마저 차면 운영체제가 연결을 거부하거나 타임아웃시킨다. 즉 요청은 스레드 → 연결 → OS 대기열 순으로 밀린다. minSpareThreads 는 항상 살려 두는 스레드 수로 기본 10 이다. connectionTimeout 은 연결을 받은 뒤 요청 줄(request line)이 오기까지 기다리는 밀리초로, 문서 기본은 60000 이지만 배포판 server.xml 은 20000 으로 적어 둔다. keepAliveTimeout 은 다음 요청을 기다리는 시간이며 지정하지 않으면 connectionTimeout 을 따른다. 커넥터에 executor 속성으로 공유 <Executor> 를 걸면 이 스레드 속성들은 무시되고 Executor 의 값이 쓰인다(tc-threadpool 실습이 그 길이다).
스레드 이름은 진단의 열쇠다. 커넥터의 내부 풀 스레드는 http-nio-8080-exec-N 으로 이름 붙고, 스레드 덤프에서 이 접두어를 세면 지금 몇 개가 떠 있는지, 그중 몇 개가 어디서 기다리는지 바로 보인다.
DB 커넥션 풀은 [JNDI 데이터소스 문서](https://tomcat.apache.org/tomcat-10.1-doc/jndi-datasource-examples-howto.html)의 DBCP 2 예시가 기준이다. context.xml 의 <Resource type="javax.sql.DataSource" …> 에 maxTotal(풀의 최대 커넥션 수, -1 이면 무제한), maxIdle(유휴로 유지할 최대 수), 그리고 maxWaitMillis — 커넥션이 날 때까지 기다리는 최대 밀리초로, 넘기면 예외를 던지고 -1 이면 무한정 기다린다 — 를 둔다. 사건의 원인이 바로 이 -1 이다. maxWaitMillis="2000" 이면 2초 뒤 예외가 나고, 애플리케이션은 그것을 503 으로 바꿔 스레드를 돌려보낼 수 있다.
같은 원리를 자바 코드로 보면 Semaphore(N) 이 커넥션 풀이다. acquire() 는 무한정 기다리고, tryAcquire(timeout, unit) 은 상한을 둔다. 덤프에서 acquire() 대기는 parking to wait for <…> (a java.util.concurrent.Semaphore$FairSync) 로 보인다. 실습의 PoolDemo 가 이 모양을 그대로 만든다 — 처리 스레드 4개, 커넥션 2개, 쿼리 3초. 요청 6개를 동시에 보내면 2개는 일하고 나머지는 커넥션 앞에 선다. -Ddb.pool.timeout.ms=500 을 주면 500ms 뒤 503 이 돌아온다.
관리 포트를 다른 스레드로 두는 것도 설계다. PoolDemo 의 /stats 는 8087 의 별도 스레드가 답하므로 처리 스레드가 전부 막혀도 상태를 읽을 수 있다. Tomcat 에서 JMX 나 별도 커넥터를 두는 이유가 같다 — 멈춘 서비스에 물어볼 길은 남겨 둔다.
Tomcat 을 여러 벌 운영하는 관례는 [소개 문서](https://tomcat.apache.org/tomcat-10.1-doc/introduction.html)의 CATALINA_HOME 과 CATALINA_BASE 다. HOME 은 설치본(bin·lib), BASE 는 인스턴스별 설정·로그·웹앱(conf·logs·temp·webapps·work)이다. 설정을 바꿀 때 설치본을 건드리지 않고 BASE 만 새로 만들어 띄우면, 실험용 인스턴스를 운영 설정과 격리할 수 있다. 실습에서 그렇게 띄운다.
현장에서 만나는 모습
크기만 늘리는 대응이 가장 흔하다. maxThreads 를 200 에서 800 으로 올리면 커넥션 앞에 서는 스레드가 800개가 된다 — 더 오래, 더 크게 멈춘다. 풀은 하류(DB)가 감당할 만큼만 두고, 대기에 상한을 두어 빨리 실패시키는 것이 답이다. 두 번째는 헬스체크가 DB 를 거치는 것이다. DB 가 느려지면 헬스체크도 실패해 정상 인스턴스까지 빠진다. 헬스체크는 풀을 건드리지 않는 경로여야 한다. 세 번째는 타임아웃을 한 층에만 두는 것이다. 커넥션 대기, 쿼리 실행, HTTP 클라이언트, 로드밸런서 — 층마다 상한이 있어야 하고 바깥이 안쪽보다 길어야 한다.
다음 실습에서 할 것
PoolDemo.java 를 띄워 동시 요청으로 커넥션 풀을 말리고 덤프에서 Semaphore 대기를 확인한 뒤, -Ddb.pool.timeout.ms=500 으로 다시 띄워 503 이 돌아오는 것을 본다. 그다음 CATALINA_BASE 를 /root/jvm/pool/tc 에 새로 만들어 server.xml 의 8080 커넥터에 maxThreads·acceptCount·connectionTimeout 을, context.xml 에 maxTotal·maxIdle·maxWaitMillis 가 든 DataSource 를 넣고, 그 인스턴스를 실제로 띄워 http-nio-8080-exec- 스레드를 덤프에서 센다.