스프링부트 스레드?

yboy·2022년 9월 25일
4

Learning Log 

목록 보기
21/41
post-thumbnail

학습 동기

우아한테크코스 프로젝트에서 스프링부트를 사용하여 백엔드 개발을 하고 있다. 데모데이 요구사항으로 스프링부트 스레드 설정을 적절하게 바꾸라는 요구사항이 있었고 각 팀마다 바꾼 설정 값을 다른 팀들과 데모데이 날 공유하는 시간을 가졌다. 설정 값을 바꾸는 과정에서 새로운 경험을 많이 했는데 이를 글로 남기고자 한다.

학습 내용

처음에 스레드 라는 개념 자체도 모호했다. 스레드란 뭘까?

Thread

어떤 프로그램 내에서 코드를 실행하는 하나의 단위

스레드를 한 마디로 정의하면 다음과 같다. 스레드는 프로세스 내에서 각각 Stack 영역만 따로 할당 받고 Code, Data, Heap 영역은 다른 스레드와 공유한다. 따라서 상태가 있는 변수(인스턴스 변수는 Heap 영역에 의해 관리되므로)를 스레드끼리 공유할 때 문제가 발생할 수 있다. 스레드를 직접적으로 다룰 일이 있을 때는 이러한 동시성에 의한 공유 자원 문제를 신경써야 한다. 하지만 이러한 복잡한 스레드 문제를 스프링에서는 기본적으로 해결해 줘서 우리는 그 동안 스레드에 대해 깊게 생각해 보지 못했던 것이다. 그래서 스프링이 어떻게 스레드를 통해 다중 요청을 처리해 주는 건데? 답을 알아보기 전에 답의 단서가 될 스레드 풀에 대해 서도 간략히 알아보고 가자.

Thread Pool

프로그램 실행에 필요한 Thread를 미리 생성해 두는 것

요구사항에서 톰켓의 스레드 설정을 바꾸라는 말은 이 스레드 풀의 설정을 바꾸는 것이다. 그럼 기본적으로 스프링부트는 스레드풀을 사용한다는 것인데.... 왜 스레드풀을 사용해야 할까?

Thread Pool을 사용하는 이유

한마디로 말하면 스레드 생성 비용을 줄이기 위함이다. 스레드를 생성하거나 파괴할 때, 생성 비용이 크게 발생하게 된다. 톰켓 3.2 이전 버전에서는, 유저의 요청이 들어올 때마다 요청을 처리할 스레드를 생성하고 요청이 끝나면 파괴했다. 이는 다중 요청이 들어올 때 억제하기가 어려워 서버가 일시 다운되는 등의 문제를 야기했고 이를 해결하기 위해 톰켓에서 스레드풀을 도입하게 됐다.

Tomcat Thread Pool 동작

  1. 첫 요청이 들어오면, core size(thread.min-spare)만큼 idle 상태의 스레드 생성
    • 처리할 수 있는 요청은 max-connection 설정 값 만큼 가능
  2. idle 상태의 스레드가 들어오는 요청을 처리
    • idle 상태의 스레드가 없으면 요청을 처리하기 위해 threads max 설정 값만큼 스레드를 생성
  3. max-connection 이상의 요청이 들어오면 작업 큐에 대기
    • 작업 큐 사이즈는 accept-count 설정으로 관리
  4. 작업 큐가 가득차면 connection-refused 오류를 반환

근데 계속 톰켓, 톰켓하는데 톰켓이랑 스프링부트는 무슨 상관인거지?

스프링부트와 톰켓

스프링부트는 내장 서블릿 컨테이너로 Tomcat을 지원한다. 서블릿 컨테이너인 Tomcat으로 인해 스프링부트에서다중 요청 처리를 쉽게 할 수 있었던 것이었다😳

스프링은 어떻게 다중 요청을 처리할 수 있을까?

이에 대한 답은 위에서 이미 나왔다. 스프링 자체가 다중 요청을 처리한다기 보다 스프링부트에 내장되어 있는 톰켓에서 다중 요청을 처리해 주는 것이다. 이렇기 때문에 우리는 application.yml이나application.properties 같은 설정 파일에서 간편하게 스레드 설정을 바꿀 수 있다.

ex) 스프링부트 톰켓에서 기본 스레드 설정 값

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

threads.max

  • 사용될 수 있는 최대 스레드 개수 (생성할 수 있는 thread의 총 개수)
  • 디폴트 설정은 200이다.

threads.min-spare

  • 항상 활성화 되어 있는(idle) thread의 개수
  • 디폴트 설정은 10이다.

max-connections

  • 서버가 지정된 시간에 수락하고 처리할 최대 연결 수
  • 이 수에 도달하면 서버는 추가 연결을 허용하지만 처리하지는 않는다.
  • 디폴트 설정은 8192이다.

accept-count

  • 작업 큐의 사이즈
  • maxConnections에 지정한 수보다 많은 요청이 들어 왔을 때, 초과된 요청들은 작업 큐에 쌓여 기다리게 된다. 이 작업 큐의 사이즈가 accept count이다.
  • 디폴트 설정은 100이다.

디폴트 설정 값은 ServerProperties.java 파일에서 확인할 수 있다.

그럼 이제 이 설정 값을 우리 프로젝트에 맞게 적절히 바꿔보자. 스레드가 많으면 많은 대로 스레드가 cpu 자원을 두고 경합하게 되므로 처리 속도가 느려 질 수 있고, 적으면 cpu 자원을 최적으로 활용하지 못하여 마찬가지로 처리속도가 느려질 수 있기 때문이다. 적절한 설정 값을 정하기 위해 부하테스트를 진행했는데 부하테스트란 뭘까?

부하테스트

