• 카테고리

    질문 & 답변
  • 세부 분야

    프로그래밍 언어

  • 해결 여부

    해결됨

한 suspend fun 의 반환값이 다른 suspend fun의 파라미터로 쓰일 때

24.04.05 22:12 작성 조회수 75

1

fun main(): Unit = runBlocking {
   val job1 = async { apiCall1() }
   val job2 = async { apiCall2(job1.await()) }
   printWithThread(job2.await())
}

suspend fun apiCall1(): Int {
   delay(1_000L)
   return 1
}

suspend fun apiCall2(num: Int): Int {
   delay(1_000L)
   return num + 2
}

위와 같은 코드에서, 코루틴을 사용해도 apiCall1() 의 반환값이 apiCall2()의 인자로 사용되기 때문에, apiCall1()이 완료된 후에 apiCall2()가 실행되는 것은 이해했습니다. 그리고 코루틴을 사용할 때의 이점이 마치 한줄한줄 동기식 코드를 작성할 때처럼 비동기 코드를 사용할 수 있다. 로 이해했습니다.

그럼 위와 같은 상황에서는 굳이 코루틴을 사용하지 않고, 아래와 같이 동기적으로 코드를 작성해도 같은 것일까요? (두 apiCall1,2를 사용하는 외부 메서드가 없을 때)

fun main() {
   val job1 = apiCall1()
   val job2 = apiCall2(job1)
   printWithThread(job2)
}

fun apiCall1(): Int {
   delay(1_000L)
   return 1
}

fun apiCall2(num: Int): Int {
   delay(1_000L)
   return num + 2
}

비동기 처리가 처음이다 보니 직관적으로 잘 와닿지 않는 부분이 많아 자꾸 질문드리네요 ㅜㅜ 죄송합니다.

답변 2

·

답변을 작성해보세요.

1

안녕하세요 응애님! 🙂 좋은 질문 감사합니다~~

비동기 자체가 직관적이지 않다보니 이해하기 되게 어려운 것 같아요!! 편하게 계속 질문 남겨주셔도 괜찮습니다~! 😊

하나씩 답변 드려 볼게요!!

 

[1. 본문 - awiat()을 사용하면 굳이 비동기 코드를 사용하지 않아도 되는가]

  • 결론부터 말씀드리면 아래 코드 처럼 두 번째 작업이 첫 번째 작업에 의존하는 상황이라면, 비동기 코드를 사용하건~ 동기 코드를 사용하건 최종 시간은 동일합니다.

val job1 = async { apiCall1() }
val job2 = async { apiCall2(job1.await() }
  • 예를 들어 apiCall1()이 1초 apiCall2()가 1초 걸린다면, 어차피 apiCall2()apiCall1() 이 완전히 끝난 후 호출할 수 있기 때문에 총 2초가 걸리게 되고, 이는 그냥 동기적으로 작성하는 것과 같죠

 

하지만, 단순히 blocking + sync 스타일로 작성하는게 아니라 코루틴을 활용해 non-blocking + async 스타일로 작성하게 되면, 내부적으로는 조금 다르게 동작합니다.

blocking + sync 스타일로 작성하면 스레드가 2초간 완전히 blocking 되어 그 스레드를 다른 곳에 활용할 수 없지만, non-blocking + async 스타일로 작성하게 되면, API를 호출해야 할 때만 잠시 스레드를 사용하고, 그 외에는 다른 작업에 해당 스레드를 사용할 수 있게 되죠

결론적으로, 최종 시간은 동일하되 스레드 사용량은 코드 구현에 따라 다를 수 있습니다.

 

[2. 리스트를 순회하면서 suspend fun A, B를 호출했을 때는 단건 호출과 다르게 비동기 코드의 이점이 있다? (리스트 하나의 원소의 처리가 모두 다 끝나기를 기다리지 않고 다음 원소 처리로 넘어가는가?)]

이는 구현에 따라 다릅니다!

  1. 호출하는 함수가 Thread를 blocking 하고, for 문 전체를 하나의 Thread에서 돌리는 경우

    1. 이 경우는 loop의 로직 전체가 실행되어야만 다음 loop로 넘어갈 수 있기 때문에 리스트 처리 역시 하나씩 하나씩 이루어지게 됩니다.

  2. 호출하는 함수가 Thread를 blocking 하고, for 문 전체를 여러개의 Thread에서 돌리는 경우

     

    1. 이 경우는 몇개의 Thread에서 돌리느냐에 따라 여러 루프 로직이 동시에 실행되게 됩니다.

  3. 호출하는 함수가 Thread를 blocking 하지 않고, for 문 전체를 하나의 Thread에서 돌리는 경우

    1. 이 경우는 리스트 하나의 원소 처리가 모두 끝나지 않더라도 다음 리스트를 처리할 수 있습니다.

  4. 호출하는 함수가 Thread를 blocking 하지 않고, for 문 전체를 여러개의 Thread에서 돌리는 경우

    1. 3번과 동일하게, 리스트의 여러 원소를 동시에 처리할 수 있습니다.

 

Thread를 blocking 한다에 대해 더 말씀드리면

  • Thread.sleep(1_000L) 과 같은 코드로 스레드를 잠시 재워둘 수도 있고

  • delay(1_000L) 과 같은 코드로 해당 코루틴을 잠시 멈춰둘 수 있죠!

전자는 Thread자체가 blocking 되고, 후자는 Thread를 blocking 하지는 않습니다.

 

[3. EDIT - 리스트의 각 원소마다 새로운 코루틴이 생성되어 각각의 원소를 병렬처리할 수 있을 것으로 보았는데 맞을까요?]

이 역시 내부 구현이 중요합니다! 현재 구현상 리스트는 하나의 스레드에서만 돌도록 되어 있고요!

만약 리스트에서 특정 함수를 호출했는데, 그 함수 내부에 Thread를 blocking 하는 코드가 들어 있다면, 병렬 처리가 불가능합니다.

Thread를 blocking 하는 코드가 없다면, 위에서 말씀드린 3번 경우와 비슷하게 새로운 코루틴이 계속해서 생기며 각각의 원소를 병렬 처리할 수 있을거에요! 😊

 

답변이 도움이 되었으면 좋겠습니다.

감사합니다! 🙏

응애님의 프로필

응애

질문자

2024.04.07

질문량이 상당했는데 하나하나 정성스럽게 답변해주셔서 정말 감사합니다.

코루틴을 사용하여 비동기 코드를 작성하면 내부적으로 스레드를 다른 곳에 사용할 수 있게 되겠네요. 실행 속도에만 집중하다보니 그러한 이점을 생각지 못했습니다. (_ _)

3. 사실 제가 지금 작성하고 있는 코드에서 코루틴을 적용하려고 하다 보니 좀 지엽적인 질문이 되었는데요, 상세하게 답변 달아주셔서 정말 감사합니다! 저는 현재 유저 리스트를 순회하면서 각각의 유저에 대해 외부 API 2개를 호출하는 동작을 개발하고 있었습니다. 외부 API 호출 또한 네트워크 I/O 작업이므로 스레드를 Blocking 하게 되니까, EDIT)의 코드와 비슷한 형태로 코루틴을 적용하더라도 말씀 주신 4가지 경우 중

  1. 호출하는 함수가 Thread를 blocking 하고, for 문 전체를 하나의 Thread에서 돌리는 경우

    1. 이 경우는 loop의 로직 전체가 실행되어야만 다음 loop로 넘어갈 수 있기 때문에 리스트 처리 역시 하나씩 하나씩 이루어지게 됩니다.

