Develop/Reactor+Coroutine

왜 코루틴을 써야할까?

연로그 2024. 6. 9. 21:19
반응형

코루틴 스터디를 하며 정리 중인 시리즈 글

  1. 왜 코루틴을 써야할까?
  2. [Coroutine] 코루틴 빌더, 코루틴 컨텍스트
  3. [Coroutine] 구조화된 동시성
  4. [Coroutine] 코루틴 스코프 함수
  5. [Coroutine] 디스패처
  6. [Coroutine] 코루틴과 공유상태

 


😏 스레드의 한계

 

JVM은 스레드를 기반으로 동작한다. 단일 스레드는 동시 작업을 수행할 수 없기 때문에 하나의 작업이 오래 걸리면 다른 작업의 처리도 지연된다. 이를 극복하기 위해 등장한 것이 멀티 스레드다. 멀티 스레드는 스레드를 여러 개 사용해 작업을 처리하는 것으로, 병렬 처리가 가능해진다. (동시성과 병렬성의 차이는 동시성 (Concurrency) vs 병렬성 (Parallelism) 글을 참고하길 바란다.)

 

단일 스레드

 

멀티 스레드

 

멀티 스레드 이미지를 보면 메인 스레드가 Thread-0, Thread-1에 요청을 보내 작업을 분담시켜서 병렬로 처리하고 있다. 여기서 또 하나 주목해야할 부분은 메인 스레드는 처리할 작업이 없더라도 Thread-0, Thread-1의 작업이 모두 종료되고 나서야 메인 스레드도 응답한다. 이처럼 스레드가 아무것도 하지 못하고 사용될 수 없는 상태를 '스레드 블로킹'이라고 부른다.

 

단일 스레드, 멀티 스레드, 스레드풀까지 스레드를 직접 다루는 방식들은 한계가 있다. JVM에서 다루는 스레드는 OS의 커널 스레드와 매핑되는 비싼 자원이다. 하나의 애플리케이션에 굉장히 많은 요청이 몰렸다고 생각해 보자. 요청을 처리하는 스레드가 DB나 다른 서비스로부터 응답을 기다리기 위해 매번 블로킹 상태에 있으면 처리가 지연될 수밖에 없다.

 

이를 해결하기 위해 등장한 방식 중 하나가 리액티브 프로그래밍이다. (대표적으로 RxJava와 WebFlux가 있다.) 리액티브 프로그래밍은 비동기 데이터 스트림을 처리하기 위해 사용되며, 러닝커브가 굉장히 높은 편이다. 이를 도입하기 위해서는 많은 부분의 코드가 변형되어야 한다. 예를 들어, 상품 정보와 판매사 정보를 조회하여 ProductResponse로 변환하여 반환하는 메서드가 있다고 가정해 보자.

// 동기식 코드
fun getProductResponse(productId: String, sellerId :String): ProductResponse {
    val product = productRepository.findById(productId)
    val seller = sellerRepository.findById(sellerId)
    return toProductResponse(product, seller)
}
// WebFlux 코드
fun getProductResponse(productId: String, sellerId: String): Mono<ProductResponse> =
    productRepository.findById(productId)
        .zipWith(sellerRepository.findById(sellerId))
        .map { toProductResponse(it.t1, it.t2) }

 

 


🤔 왜 코루틴일까?

 

1. 코루틴은 가볍다. 

코루틴은 중단할 수 있다.(= 실행을 중간에 멈출 수 있다.) 코루틴이 중단될 때, 스레드를 블로킹하지 않고 다른 작업에 양보한다. 따라서 스레드를 다른 작업을 수행시키는데 이용할 수 있다. 그리고 작업을 재개할 때는 기존과 다른 스레드를 이용해도 상관없다. 코루틴이 중단될 때는 Continuation이라는 객체가 저장되는데, 이 객체를 이용하면 실행이 어디서부터 재개되어야 하는지, 그 당시 어떤 데이터를 갖고 있었는지 등에 대한 정보를 알 수 있다.

 

정리

  • 코루틴에는 스레드보다 훨씬 가벼운 '코루틴'이라는 이름의 작업 단위가 존재한다.
  • '코루틴'은 스레드에 비해 생성/전환에 필요한 비용이 적다.

 

 

2. 코루틴은 간결하다.

WebFlux를 도입했을 때는 코드 구조의 변화가 컸다. 하지만 코루틴의 도입은 코드의 변화가 그렇게 크지 않다.

