마스터Q&A 안드로이드는 안드로이드 개발자들의 질문과 답변을 위한 지식 커뮤니티 사이트입니다. 안드로이드펍에서 운영하고 있습니다. [사용법, 운영진]

suspendingCoroutine과 Continuation이 무엇인가요

0 추천
Coroutine 개념이 많이 부족한것같아서 처음부터 개념 읽어가면서 보고있는데

이 두개념은 좀 적어도 국내자료는 거의 없고 쉽게 설명된곳도 없더라구요..

이 두개는 도대체 무엇인가요..?
codeslave (3,940 포인트) 님이 2022년 2월 8일 질문

1개의 답변

0 추천

Coroutine은 내부적으로 콜백으로 동작을 합니다. Coroutine이 컴파일된 코드를 보면 결국 자바 코드가 되는데, Continuton 이 콜백에 대한 정보를 담고있는 클래스라고 보시면 됩니다. 

코루틴 개발자가 프레젠테이션을 했던 예제를 가지고 설명을 해볼게요..  글을 서버에 올리는 서비스가 있다고 하죠. 이 서비스를 이용하기 위해서는 먼저 토큰을 가져와서, 서버에 글을 올리고, 생성된 글을 가지고 추가 처리를 해야한다고 가정해 보죠.

이 경우, 콜백을 이용한 코드를 작성하면 아래처럼 될 겁니다.

fun PostItem(item: Item) {
    requestToken { token ->
        createPost(toke, item) { post ->
             processPost(post)
        }
    }
}

콜백에 콜백을 호출하는 되는 'Callack hell"이라고 불리우는 중첩 콜백을 작성하게 됩니다. 코드를 읽기도 힒들고 실수를 하기도 쉽죠. 위의 코드를 아래처럼 비동기이지만 순차적으로 코드를 작성할 수 있다면 훨씬 가독성이 뛰어나고 유지보수가 쉬운 코드를 작성할 수 있게 됩니다.

suspend fun PostItem(item: Item) {
    val token = requestToken()
    val token = createPost(toke, item)
    processPost(post)
}

Continuation은 이것을 가능하게 해주는 특별한 callback wrapper입니다. Continuation의 아랫처럼 생겼습니다.

interface Continuation<in T> {
    val context: CoroutineContext,
    fun resume(value: T)
    fun resumeWithException(exception: Throwable)
}

위의 createPost함수는 실제로는 Continuation을 사용하여 아래와 같이 변환됩니다.

suspend fun createPost(token: Token, item: Item): Post  {...}

Object createPost(Token token, Item item, Continuation<Post> continuation) { ... }

즉, 코틀린 함수가 suspend 키워드를 가지고 있으면, 자바로 변환될 때 Continuation 인자를 만들어 주게 됩니다. Continuation 인터페이스에서 보시는 것처럼, 이 인터페이스는 다음에 실행할 함수에 대한 정보를 담고 있습니다. 

postItem함수는 실제로 아래와 같은 형태로 동작한다고 보시면 됩니다.

fun postItem(item: Item, continuation: Continuation) {

   val sm = object: CoroutineImpl {.... }
   switch (sm.label) {
       case 0:
             sm.item  = item
             sm.label = 1
             requestToken(sm)
        case 1: 
            val item = sm.item
            val token = sm.result as Token
            sm.label = 2
            createPost(token, item, sm)
       case 2:
            processPost(post)
    }
}

postItem함수가 재귀적으로 호출되는데, continuation 인자에 따라 적절한 함수를 호출하고 있습니다.

suspendCoroutine은 콜백을 Coroutine 으로 변환하도록 해주는 함수입니다. 결국은 이것도 Continuation이 내부적으로 사용됩니다.

spark (227,830 포인트) 님이 2022년 2월 8일 답변
spark님이 2022년 2월 8일 수정
suspend를 쓰레드를 블락하지 않으면서 실행 완료되는 것을 대기 중인 것이라고 보면 될 것 같습니다. 쓰레드의 상황에 따라 코루틴이 Dispatcher를 통해 실행을 해줍니다.
감사합니다. 읽히기는하는데 많이 어렵네요 ㅠㅠ
질문이 좀많습니다 ㅠㅠ

1. createPost()가 Continuation을 사용하여 변환될때 suspend fun createPost() 에서 object createPost()로 바뀌는 것이죠?

2.변환된 postItem 함수에서 어디를 보고 재귀적으로 호출된다는 것을 알 수 있을까요? 변환된 postItem에서 continuation 인자에 따라 적절히 호출된다고 하셨는데 어디서 사용되는건지 안보여요ㅠ

