본문 바로가기
Android/Coroutine

[Android] 콜백을 코루틴(Coroutine)으로 바꿔보자(SuspendCoroutine/SuspendCancellableCoroutine)

by 태크민 2024. 11. 16.

필자는 서버나, 외부 DB가 필요한 Android 프로젝트에 Firebase를 사용하고 있다. Firebase sdk의 경우 Java 코드로 구현되어 있으며, callback을 통해 비동기를 지원하는 것이 특징이다.

필자는 이러한 callback을 SuspendCoroutines를 통해 처리하고 있으며, 해당 SuspendCoroutines에 대해 공부한 내용을 포스팅하려고 한다. 

 

SuspendCoroutine

suspendCoroutine은 현재 코루틴을 일시 중단(suspend) 시키고, 현재의 실행 상태(Continuation) 를 외부로 전달하여 직접 재개(resume)하거나 예외 처리할 수 있게 해주는 함수입니다.

 

다른 suspend함수와 다른 특징은 개발자가, Continuation을 통해 코루틴의 재개(resume)를 직접 제어할 수 있다는 점입니다. 

따라서, 콜백 기반의 비동기 코드를 코루틴으로 변환할 때 사용됩니다.

resume(value) -> 정상적으로 코루틴 재개

resumeWithException(e) -> 예외로 코루틴 종료

 

아래 예시를 살펴보겠습니다.

fun main() = runBlocking {
    println("Before")
    suspendCoroutine<Unit> {continuation ->
        println("Suspend")
    }
    println("After")
}
// Before
// Suspend

 

suspendCoroutine 이후의 로직은 실행되지 않는 것을 확인할 수 있습니다.

"After"가 출력되지 않는 이유는 suspendCoroutine이 현재 코루틴인 runblocking을 일시중단 시켰기 때문입니다. 

위에서 언급했다시피 suspendCoroutine은 직접 Continuation을 통해 재개 처리를 해줘야 중단이 해제됩니다.

 

따라서, 다음과 같이 resume을 호출해줘야 중단된 상태를 재개할 수 있습니다.

fun main() = runBlocking {
    println("Before")
    suspendCoroutine<Unit> {continuation ->
        println("Suspend")
        continuation.resume(Unit)
    }
    println("After")
}
// Before
// Suspend
// After

 

 

SuspendCancellableCoroutine

suspendCancellableCoroutine은 코루틴의 일시 중단(suspend)함수 중 하나로,

suspendCoroutine과 매우 유사하지만, 코루틴의 취소(Cancellation)를 안전하게 처리할 수 있도록 설계된 함수입니다.

 

suspendCoroutine은 취소 처리를 직접 구현해야 하지만, suspendCancellableCoroutine은 취소 신호를 자동으로 감지하고 처리할 수 있습니다.

코루틴이 취소되었을 경우 invokeOnCancellation()가 호출되어 취소 상태를 감지하고 리소스 해제, 네트워크 요청 취소 등과 같은 작업을 처리할 수 있습니다.

 

취소 상태가 어떻게 다른지, 예시를 통해 알아보겠습니다.

 

SuspendCoroutine vs SuspendCancellableCoroutine

아래 예제는 1~10까지 1초마다 숫자를 출력하는 코드입니다.

SuspendCoroutine 예제

suspend fun main() = coroutineScope {
    val job = launch {
        try {
            suspendCoroutine<Unit> { continuation ->
                launch {
                    println("작업 시작")
                    var i = 0
                    repeat(10) {
                        delay(1000)
                        println(i++)
                    }

                    println("작업 완료")
                    continuation.resume(Unit)
                }
            }
        } catch (e: CancellationException) {
            println("코루틴이 취소되었습니다: ${e.message}")
        }
    }

    //3초 후 취소
    delay(3000)
    job.cancel()  // 취소 호출
    job.join()
}

 

위 코드는 suspendCoroutine을 실행하고 3초뒤에 job을 취소 하는 코드입니다.

실행했을 때 어떤 결과가 나올까요?

 

