2022년 7월 16일 토요일

Spring: Blocking vs non-blocking: R2DBC vs JDBC and WebFlux vs Web MVC


2017년 9월에 출시된 Spring Framework 버전 5에는 Spring WebFlux가 도입되었습니다. 완전히 반응하는 스택. 2019년 12월, 반응형 드라이버를 사용하여 관계형 데이터베이스를 통합하는 인큐베이터인 Spring Data R2DBC가 출시되었습니다. 이 블로그 게시물에서 나는 높은 동시성에서 WebFlux와 R2DBC가 더 나은 성능을 보인다는 것을 보여줄 것입니다. 더 나은 응답 시간과 더 높은 처리량을 제공합니다. 추가 이점으로 처리된 요청당 메모리와 CPU를 덜 사용하고 (R2DBC의 경우 JPA를 생략할 때) 뚱뚱한 JAR이 훨씬 작아집니다. 높은 동시성에서는 WebFlux와 R2DBC(JPA가 필요하지 않은 경우)를 사용하는 것이 좋습니다!

방법

이 블로그 게시물에서는 4가지 구현을 살펴보았습니다.

  • Spring Web MVC + JDBC 데이터베이스 드라이버
  • Spring Web MVC + R2DBC 데이터베이스 드라이버
  • Spring WebFlux + JDBC 데이터베이스 드라이버
  • Spring WebFlux + R2DBC 데이터베이스 드라이버

진행 중인 요청 수(동시성)를 4에서 500까지 50단계로 변경하고 부하 생성기와 서비스에 4개의 코어를 할당했습니다(내 랩톱에는 12개의 코어가 있음). 모든 연결 풀을 100으로 구성했습니다. 코어 수와 연결 풀 크기가 고정된 이유는 무엇입니까? JDBC 대 R2DBC 데이터에 대한 이전 탐색에서 이러한   변수를 변경하는 것은 많은 추가 통찰력을 제공하지 않았기 때문에 이 테스트를 위해 고정된 상태로 유지하기로 결정하여 몇 가지 요인에 의해 테스트 실행 기간을 줄였습니다.

서비스에 대해 GET 요청을 했습니다. 서비스는 데이터베이스에서 10개의 레코드를 가져와 JSON으로 반환했습니다. 먼저 서비스에 무거운 부하를 가하여 2초 동안 서비스를 '프라이밍'했습니다. 다음으로 2분 벤치마크로 시작했습니다. 나는 모든 시나리오를 20번 반복하고(다른 테스트로 구분하여 5번이 아닌) 결과를 평균화했습니다. 오류가 발생하지 않은 실행만 보았습니다. 동시성을 1000 이상으로 늘리면 모든 구현에서 예외 없이 추가 동시 요청이 실패했습니다. 결과는 재현 가능한 것으로 나타났습니다.

백엔드 데이터베이스로 Postgres(12.2)를 사용했습니다. 나는  wrk  를 사용하여 구현을 벤치마킹했습니다(여러 권장 사항으로 인해). 여기 에서 다음을 사용하여 wrk 출력을 구문 분석 했습니다 나는 측정했다

  • 응답 시간
    wrk에서 보고한 대로
  • 처리량(요청 수)
    wrk에서 보고한 대로
  • CPU 사용량 처리
    사용자 및 커널 시간(/proc/PID/stat 기준)
  • 메모리 사용량
    개인 및 공유 프로세스 메모리(/proc/PID/maps 기반)

여기에서 사용된 테스트 스크립트를 볼 수 있습니다  여기에서 사용된 구현을 볼 수 있습니다  .

결과

여기 에서 그래프에 사용한 원시 데이터를 볼 수 있습니다  .

응답 시간

더 높은 동시성에서는 Spring Web MVC + JDBC가 최선의 선택이 아닐 수 있음이 분명합니다. R2DBC는 더 높은 동시성에서 더 나은 응답 시간을 분명히 제공합니다. Spring WebFlux는 또한 Spring Web MVC를 사용하는 유사한 구현보다 더 나은 성능을 제공합니다.

처리량

응답 시간과 유사하게 JDBC를 사용하는 Spring Web MVC는 더 높은 동시성에서 더 나빠지기 시작합니다. 다시 R2DBC가 최선을 다합니다. 백엔드가 여전히 JDBC인 경우 Spring Web MVC에서 Spring WebFlux로 이동하는 것은 좋은 생각이 아닙니다. 낮은 동시성에서는 Spring Web MVC + JDBC가 가장 좋습니다.

CPU

CPU는 전체 실행 중 CPU 시간, 프로세스 사용자 및 커널 시간의 합으로 측정되었습니다.

JDBC가 있는 웹 MVC는 높은 동시성에서 대부분의 CPU를 사용했습니다. JDBC를 사용하는 WebFlux는 가장 적게 사용되었지만 처리량도 가장 낮았습니다. 처리된 요청당 사용된 CPU를 보면 효율성을 측정할 수 있습니다.

R2DBC는 JDBC보다 처리된 요청당 더 적은 CPU를 사용합니다. JDBC를 사용하는 WebFlux는 (다시) 좋은 생각이 아닌 것 같습니다. JDBC를 사용하는 웹 MVC는 높은 동시성에서 악화되는 반면, 하나 이상의 비차단 구성 요소가 있는 다른 구현은 더 안정적으로 보입니다. 그러나 낮은 동시성에서 Web MVC + JDBC는 사용 가능한 CPU를 가장 효율적으로 사용할 수 있습니다.

메모리

메모리는 실행이 끝날 때 프로세스 개인 메모리로 측정되었습니다. 메모리 사용량은 가비지 수집에 따라 다릅니다. G1GC는 JDK 11.0.6에서 사용되었습니다. Xms는 0.5Gb였습니다(기본값은 사용 가능한 32Gb의 1/64). Xmx는 8Gb였습니다(기본값은 사용 가능한 32Gb의 1/4).

