티스토리 뷰

728x90

 

 

들어가며

요즘은 Retrofit, Room 같은 라이브러리에서 내부적으로 코루틴을 지원하고 있다.

함수 앞에 suspend 키워드만 붙이면, 라이브러리가 알아서 처리해주기 때문에 개발자가 따로 신경쓸 필요가 없다.

 

예를 들어, 메인 디스패처에서 네트워크 요청을 보내는 함수를 호출하더라도, 메인 스레드가 블로킹되지 않는다.

이는 라이브러리 내부에서 적절한 디스패처로 전환해 백그라운드 스레드에서 동작하기 때문이다.

 

그래서 직접 저수준의 코드를 구현하는 게 아니라면, 평소에 자주 접하게 되는 코루틴 API는 suspend, viewModelScope, Dispatchers 정도에 그친다고 생각한다.

 

그런데 최근 이미지 라이브러리를 만들다가 예상치 못한 오류를 만났고, 이를 해결하는 과정에서 suspendCancellableCoroutine을 사용하게 되었다.

코루틴을 처음 학습할 때 다뤘던 API지만, 잘 사용하지 않다보니 어떤 상황에 활용해야 하는지 잊고 있었다.

이번 기회에 확실히 되짚기 위해 이 글을 작성한다.

 

 

 

문제 상황

먼저 당시 상황을 살펴보자.

 

    override suspend fun intercept(chain: Interceptor.Chain): Bitmap {
        val request = Request.Builder()
            .url(chain.url)
            .build()
        val call = okHttpClient.newCall(request)
        val response = call.execute()

        if (!response.isSuccessful) {
            val errorBody = response.body?.string() ?: "No error body"
            error("이미지를 불러올 수 없습니다. code=${response.code}, message=${response.message}, body=$errorBody")
        }

        val byteArray = response.body?.bytes() ?: error("응답이 비어있습니다.")
        val bitmap = byteArray.decodeSampledBitmap(chain.imageRequest.size)
        if (logging) Log.d("ImageLoader", "Network Image Load: ${chain.url}")
        return bitmap
    }

OkHttp를 통해 네트워크 요청을 보내고, 응답을 받아 반환하는 간단한 함수다.

이 intercept 함수는 suspend 함수로 선언되어 있다.

 

이 함수의 문제점이 뭘까?

 

바로 intercept 함수를 호출한 코루틴이 취소되어도, 네트워크 응답은 계속 진행된다는 점이다.

이유는 OkHttp의 execute() 함수가 코루틴과 무관한 일반 함수이기 때문이다.

따라서 코루틴의 취소 신호를 전혀 감지하지 못한다.

 

이 문제를 해결하기 위해서는 코루틴이 취소되었을 때 네트워크 요청까지 취소되어야 한다.

즉, intercept 함수 내부에서 코루틴이 취소되었다는 것을 감지하고 네트워크 요청을 취소해야 한다.

여기서 필요한 것이 바로 suspendCancellableCoroutine이다.



 

suspendCancellableCoroutine의 역할

suspendCancellableCoroutine을 호출하면 코루틴이 즉시 중단된다.

이후 적절한 시점에 resume() 또는 resumeWithException()을 호출하여 코루틴을 다시 재개한다.

  • resume(): 인자로 전달한 값으로 코루틴을 재개
  • resumeWithException(): 예외를 던지면서 코루틴을 재개

 

또한 block 람다의 인자로 CancellableContinuation을 받고 있다.

이를 활용해 코루틴 취소와 관련된 필요한 작업을 수행할 수 있다.

예를 들어 다음과 같은 API들을 제공한다.

  • invokeOnCacellation(): 코루틴이 취소될 때 수행할 작업을 지정한다.
  • cancel(): 코루틴을 취소한다.
  • isCancelled: 코루틴이 취소되었는지 확인한다.

 

 

 

suspendCancellableCoroutine 사용 시 주의할 점

suspend fun main() {
    val result = fetchData()
}

suspend fun fetchData() = suspendCancellableCoroutine<String> { continuation ->
    println("데이터 요청 시작")
    // ...

    if (shouldCancel) {
        println("작업 중단")
        return@suspendCancellableCoroutine
    }

    continuation.resume("서버에서 받은 데이터")
}

resume()이나 resumeWithException()을 호출하지 않으면, 이 함수를 호출한 코루틴은 영원히 대기 상태로 남는다.

suspendCancellableCoroutine은 호출 즉시 코루틴을 중단시키기 때문이다.

 

위 코드에서 return을 호출했기 때문에 fetchData() 함수는 종료되지만, 코루틴이 재개되지 않아 main() 함수의 코루틴은 계속 대기하게 된다.

이러한 문제를 방지하기 위해 suspendCancellableCoroutine 내부에서는 early return을 최대한 지양해야 한다.

 

 

 

취소 가능하도록 개선하기

