thumbnail
Thread-per-a-request (Spring MVC) 상황별 스레드 상태 이해하기
Java / Spring / Server
2025.08.07.

들어가며

지금까지 다양한 형태의 시스템 분리와 통합 그리고 팀이동등을 하면서 정말 다양한 서버 프로젝트 (Repo)를 경험했다. 그리고 대부분의 서버는 어드민향, 전시향 상관없이 Spring MVC 기반의 서버가 대부분이었다.

개인적으로 전시향의 I/O bound가 대부분인 서버는 Webflux와 같이 Non-blocking을 선호하지만, 그럼에도 혼자하는 프로젝트가 아니기에 Spring MVC가 채택되는경우가 대다수이기도했다.

그러다보니 자연스레 트래픽을 많이 받기 시작하면 “성능”과 “확장성” 관련하여 얘기가 많이 나온다.

앞선 성능 테스트를 통한 서비스 병목점 찾기에서도 강조했듯이, 성능 향상의 모든 출발점은 현 상태를 제대로 분석하는 것이다.

그리고 내 경험상 대다수의 웹 서버내 성능 병목점은 사실 JVM 스레드 상태를 통해서 파악할 수 있다.

이번 글은 Thread-per-a-request 웹 서버에서 각 상황별 JVM 스레드 상태를 어떻게 해석해야할지에 대해서 정리한다.


1 Spring MVC (Thread-per-a-request) 처리 과정 이해

JVM 스레드 상태별 어떻게 해석해야할지에 대해서 알아보기전에 Spring MVC는 어떻게 스레드를 활용하는지에 대한 이해가 필요하다.


1-1 서버는 요청을 어떻게 처리할까?


🤔 서버는 클라이언트로부터 요청을 어떻게 받아 처리하고 응답할까?

서버는 클라이언트로부터 요청을 받아 처리하고 응답하기위해 다양한 과정을 거친다.

서버 시동 -> 특정 port listen -> 클라이언트 요청오면 -> TCP 연결 수락 -> IO 이벤트 감시 -> 요청 read/parse/process -> response

몇가지는 생략했지만 아무튼 위 과정처럼 kernel과 application간의 다양한 상호작용을 통해 클라이언트 요청이 처리된다.

그리고 위 과정을 스레드 관점에서 추상적으로 정리해보면 아래와 같은 간단한 레이어 그림이 나온다.

예를 들어, HTTP 서버가 클라이언트의 요청을 받는다면 아래와 같은 작업을 수행하여 비즈니스를 수행하고 응답을 보낸다.

  1. TCP Connection 수립
  2. 서버 애플리케이션은 OS커널 소켓 파일내 Client로부터 전송 받은 데이터를 네트워크 I/O를 통해 OS로부터 read한다. (Network I/O 계층)
    • Kernel Socket Read Buffer로부터 Application에 데이터를 read.
  3. 서버 애플리케이션은 전송 받은 데이터에 맞는 비즈니스 처리후 클라이언트에게 응답을 보내기위해 해당 Socket에 네트워크 I/O를 통해 write한다. (Application 계층)
    • Application에서 처리된 응답값을 Kernel Socket Write Buffer에 write.

위 과정을 조금 자세하게 나타내면 아래와 같이 Kernel내 Socket의 버퍼를 통해 데이터를 주고 받는다.

그리고 이 과정중 스레드가 사용되는 부분도 저 Networl I/O와 Application 부분이다.


1-2 Network I/O 스레드와 Worker 스레드

앞서 일반 웹 서버가 어떻게 클라이언트 요청을 처리하는지에 대해서 스레드 관점에서 살펴보았다. 이제 처리에 사용되는 Network I/O 스레드와 Worker 스레드에 대해서 살펴보자.


💁‍♂️ Network I/O

애플리케이션 프로세스에서 원격에 있는 디바이스와 데이터를 전송(write) 및 수신(read)하기위해선 OS 커널 system call이 불가피하다.

  • write system call의 경우, OS 커널은 전송하고자하는 데이터를 소켓 전송 버퍼(send buffer)에 복사한다.
  • read system call의 경우, OS 커널은 수신 받은 데이터를 소켓 수신 버퍼 (receive buffer)에 복사한다.

보통 이러한 OS 커널 작업은 대부분 DMA가 네트워크 카드로부터 OS 커널에 데이터를 버퍼에 복사하고, 인터럽트를 발생시켜 애플리케이션 프로세스가 해당 데이터를 읽어가거나, 외부 디바이스에 데이터를 전송하게된다.