위와 같이 3초 뒤 job을 취소했지만, '0', '1' 까지 print하고 무한히 멈춰 있는 상태가 됩니다.

취소를 했음에도 resume 또는 resumeException이 내부적으로 호출이 되지 않았기 때문에 계속 중단된 상태가 되어있는 것입니다.

즉, 프로그램이 계속 종료가 되지 않게 되겠죠.

 

위와 같은 문제는 SuspendCoroutine이 취소 시점을 감지하지 못하기 때문입니다. 

따라서 이경우, 취소 감지가 자동으로 되는 SuspendCancellableCoroutine을 사용하는 것이 좋습니다.

 

SuspendCancellableCoroutine 예제

suspend fun main() = coroutineScope {
    val job = launch {
        try {
            suspendCancellableCoroutine<Unit> { continuation ->
                // 취소 시 호출되는 핸들러 등록
                continuation.invokeOnCancellation {
                    println("작업이 취소되었습니다.")
                }
                
                launch {
                    println("작업 시작")
                    var i = 0
                    repeat(10) {
                        delay(1000)
                        println(i++)
                    }

                    println("작업 완료")
                    continuation.resume(Unit)
                }
            }
        } catch (e: CancellationException) {
            println("코루틴이 취소되었습니다: ${e.message}")
        }
    }

    //3초 후 취소
    delay(3000)
    job.cancel()  // 취소 호출
    job.join()
}

 

위 코드를 실행하면 어떻게 나올까요?

SuspendCancellableCoroutine을 사용하면 취소를 자동으로 감지하여 CancellationException을 throw하기 때문에 아래와 같이 결과가 출력됩니다. 

 

또한, 취소 발생 시 invokeOnCancellation이 호출되므로 취소 시점에 리소스 정리 작업을 할 수 있는 장점이 있습니다.

 

※ 주의
단, 취소를 하려면 suspendCancellableCoroutine 내부에 delay와 같은 중단함수가 필요합니다.
코루틴 협력형 멀티 프로그래밍 방식이기 때문에, delay(), withContext와 같은 중단함수에서 취소 시점을 체크하고, 실제 cancel을 시킵니다.
만약, 위 예제에서 delay()를 사용하지 않으면 취소가 되지 않습니다.

 

실제로 SuspendCancellableCoroutine은 SuspnedCoroutine 보다 구조적 동시성(Structure Concurrency) 환경에서 유용하게 쓰일 수 있습니다.

부모 코루틴에서 종료하면 모든 자식 코루틴이 취소를 감지할 수 있기 때문입니다.

 

정리해보면, SuspendCancellalbleCoroutine의 장점을 아래와 같이 정리할 수 있겠습니다.

1. 취소를 자동으로 감지하여 CacnellationException을 발생하여 정상 종료 처리를 할 수 있다.

2. invokeOnCancellation을 호출하여 취소 시점에 리소스 정리 작업을 할 수 있다.

 

다시 본론으로 넘어가서,

그렇다면 SuspendCoroutine과 SuspendCancellableCoroutine은 왜 콜백코드에서 유용하게 사용될까요?

 

 

왜 콜백 코드에서 유용한가?

1. 콜백 지옥(Callback Hell) 방지

  • 전통적인 비동기 코드에서는 콜백이 중첩되어 가독성이 떨어집니다.
  • 코루틴은 이를 일시 중단(suspend) 으로 대체하여 더 간결하고 읽기 쉬운 코드를 만듭니다.

2. 비동기 → 동기 스타일로 변환

  • suspendCoroutine과 suspendCancellableCoroutine은 콜백 기반 API를 마치 동기 코드처럼 사용할 수 있게 해줍니다.
  • 더 이상 복잡한 상태 관리나 콜백 체이닝이 필요 없습니다.

3. 취소(Cancellation) 및 예외 처리(Easy Error Handling)

  • suspendCancellableCoroutine은 취소 처리를 지원하여 리소스 누수 방지안정성을 강화합니다.
  • 코루틴의 try-catch 문으로 간단하게 예외를 처리할 수 있습니다.
Callback 이란?

