Server/Spring (Boot & Framework)

[Spring] 비동기 처리시 blocking 되는 servlet thread 관리

ooeunz 2021. 9. 25. 21:54
반응형

Tomcat에서의 IO는 HttpServletRequest와 HttpServletResponse를 사용하고 있고, 이 둘은 InputStream과 OutputStream을 구현하고 있습니다. 즉 IO가 이루어질 때마다 blocking이 발생한다는 뜻입니다.

 

tomcat에서 NIO connector가 구현된 이후부터 connection을 nonblocking하게 맺고 있지만, 결국엔 servlet을 실행하는 순간 servlet thread가 필요로 하기 때문에 근본적인 문제의 해결책이 되지 않습니다.

 

서버에서 일어나는 작업들, 흔히 req - logic - res가 이루어질 때 빠르게 thread가 작업을 처리하고 pool로 반납하면 이러한 방식도 문제가 되진 않습니다. 다만 최근 많이 가져가고 있는 MSA 아키텍처에서의 처리 방식, 이를테면 req - Blocking IO (db, api) 혹은 cpu bouond 작업 - res 와 같이 중간에 blocking io가 발생하게 되면 blocking io가 발생하는 긴 시간 동안 servlet thread는 놀고 있게 됩니다.

 

시간이 오래걸리는 작업을 병렬적으로 해결하기 위해 background에 worker thread를 생성하여 병렬적으로 io작업을 수행한다면 직렬적으로 io를 수행하는 것보다 수행 시간을 줄일 수 있겠지만, 그 사이 servlet thread가 blocking을 당한다는 문제점이 여전히 존재하게 됩니다. Blocking은 cpu resource 운영에 상당한 악영향을 주게 되는데 blocking io가 발생했을 때 한번, 다시 cpu를 할당받을 때 또 한 번 총 두 번의 context switching이 발생하기 때문에 되도록 thread가 blocking 당하는 일을 피하는 것이 좋습니다.

 

비동기적으로 시간이 긴 작업을 처리하더라도 servlet thread가 blocking 당하면 성능의 저하가 올 수 있음을 확인할 수 있는 간단한 테스트를 진행해 보도록 하겠습니다.

 

테스트를 위해 tomcat의 max thread를 1개로 제한하고 io가 오래 걸리는 작업을 의미하는 slowThread 메서드(요청당 2초의 delay가 걸림)를 실행하는 서버 코드를 작성했습니다. 그리고 서버에선 slowThread 메서드를 비동기적으로 실행하도록 합니다.

 

server.tomcat.threads.max=1
@SpringBootApplication
class NoahDemoApplication

fun main(args: Array<String>) {
    runApplication<NoahDemoApplication>(*args)
}

@RestController
class DemoControler {
    val es = Executors.newFixedThreadPool(100)

    @GetMapping("/block")
    fun block(idx: Int): String {
        val future = es.submit<String>{ slowThread(idx) }
        return future.get()
    }
    
	private fun slowThread(idx: Int): String {
        Thread.sleep(2000L);
        return "SLOW-$idx";
    }
}

 

부하테스트를 위해 실행 시간을 재기 위해 StopWatch를 사용했고, 100개의 요청을 비동기로 동시에 요청하도록 합니다.

private val log = KotlinLogging.logger {}
private val es = Executors.newFixedThreadPool(100)

class LoadTest(
) {
    private val BASE_URL: String = "http://localhost:8080"

    private val restTemplate = RestTemplate()

    fun fetchApi() {
        for (i in 1..100) {
            es.execute {
                val stopWatch = StopWatch()

                stopWatch.start()
                val response = restTemplate.getForObject("$BASE_URL/block?=idx=$i", String::class.java)
                stopWatch.stop()

                log.info { "response=$response, stopWatch=${stopWatch.totalTimeSeconds}" }
            }
        }
    }
}

fun main(args: Array<String>) {
    val loadTest = LoadTest()
    val stopWatch = StopWatch()

    stopWatch.start()
    loadTest.fetchApi()
    stopWatch.stop()

    es.shutdown()
    es.awaitTermination(1000, TimeUnit.SECONDS)
    log.info { "Total stop watch ${stopWatch.totalTimeSeconds}" }
}