여기서 중요한 점은 통신을 위한 커널 소켓내 버퍼에 데이터를 어떻게 읽고 쓰기할지에 대한 I/O 모델은 Blocking I/O와 Non-blocking I/O로 나뉜다.

관련해서 이미 정리한 글이 있어 정리된 글을 첨부한다. 혹시 위 내용이 이해가 안간다면 사례를 통해 이해하는 네트워크 논블로킹 I/O와 Java NIO를 참고하자.


💁‍♂️ 현재 대부분의 Network I/O 라이브러리는 모두 NIO기반의 Non-Blocking을 제공한다.

Network I/O 스레드에서 가장 중요한 점은 Blocking하게 처리할 것인지 Non-Blocking하게 처리할 것인지다.

조금 쉽게 말하자면 애플리케이션내 스레드에서 특정 Socket를 Pull 방식으로 받을 것이냐? Kernel로부터 Socket 버퍼(파일)에 수정사항이 있을때만 push로 알림을 받아 이벤트 루프처럼 처리할 것느냐의 차이다.

당연히 이미 OS kernel에서 select, poll, epoll등을 통해 push 방식을 제공하기에 자바에서 자주 사용되는 Network I/O인 Netty, Tomcat 모두 Non-Blocking으로 동작한다. (Tomcat의 경우 NIO Connector가 default로 된 9버전부터)


💁‍♂️ Spring에선 Network I/O 부분을 Servlet이라는 인터페이스로 추상화 시켰으며, 디폴트로 Tomcat이라는 구현체를 사용한다.

Spring으로 구현된 프로젝트를 해본 사람이라면 아마 다 접하게되는 것이 Servlet일 것이다.

Servlet은 사실 Network I/O 처리 부분 추상화하는 인터페이스라고 볼 수 있다.

정확하게는 Servlet 컨테이너 (서블릿 수명주기, 필터/리스너, JSP, WebSocket)와 Network I/O 부분을 인터페이스화한 것인데 Spring Boot가 나오고 대부분 DispatcherServlet에서 처리되는게 많아 현재는 사실상 Network I/O 처리하는 애플리케이션이라고 볼 수 있을듯하다.


💁‍♂️ Tomcat 동작 방식

Spring MVC에서 기본적으로 사용되는 Tomcat은 아래와 같이 Network I/O read/write를 처리한다.

출처: https://www.baeldung.com/spring-webflux-concurrency
출처: https://www.baeldung.com/spring-webflux-concurrency

실제로 Spring MVC 애플리케이션을 실행후 스레드를 보면 아래와 같이 3개의 스레드 그룹이 존재한다.

Spring MVC 실행시 Network I/O와 Application 처리하는 스레드 그룹
Spring MVC 실행시 Network I/O와 Application 처리하는 스레드 그룹

  • http-nio-8080-Acceptor (Netty의 Boss Group과 동일한 역할이다)
    • 8080 port 연결 수락 담당 스레드
    • 서버 소켓을 열고, 새 연결(SocketChannel)을 수락(Accept)함.
  • http-nio-8080-Poller == Netty Worker Group
    • 8080 port 등록된 채널에서 read/write 가능한 Socket에 대한 변경 이벤트를 감지하고, Worker로 전달하는 스레드.
    • I/O 이벤트 감시 (Selector 기반 Multiplexing)
  • http-nio-8080-exec-* (Application Worker 처리 Thread Group)
    • 실제 요청 비즈니스 처리 스레드
    • 실제 요청(Request)을 처리하는 스레드 (Servlet, Controller 로직 실행 등).

자세한 처리 과정은 Netty 이해하기를 참고하길 추천한다. 왜냐하면 사실상 처리 과정이 Netty와 굉장히 비슷하며, 이번 글의 목적과 다르게 이 부분에 대해서만 이야기 할게 많아서다.


💁‍♂️ Worker Thread

정리하면 앞선 Network I/O 스레드 그룹은 아래와 같은 역할을 담당한다.

  • TCP 연결 수락 / 이벤트 감시 / 데이터 읽기·쓰기 담당.
  • HTTP 레벨에서 Request를 읽어들여 Application으로 전달하고, 응답을 다시 Socket에 write하는 책임.

그리고 요청 파싱이 끝나면 바로 Application Business 처리와 관련해서는 Worker 스레드에 위임한다.

쉽게말해, 개발자들이 Spring MVC에서 작성한 비즈니스 코드들은 모두 Worker 스레드에서 실행된다.


