Spring Tomcat 관련 테스트

    Spring Tomcat Thread Pool

    스프링 MVC는 내장 톰캣을 사용하고, 요청마다 새로운 쓰레드가 배정된다. Tomcat 서버에서는 쓰레드 생성 비용을 절감하기 위해 쓰레드 풀에 미리 생성되어 있는 쓰레드를 재활용한다. 톰캣 서버와 관련된 설정값을 바꿔서 이런 부분들 조절할 수 있다.

    server:
      tomcat:
        threads:
          max: 200
          min-spare: 50
      accept-count: 10
      max-connections: 1000

    각각이 의미하는 것은 다음과 같다. 

    • max : 톰캣 쓰레드풀이 동시에 사용할 수 있는 최대 쓰레드 개수. 쓰레드 개수만큼 동시 요청 처리가 가능함
    • min-spare : 톰캣 쓰레드풀에 대기 상태로 있는 쓰레드 개수. 
    • max-connections : 톰캣 커넥터가 동시에 맺을 수 있는 TCP 커넥션 개수. 
    • accept-count : max-connections 갯수만큼 Tomcat 커넥터가 TCP 커넥션을 가지고 있을 때, 추가적인 요청이 오면 accept-count의 갯수만큼 백로그에 저장함. (커넥션이 늘어나지는 않음) 

    위의 설정 파일을 예로 들면 다음과 같이 이해할 수 있다. 

    • 평소에는 50개의 쓰레드가 쓰레드풀에 생성되어 대기 상태임. 
    • 동시에 200개의 요청이 오면, 150개의 쓰레드가 더 생성되어 200개의 요청을 동시에 처리할 수 있음. 톰캣 커넥터는 200개의 TCP 커넥션을 맺고 있음.
    • 200개의 동시 요청을 처리할 때, 1000개의 요청이 추가로 오는 경우가 있을 수 있음.
      • 이 때 기존 200개의 TCP 커넥션이 있음. 그리고 톰캣 커넥터는 800개의 추가 TCP 연결을 맺을 수 있으므로, 800개의 TCP 커넥션을 맺음. 그리고 나머지 200개 요청 중 10개는 accept-count(백로그 형태, TCP 커넥션 아님)에 들어가고, 190개의 요청은 거절됨. 

    테스트 조건 

    • Spring 3.1.2
    • k8s 1.27
      • CP : Rasberry pi 4 8GB
      • worker 1~3 : Rasberry pi 4 8GB
    • pod resource : no limit.

    대부분의 테스트는 k8s 클러스터에 Pod를 배포하면서 진행했으며, accept-count 관련 테스트만 네트워크 문제로 로컬에서 진행함. 


    Thread Pool의 대기 쓰레드의 영향

    Tomcat의 Thread Poo의 여유 쓰레드를 얼마나 만들어두는 것이 좋을까? 항상 적은 것이 좋을까? 아니면 항상 많은 것이 좋을까? 테스트 결과는 '그 때 상황마다 다르다'이다. 

    다음 방식으로 테스트 했다. 

    • 서버 쪽 설정값은 최대 쓰레드 개수 50, accept-count 100개로 고정한다.
    • 클라이언트에서는 동일한 CPU 바운드 작업을 비동기로 50번 요청한다. (거의 동시) 
      • 동일한 요청을 한번만 보냈을 때의 처리 시간 2.8초임.
    • 대기 쓰레드의 값만 10 / 30 / 50으로 변경한다. 

    결과를 표로 요약하면 다음과 같다. 

    여유 쓰레드 최대 쓰레드  accept-count 평균 응답 시간 응답코드 / 건수
    10 50 100 136초 504 / 50
    30 50 100 135초 504 / 50
    50 50 100 86초 504 / 50

    각 요청별 응답 시간은 아래 그래프에서 확인할 수 있다. 

    해석하면 다음과 같이 정리할 수 있다.

    • 쓰레드 풀에 여유 쓰레드를 생성하는 것은 응답 시간 관점에서 성능 개선이 있음. 
    • 요청 갯수를 동시에 처리할 수 있을 만큼의 쓰레드가 대기 상태라도 Timeout으로 인해 응답이 실패할 수 있음. 

    여기서 알 수 있었던 것은 많은 요청이 왔을 때, 동시에 처리할만큼의 쓰레드가 대기하고 있어서 요청을 받자마자 바로 작업을 하더라도 응답 시간이 느리면 클라이언트는 '504'를 받을 수 있다는 것이었다. 즉, 요청을 처리하는데 오래 걸리는 작업을 가급적이면 작은 단위로 분리해야 한다는 것을 알 수 있었다. 


    Thread Pool의 대기 쓰레드는 항상 많으면 좋을까?

    결론부터 이야기하면 그렇지는 않았다. 실험 조건과 테스트 결과를 확인해보자.

    • 서버는 대기 쓰레드 수와 최대 쓰레드 갯수를 동일하게 설정함. 대기 쓰레드 갯수 이상의 요청이 왔을 때, 새로운 쓰레드를 만들지 않고 대기 큐에 넣은 다음에 차근차근히 처리하기 위함. 
    • 클라이언트는 CPU 바운드 작업을 100번을 비동기로 요청한다.

    이 실험의 목적은 CPU 바운드 작업이 30개 / 50개 / 100개가 동시에 진행되었을 때, 대기 쓰레드의 갯수가 처리 속도에 어떤 영향을 주는지 살펴보기 위함이다. 아래 결과를 살펴보자. 

    요약하면 다음과 같다. 

    • 동시 처리하는 작업이 적을수록, 전체 처리 시간이 빠르다.
    • 각 쓰레드만큼의 작업을 처리하면, 다음 작업부터는 처리 시간이 더 줄어든다.
      • 예를 들어 ready 30 / max 30의 경우 31번째 작업부터 처리 시간이 절반으로 줄어듦. 
      • 왜 인지는 잘 모르겠음.  (아래에서 설명)

    결론적으로 많은 동시 요청을 처리하기 위해서 쓰레드 풀의 대기 쓰레드를 많이 만들었을 때, CPU 바운드의 대다수 작업인 경우 오히려 치명적으로 응답 속도를 느리게 만든다는 것을 알 수 있다. 컨텍스트 스위칭 횟수와 CPU 바운드 작업의 빈도를 고려해서 여유 쓰레드 갯수를 정해야하는 것을 알 수 있다. 


    Accept-count와 Max-Connection의 관계

    다음과 같이 실험했다.

    • min-spare, max-thread의 갯수를 50으로 고정
    • accept-count 10000으로 고정
    • max-connection 10 / 20 / 30 / 50으로 변경하면서 테스트

    테스트의 목적은 TCP 커넥션을 맺는 것이 각 요청을 처리하는데 어떠한 영향을 주는지(처리 시간 관점), 전체적인 처리 시간은 얼마나 걸리는지를 보기 위함이었다.

    각 요청 당 처리 시간은 max-connection의 갯수가 적을수록 빠르다. 

    max-conn이 20개인 경우, 50개의 요청을 톰캣이 받았을 때 20개의 TCP를 맺으면서 CPU 작업을 해야한다. 즉, 계산을 하는데 CPU를 쓰는 것이 아니라 TCP를 맺는데에도 CPU를 할애해야하기 때문에 잦은 컨텍스트 스위칭이 발생하며 요청당 처리 시간이 증가하는 것으로 보인다. max-conn이 증가하면 할수록 이 경향은 뚜렷해진다. 

    반면 전체 처리 시간은 max-conn이 클수록 짧아진다. 

    이유는 max-connnection 만큼의 TCP를 처리해두었기 때문에 각 쓰레드가 바로바로 처리를 할 수 있기 때문이다. 중간에 뜨는 구간들(17초 정도)이 있는데, 이 구간은 TCP를 다시 맺고 있는 구간이다. 여기서 보면 내 테스트 환경에서 TCP 1건을 맺는데 필요한 시간이 17초 정도로 이해를 할 수 있다.

    이 부분의 결론은 다음과 같다.

    • Max-Connection이 크지 않은 경우에는 TCP를 맺는데 필요한 요청이 많이 없기 때문에 컨텍스트 스위칭이 덜 발생할 것이다. 따라서 동일한 CPU 바운드 요청이라도 요청 한 건을 처리하는데 필요한 시간은 적어진다. 
    • Max-Connection이 크지 않은 경우에는 쓰레드가 모든 TCP 요청을 처리하면, accept-count에 있는 요청들에 대해서 다시 TCP 연결을 맺어야 한다. 따라서 더 자주 TCP를 맺어야 하고, 내 테스트 환경에서는 17초가 걸린다. 즉, 이 시간만큼 요청을 처리하는데 많은 시간이 걸린다. 

    Tomcat의 Accept-count는 많아야 좋을까? 

    아래 환경에서 테스트를 했다. 

    • 서버쪽 ready Thread는 10 / max Thread  10 / max-connections 10으로 고정함 
    • 서버쪽은 accept-count만 늘려감. 
    • 클라이언트는 50개의 요청을 비동기로 보냄.  (CPU 바운드 작업) 

    결과를 살펴보면 아래와 같다. 

    • accept-count가 늘어날수록 최종적으로 처리한 요청은 많아짐. 

    이 결과만 보면 좋은 것처럼 보이지만, 실제로는 좋지 않은 결과다. 

    accept-count는 백로그만 포함되어 있고 TCP 커넥션을 맺어두지 않은 상태다. 따라서 각 쓰레드가 요청을 처리하고, accept-count에 있는 요청을 처리하려고 한다면 백로그 형식의 값을 꺼내서 TCP 연결을 맺어야 한다. TCP 연결은 꽤나 비용이 높은 작업이기 때문에 시간이 지연된다. 

    위의 그래프는 컨트롤러가 호출되어서 계산이 완료되는데 소요된 시간만 나타난다. 위에서는 2초 안에 모든 연산이 해결된 것으로 보인다. 쓰레드 10개가 2초씩 작업을 하면, 50개의 요청에 대해서는 10초면 응답을 해야한다. 하지만 실제로 요청 ~ 응답까지 소요된 시간은 5분 가량 된다. 왜냐하면 각 요청을 순차적으로 끝내고 다시 TCP 연결을 맺은 후 작업을 처리해야하기 때문이다. 

    accept-count의 장단점은 명확해보인다.

    • 처리 시간이 짧은 요청이 많을 때는 accept-count를 쓰는 것이 좋음. 
    • 처리 시간이 긴 요청이 많을 때는 accept-count를 쓰지 않는 것이 좋음. 

    서버는 어떻게든 응답은 하겠지만, 응답 시간이 길어져서 Timeout이 발생할 것이다. 클라이언트 입장에서는 504라는 응답을 받게 될 것이고, 서버가 한 일은 무의미해 질 것이다. 


    Tomcat과 LoadBalancer

    앞선 실험 결과에서 알 수 있듯이 Tomcat에 TCP 커넥션이 맺어져 있으면, 서버는 어떻게든 그 작업을 처리하고 응답하려 한다. 그렇지만 클라이언트 입장에서는 이미 Timeout이라는 응답을 받은 후이기 때문에 서버가 한 작업은 의미가 없어진다. 

    LB to Tomcat

    위는 로드 밸런서가 톰캣 서버 4군데에 라우팅을 하는 그림이다. 이런 경우, LoadBalancer는 바쁜 Tomcat이 다시 한가해질 때까지 라우팅 대상에서 일시적으로 제외해주는 것이 맞을 것이다. 

    앞의 실험 결과를 살펴보면 이런 특징이 있음을 알 수 있다. 

    • Tomcat은 동시에 처리하지 못하더라도 커넥터에 max-connections 갯수만큼 TCP 커넥션을 가지고 있음. 
    • Tomcat은 max-connections 이상의 요청이 오면 accept-count에 백로그 형태로 보관하고 있음. 

    톰캣은 클라이언트가 애타게 기다리다가 포기하건 말건 간에 자기 할 일을 한다는 것이다. 톰캣이 가지고 있는 TCP 커넥션이 일정 시간이 지나면 톰캣이 자동으로 끊어버리는 기능도 없는 것 같다. (connection-timeout은 톰캣이 가지고 있는 TCP 커넥션을 통해 클라이언트에게 요청했을 때, 대기하는 시간인 것 같음.) 

    만약 그런 기능이 있다고 하더라도 톰캣에서 그 기능을 글로벌하게 설정하는 방법은 좋지 않은 것 같다. 왜냐하면 클라이언트가 충분히 오래 기다리면서 응답을 받는 작업이 있을 수도 있기 때문이다. 

    따라서 로드 밸런서가 톰캣에 헬스체크를 보낼 때, 헬스체크에 대한 타임아웃을 충분히 짧게 가져가는 것이 좋은 방안일 것 같다. 이렇게 하면 다음 두 가지 경우를 포함해서 적절한 헬스체크가 가능할 것이기 때문이다.

    1. 쓰레드가 모두 일하고, 헬스 체크 요청에 대해 TCP 커넥션이 커넥터에 맺어만 져있는 상황 (일을 안할 때) 
    2. 헬스 체크 요청이 accept-count 큐에 들어가있는 경우 

    일반적으로 헬스 체크는 굉장히 빠르게 응답되는 요청인데, 이런 요청조차 바로 처리되지 못하는 상황이라면 어떤 요청도 받아들일 수 없는 상태라고 판단할 수 있기 때문이다.


    Tomcat의 쓰레드 State

    스프링 actuator를 이용하면 프로메테우스로 톰캣이 제공하는 메트릭을 확인할 수 있다. 여기서 톰캣의 쓰레드 관련 메트릭을 살펴보고자 한다. 

    톰캣은 쓰레드와 관련되어 다음 메트릭을 제공해준다. 살펴보면 다음과 같다. 

    • live-thread : daemon-thread + time-waiting thread
    • daemon-thread : waiting thread + runnable thread

    이 메트릭에서 waiting thread + runnable thread + time-waiting thread는 모두 톰캣 서버에 요청이 왔을 때, 사용되는 쓰레드다. 아래 설정을 했을 때, 위 그라파나 이미지가 보인다.

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

    하나씩 살펴보면 다음과 같다.

    • waiting-thread : 톰캣 쓰레드 풀에서 요청을 처리하기 위해 대기중인 쓰레드 
    • runnable-thread : 톰캣 쓰레드 풀에서 꺼내져서 요청을 처리하고 있는 쓰레드. 여기서는 기본값이 7임. 
    • timed-waiting : runnable-thread가 일을 다 정리하면, timed-waiting 쓰레드로 변경된다. 그리고 이 쓰레드는 waiting-thread로 변경되는 듯함. 

    서버 개발자 입장에서 주로 살펴봐야 할 녀석은 waiting-thread / runnable-thread이고, 기저 수준이 있다는 것만 알아두면 좋을 것 같다.


    참고

    비동기 테스트 코드는 다음과 같다. 

    async def do_request2():
        my_dict = defaultdict(int)
        async with aiohttp.ClientSession() as s1:
            tasks = [s1.request(method='GET', url=URL2) for _ in range(15)]
            result, _ = await asyncio.wait(tasks)
            for r in result:
                status_code = r.result().status
                my_dict[status_code] += 1
    
        for k,v in my_dict.items():
            print(f'{k} count = {v}')

    CPU 바운드 작업은 다음과 같다. 

    @GetMapping("/cal")
    public String cal() {
        Long mySum = 0L;
        long start = System.currentTimeMillis();
        for (long i = 0; i < 100_000_000_0L; i++) {
            mySum += i;
        }
        long end = System.currentTimeMillis();
        log.info("spend time = {}, request count = {}", (end-start), count++);
        return String.valueOf(mySum);
    }

    요약

    • 톰캣 쓰레드풀의 대기 쓰레드가 적절한 숫자만큼 있을 경우, 응답 시간이 빨라질 수 있음.
    • 톰캣의 쓰레드풀의 대기 쓰레드의 적절한 수는 어플리케이션 특성에 따라 결정되어야 함. CPU 작업이 많을 때, 너무 많은 여유 쓰레드가 있으면 컨텍스트 스위칭에 의해 응답 시간이 오히려 나빠짐. 
    • 톰캣의 Accept-count는 많으면 좋지 않은 것 같음. 왜냐하면 TCP 연결을 맺지 않은 채로 대기하기 때문이다.
      • 필요하다면 max-connections를 많이 가져가는 것이 좋은 방향인 듯 하다. 미리 TCP 연결을 맺어두기 때문에 그만큼 시간이 단축됨. 
    • 톰캣의 connection-timeout은 클라이언트 입장의 톰캣이 Timeout까지 기다리는 시간으로 활용되는 것 같음. 
    • 톰캣 서버에 이런 특징 때문에 LB는 헬스 체크 시간을 어느정도 짧게 가져가면서, 바쁜 톰캣을 라우팅 패쓰에서 제외해주는 방법도 필요할 것 같은.

    댓글

    Designed by JB FACTORY