이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • TCP 연결은 성공하는데 요청 처리가 밀릴 때 backlog, connection, thread를 어떻게 나누어 보는가?
  • accept-count, max-connections, max-threads는 각각 어떤 병목을 의미하는가?
  • 서버 앞단 LB/proxy가 502, 503, 504를 만들 때 app server에서 어떤 증거를 확인해야 하는가?

개요

서버는 요청을 “받는다”는 한 문장으로 설명되지만 내부에는 여러 대기열이 있다. TCP handshake가 끝나기 전의 SYN queue, handshake가 끝나고 애플리케이션이 accept하기를 기다리는 accept queue, accept된 뒤 실제 요청을 처리하는 worker thread 또는 event loop, 그리고 애플리케이션 내부의 DB/HTTP client pool이 순서대로 영향을 준다.

이 문서는 그중 서버 listener, backlog, thread pool을 중심으로 본다. 장애 때 “서버가 느리다”는 말만으로는 부족하다. 연결을 못 맺는지, 연결은 맺지만 accept가 밀리는지, accept는 되지만 worker가 바쁜지, worker는 있는데 후단 pool이 막혔는지를 나누어야 한다.

왜 백엔드 개발자가 알아야 하는가

백엔드 애플리케이션은 CPU가 낮아도 요청을 못 받을 수 있다. thread가 모두 외부 API를 기다리거나, connection limit이 차거나, accept queue가 넘치면 사용자에게는 timeout, 502, 503, 504처럼 보인다.

  • LB health check는 통과하지만 실제 요청은 worker thread 부족으로 느려질 수 있다.
  • traffic spike에서 accept queue overflow가 생기면 일부 연결만 실패한다.
  • slow request가 worker를 오래 점유하면 새 요청은 네트워크 문제가 아닌 내부 대기 때문에 밀린다.
  • max connection을 크게 늘리면 서버가 더 많은 요청을 받아들이지만, 처리 pool이 부족하면 장애가 더 넓게 퍼진다.

이 주제는 인프라와 애플리케이션의 경계에 있다. OS backlog와 LB timeout은 인프라 영역처럼 보이지만, worker thread를 오래 붙잡는 코드는 백엔드 구현의 책임이다.

요청 흐름에서의 위치

서버 listener는 LB/proxy가 애플리케이션으로 요청을 넘기는 지점이다.

Load Balancer / Reverse Proxy
→ TCP SYN
→ server listen socket
→ accept queue
→ application connector
→ worker thread or event loop
→ controller / handler

이 구간이 막히면 application handler가 실행되기 전이므로 비즈니스 로그가 없을 수 있다. 대신 OS socket 상태, container connector metric, access log의 request time, LB target error가 증거가 된다.

원리

서버 수신 경로의 주요 자원은 다음처럼 나눌 수 있다.

자원의미부족할 때 증상
SYN queuehandshake 완료 전 연결 대기connect timeout, SYN retransmission
accept queuehandshake 완료 후 accept 대기연결 지연, listen overflow
max connections서버가 동시에 유지할 연결 수새 연결 거절, LB 502/503
worker threads요청 처리를 맡는 thread요청 대기, p99 latency 증가
event loopnon-blocking I/O 처리 loopevent loop blocking 시 전체 지연
downstream poolDB/HTTP client 등 후단 자원worker가 WAITING/TIMED_WAITING으로 묶임

Tomcat 같은 servlet stack에서는 요청 처리 thread가 blocking 작업에 묶이기 쉽다. WebFlux/Netty 같은 event loop 기반 stack에서는 event loop를 block하지 않는 것이 중요하다. 모델은 다르지만 공통 질문은 같다. “요청을 받을 capacity”와 “요청을 끝낼 capacity”가 함께 충분한가?

Tomcat 기준으로 값 나누기

Spring Boot 내장 Tomcat을 예로 들면 값의 의미가 다르다.

설정의미조정할 때 볼 것
server.tomcat.max-connections동시에 받을 수 있는 connection 수keep-alive, slow client, file descriptor
server.tomcat.accept-countmax connection에 도달했을 때 OS가 대기시킬 요청 수burst traffic, overflow, LB retry
server.tomcat.threads.max요청 처리 worker thread 최대 수blocking I/O, DB/API wait, CPU
server.connection-timeout 또는 connector timeout연결 수립 후 요청을 기다리는 시간slowloris, idle client, proxy timeout

accept-count를 키우면 순간 burst를 더 오래 버틸 수 있지만, 실제 처리량이 늘어나는 것은 아니다. threads.max를 키우면 blocking 요청을 더 많이 처리할 수 있지만, DB pool이나 CPU가 그대로면 downstream 고갈로 이동할 수 있다.

실무에서 자주 만나는 문제

  • CPU 사용률이 낮아서 서버 여유가 있다고 판단한다.
    • 요청 thread가 외부 API나 DB lock을 기다리면 CPU는 낮아도 throughput은 무너진다.
    • thread dump와 pool metric을 같이 본다.
  • max thread를 크게 올려 latency를 숨긴다.
    • 더 많은 요청이 동시에 DB/API를 때려 downstream 장애가 커질 수 있다.
    • thread 증가는 임시 완화이고, 원인은 slow dependency나 blocking 구간일 수 있다.
  • health check endpoint만 가볍게 만들어 실제 장애를 숨긴다.
    • /health는 200인데 핵심 API는 thread나 DB pool 때문에 실패할 수 있다.
    • readiness는 최소 의존성과 처리 가능성을 반영해야 한다.
  • backlog overflow를 애플리케이션 exception으로 찾는다.
    • handler까지 도달하지 않으면 app error log가 없다.
    • OS listen overflow, LB target connection error, connector metric을 확인한다.