💁‍♂️ Worker Thread는 실제 비즈니스 로직을 수행하는 핵심 스레드다. 이번 글의 목적도 이 스레드 그룹내 스레드의 상태를 이해하는 것이다.

실제 Worker Thread는 Filter → DispatcherServlet → Business Code 순으로 실행되며, DB 쿼리, 외부 API 호출, 파일 IO 등 대부분의 애플리케이션 로직은 이 스레드에서 수행된다.

당연히 이번 글의 목적이기도한 스레드의 상태를 이해하는 대상도 이 스레드 그룹내 스레드들을 의미한다.


1-3 Thread-per-a-request 구조에서 JVM 스레드 상태 이해하는게 중요한 이유

그렇다면 왜 Thread-per-a-request 구조에서 JVM 스레드 상태를 이해하는게 중요할까?

가장 큰 이유는 Thread-per-a-request라는 말에서 알 수 있듯이, 클라이언트 요청당 하나의 Worker Thread를 할당하여 처리하기때문에 트래픽이 많은 서버 입장에서 효율적으로 동작하기위해선 스레드 상태를 이해하는게 중요하기 때문이다.

즉, 이유를 정리하면 아래와 같다.

  1. 성능·확장성의 핵심 지표를 쥐고 있기 때문
    • 요청당 스레드 모델은 처리량, 지연 시간, 동시성 한도를 사실상 스레드 수와 스케줄링에 “직접적으로” 묶어둔다. 스레드가 어디서 멈추고(블로킹), 얼마나 오래 일하며(런), 언제 대기하는지(워AIT)를 이해해야 병목을 제거하고 확장 전략을 세울 수 있다.
  2. 서버 성능을 악화하는 원인을 빠르게 파악하기 위함.
    • 타임아웃, 큐 적체(backlog), 컨텍스트 스위칭 폭증, 데드락·라이브락 같은 현상은 대부분 스레드 상태와 관련 있다. 관찰 가능한 지표(활성 스레드 수, RUNNABLE 비율, BLOCKED/WAITING 분포, 요청당 스레드 체류시간)로 문제를 정의하면 디버깅이 빨라진다.
  3. 스레드 풀 튜닝의 근거를 마련하기 위해
    • 최대 스레드 수, 큐 길이, 타임아웃, 커넥션 풀, DB/외부 API 소켓 풀은 서로 연결되어 있다. 스레드 상태를 모르면 “감”에 의존한 튜닝이 되고, 알면 워크로드 특성(블로킹 I/O 비율, CPU 바운드 비율)에 맞춘 합리적 파라미터를 도출할 수 있다.
  4. 비용과 신뢰성을 동시에 잡기 위해
    • 과도한 스레드는 문맥 전환·메모리 소비로 비용을 올리고 오히려 성능을 떨어뜨린다. 반대로 부족한 스레드는 처리율 제한과 오류로 이어진다. 상태를 기반으로 용량 계획과 가용성 관리를 할 수 있다.
  5. 아키텍처 전환의 판단 근거를 위해
    • I/O bound 위주의 동시 요청자수가 많은 전시향 서버는 webflux나 coroutine 도입이 훨씬 유리하다. 현재 스레드 사용 실태를 수치화해 “언제, 어느 부분을” 전환할지 결정할 수 있다.

2 JVM 스레드와 Kernel 스레드 상태


2-1 JVM 스레드 상태와 Kernel 스레드 상태는 서로 다르다

💁‍♂️ JVM 스레드 상태

공식 문서. 24기준를 보면 자바에서 제공하는 스레드 상태를 볼 수 있다.

JVM 상태 설명 예시 호출 스택 / 상황
NEW 스레드 객체가 생성되었지만 아직 start()가 호출되지 않음 new Thread(...)start()
RUNNABLE 실행 중이거나 OS에서 실행 가능한 상태. I/O 대기 포함 CPU 연산 중, Socket.read() 등 네이티브 I/O 대기
BLOCKED synchronized 모니터 락을 얻으려다 대기 중 synchronized(obj) 진입 대기
WAITING wait(), join(), LockSupport.park() 등으로 무기한 대기 Object.wait()
TIMED_WAITING sleep(), wait(timeout), parkNanos() 등으로 타임아웃 있는 대기 Thread.sleep(1000)
TERMINATED 실행 종료 (스레드 run() 완료) 스레드 종료 후 join() 가능

💁‍♂️ JVM 스레드는 JVM 레벨의 스레드 추상 상태값이다.

