ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Kotlin Coroutine과 CompletableFuture
    Java & Kotlin 2023. 7. 27. 02:42
    반응형

    1) 서론

    혹시 비슷한 일들을 두고 다른 점을 찾기 어려웠던 경험이 있으신가요? 무언가 다르다고 주장하며 존재하지만 자신이 볼 때는 똑같다고 생각이 들 수도 있는데요.
     
    저는 유독 안심, 등심 돈가스의 차이점에 대해서 공감하지 못했습니다.
     
    안심 돈까스는 지방이 포함되어 부드럽고, 등심 돈가스는 좀 더 쫄깃한 식감을 느낄 수 있다고 하는데요. 실제로 매장에서도 다르게 판매하고 있지만 개인적으로는 똑같이 돼지고기를 튀긴 돈가스라고 생각했습니다.
     
    우연히 모 돈까스 가게를 방문하고 등심+안심이 함께 나오는 세트를 주문했는데요. 평소의 궁금증을 해소하고자 실험정신을 발휘하여 번갈아가며 먹어봤습니다. 막상 진지하게 번갈아먹어 보니 확실히 안심이 더 부드러웠는데요.
     
    이처럼 겉모습만 보거나, 깊게 알지 못하는 비슷한 일들에 대해서는 마냥 비슷하다고 느낄 수 있습니다. 하지만 속사정을 들여다보면 모든 일들은 다르기 마련입니다.
     
    코틀린의 Coroutine을 사용하며 자바의 CompletableFuture와 비슷하다고 느꼈었는데요. 이번에는 두 가지의 차이점에 대한 의문을 파악해 본 것을 공유드리려고 합니다.
     
     


    2) 코루틴은 비동기 요청을 쉽게 해줘요

    • 1개의 스레드를 가진 쓰레들풀을 coroutine scope에 할당합니다
    • 2개의 외부 API를 요청합니다
    • 각 API는 3초의 delay가 있습니다
    • 각 API는 비동기로 요청합니다

     
    코루틴을 이용해서 손쉽게 비동기 요청을 보낼 수 있습니다.

    • 장황한 콜백 패턴을 작성하지 않아도 됩니다
    • 스레드풀을 지정하여 각 요청에 대한 스레드를 할당할 수 있습니다
    • joinAll 같은 함수를 이용하여 비동기 요청에 대한 대기도 가능합니다

    3) Future 객체도 가능해요

    자바, 스프링 프레임워크에서 비동기 요청은 다양한 방법이 있습니다.

    • Thread 객체 직접 호출
    • @Async 애노테이션 사용

     
    다만 Thread 객체를 직접 사용하게 되면 로직이 복잡해지는 단점이 있습니다. 그리고 직접 쓰레드를 다루기 때문에 어떠한 문제점을 발생시킬지 쉽게 예측할 수 없습니다.
     
    스프링의 @Async는 AOP 기반으로 동작합니다. 프록시 객체를 이용하기 때문에 동일한 클래스의 함수에는 동작하지 않습니다. 억지로 별도의 클래스를 분리하여합니다.
     
    그래서 주로 CompletableFuture를 사용하게 되는데요. 코루틴과 비교해 보겠습니다.

    • 동일하게 1개의 스레드를 가진 풀을 만들고, 사용합니다
    • CoroutineScope > CompletableFuture로 대체되었습니다
    • 첫 번째, 두 번째 API를 요청합니다
    • allof().join()을 이용하여 모든 요청의 완료를 기다립니다

     
    이미 자바에서부터 존재하던 Future 객체를 코틀린에서도 동일하게 사용할 수 있습니다. 위에서 작성했던 코루틴 로직과 동일하게 동작합니다. 코루틴에 비해서 로직이나 사용법이 복잡하지도 않습니다.
     
    자바를 비교적 많이 사용했던 입장에서 의문이 듭니다. 꼭 코루틴을 사용해야 할까요? 무엇이 다른 걸까요?


    4) 코루틴은 논블럭킹(non-blocking)이에요

    위의 코루틴, Future를 사용한 로직들의 수행시간을 측정해 봅시다.

    // Future
    
    // 첫번째 API
    2023-07-23T20:15:34.562251: first api start
    2023-07-23T20:15:37.568754: first api finish  // 3초 지연
    
    // 두번째 API
    2023-07-23T20:15:37.571075: second api start
    2023-07-23T20:15:40.576289: second api finish // 3초 지연
    
    all apis finished
    
    6047ms // 총 수행 시간
    • Future 객체를 사용합니다
    • 각 API 요청 시 3초의 delay가 발생합니다
    • 싱글스레드를 사용하기 때문에 순차적으로 처리됩니다
    // coroutine
    
    // 첫번째 API 요청
    2023-07-23T20:16:58.237301: first api start
    // 두번째 API 요청
    2023-07-23T20:16:58.241090: second api start
    
    ... 3초 지연 ...
    
    // 첫번째 API 응답
    2023-07-23T20:17:01.256741: first api finish
    // 두번째 API 응답
    2023-07-23T20:17:01.264407: second api finish
    
    all apis finished
    3089ms // 총 수행시간
    • Coroutine을 사용합니다
    • 첫 번째 API에서 blocking 발생합니다
    • 곧바로, 두 번째 API를 요청합니다
    • 동일하게 싱글스레드입니다

     
    두 로직 모두 동일하게 싱글스레드를 사용합니다.
     
    각 API는 3초의 지연이 발생합니다. 하지만 future를 사용했을 때는 6초, coroutine은 3초의 수행시간이 소요되는데요.
     
    Future의 경우 싱글스레드를 사용하기 때문에 runAsync()를 사용하였지만, 실제로는 동기식으로 수행될 수밖에 없습니다. 첫 번째 API에서 3초 지연 발생하고, 스레드는 block 됩니다. 즉 해당 API의 응답이 올 때까지 대기하게 됩니다.
     
    하지만 Coroutine의 경우 동일한 싱글스레드를 사용했지만 blocking 되지 않습니다. 첫 번째 API에서 3초 지연이 발생하지만 곧바로 두 번째 API를 요청합니다. 그리고 응답이 온 순서대로 처리해 줍니다.
     
    이렇게 동일한 싱글스레드를 사용했지만 결과는 다릅니다. 겉으로 보기에는 동일한 로직이지만, 수행시간에서 큰 차이를 보이는데요.
     
    왜 그럴까요?


    5) Coroutine은 CPS 스타일을 사용하고 있어요

    코루틴은 suspend 수정자를 이용해서 수행을 연기시킬 수 있는데요. suspend 함수에서 외부 IO와 같이 대기가 필요한 순간이 오면, 스레드는 기다리지 않고 다른 일을 하러 떠납니다.

    suspend fun requestFirstExternalApi(): String {
        val start = LocalDateTime.now()
        println("$start: first api start")
        
        // 3초 지연 -> 쓰레드는 기다리지 않고 다른 API 요청하러 떠남
        delay(3_000L)
    
        val finish = LocalDateTime.now()
        return "$finish: first api finish"
    }

     
    그렇다면 지연된 함수가 어떻게 재개할 수 있을까요?
     
    코틀린은 CPS를 사용하고 있는데요. Continuation Passing Style의 약자입니다. 기존의 코드들을 컴파일하게 되면 아래와 같이 되는데요.

     
    코틀린 컴파일러는 suspend 함수 사용 시 파라미터로 Continuation 객체를 넘겨주고 있습니다. 그리고 suspend 함수 내부에 중단지점(suspension point)을 label로 표시합니다.
     
    코루틴에 할당된 스레드는 중단지점을 만났을 때 현재의 label을 Continuation 객체에 기록합니다. 그리고 해당 스레드는 실행 혹은 재개가 필요한 다른 label을 실행하러 함수를 빠져나가게 됩니다.
     
    마치 state machine처럼 현재 함수의 상태를 기록해 두고, 다른 함수를 실행시키러 가게 됩니다. 그리고 재개될 때 상태를 다시 읽고, 실행시키게 됩니다.
     
    즉 코틀린에서의 Continuation Passing Style은 컴파일 시점에 Continuation 객체를 파라미터로 전달하고, 상태를 기록하는 것을 의미합니다.
     
    CompletableFuture와 Coroutine을 싱글스레드로 동작시켰을 때 차이가 나는 이유입니다.
     
    Future의 경우 싱글스레드를 사용할 때는 외부 IO 등이 발생하면 스레드는 blocking 됩니다. 즉 그대로 멈추고, 응답이 오기를 기다리게 되는데요. 일반적인 스레드가 동작하는 방식입니다.
     
    만약 아주 큰 파일을 읽거나, throughput이 낮은 서버에서 응답에 5초가 걸린다면 어떻게 될까요? 해당 로직을 사용하는 모든 요청에 지연이 발생합니다. 
     
    이러한 장애를 피하고자 요청마다 스레드를 생성하여 할당한다면 한정된 스레드는 금방 고갈될 것입니다. 심지어 전혀 관계없는 다른 요청이 스레드가 부족하여 함께 지연될 수 있습니다.
     
    그리고 스레드도 프로세스와 마찬가지로 CPU가 문맥교환 해야 합니다. 코루틴과 마찬가지로 기존의 스레드를 suspend 시키고, 레지스터에 상태를 기록합니다. 그리고 switch() 함수를 요청하여 다음 수행되어야 할 스레드의 주소를 PC에 올려 사용하게 됩니다.
     
    즉 성능을 위해 요청마다 스레드를 생성하여 수행시킨다면, 위와 같은 CPU의 문맥교환이 지나치게 많이 발생할 수 있고 자원을 낭비하게 됩니다.
     
    Coroutine의 경우에는 잠시 지연시켜 두고, 다른 일을 처리할 수 있기 때문에 적은 수의 스레드로도 많은 요청을 처리할 수 있게 됩니다. 즉  위에서 언급한 CPU 문맥교환이 비교적 덜 발생합니다. 이렇게 효율적인 자원 사용의 측면에서도 Coroutine은 많은 이점이 있습니다.
     
    또한 적은 스레드로 여러 가지의 일을 할 수 있기 때문에 동시(concurrency) 수행을 효율적으로 가능하게 합니다.


    6) 총 100번 요청을 한다면, 얼마나 차이 날까요?

    위와 동일하게 싱글스레드를 사용하여, 총 100번의 요청을 보내봅니다. 각 요청당 지연은 3초입니다.

    • 코루틴: 3130ms

    • Future: 300510 ms
    • 사용된 API 함수는 suspensd가 아니며, Thread.sleep()을 이용해서 block 시켰습니다

     
    동일한 환경이지만 Future의 수행시간이 100배 느립니다.
     
    코루틴은 지연이 발생했을 때 상태를 저장해 두고, 다른 루틴을 실행시키기 때문입니다. 즉 동시(concurrency) 수행이지만, 병렬 수행인 것처럼 동작시킬 수 있습니다.


    7) 결론

    겉보기의 코드만 봤을 때는 동일하게 동작하기 때문에 궁금증을 가졌습니다. 하지만 실제 내부의 동작과 수행시간을 측정했을 때는 아주 큰 차이가 보이는 것을 알 수 있었는데요.
     
    이러한 코루틴의 장점 덕분에 코틀린에서는 Future 객체의 필요성은 아직까지 느끼지 못하고 있습니다. 적은 리소스를 이용해서 더 좋거나 혹은 동일한 성능을 가지고 올 수 있다면 큰 이득입니다. 


    8) 참고 문서

    코틀린 공식 문서
    Mechanics of Thread Switching

    반응형

    댓글

Designed by Tistory.