위와 같은 상황에 해당하기 때문에 병렬 처리가 되지 않을 것으로 이해하였습니다. ㅎㅎ

네네 맞습니다! 맞게 이해하셨습니다! 🙂

혹시나 스프링이라는 프레임워크를 사용하고 계시는거라면, 외부 API를 호출할 때 WebClient를 이용해 non-blocking 호출을 할 수도 있습니다. 그렇게 되면 하나의 thread에서 여러 API 호출을 돌리려 할 때 훨씬 성능이 개선될 거에요!

좋은 방법 잘 찾으셨으면 좋겠습니다. 감사합니다! 🙏

0

응애님의 프로필

응애

질문자

2024.04.05

앗 그리고 추가적으로, 만약 아래와 같이 main 함수 내에서 List를 돌면서 apiCall1, apiCall2를 호출했을 때,

  1. 동기적 코드

fun main() {
   val list: List<Example> = listOf(Example(1), Example(2)... ,Example(100))

   list.forEach { ex ->
      val job1 = apiCall1(ex)
      val job2 = apiCall2(job1)
   } 
}

fun apiCall1(ex: Example): Int {
   // 네트워크를 타는 어떤 외부 api A
   return 1
}

fun apiCall2(num: Int): Int {
   // 네트워크를 타는 어떤 외부 api B
   return num + 2
}
  1. 비동기적 코드

fun main(): Unit = runBlocking {
   val list: List<Example> = listOf(Example(1), Example(2)... ,Example(100))

   list.forEach { ex ->
      val job1 = async { apiCall1(ex) }
      val job2 = async { apiCall2(job1.await()) }
   } 
}

suspend fun apiCall1(ex: Example): Int {
   // 네트워크를 타는 어떤 외부 api A
   return 1
}

suspend fun apiCall2(num: Int): Int {
   // 네트워크를 타는 어떤 외부 api B
   return num + 2
}

이 상황에서는 apiCall2() 의 실행은 마찬가지로 apiCall1() 이 완료된 후에 진행되지만,

비동기적으로 코드를 작성했을 경우에는 리스트의 첫번째 원소의 apiCall1()이 끝나면, apiCall2() 가 완료되기를 기다리지 않고, 바로 다음 원소의 apiCall1() 이 실행될 것으로 예상했는데... 혹시 맞을까요?

forEach 자체가 비동기 함수가 아니라서 아닐 수도 있겠네요...ㅜㅜ

 

첫번째 질문과 두번째 질문을 정리하자면,

1. 한 suspend fun A()의 반환값을 파라미터로 받는 다른 suspend fun B()가 있으면, B의 실행은 A가 종료된 이후에 실행되므로 동기적 코드에 비교했을 때 큰 이점이 없다?

2. 리스트를 순회하면서 suspend fun A, B를 호출했을 때는 단건 호출과 다르게 비동기 코드의 이점이 있다? (리스트 하나의 원소의 처리가 모두 다 끝나기를 기다리지 않고 다음 원소 처리로 넘어가는가?)

입니다.


EDIT) 혹시 다음과 같이 작성하면 리스트의 각 원소마다 새로운 코루틴이 생성되어 각각의 원소를 병렬처리할 수 있을 것으로 보았는데 맞을까요? 그리고 아래와 같은 상황이라면 apiCall1, apiCall2는 suspend fun이 아니어도 될 것 같습니다..!

fun main(): Unit = runBlocking {
   val list: List<Example> = listOf(Example(1), Example(2)... ,Example(100))

   list.forEach { ex ->
      launch {
         val job1 = apiCall1(ex)
         val job2 = apiCall2(job1)
      }      
   } 
}


fun apiCall1(ex: Example): Int {
   // 네트워크를 타는 어떤 외부 api A
   return 1
}

fun apiCall2(num: Int): Int {
   // 네트워크를 타는 어떤 외부 api B
   return num + 2
}