많은 사람들이 가장 많이 오해하는 것 중 하나는 JVM 스레드 상태가 Kernal 스레드 상태와 동일하다고 생각하는 것이다. 실제론 JVM 스레드는 Kernel 스레드와 동일하지않다.

JVM 스레드 상태는 JVM이 관리하는 언어 레벨의 추상 상태를 의미할 뿐이다. 즉, JVM 스레드 상태는 Kernel 스레드와 매핑된 JVM 스레드를 “추상적으로 표현”만 할 뿐, 실제 Kernel 스레드처럼 OS 레벨에서의 스케줄링 대상이 아니다. (그 대상을 추상적으로 표현만 한 것 뿐이다.)

조금 더 자세히 말하자면 JVM 스레드는 Kernel 스레드와 1대 1 관계는 맞다. 즉, 아래 그림에서 One-To-One에 속한다.

출처: Understanding the Linux Kernel, 3rd Edition
출처: Understanding the Linux Kernel, 3rd Edition

각 JVM 스레드(User 스레드)는 Kernel 스레드 하나에 매핑된다. 그리고 이는 JVM 스레드는 Kernel 스레드를 User-Level에서 표현만 하기때문에 Kernel에 의해 스케줄링된다는 의미이기도하다.

당연한거 아닌가?라고 생각 할 수 있는데, 실제로 많은 개발자들이 이와 관련해서 동일하다고 생각하는 경우가 많았다. 그리고 JVM 스레드와 Kernel 스레드는 1대1로 매핑되긴하지만, 상태값이 완전 동일하진 않다.


💁‍♂️ Kernel 스레드 상태

리눅스는 모든 실행 단위를 task_struct로 표현하며, 각 task를 아래와 같은 Kernel 스케줄링 상태를 가진다.

즉, Kernel 스레드는 JVM 스레드와 다르게 아래와 같은 상태 값들을 가진다.

커널 상태 코드 매크로 이름 설명 특징
R TASK_RUNNING 실행 중이거나 CPU 스케줄 대기 중 runqueue에 있음
S TASK_INTERRUPTIBLE 대기 중 (interruptible sleep), 시그널/이벤트로 깨울 수 있음 일반 I/O 대기
D TASK_UNINTERRUPTIBLE 대기 중 (non-interruptible sleep), 시그널로 깨울 수 없음 디스크 I/O, 커널 락 등
T TASK_STOPPED / TASK_TRACED 중지됨 (signal, ptrace 등) SIGSTOP, gdb
Z EXIT_ZOMBIE 종료됐지만 부모가 아직 wait() 안 함 좀비 프로세스
X EXIT_DEAD 완전히 종료되어 정리됨 거의 보이지 않음
I IDLE 커널 idle 스레드 CPU idle loop용

2-2 JVM 스레드 상태와 Kernel 스레드 상태의 이해해야하는 이유


💁‍♂️ 차이점을 제대로 이해해야하는 이유 -> 서로 다른 상태를 가지는 경우가 존재한다

실제로 Spring MVC내 Worker 스레드에서 DB나 다른 API 서버를 호출하고 응답이 느려 대기해야하는 경우가 있을 수 있다.

즉, 요청을 Socket에 write하고 응답이 오면 read하려고 대기하는 상황을 말한다.

이때 많은 개발자들은 JVM 스레드가 WAITING이나 TIME_WAITING이라고 생각한다. 하지만, 실제론 아래와 같이 JVM 스레드의 상태는 RUNNABLE이다..

OpenFeign(Http-Client 5)로 다른 API 서버 호출후 응답 오기전까지 대기하는 스레드의 덤프 결과
OpenFeign(Http-Client 5)로 다른 API 서버 호출후 응답 오기전까지 대기하는 스레드의 덤프 결과

덤프 결과를 보면 알 수 있듯이, Net.poll중이다. 즉, Socket내 읽을 데이터 관련하여 변경사항이 있을지 대기하는 상태다. 이때의 상태는 JVM 스레드의 상태는 RUNNABLE이다.

문제는 이때 Kernel 스레드의 상태는 S(TASK_INTERRUPTIBLE) 상태가 된다. 즉, Kernel 레벨에선 CPU 스케줄링 대상이 아닌 상태를 가진다.

정리하면 아래와 같다.

구분 JVM 스레드 상태 커널 스레드 상태 커널 스케줄러 관점
CPU 계산 중 RUNNABLE TASK_RUNNING (on CPU) ✅ 스케줄링 대상
I/O read/poll 중 RUNNABLE TASK_INTERRUPTIBLE or TASK_UNINTERRUPTIBLE (wait queue) ❌ 스케줄링 대상 아님
sleep / park TIMED_WAITING or WAITING TASK_INTERRUPTIBLE ❌ 대상 아님