Throughput, Latency등의 성능 지표를 확인하여 서버가 어느 정도의 요청까지 버틸 수 있는 지 확인하는 테스트

부하테스트에서 나온 지표를 통해 서비스의 성능을 개선할 수 있다.

Throughput

시간 당 처리량

TPS(Transaction Per Second), RPS(Request Per Second)로 불리며, 1초에 처리하는 단위 작업의 수 혹은 1초에 처리하는 HTTP 요청 수이다.

Latency

서버가 클라이언트로부터 요청을 받아서 응답을 보내주기 까지 걸리는 시간

쉽게 말해서 Latency는 서비스가 적업을 얼마나 빠르게 처리할 수 있는지를 나타내는 지표이다.

그래서 우리는 왜 부하테스트를 했지?

우리가 톰켓 스레드 설정을 바꾸기 위해 부하테스트를 한 이유는

  1. 우리가 최대 처리해야 할 요청에 대해 디폴트 톰켓 설정일 때의 성능 지표(Throughput, Latency)를 확인하기 위함이다.

  2. 1번에서 확인한 성능을 유지하는 선에서 자원 낭비를 방지하기 위해 스레드 설정 값을 프로젝트에 맞게 줄여나가기 위함이다.

  3. 스레드 설정 뿐만 아니라 추후에 쿼리 개선 등의 리팩토링에서 의미 있는 자료가 될 것이라 생각했기 때문이다.

부하테스트 과정

이제 우리의 부하테스트 과정에 대해 살펴보자.

부하테스트 도구

Locust
부하테스트를 하기에 앞서 어떤 도구를 사용할 지 고민이 많았다. 우리의 고려 대상이된 도구는 jMeter, Locust, nGlinder였다. jMeter는 여러 프로토콜과 플러그인을 지원하는 등의 장점이 있었고, nGrinder는 국내 사용자가 많아 한국어로 된 레퍼런스가 많다는 등의 장점이 있었지만, 그 중 최종적으로 Locust를 선택했다. 그 이유는 우리가 하고자 하는 테스트에 필요한 기능(RPS, Median Response time)만을 제공하며 가볍고, 파이썬을 이용해 스크립트를 작성할 수 있어 테스트에 용이했기 때문입니다. 또 많은 레퍼런스와 자세한 공식 문서가 선택의 기준이 되었다.

테스트 환경

1. prod 서버와 같은 스펙의 테스트용 서버에서 실행
2. 150명이 동시에 접속하는 경우를 최대 동시 접속자로 가정

테스트 환경은 실제 운영 서버와 최대한 비슷한 환경에서 테스트를 진행하기 위해 prod 서버와 같은 스펙의 테스트용 서버를 만들었다.

또 우테코 한 기수의 면담을 모두 진행했을 상황을 가정하여 더미 데이터를 만들어 테스트용 db에 저장해 환경을 세팅했다. 최대 동시 접속자의 수도 서비스 속성에 맞게 현재 우아한테크코스 크루와 코치의 수를 여유롭게 잡아 150명으로 설정하고 테스트를 진행했다.

테스트 진행 - threads.max 변경

threads.max, max connections, acceptCount 총 세가지 설정 값을 적절하게 바꾸는 것을 목적으로 테스트를 진행했다.

톰켓 설정 default (threads.max=200, max connections= 8192, acceptCount=100)

디폴트 설정으로 테스트를 해보았을때는 1초에 약 14개의 요청을 처리할 수 있었으며, 요청의 응답에는 약 10 밀리세컨드가 소요되었다.

처음에는 동시 접속자가 150명이라는 점을 감안해 max connection을 200, accept count은 100으로 넉넉히 두고 max thread 값을 수정하며 테스트 해보았다. 150, 100, 50, 30, 10, 5 순으로 조금씩 줄여나가 보았지만 성능상 차이(RPS, Median Response time 기준)가 없다는 것을 발견했다.
이유는 150명의 접속자(요청)는 디폴트 idle 스레드 수인 10(threads.min-spare)으로 충분히 처리가능하기 때문일 것이라고 유추해보았다. 10개의 스레드로 150개의 요청을 처리할 수 있는 이유는 NIO 커넥션의 스레드 처리 방식에서 답을 얻을 수 있을 것 같다.

테스트 진행 - max connections, accept count 변경

따라서 threads.max 설정 값은 넉넉잡아 50으로 고정해두고 max connections과 accept count를 변경시키며 테스트를 해보았다.

threads.max=50, max connections= 150, acceptCount=50

우선 max connections를 200 -> 150, acceptCount를 100 -> 50으로 줄여 테스트해보았다.

성능 상 max connections=200, acceptCount=100일 때와 비교해 봤을 때 성능 차이가 크게 없다는 것을 알 수 있었다.

threads.max=50, max connections= 100, acceptCount=50

이어서 max connections를 150 -> 100으로 줄여서 테스트를 해보았다.

Latency는 변화가 없었으나, RPS 성능이 2/3로 떨어진 것을 확인할 수 있었다. max connections를 100으로 줄임에 따라 대기 큐에 요청이 쌓이게 되면서 성능이 저하된 것으로 보인다.

결론

threads.max=50, max connections= 150, acceptCount=50


threads.max를 그래도 넉넉잡아 50, max connection을 150, accept count를 50로 설정하기로 결정했다.

마무리

톰켓 설정을 통해 전혀 몰랐던 스레드의 개념에 대해서도 알게 되었고 기능 개발 뿐만 아니라 서버의 성능에 대해서도 생각해 볼수 있는 시간이 되었다. 다음에는 DB 성능 개선을 통해 성능을 높이는 작업을 해보고 싶다.

1개의 댓글

comment-user-thumbnail
2024년 1월 1일

잘 보고 갑니다!!

답글 달기