WebFlux는 더 높은 동시성에서 더 높은 메모리 사용량을 갖는 Web MVC와 비교할 때 메모리 사용량이 더 안정적인 것으로 보입니다. WebFlux를 사용할 때 R2DBC도 함께 사용하면 높은 동시성에서 메모리 사용량을 최소화할 수 있습니다. 낮은 동시성에서 Web MVC + JDBC가 가장 잘 수행되지만 높은 동시성에서 WebFlux + R2DBC는 처리된 요청당 최소 메모리를 사용합니다.

뚱뚱한 JAR 크기

아래 그래프는 JPA가 큰 것임을 보여줍니다. R2DBC의 경우 사용할 수 없으면 뚱뚱한 JAR 크기가 15Mb 정도 줄어듭니다!

요약

R2DBC 및 WebFlux, 높은 동시성에서 좋은 아이디어!

  • 높은 동시성에서 JDBC 대신 R2DBC를 사용하고 Web MVC 대신 WebFlux를 사용하는 이점은 분명합니다. 
    • 단일 요청을 처리하는 데 더 적은 CPU가 필요합니다. 
    • 단일 요청을 처리하는 데 필요한 메모리가 적습니다. 
    • 높은 동시성의 응답 시간이 더 좋습니다.
    • 높은 동시성에서의 처리량이 더 좋습니다.
    • 뚱뚱한 JAR 크기가 더 작습니다(R2DBC가 있는 JPA 없음).
  • 차단 구성 요소만 사용하는 경우 높은 동시성에서 메모리 및 CPU 사용 효율성이 떨어집니다.
  • JDBC를 사용하는 WebFlux는 좋은 생각이 아닌 것 같습니다. R2DBC를 사용하는 웹 MVC는 JDBC를 사용하는 웹 MVC보다 높은 동시성에서 더 잘 작동합니다.
  • R2DBC 사용의 이점을 얻기 위해 완전히 비차단 스택이 필요하지 않습니다. 그러나 Spring의 경우 WebFlux와 결합하는 것이 가장 좋습니다.
  • 낮은 동시성(200개의 동시 요청 미만)에서 Web MVC 및 JDBC를 사용하면 더 나은 결과를 얻을 수 있습니다. 이것을 테스트하여 자신의 손익분기점을 결정하십시오!

R2DBC를 사용할 때의 몇 가지 문제

  • JPA는 Spring Data R2DBC에서 제공하는 것과 같은 반응형 리포지토리를 처리할 수 없습니다. 즉, R2DBC를 사용할 때 수동으로 더 많은 작업을 수행해야 합니다.
  • Quarkus Reactive Postgres 클라이언트(Vert.x 사용)와 같은 다른 반응 드라이버가 있습니다. 이것은 R2DBC를 사용하지 않으며 다른 성능 특성을 갖습니다(  여기 참조 ).
  • 제한된 가용성
    모든 관계형 데이터베이스에 사용 가능한 반응 드라이버가 있는 것은 아닙니다. 예를 들어, Oracle에는 (아직?) R2DBC 구현이 없습니다. 
  • 애플리케이션 서버는 여전히 JDBC에 의존합니다.
    이 Kubernetes 시대에 사람들은 여전히 ​​레거시가 아닌 용도로 사용합니까?
  • Java Fibers가 도입될 때(Project Loom, Java 15일 수 있음) 드라이버 환경이 다시 변경될 수 있고 R2DBC가 결국 JDBC의 후계자가 되지 않을 수 있습니다.

Spring Webflux: EventLoop vs Thread Per Request Model

 Spring Webflux: EventLoop vs Thread Per Request Model - DZone Java 의 번역글 


이 기사에서는 Spring Webflux를 살펴보고 반응형 프로그래밍을 사용하여 애플리케이션 성능을 높이는 방법을 이해합니다.

스프링 웹플럭스 소개

Spring Webflux는 Spring 5의 일부로 도입되었으며 이를 통해 Reactive Programming을 지원하기 시작했습니다. 비동기 프로그래밍 모델을 사용합니다. 반응형 프로그래밍을 사용하면 일정 기간 동안 높은 요청 로드를 지원하도록 애플리케이션을 고도로 확장할 수 있습니다.

Spring MVC와 비교하여 해결한 문제

Spring MVC는  각 요청이 스레드에 매핑되고 요청 소켓에 응답을 다시 가져오는 책임이 있는 동기 프로그래밍 모델을 사용합니다. 응용 프로그램이 데이터베이스에서 데이터 가져오기, 다른 응용 프로그램에서 응답 가져오기, 파일 읽기/쓰기 등과 같은 일부 네트워크 호출을 수행하는 경우 요청 스레드는 원하는 응답을 얻기 위해 기다려야 합니다. 

여기에서 요청 스레드가 차단되고 이 기간 동안 CPU 사용률이 없습니다. 이것이 이 모델이 요청 처리를 위해 큰 스레드 풀을 사용하는 이유입니다. 이것은 낮은 요청 비율의 응용 프로그램에 적합할 수 있지만 높은 요청 비율의 응용 프로그램의 경우 궁극적으로 응용 프로그램이 느려지거나 응답하지 않을 수 있습니다. 이것은 확실히 오늘날의 시장에서 비즈니스에 영향을 미칩니다.

서블릿 컨테이너가 있는 Spring MVC

이러한 응용 프로그램의 경우 반응 프로그래밍을 효과적으로 사용할 수 있습니다. 여기서 요청 스레드는 필요한 입력 데이터를 네트워크로 보내고 응답을 기다리지 않습니다. 오히려 이 차단 작업이 완료되면 실행되는 콜백 함수를 할당합니다. 이런 식으로 요청 스레드는 다른 요청을 처리하는 데 사용할 수 있습니다. 