💁‍♂️ JVM 스레드 상태와 Kernel 스레드 상태를 함께 이해해야 진짜 병목이 어디인지 구분할 수 있다.

다시 한번 강조하고 싶은 말은 JVM의 Thread.State (RUNNABLE, WAITING 등)는 언어 레벨의 논리적 상태일 뿐이다. 그래서 이 정보만 보면 “왜 느린지”, “CPU가 바쁜 건지, I/O 때문인지” 를 판단하기 어렵다.

왜냐하면 Thread-per-a-request에서 외부 호출로 인해 대기하는 스레드로인해 스레드가 밀리면서 전체적인 서버의 성능이 느려질 수 있기 때문이다. (실제로 많이 겪은 문제기도하다)

JVM 스레드 상태만보면 JVM 스레드 모니터링할 때 보면 “RUNNABLE이 많네 → CPU 바쁨”으로 해석하기 쉽지만, Kernel에서 대부분 S 상태(소켓 I/O wait) 일 수도 있다.

JVM 스레드 상태는 “자바 코드 입장에서 지금 뭐 하고 있나”를 의미하고, Kernel 스레드 상태는 “OS 입장에서 CPU나 I/O 자원을 어떻게 쓰고 있나”를 나타낼 뿐이다.

따라서 성능 분석을 정확하게하고 병목의 원인을 식별하기위해선 두 스레드의 상태를 모두 이해해야한다.


3 상황별 JVM 스레드 상태

이제 실제로 Spring MVC 서버를 구성해 nginder로 일부러 트래픽을 발생시켜 Spring MVC에서 발생 할 수 있는 상황별로 JVM 스레드 상태가 어떻게 되는지 분석해본다.


3-1 스레드 상태 분석을 위한 테스트 환경 구축

Spring MVC(Thread-per-request) 환경에서 외부 I/O 호출(DB/HTTP) 특성에 따라 JVM 스레드 상태(RUNNABLE/BLOCKED/WAITING/TIMED_WAITING)가 어떻게 달라지는지 체계적으로 관찰하기위해 아래와 같이 테스트 환경을 구성했다.

  • Spring MVC 서버
    • 테스트에 사용될 대상 서버이며, 내부적으로 Internal API와 MySQL을 조회하도록 구성했다. (Internal API만 조회하거나 MySQL만 조회하도록하는 API도 따로 구성했다.)
  • Internal API
    • Internal API는 Webflux로 구성했으며, 쿼리 파람으로 받은 ms만큼 Mono.delay하고 응답하는 간단한 서버다.
  • MySQL
    • k8s statefulset으로 MySQL을 실행시켜 간단한 테이블 하나만 생성하여 응답하도록 하였다.
    • 매 쿼리마다 SELECT sleep(?)으로 몇 초동안 sleep하고 응답하도록 코드를 구성했다.

3-1 I/O bound


외부 호출 응답 대기

Spring MVC 서버에서 외부 (API or 데이터베이스)를 호출하고 응답이 느리거나 OS내 소켓관련 병목이 있는 경우


💁‍♂️ OpenFeign (HTTP Client 5)로 외부 호출하고 대기할 때 스레드 상태

Internal API내 3초간 대기하고 응답하도록 구성하고 Spring MVC 서버 대상으로 트래픽을 유발하고 스레드 상태를 모니터링하였다.

그리고 테스트 시간동안 스레드 덤프를 10개정도 떠서 여러번 확인 결과

스레드 덤프 결과
스레드 덤프 결과

  • JVM 스레드 - RUNNABLE
    • 덤프 결과를 보면 알 수 있듯이, Net.poll중이다. 즉, Socket내 읽을 데이터 관련하여 변경사항이 있을지 대기하는 상태다. 이때의 상태는 JVM 스레드의 상태는 RUNNABLE이다
  • Kernel 스레드 - S(TASK_INTERRUPTIBLE)
    • Kernel 레벨에선 CPU 스케줄링 대상이 아닌 상태가 된다. 그리고 Socket내 요청한 데이터가 도착하면 다시 TASK_RUNNING 상태가 된다.

💁‍♂️ MySQL 쿼리 요청하고 대기할 때 스레드 상태

동일하게 MySQL에 3초간 대기하고 응답하도록 구성하고 Spring MVC 서버 대상으로 트래픽을 유발하고 스레드 상태를 모니터링하였다.