3.suspend와 관련해서 조금 헷갈리는데  suspend 함수가 호출되면 호출된 곳의 해당 코루틴이 멈추는 것이죠? 쓰레드는 안멈추구요. 그게 코루틴이라고 공부를 했거든요. 그래서 멈춘동안 쓰레드는 다른 코루틴을 실행 가능하구요..
그런데 suspend가 붙으면 무조건 일시정지하게 되어있나요? delay라던지 await라던지 이런 코드들이 없어두요?

4.위 질문과 연동해서..
CoroutineScope(Dispatchers.IO).launch {
    println("시작")
    test()
    println("끝")
}
suspend fun test() {
    println("테스트 코드입니다.")
}
---
suspend가 일시중지 함수라면 위 Coroutine은 test()가 실행되면 test()가 완료될때까지 멈춰야할텐데요, 이상한것이.. 코루틴 블록내에서 어차피 suspend가 없어도 test()가 실행되고 이후 코드가 실행될텐데(시작 - 테스트코드입니다 - 끝 순) 이렇게 생각하니 왜 suspend 필요한지 이상하게 생각하게 됐습니다.. 분명 제가 어딘가 잘못이해해서 꼬이게 생각하게 된것같아요.. 어디가 잘못됐을까요. (그런데 위 코드를 만드니 suspend는 회색표시가 되고 필요 없다고 IDE가 툴팁을 띄워주더라구요..)

5. 위 질문과 좀 연동해서..
fun main() {
    launch(Dispatchers.IO) {
      // 병렬로 2개 함수 실행
      async { suspendTask1() }
      async { suspendTask2() }
    }
}

suspend fun suspendTask1() {
    delay(3000)
    Log.d(TAG, "[suspendTask1] After 3s in (${Thread.currentThread().name})")
    delay(3000)
    Log.d(TAG, "[suspendTask1] After 6s in (${Thread.currentThread().name})")

    Log.d(TAG, "[suspendTask1] END in (${Thread.currentThread().name})*****")
}
suspend fun suspendTask2() {
    delay(1000)
    Log.d(TAG, "[suspendTask2] After 1s in (${Thread.currentThread().name})")
    delay(3000)
    Log.d(TAG, "[suspendTask2] After 4s in (${Thread.currentThread().name})")

    Log.d(TAG, "[suspendTask2] END in (${Thread.currentThread().name}) *****")
}

---
이 코드는 코루틴 안에 두개의 코루틴이 존재하는 형태로 보여지는데요,
그렇다면 두개의 코루틴이 각각 존재하고 suspendTask1()이 실행되면 async { suspendTask1() } 만 중단되고 async { suspendTask2() }는 관계없이 시행되는 것이죠?
1. 네 맞습니다. 코루틴은 코틀린이므로, 자바코드로 변환됩니다.
2. switch문을 보시면 됩니다. sm.label을 증가시키면서 완료될 때까지 postItem을 호출하는 식입니다. 위의 코드는 설명을 위해 간략하게 만든 것이라 전부를 보여주지는 못합니다. 더 디테일한 구현방법은 직접 소스코드를 확인해보는 것이 나을 것 같습니다.
3. 네, 맞습니다. 코루틴이 멈춘다 - 중지(stop)가 아니라 정지(pause)의 개념이라고 보는게 좀 더 정확할 것 같습니다. 그래서 Continuation에 있는 함수이름이 resume인 거구요.
4. Parallel dispatch라고 하는 건데요. 두개의 async를 동시에 실행해서 먼저 완료된 녀석이 다른 하나가 끝날때까지 대기합니다. 이 둘은  별도의 코루틴이지만, 부모 launch함수에서 생성된 Coroutine scope을 그대로 사용하게 됩니다. Thread이름을 출력해보시면 Worker thread(= Background thread)가 출력이 될 겁니다. launch를 호출할 때 Dispatcher.IO를 전달했기 때문입니다. 이렇게 비동기지이지만  모두 완료될 때까지 대기해서 처리할 수 있도록 보장해주는 개념을 structured concurrency라고 합니다. 코루틴이 이 structure concurrency를 아주 쉽게 처리할 수 있도록 지원하는 도구라고 보시면 됩니다.
선생님 suspendCoroutine을 사용하면 콜백을 Coroutine으로 변환한다고 하셨잖아요? 그럼 처음 postItem() 에 suspendCoroutine을 사용하면 어떤식으로 변형이 되나요? Continuation은 쪼금 이해가 갔는데 suspendCoroutine은 아직 잘 모르겠네요
글쎄요, 위의 코틀린 코드는 콜백을 사용하는게 아니라서 suspendCoroutine을 사용할 이유가 없어 보이는데요. 예제가 적절하지 않아 보여요.
쉽게 생각할 수 있는 적절한 예제는, Volley나 Retrofit 의 http 요청시에 callback이 들어가는 부분을 suspendCoroutine을 이용해서 처리할 수 있습니다. Firebase의 콜백도 마찬가지이구요.
엇 선생님이 작성해주신 예제의 postItem() 내부에 있는requestToken 이나 createPost, processPost는 콜백이 아닌가요?
아, 최초에 있던 코드는 콜백이 맞아요. 다른 부분을 봤네요.
근데 postItem같은 경우는 내부에 다른 함수가 세개나 되기 때문에 suspendCoroutine을 쓰는게 좀 부적절해 보이구요, 단일 함수에 대해서만 적용하는게 나아 보여요. 아래처럼  suspendCoroutine으로 감싸고 해당 콜백을 받으면 resume을 호출하면 됩니다.