적절하게 사용하면 단일 스레드를 차단할 수 없으며 스레드가 CPU를 효율적으로 사용할 수 있습니다. 여기서 적절한 방식은 이 모델의 경우 모든 스레드가 반응적으로 동작해야 함을 의미합니다. 따라서 데이터베이스 드라이버, 서비스 간 통신, 웹 서버 등도 반응 스레드를 사용해야 합니다. 

임베디드 서버

Spring Webflux는 기본적으로 Netty를 임베디드 서버로 가지고 있습니다. 그 외에도 Tomcat, Jetty, Undertow 및 기타 Servlet 3.1+ 컨테이너에서도 지원됩니다. 여기서 주목해야 할 점은 Netty와 Undertow는 비서블릿 런타임이고 Tomcat과 Jetty는 잘 알려진 서블릿 컨테이너라는 것입니다.

Spring Webflux가 도입되기 전에 Spring MVC는 요청당 스레드 모델을 사용하는 기본 임베디드 서버로 Tomcat을 사용했습니다 이제 Webflux에서는 Event Loop Model을 사용하는 Netty가 기본값으로 선호되었습니다 Webflux는 Tomcat에서도 지원되지만 Servlet 3.1 이상 API를 구현하는 버전에서만 가능합니다. 

참고 : Servlet 3은 비동기 프로그래밍을 위한 기능을 도입하고 Servlet 3.1은 모든 비동기를 허용하는 비동기 I/O를 추가로 도입했습니다. 

이벤트 루프

EventLoop은 NIO (Non-Blocking I/O Thread )로, 지속적으로 실행되고 다양한 소켓 채널에서 새 요청을 받습니다. EventLoop가 여러 개인 경우 각 EventLoop는 소켓 채널 그룹에 할당되고 모든 EventLoop는 EventLoopGroup.

다음 다이어그램은 EventLoops의 작동 방식을 보여줍니다. 

이벤트 루프 작동

여기에서 EventLoop은 서버 측에서의 사용법을 설명하기 위해 표시됩니다. 그러나 클라이언트 측에 있을 때 동일한 방식으로 작동하며 I/O 요청과 같은 다른 서버에 요청을 보냅니다.

  1. 모든 요청은 로 알려진 채널과 연결된 고유 소켓에서 수신됩니다 SocketChannel
  2. 의 범위와 연결된 단일 EventLoop 스레드가 항상 있습니다 SocketChannels따라서 해당 Sockets/SocketChannels에 대한 모든 요청은 동일한 EventLoop로 전달됩니다.
  3. EventLoop에 대한 요청은 채널 파이프라인을 통과하며, 여기에서 다수의 인바운드 채널 핸들러 WebFilters가 필요한 처리를 위해 구성됩니다.
  4. 그 후 EventLoop는 애플리케이션별 코드를 실행합니다. 
  5. 완료되면 EventLoop는 구성된 처리를 위해 여러 아웃바운드 채널 처리기를 다시 거칩니다. 
  6. 결국 EventLoop는 동일한 SocketChannel/Socket에 대한 응답을 반환했습니다.
  7. 루프에서 1단계에서 6단계를 반복합니다.

이것은 단일 스레드 사용으로 인한 리소스 사용을 확실히 최소화하는 간단한 사용 사례이지만 실제 문제를 해결하지는 못합니다. 

다음과 같은 이유로 애플리케이션이 EventLoop를 오랫동안 차단하면 어떻게 됩니까?

  • 높은 CPU 집약적 작업.
  • 읽기/쓰기 등과 같은 데이터베이스 작업
  • 파일 읽기/쓰기.
  • 네트워크를 통해 다른 응용 프로그램에 대한 모든 호출. 

이러한 경우 EventLoop은  4단계 에서 차단되며  애플리케이션이 곧 느려지거나 응답하지 않을 수 있는 동일한 상황에 처할 수 있습니다. 추가 EventLoop를 만드는 것은 솔루션이 아닙니다. 이미 언급했듯이 소켓 범위는 단일 EventLoop에 바인딩됩니다. 따라서 EventLoop의 블록은 이미 바인딩된 소켓에 대해 다른 EventLoop가 대신 작동하도록 허용하지 않습니다.  

참고 : EventLoops는 계속 실행되어야 하고 단일 CPU는 여러 EventLoops를 동시에 실행할 수 없기 때문에 애플리케이션이 다중 CPU 플랫폼에서 실행 중인 경우에만 다중 EventLoops를 생성해야 합니다. 기본적으로 애플리케이션은 CPU 코어 수만큼 EventLoops로 시작합니다.

이제 NIO EventLoop의 진정한 힘은 그 이면에 있는 뛰어난 단순성과 함께 그림으로 나타납니다. 사용자 애플리케이션은 요청을 다른 스레드에 위임하고 콜백을 통해 비동기적으로 결과를 반환하여 새로운 요청 처리를 위해 EventLoop의 차단을 해제해야 합니다. 따라서 EventLoop의 업데이트된 작업 단계는 다음과 같습니다.

  1. 위와 같이 1~3단계를 진행합니다.
  2. EventLoop는 요청을 새 작업자 스레드에 위임합니다.
    1. 작업자 스레드는 이러한 긴 작업을 수행합니다.
    2. 완료 후 작업에 대한 응답을 작성하고 ScheduledTaskQueue.
  3. 작업 대기열의 EventLoop 폴링 작업ScheduledTaskQueue
    1. 있다면 태스크의 Runnable run방법으로 5~6단계를 수행합니다.
    2. 그렇지 않으면 SocketChannel에서 새 요청을 계속 폴링합니다.