스레드 덤프 결과
스레드 덤프 결과

  • JVM 스레드 -RUNNABLE
    • 덤프 결과를 보면 알 수 있듯이, 동일하게 Net.poll중이다. OpenFeign과 하나 다른 점은 아래 스택트레이스가 다르다. (HikariCP 풀내 커넥션 가져와서 쿼리 날림)
  • Kernel 스레드 - S(TASK_INTERRUPTIBLE)
    • 동일하게 Kernel 레벨에선 CPU 스케줄링 대상이 아닌 상태가 된다. 그리고 Socket내 요청한 데이터가 도착하면 다시 TASK_RUNNING 상태가 된다.

TCP 혹은 DB Connection 고갈로 대기시


💁‍♂️ OpenFeign (HTTP Client 5)로 외부 호출하기위해 커넥션 얻으려고했으나 고갈되어 대기할 때

테스트는 아래와 같이 설정하고 진행했다.

  • Target Server(Spring MVC) HTTP Client 5 Connection Pool 최대 개수 50개 설정.
  • Internal Server내 3초간 대기후 응답하도록 설정.
  • 커넥션 100개 동시에 요청하도록 성능 테스트 진행

예상한대로 아래와 같이 정확하게 50개는 커넥션을 얻어 호출하고 대기하기때문에 RUNNABLE, 50개는 커넥션을 대기하는 TIME_WAITING 결과가 나왔다.

Worker Thread 상태 통계
Worker Thread 상태 통계

RUNNABLE은 위와 동일하게 Net.poll로 대기하는 스레드였으며, 나머지 커넥션 대기는 아래와 같이 TIME_WAITING 상태가 된다.

스레드 덤프 결과
스레드 덤프 결과

  • JVM 스레드 - TIME_WAITING
    • ...hc.core5.pool.StrictConnPool.get() 호출되면서 커넥션 얻기위해 대기하는 것을 볼 수 있으며, 이때 JVM 스레드 상태는 TIME_WAITING이다.
    • 커넥션 타임아웃을 설정하지않으면 아마 WAITING이 될 것으로 보인다.
  • Kernel 스레드 - TASK_INTERRUPTIBLE
    • 커넥션 대기하면서 park()되면서 kernel 스레드는 S(TASK_INTERRUPTIBLE) 상태가 된다.

💁‍♂️ MySQL 쿼리 요청하기위해 커넥션을 얻으려고했으나 고갈되어 대기할 때

동일하게 HikariCP 커넥션 풀 설정하고 성능 테스트를 진행하였다.

스레드 덤프 결과
스레드 덤프 결과

  • JVM 스레드 - TIME_WAITING
    • API 외부 호출 커넥션 대기랑 동일하며 하나 다른 점은 커넥션 풀이 HikariCP라 ...hikari.poo.HikariPool.getConnection()으로 대기하는 걸 볼 수 있다.
  • Kernel 스레드 - TASK_INTERRUPTIBLE
    • 동일하게 커넥션 대기하면서 park()되면서 kernel 스레드는 S(TASK_INTERRUPTIBLE) 상태가 된다.

🤔 커넥션 풀은 왜 LockSupport.park()를 사용할까?

커넥션 풀에선 아래와 같은 상황이 비교적 자주 발생한다.

스레드가 커넥션을 빌리려고 함 -> 하지만 풀의 커넥션이 모두 사용중 -> 스레드는 커넥션을 반환할 때까지 기다려야한다.

이를 구현하는 방법은 많다.

방법 방식 단점
while(poolEmpty){} busy-spin CPU 100% 낭비
Thread.sleep(10) polling 깨어나는 타이밍 부정확, latency↑
Object.wait() / Condition.await() JVM monitor 기반 대기 오버헤드 크고, 관리 복잡
LockSupport.park() 커널 futex 기반 대기 정확, 효율적, 저지연

이때 “기다림”을 구현하는 방법이 여러 가지인데, 대부분의 커넥션 풀은 CPU 낭비 없는 고성능 대기(wait) 를 위해 LockSupport.park()을 직접 사용한다. (물론 아닌 경우도 있다.)

그리고 다른 스레드가 커넥션을 반환하면 커넥션 풀에서 LockSupport.unpark(waitingThread) 호출하여 커넥션을 빌려줌과 동시에 스레드를 깨운다.


park()로 스레드를 명시적으로 대기하도록 하지 않는 이상 I/O 작업은 모두 RUNNABLE.