callback이란 비동기를 구현하기 위한 하나의 방법으로, 어떤 시점에 도달하였거나, 이벤트가 발생하였을 때 실행되는 코드를 의미한다. 가장 쉽게 Android에서 onCreate(), onResume()의 경우 모두 callback 함수라고 볼 수 있다. 이러한 callback을 통해, 특정 지점에 도달하거나 특정 이벤트에 따라 처리를 할 수 있다. 

 

 

 

suspendCoroutine vs callbackFlow

suspendCoroutine은 반환값이 단일 객체이기 때문에 값을 한 번만 받아서 사용하는 API와 같이 일회성 callback에서 주로 사용됩니다.

이와 달리 callbackFlow는 Flow스트림을 반환하기 때문에 지속적으로 관찰(observe)이 필요하거나 값이 전달되는 callback에서 사용하는 것이 좋습니다.

callbackFlow의 예로는 Socket 연결 후 전달되는 지속적인 message에 대한 callback, android의 TextWatcher 등이 있습니다.

 

callbackFlow 예시 1

fun EditText.textChangesFlow(): Flow<String> = callbackFlow {
    val textWatcher = object : TextWatcher {
        override fun afterTextChanged(s: Editable?) {
            //  텍스트 변경 시마다 새로운 값을 Flow로 전송
            trySend(s.toString())
        }

        override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
        override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
    }
    
    addTextChangedListener(textWatcher)
    
    // flow가 취소되면 텍스트 리스너를 제거
    awaitClose {
        removeTextChangedListener(textWatcher)
    }
}

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        val editText = EditText(this)

        // 코루틴 스코프에서 Flow를 수집
        GlobalScope.launch(Dispatchers.Main) {
            editText.textChangesFlow()
                .collect { text ->
                    println("텍스트 변경: $text")
                }
        }
    }
}

 

callbackFlow 예시 2

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val button: Button = findViewById(R.id.button)

        // 버튼 클릭 이벤트를 Flow로 변환하여 처리
        val clickFlow = buttonClicksFlow(button)

        // 버튼 클릭 이벤트를 수집하고 처리
        GlobalScope.launch(Dispatchers.Main) {
            clickFlow
                .collect { clickCount ->
                    println("버튼 클릭 횟수: $clickCount")
                }
        }
    }

    // 버튼 클릭 이벤트를 Flow로 변환하는 함수
    fun buttonClicksFlow(button: Button): Flow<Int> = callbackFlow {
        var clickCount = 0

        val clickListener = View.OnClickListener {
            clickCount++
            trySend(clickCount) // 클릭 이벤트마다 클릭 횟수를 Flow에 전달
        }

        button.setOnClickListener(clickListener)

        // Flow가 취소되면 클릭 리스너를 제거
        awaitClose {
            button.setOnClickListener(null)
        }
    }
}

참고자료

https://jeongjaino.tistory.com/67

 

Kotlin SuspendCoroutine : Callback to Coroutines

필자는 서버나, 외부 DB가 필요한 Android 프로젝트에 Firebase를 사용하고 있다. Firebase sdk의 경우 Java 코드로 구현되어 있으며, callback을 통해 비동기를 지원하는 것이 특징이다. 필자는 이러한 callbac

jeongjaino.tistory.com

https://reco-dy.tistory.com/20

 

[Coroutine] suspendCoroutine과 suspendCancellableCoroutine

지난 callbackFlow에 이어서 callback를 coroutine 형태로 받을 수 있는 suspendCoroutine과 suspendCancellableCoroutine에 대해 알아보려고 합니다. 1. suspendCoroutine public suspend inline fun suspendCoroutine( crossinline block: (Cont

reco-dy.tistory.com

https://reco-dy.tistory.com/19

 

[Coroutine] callback API를 Flow로 변환해보기

라이브러리나 sdk에서 callback 형태로 받는 API를 Flow로 받고 싶을 땐 어떻게 해야 할까요?! callbackFlow, suspencoroutine 등을 이용하여 Flow로 받을 수 있습니다. 이번에는 callbackFlow에 대해 공부해보려고

reco-dy.tistory.com