Webclient를 사용한 완전 반응형 접근 방식

이점 :

  1. 경량 요청 처리 스레드.
  2. 하드웨어 리소스의 최적 활용.
  3. 단일 EventLoop는 HTTP 클라이언트와 요청 처리 간에 공유될 수 있습니다.
  4. 단일 스레드는 여러 소켓(즉, 다른 클라이언트)에 대한 요청을 처리할 수 있습니다.
  5. 이 모델은 무한 스트림 응답 의 경우 배압  처리를 지원합니다 .

요청당 스레드 모델(  서블릿 3.1+ 구현 )

요청당 스레드 모델은 동기식 서블릿 프로그래밍이 도입된 이후로 실제로 사용되었습니다. 이 모델은 들어오는 요청을 처리하기 위해 서블릿 컨테이너에 의해 채택되었습니다. 요청 처리가 동기식이므로 이 모델은 요청을 처리하기 위해 많은 스레드가 필요합니다. 따라서 리소스 활용도가 높아집니다. 이러한 스레드를 차단할 수 있는 네트워크 I/O는 고려하지 않았습니다. 따라서 확장성을 위해 일반적으로 리소스를 확장해야 합니다 이것은 시스템 비용을 증가시킵니다. 

반응형으로 이동하여 Spring Webflux를 사용하면 Servlet 3.1+ API를 구현하는 경우 Servlet 컨테이너도 사용할 수 있는 옵션이 제공됩니다. Tomcat은 가장 일반적으로 사용되는 Servlet Container이며 반응형 프로그래밍도 지원합니다.

Tomcat에서 Spring Webflux를 선택하면 응용 프로그램은 스레드 풀(예: 10)에서 특정 수의 요청 처리 스레드로 시작합니다. 소켓의 요청은 이 풀의 스레드에 할당됩니다. 여기서 주의할 점은 소켓이 스레드에 영구적으로 바인딩되지 않는다는 것입니다.

요청 스레드가 I/O 호출에 대해 차단되면 스레드 풀의 다른 스레드에서 요청을 계속 처리할 수 있습니다. 그러나 그러한 요청이 많을수록 모든 요청 스레드를 차단할 수 있습니다. 지금까지는 동기 처리에서 사용하던 것과 동일합니다. 그러나 반응적 접근 방식에서는 EventLoop에서 설명한 것처럼 사용자가 요청을 다른 작업자 스레드 풀에 위임할 수 있습니다. 그렇게 하면 요청 처리 스레드를 사용할 수 있게 되고 애플리케이션이 응답을 유지합니다. 

Webclient를 사용한 요청 모델당 반응 스레드

다음은 이 모델에서 요청을 처리하는 동안 실행되는 단계입니다.

  1. 모든 요청은 SocketChannel이라는 채널과 연결된 고유 소켓에서 수신됩니다. 
  2. 요청은 스레드 풀에서 스레드에 할당됩니다.
  3. 스레드에 대한 요청은 필요한 사전 처리를 위해 특정 처리기(예: 필터, 서블릿)를 거칩니다.
  4. 요청 스레드는 컨트롤러에서 차단 코드를 실행하는 동안 작업자 스레드 또는 반응 웹 클라이언트에 요청을 위임할 수 있습니다.
  5. 작업이 완료되면 작업자 스레드 또는 웹 클라이언트(EventLoop)가 해당 소켓에 응답을 되돌려 줄 책임이 있습니다.

다시 말하지만, 반응형 클라이언트(예: Reactive Webclient) 및 Reactive DB 드라이버를 선택하여 응용 프로그램을 완전히 반응형으로 만들면 최소 스레드가 시스템에 효과적인 확장성을 제공할 수 있습니다. 

장점 :

  1. Servlet API의 사용을 지원하고 허용합니다.
  2. 요청 스레드가 차단되면 EventLoop에서와 같이 소켓 범위가 아닌 단일 클라이언트 소켓만 차단합니다.
  3. 하드웨어 자원의 최적 활용

성능 비교

이 비교를 위해 Spring Webflux를 사용하여 100ms의 의도적인 지연을 사용하여 샘플 Reactive Spring Boot 애플리케이션을 생성했습니다. 요청 처리 스레드를 차단 해제하기 위해 요청이 다른 작업자 스레드에 위임되었습니다. 

이 비교에는 다음 구성이 사용되었습니다.

  • 속성을 사용하여 Netty 성능을 위해 구성된 단일 EventLoopreactor.netty.ioWorkerCount=1
  • 속성을 사용하여 Tomcat 성능에 대해 구성된 단일 요청 처리 스레드server.tomcat.max-threads=1
  • (1GB RAM, 1CPU) 구성의 AWS EC2 인스턴스 . t2.micro
  • 100명의 사용자와 10분 동안 1초의 램프업이 실행되는 Jmeter 테스트 스크립트 .

CPU 사용률 비교

최대 CPU 사용률

요청 모델당 Tomcat 스레드: 37.6%

네티 이벤트 루프: 34.6% 

결과  : Tomcat은CPU 사용률이 8.67052% 증가한 것으로 나타났습니다.

성과 결과

Tomcat 90 백분위수 응답 시간: 114ms

Tomcat의 응답 시간

 

Netty EventLoop 90 백분위수 응답 시간: 109ms

Netty EventLoop 응답 시간

The importance of tuning your thread pools

https://blog.bramp.net/post/2015/12/17/the-importance-of-tuning-your-thread-pools/

의 번역글 


알고 있든 모르든 Java 웹 애플리케이션은 들어오는 요청을 처리하기 위해 스레드 풀을 사용하고 있을 가능성이 큽니다. 이것은 많은 사람들이 간과하는 구현 세부 사항이지만 조만간 풀이 사용되는 방식과 애플리케이션에 맞게 풀을 올바르게 조정하는 방법을 이해해야 합니다. 이 기사는 스레드 모델, 스레드 풀이 무엇인지, 올바르게 구성하기 위해 무엇을 해야 하는지 설명하는 것을 목표로 합니다.