궁금하여 다른 Java 프로젝트를 만들어 Socket에 write할 때 버퍼가 꽉 찼거나, 파일/디스크 I/O 할 때와 같이 native I/O가 오래걸려 대기할 때를 테스트해보니 모두 RUNNABLE이었다.

즉, 명시적으로 Locksupport.park()를 호출하지 않는 이상 JVM 입장에선 해당 스레드는 여전히 RUNNABLE하다.

다만 Kernel 스레드에선 I/O 호출로 대기할 때는 JVM 스레드와 달리 R(TASK_RUNNING)이 아닌 S(TASK_INTERRUPTIBLE) 상태가 된다.

더 정확히는 LockSupport.park(), Object.wait(), Thread.sleep()와 같은 대기 메서드를 호출하지 않는 이상 JVM 입장에선 그 스레드는 여전히 RUNNABLE이다.


3-2 CPU bound

보통의 웹 서버에서 CPU bound로 사용될만한 부분은 아래 두가지가 대표적일 듯 하다.

  • JSON/Proto 직렬화·압축·템플릿 렌더링
  • 클래스 초기화/정적 초기화 블록 수행 중

당연히 CPU bound인 경우 CPU를 점유하기위해 RUNNABLE 상태를 유지한다.

그래도 요청하면 DB로부터 받은 데이터를 1000번 직렬화하고 응답하도록하고 테스트해보았다.

결과를 아래와 같이 RUNNABLE 상태로 유지되는 것을 볼 수 있었다.

스레드 덤프 결과
스레드 덤프 결과


3-3 CompletableFuture 비동기 처리시


💁‍♂️ Thread-per-a-request에서 보통 외부 I/O 호출 (DB, API등)으로 인한 블로킹을 줄이기 위해 비동기 처리를 많이 한다.

Spring MVC와 같이 기본적으로 요청당 하나의 스레드를 사용하는 경우 외부 API나 DB를 호출할 때 블로킹 I/O로 인해 대기하게 된다.

그리고 1개이상의 외부 I/O 호출이 있을 때 보통 CompletableFureu나 @Async 등을 사용해 별도의 스레드 풀에서 병렬로 외부 호출을 실행함으로써 I/O wait 시간을 겹치게 만든다.

이렇게 함으로써 처리량(throughput)을 높이고, 응답 지연(latency)를 줄인다.

물론 이때 사용되는 스레드가 Platform Thread (kernel 스레드와 1:1 매핑된 JVM 스레드) 이기때문에 특정 개수 이상부터는 성능 개선 효과가 미비해진다.


n개 외부 I/O 비동기 요청후 join으로 대기시


💁‍♂️ 3개의 외부 I/O 비동기 요청후 join할 때 worker thread의 상태

아래와 같이 실제로 3개의 외부 I/O 요청 (API, DB)을 비동기로 호출하고 allOf.join으로 묶어 모든 응답이 오면 Worker Thread의 블로킹이 끝나고 응답되도록 코드를 설계후 테스트를 진행했다.

CompletableFuture<List<Member>> dbFuture = findByIdInWithSleepAsync(memberIds, dbDelaySec);
CompletableFuture<InternalApiResponse> apiFuture = internalApiDelayAsync(apiDelayMs);
CompletableFuture<InternalApiResponse> apiFuture2 = internalApiDelayAsync(apiDelayMs);
CompletableFuture.allOf(dbFuture, apiFuture, apiFuture2).join();
return new PerformanceTestResponse(dbFuture.join());

스레드 덤프 결과
스레드 덤프 결과

  • JVM 스레드
    • Timeout이 없으면 WAITING, Timeout이 있다면 TIME_WAITING
  • Kernel 스레드 - TASK_INTERRUPTIBLE
    • join()으로 대기할 때 스레드 덤프 결과에서 알 수 있듯이, park()되면서 kernel 스레드는 S(TASK_INTERRUPTIBLE) 상태가 된다.

비동기 스레드풀 꽉 차서 스레드풀 대기시


💁‍♂️ 3개의 외부 I/O 비동기 요청할 때 비동기 스레드 풀내 스레드가 부족하여 대기할 때 worker thread의 상태

한 클라이언트 요청당 비동기 스레드는 보통 n개 사용되기에 더욱 더 빠르게 고갈되는 경우가 많다.

실제로도 내 경험상 Spring MVC + CompletableFuture / @Async 구조에서 자주 발생하는 중요한 병목 상황이다.