// 동기식 코드
fun getProductResponse(productId: String, sellerId :String): ProductResponse {
    val product = productRepository.findById(productId)
    val seller = sellerRepository.findById(sellerId)
    return toProductResponse(product, seller)
}
// 코루틴 적용 후 코드
suspend fun getProductResponse(productId: String, sellerId: String): ProductResponse = coroutineScope {
    val product = async { productRepository.findById(productId) }
    val seller = async { sellerRepository.findById(sellerId) }
    return toProductResponse(product.await(), seller.await())
}

 

💥 WebFlux와 Coroutine의 오해
코드의 극적인 변화를 보여주기 위해 둘을 비교하는 듯한 표현을 썼지만, 이 둘은 서로의 대체품이 아니다. WebFlux와 Coroutine은 사용 목적이 다르기 때문에 상호 보완하는 형태로 함께 사용할 수 있다.

WebFlux
- 리액티브 스트림을 통해 데이터를 스트림으로 처리하고, 데이터 흐름의 변화를 쉽게 처리
- 높은 처리량과 낮은 지연 시간이 요구되는 경우에 주로 사용

Coroutine
- 특정 시점에서 작업 일시 중지 후 재개 가능하며, 이 과정에서 스레드를 블록 하지 않음
- 단순한 비동기 작업에서 주로 사용

 

 

3. 코루틴은 구조적 동시성을 보장한다.

구조적 동시성, 구조화된 동시성, Struectured Concurrency 다양하게 불리는 이 개념은 코루틴에서 매우 중요하게 쓰인다. main()에서 새로운 스레드를 만들어 별도의 작업을 수행시키게 했다고 가정하자. 자식 스레드에서 예외가 발생해도 부모 스레드의 흐름에서 이를 확인할 수가 없다.

fun main() {
    println("start")
    Thread {
        println("new thread start")
        throw RuntimeException()
    }.start()
    Thread.sleep(1000)
    println("end")
}

Thread-0에서 에러가 나도 end가 출력됐다

 

하지만 코루틴에서는 자식 코루틴에서 예외가 발생하면 부모 코루틴으로 예외가 전파된다. 위 예제에 코루틴을 적용시킨 결과를 비교해 보자.

fun main() = runBlocking {
    println("start")
    launch {
        println("new thread start")
        throw RuntimeException()
    }
    delay(1000)
    println("end")
}

launch { ... } 에서 예외가 발생했지만, Thread 사용때와는 달리 end가 출력되지 않는다

 

부모/자식 코루틴이 하나의 블록으로 감싸진 형태를 상상하면 이해하기 쉽다. 블록 내부에서 예외가 발생하면, 블록 내부의 다른 흐름에도 영향이 간다. 이를 통해 부모 코루틴에서는 자식 코루틴의 예외를 쉽게 캐치할 수 있고, 예외를 쉽게 다룰 수 있게 된다.

 

 


번외 - Virtual Thread가 있는데 코루틴을 써야 할까요?

 

2023년 9월, JDK 21에 경량 스레드 모델인 Virtual Thread가 정식 feature로 포함되었다. 이제 코루틴을 쓰지 않고 Java만으로도 경량 스레드 모델을 다룰 수 있게 되었다. 그렇다면 앞으로 코루틴은 사라지게 될까?

 

먼저 Virtual Thread에 대해 간략히 설명해 보겠다. 기존 Java의 스레드는 모두 Native Thread로, OS의 커널 스레드와 매핑되어 작업을 수행하였다. 커널 스레드와 매핑되어야 하므로 스레드 수에 한계가 있고, 컨텍스트 스위칭 비용도 무시할 수 없다. 여기서 새롭게 나타난 스레드 모델이 Virtual Thread라는 경량 스레드다. Virtual Thread는 플랫폼 스레드 위에서 동작하는 경령화된 스레드로, JVM에 의해 생성되기 때문에 메모리도 적게 차지하고, 컨텍스트 스위칭 비용도 적다.

 

얼핏 듣기로는 코루틴과 비슷해 보이기도 하지만 이 둘은 도입된 목적이 다르다.

 

가상 스레드; Virtual Thread

  • 기존에 존재하던 java.lang.Thread API의 최소한의 변경
  • thread per request 모델에서의 최적화
  • ex: 외부 네트워크 콜 하는 경우 

 

코루틴; Coroutine

  • 기존에 존재하는 다양한 비동기 API(Java NIO, Future 등)을 래핑해 사용하기 쉽게 만드는 것
  • ex: 여러 서비스에 동시에 요청을 보내 응답을 머지해야 하는 경우 등의 복잡한 케이스

 

위와 관련한 내용이 더 궁금하다면? 아래 글들을 재밌게 읽었어서 추천하고 싶다.


참고

반응형