단일 스레드

몇 가지 기본 사항부터 시작하여 스레드 모델의 진화를 진행해 보겠습니다. 어떤 애플리케이션 서버나 프레임워크를 사용 하든 Tomcat , Dropwizard , Jetty 는 모두 동일한 기본 접근 방식을 사용합니다. 웹 서버 깊숙한 곳에는 소켓이 있습니다. 이 소켓은 들어오는 TCP 연결을 수신 대기 중이며 수락합니다. 일단 수락되면 새로 설정된 TCP 연결에서 데이터를 읽고 구문 분석하여 HTTP 요청으로 전환할 수 있습니다. 그런 다음 이 요청은 원하는 작업을 수행하기 위해 웹 응용 프로그램으로 전달됩니다.

스레드의 역할에 대한 이해를 돕기 위해 애플리케이션 서버를 사용하지 않고 처음부터 간단한 서버를 구축합니다. 이 서버는 대부분의 애플리케이션 서버가 내부에서 수행하는 작업을 미러링합니다. 우선 단일 스레드 웹 서버는 다음과 같이 보일 수 있습니다.

ServerSocket listener = new ServerSocket(8080);
try {
	while (true) {
		Socket socket = listener.accept();
		try {
			handleRequest(socket);
		} catch (IOException e) {
			e.printStackTrace();
		}
	}
} finally {
	listener.close();
}

이 코드 는 포트 8080에 ServerSocket 을 생성한 다음 엄격한 루프에서 ServerSocket이 수락할 새 연결을 확인합니다. 수락되면 소켓이 handleRequest 메소드로 전달됩니다. 이 메서드는 일반적으로 HTTP 요청을 읽고 필요한 모든 프로세스를 수행하고 응답을 작성합니다. 이 간단한 예에서 handleRequest는 한 줄을 읽고 짧은 HTTP 응답을 반환합니다. handleRequest가 데이터베이스에서 읽거나 다른 종류의 IO를 수행하는 것과 같이 더 복잡한 작업을 수행하는 것은 정상입니다.

final static String response =
	"HTTP/1.0 200 OK\r\n" +
	"Content-type: text/plain\r\n" +
	"\r\n" +
	"Hello World\r\n";

public static void handleRequest(Socket socket) throws IOException {
	// Read the input stream, and return "200 OK"
	try {
		BufferedReader in = new BufferedReader(
			new InputStreamReader(socket.getInputStream()));
		
		log.info(in.readLine());

		OutputStream out = socket.getOutputStream();
		out.write(response.getBytes(StandardCharsets.UTF_8));
	} finally {
		socket.close();
	}
}

수락된 모든 소켓을 처리하는 단일 스레드만 있으므로 다음 요청을 수락하기 전에 각 요청을 완전히 처리해야 합니다. 실제 응용 프로그램에서는 동등한 handleRequest 메서드가 반환되는 데 100밀리초 정도 소요되는 것이 정상일 수 있습니다. 이 경우 서버는 초당 10개의 요청만 처리하도록 제한됩니다.

다중 스레드

IO에서 handleRequest가 차단될 수 있지만 CPU는 더 많은 요청을 처리할 수 있습니다. 단일 스레드 접근 방식에서는 이것이 불가능합니다. 따라서 이 서버는 여러 스레드를 생성하여 동시 작업을 허용하도록 개선할 수 있습니다.

public static class HandleRequestRunnable implements Runnable {
	final Socket socket;

	public HandleRequestRunnable(Socket socket) {
		this.socket = socket;
	}

	public void run() {
		try {
			handleRequest(socket);
		} catch (IOException e) {
			e.printStackTrace();
		}
	}
}

// Main loop here
ServerSocket listener = new ServerSocket(8080);
try {
	while (true) {
		Socket socket = listener.accept();
		new Thread( new HandleRequestRunnable(socket) ).start();
	}
} finally {
	listener.close();
}

여기에서 accept()는 여전히 단일 스레드 내에서 긴밀한 루프로 호출되지만 TCP 연결이 수락되고 소켓이 사용 가능하면 새 스레드가 생성됩니다. 이 생성된 스레드는 위에서 동일한 handleRequest 메서드를 단순히 호출하는 HandleRequestRunnable을 실행합니다.

새 스레드를 생성하면 이제 원래 accept() 스레드를 해제하여 더 많은 TCP 연결을 처리하고 애플리케이션이 요청을 동시에 처리할 수 있습니다. 이 기술을 "요청당 스레드"라고 하며 가장 널리 사용되는 접근 방식입니다. 이벤트 기반 비동기 모델 NGINX 및 Node.js 배포 와 같은 다른 접근 방식이 있지만 스레드 풀을 사용하지 않으므로 이 기사의 범위를 벗어납니다.

요청당 스레드 접근 방식에서는 JVM과 OS 모두 리소스를 할당해야 하므로 새 스레드를 생성하고 나중에 삭제하는 데 비용이 많이 들 수 있습니다. 또한 위의 구현에서 생성되는 스레드의 수는 무제한입니다. 무제한은 리소스 고갈로 빠르게 이어질 수 있으므로 매우 문제가 됩니다.

자원 고갈