이렇게 비동기 스레드풀이 고갈되어 worker thread가 대기할 때의 스레드 상태를 테스트하기위해 커넥션 풀 최대 개수는 30개정도로하고 100개의 VUser가 연속적으로 계속 요청을 날리도록하여 상태를 확인했다.

비동기 스레프 풀의 Reject Policy (큐가 꽉 찼을 때 전략)별로 아래와 같이 결과가 나온다.

  • AbortPolicy
    • 동작
      • RejectedExecutionException 즉시 던짐.
    • worker thread 상태
      • RUNNABLE(예외 처리하고 다음 로직 진행). (커널: R 또는 짧게 유저 모드)
  • CallerRunsPolicy
    • 동작
      • 현재 스레드(Worker Thread)가 직접 실행
    • worker thread 상태
      • RUNNABLE(작업을 수행하는 동안 쭉 점유; 작업이 I/O 블록이면 JVM은 RUNNABLE이지만 커널은 S로 보일 수 있음).
  • DiscardPolicy
    • 동작
      • 조용히 버림(로그/예외 없음).
    • worker thread 상태
      • RUNNABLE(곧바로 다음 로직).
  • DiscardOldestPolicy
    • 동작
      • 큐의 가장 오래된 작업을 버리고 재시도(다시 실패하면 또 거부 핸들러 호출).
    • worker thread 상태
      • RUNNABLE(짧은 추가 연산만).

개인적으로 트래픽이 비교적 많은 서버에선 레이턴시가 중요하므로 빠르게 실패시키고 RejectedExecutionException로 병목 인지하고 개선하는게 더 좋았다. worker thread에서 실행시키는건 자칫 worker thread의 잦은 고갈로 이어질 수 있어서다. (물론 상황에 따라 다르겠지만..)


3-4 동기화/락/대기큐


💁‍♂️ 동기화 및 대기 메커니즘별 Worker Thread 상태 정리

스레드는 동기화 블록이나 동시성 제어 구조를 사용할 때 락(lock) 을 획득하거나 조건(Condition) 을 기다리는 과정에서 서로 다른 JVM 스레드 상태를 갖는다.

아래는 대표적인 상황별 상태다.

상황 주요 호출 스택 JVM 스레드 상태 커널 상태(일반적)
synchronized 경합 waiting to lock <...> BLOCKED S
ReentrantLock 경합 Unsafe.park / AQS.acquire WAITING / TIMED_WAITING (parking) S
Condition, CountDownLatch, Semaphore, CyclicBarrier 대기 ConditionObject.await / Latch.await / acquire WAITING / TIMED_WAITING S

3-5 서버에 요청이 없을 때


서버에 요청 자체가 없어서 worker 스레드가 쉴 때

스레드 덤프 결과
스레드 덤프 결과

  • JVM 상태 - WAITING
    • park되어 스레드는 WAITING 상태가 유지된다.
  • Kernel 스레드 - TASK_INTERRUPTIBLE
    • park()되면서 kernel 스레드는 S(TASK_INTERRUPTIBLE) 상태가 된다.

트래픽이 비교적 몰려오고나서 잠잠해진 경우 worker 스레드 상태

모든 worker 스레드가 TIME_WAITING 상태가 된다.

스레드 덤프 결과를 보면 아래와 같이 parkNanosTIME_WAITING이 된 것을 알 수 있다.

스레드 덤프 결과
스레드 덤프 결과

이는 Spring MVC에서 Tomcat내 트래픽이 많을 때 최대 스레드 개수까지 늘렸다가 없으면 다시 일정 시간 지나 줄이기위해 이런 상태가 되는 듯하다.

실제로 일정시간 지나면 worker thread의 개수가 첫 서버 시동때처럼 확 줄어들었다.


하고싶은 이야기

이 글을 통해 말하고 싶었던 핵심 내용은 Spring MVC의 Thread-per-a-request 구조에선 외부 I/O 대기가 많아도 JVM에선 대개 RUNNABLE, 커널에선 S(대기) 로 보이는 등 JVM 상태와 커널 상태가 다르게 나타난다는 것이다.

즉, JVM 스레드 상태만으로는 정확한 웹 서버의 스레드 사용 형태를 분석하기 어렵다. 상황에 맞게 여러 번의 스레드 덤프를 통해 자세한 분석을 하는 것이 좋다.

이 글을 보는 개발자분들도 Thread-per-a-request 구조를 사용하면서 스레드의 상태를 파악하여 어디에 병목이 생기는지 찾아보기를 추천한다.


© mark-kim.blog Built with Gatsby