디버깅 방법

서버가 요청을 못 받는지, 받아도 못 처리하는지 나누어 본다.

# listen socket과 queue 확인
ss -ltn
 
# 특정 port의 established connection 확인
ss -tan '( sport = :8080 or dport = :8080 )'
 
# TCP 통계. 배포판에 따라 nstat 패키지가 필요할 수 있다.
nstat -az | grep -E 'Listen|Retrans|Reset|Timeout'

관찰할 것은 다음이다.

  • listen socket의 Recv-Q가 계속 쌓이는가?
  • established connection이 급증하는데 request throughput은 늘지 않는가?
  • ListenOverflows, ListenDrops가 증가하는가?
  • LB target response time이 늘기 전에 target connection error가 먼저 증가하는가?
  • application thread dump에서 많은 thread가 같은 DB/API 호출에 대기하는가?

JVM 서버라면 thread dump가 직접적인 증거가 된다.

jcmd <pid> Thread.print > thread-dump.txt
grep -E 'WAITING|TIMED_WAITING|BLOCKED|http-nio|reactor-http' thread-dump.txt

단일 thread dump 한 장보다 10초 간격으로 3장을 떠서 같은 stack이 계속 남는지 보는 편이 낫다.

코드로 확인하기

Spring Boot와 Tomcat을 쓴다면 서버 수신 capacity를 명시적으로 문서화한다.

server:
  tomcat:
    max-connections: 8192
    accept-count: 100
    threads:
      max: 200
      min-spare: 10

이 값은 정답이 아니라 출발점이다. 실제 검증에서는 다음을 같이 본다.

  • application instance 수를 곱한 전체 worker 수
  • DB connection pool 크기
  • outbound HTTP client pool 크기
  • LB idle timeout과 target response timeout
  • p95/p99 latency와 request queueing

부하를 일부러 걸어 thread와 connection이 어떻게 변하는지도 봐야 한다.

wrk -t4 -c300 -d60s http://localhost:8080/api/slow

이 테스트에서 CPU, request thread, DB pool, HTTP client pool, GC, LB target latency를 함께 봐야 한다. wrk 결과만 높다고 운영 capacity가 충분한 것은 아니다.

주니어가 자주 하는 오해

  • 연결이 성공하면 서버가 바로 요청을 처리했다고 생각한다.
    • TCP 연결 성공과 application handler 실행 사이에는 accept queue와 worker thread가 있다.
  • CPU가 낮으면 scale out이 필요 없다고 생각한다.
    • blocking I/O 대기가 크면 CPU가 낮아도 throughput이 낮을 수 있다.
  • thread를 늘리면 처리량도 선형으로 늘어난다고 생각한다.
    • downstream pool, lock, CPU, context switching 때문에 어느 지점부터는 장애를 키운다.
  • app log가 없으면 요청이 오지 않았다고 단정한다.
    • listener나 connector 레벨에서 밀렸다면 handler 로그가 없을 수 있다.

시니어의 설계 판단 기준

  • max connection, accept queue, worker thread, DB pool, HTTP client pool을 하나의 capacity chain으로 본다.
  • thread 수는 CPU-bound와 I/O-bound 비율에 따라 다르게 잡는다.
  • LB timeout보다 app processing timeout과 graceful shutdown이 어떻게 동작하는지 검증한다.
  • health check는 단순 process 생존보다 “새 요청을 처리할 준비”를 표현해야 한다.
  • scale out은 capacity를 늘리지만, 공통 downstream이 병목이면 장애를 더 빠르게 전파할 수 있다.

인프라 협업 포인트

  • OS의 somaxconn, tcp_max_syn_backlog, file descriptor limit을 확인한다.
  • LB target connection error, target response time, healthy host count를 앱 metric과 같은 시간대에 맞춘다.
  • Kubernetes라면 readiness, liveness, terminationGracePeriodSeconds, preStop, connection draining을 함께 본다.
  • “서버가 느립니다” 대신 “8080 listen queue와 Tomcat busy thread, LB target connection error를 같은 시간대에 확인하고 싶습니다”처럼 요청한다.

실전 팁

  • 개인 프로젝트에서도 Actuator metric이나 access log로 busy thread, request time, error rate를 볼 수 있게 둔다.
  • 운영에서는 thread dump를 뜨는 절차와 권한을 장애 전에 정해 둔다.
  • max thread를 올린 뒤에는 반드시 DB/API pool과 error rate를 같이 본다.
  • queue가 길어질수록 사용자는 timeout을 기다리므로, 빠른 실패와 backpressure도 설계 대상이다.

위험 신호!

  • /health는 200인데 핵심 API p99가 급격히 늘어난다.
  • busy thread가 max에 붙어 있는데 CPU는 낮다.
  • ListenOverflows나 LB target connection error가 늘지만 app log에는 예외가 없다.
  • thread dump에 동일한 외부 API나 DB 호출 stack이 대량으로 반복된다.
  • accept queue와 worker thread 설정값을 아무도 모른다.

확인 질문

확인 질문

  • accept queue와 worker thread 병목은 어떻게 다른가?
    • accept queue 병목은 연결이 application handler에 들어오기 전 대기하는 문제이고, worker thread 병목은 accept된 요청을 처리할 실행 자원이 부족한 문제다.
  • CPU가 낮은데 요청이 밀릴 수 있는 이유는 무엇인가?
    • thread가 DB, 외부 API, lock, pool acquire를 기다리면 CPU는 낮아도 요청 처리 capacity가 줄어들기 때문이다.
  • accept-count를 키우면 처리량이 늘어나는가?
    • 아니다. 순간 대기 공간을 늘릴 뿐 실제 처리량은 worker thread와 downstream 처리량에 의해 결정된다.

참고 문서