각 스레드에는 스택에 대해 특정 양의 메모리가 필요합니다. 최근 64비트 JVM에서 기본 스택 크기 는 1024KB입니다. 서버가 요청을 많이 받거나 handleRequest 메소드가 느려지면 서버는 엄청난 수의 동시 스레드로 끝날 수 있습니다. 따라서 1000개의 동시 요청을 관리하기 위해 1000개의 스레드는 스레드의 스택에 대해서만 1GB의 JVM RAM을 소비합니다. 또한 각 스레드에서 실행되는 코드는 요청을 처리하는 데 필요한 힙에 개체를 생성합니다. 이것은 매우 빠르게 합산되고 JVM에 할당된 힙 공간을 초과할 수 있어 가비지 수집기에 압력을 가하여 스래싱을 ​​일으키고 결국 OutOfMemoryErrors 로 이어 집니다.

스레드는 RAM을 소비할 뿐만 아니라 파일 핸들이나 데이터베이스 연결과 같은 다른 유한 리소스를 사용할 수 있습니다. 이를 초과하면 다른 유형의 오류 또는 충돌이 발생할 수 있습니다. 따라서 리소스 소진을 방지하려면 무제한 데이터 구조를 피하는 것이 중요합니다.

만병 통치약은 아니지만 스택 크기 문제는 -Xss 플래그로 스택 크기를 조정하여 어느 정도 완화할 수 있습니다. 스택이 작을수록 스레드당 오버헤드가 줄어들지만 잠재적으로 StackOverflowErrors 가 발생할 수 있습니다. 마일리지는 다양하지만 많은 응용 프로그램의 경우 기본 1024KB가 과도하며 더 작은 256KB 또는 512KB 값이 더 적절할 수 있습니다. Java가 허용하는 가장 작은 값은 160KB입니다.

스레드 풀

계속해서 새 스레드를 생성하지 않고 최대 수를 제한하기 위해 간단한 스레드 풀을 사용할 수 있습니다. 간단히 말해서 풀은 모든 스레드를 추적하여 상한선까지 필요할 때 새 스레드를 만들고 가능한 경우 유휴 스레드를 재사용합니다.

ServerSocket listener = new ServerSocket(8080);
ExecutorService executor = Executors.newFixedThreadPool(4);
try {
	while (true) {
		Socket socket = listener.accept();
		executor.submit( new HandleRequestRunnable(socket) );
	}
} finally {
	listener.close();
}

이제 이 코드는 스레드를 직접 생성하는 대신 스레드 풀 전체에서 실행할 작업(Runnable의 관점에서)을 제출하는 ExecutorService를 사용합니다. 이 예에서 4개의 스레드로 구성된 고정 스레드 풀은 들어오는 모든 요청을 처리하는 데 사용됩니다. 이것은 "진행 중인" 요청의 수를 제한하므로 리소스 사용량에 제한이 있습니다.

newFixedThreadPool 외에도 Executors 유틸리티 클래스는 newCachedThreadPool 메서드도 제공합니다. 이것은 이전의 무제한 스레드 수로 인해 어려움을 겪지만 가능할 때마다 이전에 생성되었지만 지금은 유휴 스레드를 사용합니다. 일반적으로 이 유형의 풀은 외부 리소스를 차단하지 않는 단기 요청에 유용합니다.

ThreadPoolExecutors 는 직접 구성할 수 있으므로 동작을 사용자 지정할 수 있습니다. 예를 들어 풀 내 스레드의 최소 및 최대 수와 스레드가 생성 및 소멸되는 시점에 대한 정책을 정의할 수 있습니다. 이에 대한 예가 곧 표시됩니다.

작업 대기열

고정 스레드 풀의 경우 관찰자는 모든 스레드가 사용 중이고 새 요청이 들어오는 경우 어떤 일이 발생하는지 궁금해할 수 있습니다. 글쎄, ThreadPoolExecutor는 스레드를 사용할 수 있게 되기 전에 보류 중인 요청을 보유하기 위해 큐를 사용할 수 있습니다. Executors.newFixedThreadPool은 기본적으로 무제한 LinkedList를 사용합니다. 이렇게 하면 리소스 고갈 문제가 발생하지만 대기열에 있는 각 요청이 전체 스레드보다 작고 일반적으로 리소스를 많이 사용하지 않기 때문에 속도가 훨씬 느립니다. 그러나 이 예에서 대기열에 있는 각 요청은 (OS에 따라) 파일 핸들을 사용하는 소켓을 보유하고 있습니다. 이것은 운영 체제가 제한하는 종류의 리소스이므로 필요한 경우가 아니면 보유하는 것이 가장 좋지 않을 수 있습니다. 따라서 작업 대기열의 크기를 제한하는 것도 의미가 있습니다.

public static ExecutorService newBoundedFixedThreadPool(int nThreads, int capacity) {
	return new ThreadPoolExecutor(nThreads, nThreads,
		0L, TimeUnit.MILLISECONDS,
		new LinkedBlockingQueue<Runnable>(capacity),
		new ThreadPoolExecutor.DiscardPolicy());
}

public static void boundedThreadPoolServerSocket() throws IOException {
	ServerSocket listener = new ServerSocket(8080);
	ExecutorService executor = newBoundedFixedThreadPool(4, 16);
	try {
		while (true) {
			Socket socket = listener.accept();
			executor.submit( new HandleRequestRunnable(socket) );
		}
	} finally {
		listener.close();
	}
}

다시 스레드 풀을 생성하지만 Executors.newFixedThreadPool 도우미 메서드를 사용하는 대신 ThreadPoolExecutor를 직접 생성하여 16개 요소로 제한되는 제한된 LinkedBlockingQueue 를 전달합니다. 또는 제한된 버퍼의 구현인 ArrayBlockingQueue 를 사용할 수 있습니다.