suspend fun requestToken(): Token {
     return suspendCoroutine { continuation ->
           reqeustToken { token ->
               continuation.resume(token)
           }
     }
}
개인적인 생각으로는 suspendCoroutine은 라이브러리같이 원래 소스코드에 대한 변경을 전혀할 수가 없는 경우에만 사용하는게 적절해 보여요. 변경이 가능하다면 일반적인 코루틴으로 만들어서 사용하는게 좀 더 좋은 접근방법일 듯 해요.
fun PostItem(item: Item) {
    requestToken { token ->
        createPost(toke, item) { post ->
             processPost(post)
        }
    }
}


suspendCoroutine requestToken { continuation ->
    return suspendCoroutine { continuation ->
        requestToken { token ->
            processPost {  _ ->
            }
    }
}
이런식으로 각각의 콜백에 대해 suspendCoroutine이 작성되는게 아니라 requestToken이 suspend 함수가 되고 그 다음부터가 suspendCoroutine이 되네요..? 동작을 이해하기 아직도 어렵네요..
제가 위에 suspendCoroutine을 콜백 하나에만 적용하는게 좋다라고 말씀은 드린 이유가 그거예요. 만약에 각각의 콜백에 대해 적용을 하고자 한다면 아래같은 구조가 되는게 일리가 있을 것 같아요.
suspend fun requestTokenCont(): Token  {
     return suspendCoroutine { cont ->
        requestToken { token->
             cont.resume(token)
        }
     }
}

suspend fun createPostCont(token: Token, item: Post): Post  {
     return suspendCoroutine { cont ->
             createPost(token, item) { post ->
                 cont.resume(post)
             }
        }
     }
}

suspend fun processPost Cont(item: Post)  {
     return suspendCoroutine { cont ->
         processPost(item) { cont ->
             cont.resume(Unit)
         }
     }
}

suspend fun PostItem(item: Item) {
    val token = requestToken()
    val post = createPost(toke, item)
    processPost(post)
}

님이 생각했던 것처럼 콜백 3개를 모두 하나로 묶어서 변환시키려면 다른 방법이 존재해요. (클래스이름이 길어서 정확하게는 기억이 안나네요.)
아 각각 나뉘어서 적용하면
맨처음 설명해주실때 말씀하신

suspend fun PostItem(item: Item) {
    val token = requestToken()
    val token = createPost(toke, item)
    processPost(post)
}
이러한 코드가 되는군요..

근데 이렇게 하지 않고

requestToken() 하면 위와 같은 모양은 안나오고 requestToken만 호출하는 구조로 되려나요?
지금은 그냥 suspend function을 이해하고 사용하는데만 집중하시고 suspendCoroutine 는 나중에 사용할 필요가 있을 때 공부를 하시는게 더 좋을 것 같아요. 코루틴의 내부적인 구현은 엄청 복잡해서 사용에 좀 주의가 필요한데, 안드로이드 같은 경우는 대부분의 겅우는 문제가 될 만한 경우가 없어요. 한번에 여러가지 개념을 이해하려 하지 마시고 한가지만 먼저 공부한 후에 필요가 생기면 그 때 하시는 게 더 나을 것 같아요. 저도 아직 배워야할 개념이 너무 많아서, 계속 공부를 하게 되요. 예를 들면, Flow, StateFlow, MutableStateFlow, SharedFlow, MutableSharedFlow 등등 새로운 개념들이 계속 추가되고 있는 상황이라 손대자면 끝이 없어요.
아아 감사합니다ㅠ 다름이 아니라 지난번에 작성해주신 코드에 suspendCoroutine이 있어서 그거 이해하려고 공부를 시작했더니 여기까지 왓네요 ㅠㅠ 감사합니다.. 아직도 근데 이해가 덜된게 함정이긴한데.. 아무튼 감사드립니다
...