부하 테스트시 thread 상태를 확인해보면 의도대로 servlet thread (http-nio-exec-1)는 1개만 생성된 것을 알 수 있고, background thread로 task를 처리한 것을 알 수 있습니다. 그렇다면 수행 시간은 어떻게 됐을까요?

background에 100개의 thread가 존재했지만, servlet thread의 blocking으로 인해서 slowThread 메서드가 수행되는 2초 * 100회로 총 200초가 걸린 것을 알 수 있습니다. 다시 말해, 비동기로 시간이 긴 작업을 처리하더라도 여전히 servlet thread는 blocking 상태라는 것입니다.

 

 

Tomcat은 default로 200개의 thread를 가지게 됩니다. 그리고 이 thread가 모두 소모되게 된다면 queue에 request가 쌓이게 됩니다.  이때부터 서버는 요청에 대한 응답에 latency가 발생을 하게 되고, default queue size인 100개 마저 모두 차게 된다면  서버는 SERVICE_UNAVAILABLE(503)라는 서비스 이용불가 error code를 내려주게 됩니다.

 

그렇다면 무한정 thread를 늘리면 되지 않을까?라고 생각할 수 있지만, thread 하나하나 모두 자기 고유의 stacktrace와 data 공간을 필요로 하기 때문에 그만큼 많은 memory가 필요합니다. 또한 thread의 수만큼 많은 context switching이 필요하기 때문에 cpu의 수가 극히 많은 서버를 갖고 있는 게 아니라면 좋은 방법이 아닐 것입니다.

 

시간이 오래 걸리는 작업은 background에 worker thread에 할당하고, servlet thread는 즉시 pool로 반납함으로써 이러한 문제를 해결할 수 있습니다. 이러한 문제를 해결하기 위해 servlet 3.2 spec에는 DeferredResult라는 기술이 추가되었습니다.

 

DeferredResult를 사용하면 결과를 바로 return 하여 servlet thread를 즉시 pool로 반환하고 이후에 비동기 작업이 완료되었을 때 DeferredResult에게 알려줌으로써 servlet thread를 빠르게 할당받아 client에게 결과를 return 해 줍니다.

 

max thread가 1개로 제한된 환경에서 이번엔 DeferredResult로 test 해보도록 하겠습니다.

    @GetMapping("/nonblock")
    fun nonblock(idx: Int): DeferredResult<String> {
        val def = DeferredResult<String>()
        es.submit {
            val slowResult = slowThread(idx)
            def.setResult(slowResult)
        }
        return def
    }

이번엔 100번의 요청을 동시에 실행하였지만 전체 실행시간이 고착 2초 남짓으로 끝났습니다.

 

이러한 코드를 좀 더 modern 하게 변경한다면 CompletableFuture를 사용할 수 있습니다.

    @GetMapping("/modern")
    fun modernNonBlock(idx: Int): CompletableFuture<String> {
        return CompletableFuture.supplyAsync { slowThread(idx) }
    }

CompletableFuture는 Javascript에서 Promise와 비슷한 역할을 하는 기술로, 비동기 콜백 구조의 코드를 좀 더 깔끔하게 짤 수 있다는 장점이 있습니다. 또한 동시에 spring 기반의 @Async와 같은 애노테이션을 사용하지 않고도 자체만으로 비동기 코드를 짤 수 있고, controller에서 return 시에 spring에서 알아서 callback이 이루어지는 시점에 thread를 할당하여 결과를 반환해주게 됩니다.

 

CompletableFuture는 그 자체만으로 내용이 방대하므로 이 포스팅에선 여기까지만 다루도록 하겠습니다 ㅎㅎ

 

처음과 비교하였을 때 많은 부분 성능의 개선을 이루었습니다. 하지만 여전히 비동기로 동작하는 worker thread는 blocking 방식으로 동작하고 있다는 한계가 존재하고 있습니다. Non-blocking IO와 reacctive programing에 대해서 다뤄보도록 하겠습니다.

반응형