모든 스레드가 사용 중이고 큐가 가득 차면 ThreadPoolExecutor에 대한 마지막 인수에 의해 다음에 발생하는 작업이 정의됩니다. 이 예에서 DiscardPolicy 가 사용되어 대기열을 오버플로하는 모든 작업을 단순히 버립니다. 예외를 발생시키는 AbortPolicy 또는 호출자의 스레드에서 작업을 실행하는 CallerRunsPolicy 와 같은 다른 정책이 있습니다 . 이 CallerRunsPolicy는 작업을 추가할 수 있는 속도를 자체 제한하는 간단한 방법을 제공하지만 차단되지 않은 상태로 유지되어야 하는 스레드를 차단하여 해로울 수 있습니다.

좋은 기본 정책은 작업을 삭제하는 Discard 또는 Abort입니다. 이러한 경우 HTTP 503 "서비스를 사용할 수 없음" 과 같은 간단한 오류를 클라이언트에 쉽게 반환할 수 있습니다 일부는 대기열 크기를 늘리기만 하면 모든 작업이 결국 실행될 수 있다고 주장합니다. 그러나 사용자는 영원히 기다리기를 원하지 않으며, 기본적으로 작업이 들어오는 속도가 실행할 수 있는 속도를 초과하면 대기열이 무한정 늘어납니다. 대신 대기열은 요청 버스트를 부드럽게 처리하거나 처리 중 짧은 지연을 처리하는 데만 사용해야 합니다. 정상 작동 시 대기열은 비어 있어야 합니다.

스레드는 몇 개입니까?

이제 스레드 풀을 만드는 방법을 이해했습니다. 어려운 질문은 사용 가능한 스레드 수입니다. 리소스가 고갈되지 않도록 최대 수를 제한해야 한다고 결정했습니다. 여기에는 모든 유형의 리소스, 메모리(스택 및 힙), 열린 파일 핸들, 열린 TCP 연결, 원격 데이터베이스가 처리할 수 있는 연결 수 및 기타 유한 리소스가 포함됩니다. 반대로 스레드가 IO 바운드가 아닌 CPU 바운드인 경우 물리적 코어 수는 유한한 것으로 간주되어야 하며 코어당 스레드를 하나만 생성해야 합니다.

이것은 모두 응용 프로그램이 수행하는 작업에 따라 다릅니다. 사용자는 다양한 풀 크기와 현실적인 요청 조합을 사용하여 부하 테스트를 실행해야 합니다. 중단점까지 스레드 풀 크기를 늘릴 때마다. 이를 통해 리소스가 소진되었을 때 상한을 찾을 수 있습니다. 어떤 경우에는 사용 가능한 리소스의 수를 늘리는 것이 현명할 수 있습니다. 예를 들어 JVM에서 더 많은 RAM을 사용할 수 있도록 하거나 더 많은 파일 핸들을 허용하도록 OS를 조정합니다. 그러나 어느 시점에서 이론적 상한선에 도달할 것이며 주목해야 하지만 이것이 이야기의 끝이 아닙니다.

리틀의 법칙

리틀의 법칙 방정식

큐잉 이론, 특히 리틀의 법칙 은 스레드 풀의 속성을 이해하는 데 도움이 될 수 있습니다. 간단히 말해서 리틀의 법칙은 세 변수 간의 관계를 설명합니다. L은 진행 중인 요청 수, λ는 새 요청이 도착하는 속도, W는 요청을 처리하는 평균 시간입니다. 예를 들어 초당 10개의 요청이 도착하고 각 요청을 처리하는 데 1초가 걸린다면 언제든지 평균 10개의 요청이 진행 중입니다. 이 예에서는 10개의 스레드를 사용하는 것으로 매핑됩니다. 단일 요청을 처리하는 시간이 2배이면 진행 중인 평균 요청도 20개로 두 배가 되므로 20개의 스레드가 필요합니다.

실행 시간이 진행 중인 요청에 미치는 영향을 이해하는 것은 매우 중요합니다. 일부 백엔드 리소스(예: 데이터베이스)가 중단되어 요청을 처리하는 데 더 오래 걸리고 스레드 풀이 빠르게 소모되는 것이 일반적입니다. 따라서 이론적 상한은 풀 크기에 대한 적절한 제한이 아닐 수 있습니다. 대신 실행 시간에 제한을 두고 이론상 상한선과 함께 사용해야 합니다.

예를 들어 JVM이 메모리 할당을 초과하기 전에 처리할 수 있는 최대 진행 중 요청이 1000이라고 가정해 보겠습니다. 각 요청에 대해 30초를 넘지 않도록 예산을 책정하면 최악의 경우 초당 33 ⅓ 요청을 처리할 것으로 예상해야 합니다. 그러나 모든 것이 올바르게 작동하고 요청을 처리하는 데 500ms만 걸리는 경우 애플리케이션은 1000개의 스레드에서만 초당 2000개의 요청을 처리할 수 있습니다. 짧은 지연 버스트를 부드럽게 하기 위해 대기열을 사용할 수 있도록 지정하는 것도 합리적일 수 있습니다.

왜 번거롭습니까?

스레드 풀에 스레드가 너무 적으면 리소스를 충분히 활용하지 못하고 불필요하게 사용자를 외면할 위험이 있습니다. 그러나 너무 많은 스레드가 허용되면 리소스가 고갈되어 더 큰 피해를 줄 수 있습니다.

지역 자원이 고갈될 뿐만 아니라 다른 사람들에게 부정적인 영향을 미칠 수 있습니다. 예를 들어 동일한 백엔드 데이터베이스를 쿼리하는 여러 애플리케이션을 가정해 보겠습니다. 데이터베이스에는 일반적으로 동시 연결 수에 대한 엄격한 제한이 있습니다. 제한되지 않고 오작동하는 하나의 응용 프로그램이 이러한 모든 연결을 사용하면 다른 응용 프로그램이 데이터베이스에 액세스하지 못하도록 차단합니다. 광범위한 중단을 유발합니다.