다시 intercept 함수로 돌아와서, 네트워크 요청을 취소 가능한 형태로 수정해 보자.

 

    override suspend fun intercept(chain: Interceptor.Chain): Bitmap =
        suspendCancellableCoroutine { continuation ->
            val request = Request.Builder()
                .url(chain.url)
                .build()
            val call = okHttpClient.newCall(request)
            val response = call.execute()

            continuation.invokeOnCancellation {
                call.cancel()
            }

            if (!response.isSuccessful) {
                val errorBody = response.body?.string() ?: "No error body"
                val exception = IllegalStateException("이미지를 불러올 수 없습니다. code=${response.code}, message=${response.message}, body=$errorBody")
                continuation.resumeWithException(exception)
            }

            val byteArray = response.body?.bytes()
            if (byteArray == null) {
                continuation.resumeWithException(IllegalStateException("응답이 비어있습니다."))
            } else {
                val bitmap = byteArray.decodeSampledBitmap(chain.imageRequest.size)
                if (logging) Log.d("ImageLoader", "Network Image Load: ${chain.url}")
                continuation.resume(bitmap)
            }
        }

CancellableContinuation의 invokeOnCacellation()을 사용해서, 코루틴이 취소되면 네트워크 요청도 함께 취소되도록 구현했다.

불필요한 네트워크 리소스 낭비를 방지할 수 있어 더 효율적이다.

 

 

 

콜백 기반 API와 함께 사용하기

현재 intercept 함수는 항상 IO 디스패처에서 호출하도록 설계되어 있어 execute를 사용했다.

하지만 메인 디스패처에서도 안전하게 호출하려면 enqueue를 사용할 수 있다.

이 경우에도 suspendCancellableCoroutine을 활용하면, 콜백 기반 API를 깔끔하게 코루틴 스타일로 감쌀 수 있다.

 

아래는 intercept 함수의 execute를 enqueue로 변환한 코드다.

    override suspend fun intercept(chain: Interceptor.Chain): Bitmap =
        suspendCancellableCoroutine { continuation ->
            val request = Request.Builder()
                .url(chain.url)
                .build()
            val call = okHttpClient.newCall(request)

            continuation.invokeOnCancellation {
                call.cancel()
            }

            val callback = object : Callback {
                override fun onFailure(
                    call: Call,
                    e: IOException,
                ) {
                    continuation.resumeWithException(e)
                }

                override fun onResponse(
                    call: Call,
                    response: Response,
                ) {
                    if (!response.isSuccessful) {
                        val exception = IllegalStateException("이미지를 불러올 수 없습니다. code=${response.code}, message=${response.message}")
                        continuation.resumeWithException(exception)
                        return
                    }

                    val byteArray = response.body?.bytes()
                        ?: return continuation.resumeWithException(IllegalStateException("응답이 비어있습니다."))

                    val bitmap = byteArray.decodeSampledBitmap(chain.imageRequest.size)
                    if (logging) Log.d("ImageLoader", "Network Image Load: ${chain.url}")
                    continuation.resume(bitmap)
                }
            }
            call.enqueue(callback)
        }

 

suspendCancellableCoroutine은 특히 콜백 기반 API를 코루틴 형태로 변환하고 싶을 때 유용한다.

콜백이 단일 값을 반환하면 suspendCancellableCoroutine을, 스트림처럼 여러 값을 반환하면 callbackFlow를 사용한다.

 

 

 

suspendCoroutine vs suspendCancellableCoroutine

suspendCancellableCoroutine을 학습하다보면 suspendCoroutine이 항상 함께 언급된다.

함수명에서 알 수 있듯 suspendCoroutine은 취소를 감지할 수 없는 suspendCancellableCoroutine과 유사하다.

 

  suspendCoroutine suspendCancellableCoroutine
Continuation 타입 Continuation CancellableContinuation
취소 감지 불가능 가능
사용 목적 취소 처리 없는 단순 래핑 취소 시 리소스 해제가 필요한 경우

 

두 함수 모두 코루틴을 일시 중단하고, 이후 수동으로 재개한다는 점은 같다.

suspendCoroutine은 취소 신호를 무시하기 때문에, 리소스 정리 없이 영원히 중단 상태로 남을 수 있다.
반면 suspendCancellableCoroutine은 취소 처리가 가능해 훨씬 안전하다.

 

 

 

마무리

suspendCancellableCoroutine은 콜백 기반 API를 코루틴으로 감싸고, 코루틴 취소 시 별도의 처리가 필요할 때 사용한다.

어떻게 보면 당연하고 기본적인 얘기일 수도 있겠다.

실제 코루틴을 학습하며 처음 작성했던 블로그 내용이기도 하다.

그럼에도 실전에서 겪은 경험을 바탕으로 다시 되짚어보며 개념을 정리해두는 데 의미가 있었다.

 

기본적인 개념을 확실히 이해하고 있어야겠다.
기초가 탄탄해야 복잡한 문제도 능숙하게 해결할 수 있다는 걸 다시금 깨달았다..!

 

 

728x90
250x250
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2025/10   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함