설상가상으로 계단식 오류가 발생할 수 있습니다. 공통 로드 밸런서 뒤에 단일 애플리케이션의 여러 인스턴스가 있는 환경을 상상해 보십시오. 과도한 진행 중인 요청으로 인해 인스턴스 중 하나에 메모리가 부족하기 시작하면 JVM은 가비지 수집에 더 많은 시간을 소비하고 요청을 처리하는 데 더 적은 시간을 소비합니다. 속도가 느려지면 해당 인스턴스의 용량이 줄어들고 다른 인스턴스가 수신 요청의 더 많은 부분을 처리하도록 합니다. 이제 제한되지 않은 스레드 풀을 사용하여 더 많은 요청을 처리하므로 동일한 문제가 발생합니다. 메모리가 부족하고 다시 적극적으로 가비지 수집을 시작합니다. 이 악순환은 시스템 장애가 발생할 때까지 모든 경우에 걸쳐 계속됩니다.

부하 테스트가 수행되지 않고 임의로 많은 수의 스레드가 허용되는 것을 너무 자주 관찰했습니다. 일반적인 경우 응용 프로그램은 적은 수의 스레드를 사용하여 들어오는 속도로 요청을 처리할 수 있습니다. 그러나 요청 처리가 원격 서비스에 의존하고 해당 서비스가 일시적으로 느려지면 W(평균 처리 시간) 증가의 영향으로 풀이 매우 빨리 소진될 수 있습니다. 애플리케이션이 최대 수에서 로드 테스트되지 않았기 때문에 앞에서 설명한 모든 리소스 고갈 문제가 나타납니다.

스레드 풀은 몇 개입니까?

마이크로 서비스 또는 SOA( 서비스 지향 아키텍처 ) 에서는 여러 원격 백엔드 서비스에 액세스하는 것이 일반적입니다. 이 설정은 특히 실패하기 쉬우므로 문제를 정상적으로 처리할 수 있도록 고려해야 합니다. 원격 서비스의 성능이 저하되면 스레드 풀이 빠르게 한계에 도달하여 후속 요청이 삭제될 수 있습니다. 그러나 모든 요청에 ​​이 비정상적 백엔드가 필요한 것은 아니지만 스레드 풀이 가득 차서 이러한 요청이 불필요하게 삭제됩니다.

백엔드별 스레드 풀을 제공하여 각 백엔드의 장애를 격리할 수 있습니다. 이 패턴에는 여전히 단일 요청 작업자 풀이 있지만 요청이 원격 서비스를 호출해야 하는 경우 작업이 해당 백엔드의 스레드 풀로 전송됩니다. 이렇게 하면 단일 느린 백엔드로 인해 기본 요청 풀의 부담이 줄어듭니다. 그런 다음 특정 백엔드 풀이 필요한 요청만 오작동할 때 영향을 받습니다.

다중 스레드 풀의 최종 이점은 교착 상태를 방지하는 데 도움이 된다는 것입니다. 아직 처리되지 않은 요청의 결과로 사용 가능한 모든 스레드가 차단되면 교착 상태가 발생하고 스레드가 앞으로 나아갈 수 없습니다. 여러 풀을 사용하고 풀이 실행하는 작업을 잘 이해하면 이 문제를 어느 정도 완화할 수 있습니다.

마감일 및 기타 모범 사례

일반적인 모범 사례는 모든 원격 호출에 기한이 있는지 확인하는 것입니다. 즉, 원격 서비스가 합리적인 시간 내에 응답하지 않으면 요청이 포기됩니다. 스레드 풀 내 작업에도 동일한 기술을 사용할 수 있습니다. 특히 스레드가 정의된 기한보다 긴 한 요청을 처리하는 경우 종료되어야 합니다. 새로운 요청을 위한 공간을 만들고 W에 상한선을 두는 것은 낭비처럼 보일 수 있지만 사용자(일반적으로 웹 브라우저일 수 있음)가 응답을 기다리는 경우 30초 후에 브라우저는 어쨌든 사용자가 참을성이 없어 다른 곳으로 이동할 가능성이 높습니다.

빠른 실패는 백엔드용 풀을 생성할 때 취할 수 있는 또 다른 접근 방식입니다. 백엔드가 실패하면 스레드 풀은 응답하지 않는 백엔드에 연결하기 위해 대기 중인 요청으로 빠르게 채워집니다. 대신 백엔드가 비정상으로 표시될 수 있으며, 모든 후속 요청은 불필요하게 기다리는 대신 즉시 실패할 수 있습니다. 그러나 백엔드가 다시 정상 상태가 되었는지 확인하려면 메커니즘이 필요합니다.

마지막으로 요청이 여러 백엔드를 독립적으로 호출해야 하는 경우 순차적 대신 병렬로 호출할 수 있어야 합니다. 이렇게 하면 스레드가 증가하는 대신 대기 시간이 줄어듭니다.

운 좋게도 hystrix 라는 훌륭한 라이브러리가 있어 이러한 모범 사례를 패키지로 만들고 간단하고 안전한 방식으로 노출합니다.

결론

이 기사가 스레드 풀에 대한 이해를 높이는 데 도움이 되었기를 바랍니다. 응용 프로그램의 요구 사항을 이해하고 최대 스레드 수와 평균 응답 시간을 조합하여 적절한 스레드 풀을 결정할 수 있습니다. 이렇게 하면 연쇄적인 실패를 피할 수 있을 뿐만 아니라 서비스를 계획하고 프로비저닝하는 데 도움이 됩니다.

애플리케이션이 스레드 풀을 명시적으로 사용하지 않더라도 애플리케이션 서버 또는 더 높은 수준의 추상화에서 암시적으로 사용됩니다. Tomcat , JBoss , Undertow , Dropwizard 모두 스레드 풀(서블릿이 실행되는 풀)에 여러 조정 가능 항목을 제공합니다.