[Kotlin] 모듈이 Callback으로 데이터를 전달할 때 - callbackFlow

cotton·2025년 2월 17일
1

Android

목록 보기
2/3

서론

최근 작업하던 내용 중, 프로젝트 외부 모듈을 사용하고 있는 부분을 리팩토링하는 작업을 진행하게 되었습니다.

해당 모듈은 외부 데이터 통신 과정에서 Callback을 사용하여 해당 Request의 성공, 실패, 그리고 진행 상태에 대한 값을 내려주는 모듈이었습니다.

// 예시
data class SomeResponse(
    val num: Int,
    val title: String
)

data class ErrorResponse(
    val errorCode: String?,
    val errorMessage: String?
)

interface SomeListener {
    fun onSuccess(response: SomeResponse) 
    fun onProgressChanged(progress: Int)
    fun onFailed(error: ErrorResponse)
}

object : SomeListener {
    override fun onSuccess(response: SomeResponse) {
        // Success 처리
    }

    override fun onProgressChanged(progress: Int) {
        // Progress 처리
    }

    override fun onFailed(error: ErrorResponse) {
        // Failed 처리
    }
}

Callback은 데이터 처리를 쉽게 가능하게 하며, 여러 외부 라이브러리에서도 아직 사용 중인 방식 중 하나입니다. 하지만 Callback은 여러 문제점을 가지고 있습니다. Callback의 문제점 중에서 가장 유명하고 흔한 Callback 지옥이라던가, 이외에도 오류 처리를 하기 어렵다던가, 테스트를 작성하는 데도 방해 요소가 될 수 있습니다.

특히 위의 예시처럼 Callback 같은 경우에는 Progress 값도 실시간으로 UI와 같은 곳에서 사용할 수 있어야 하는 형태라면 조금 더 복잡해집니다. 안드로이드에서는 Progress 값과 결과 Response를 모두 처리하기 위해서는 콜백을 그대로 사용한다면 ViewModel에서 해당 로직을 구현하거나, Progress 값과 성공/실패 값을 처리하는 두 개의 StateFlow를 개발하는 방식으로 구현해야 할 것입니다. 하지만 대부분의 개발자들은 외부 모듈을 통해 데이터를 가져오는 부분을 ViewModel이 아닌, Data Layer에 구현하고 싶을 것입니다.

코틀린에서는 이런 Callback를 코루틴, 그리고 Flow로 전환할 수 있게 해 주는, 더 편리하게 사용할 수 있게 여러 방식을 지원합니다. 대표적으로는 suspendCoroutine를 이용하는 방법과, callbackFlow를 이용하는 방법이 있습니다. 이번 글에서는 callbackFlow를 이용하여 CallBack을 Flow로 전환해보도록 하겠습니다.

callbackFlow

fun <T> callbackFlow(block: suspend ProducerScope<T>.() -> Unit): Flow<T>

우리가 실제로 알고 있는 flow 빌더와는 다르게, 내부 Builder Block이 FlowCollector 가 아닌 ProducerScope 를 사용하고 있습니다. ProducerScope는 Flow처럼 emit을 사용하는 형태로 구현된 것이 아닌 SendChannel을 상속하고 있기 때문에 flow를 통해 데이터를 넘겨주길 원한다면 send 메소드를 사용해야 합니다.

send vs trySend

public suspend fun send(element: E)

public fun trySend(element: E): ChannelResult<Unit>

그렇다면 callbackFlow에 데이터를 흘려 보내주기 위한 방식은 무엇이 있을까요? 바로 sendtrySend입니다. 이는 flow의 emittryEmit과 동일하게 동작합니다. send는 channel에 buffer에 여유 공간이 생길 때까지 suspend (중단) 하지만, trySend는 buffer에 공간이 없는 경우 즉시 false를 반환합니다.

Example

interface SomeListener {
    fun onSuccess(response: SomeResponse) 
    fun onProgressChanged(progress: Int)
    fun onFailed(error: ErrorResponse)
}

그렇다면 위와 같은 콜백 리스너를 callbackFlow를 이용하여 어떻게 Flow 형태로 변환시킬 수 있을까요?

위 인터페이스는 3가지의 상태값을 가집니다. 성공, 실패, 그리고 진행 상태입니다. 이를 한 개의 Flow에서 흘려보내줄 수 있기 위해 sealed class를 이용하여 모델링합니다.

// 1. 상태 모델링 (Sealed Class)
sealed class Resource {
    data class Success(val data: SomeResponse) : Resource()
    data class Progress(val percent: Int) : Resource()
    data class Error(val error: ErrorResponse) : Resource()
}

모델링을 완료했다면 해당 콜백을 Flow로 흘려줄 수 있도록 callbackFlow를 이용하여 개발하면 됩니다. Data Layer 영역에 개발하고자 했기 때문에 Repository에 해당 로직을 개발합니다.

// 2. Data Layer 개발
class SomeRepositoryImpl(private val someModule: SomeModule): SomeRepository {

    override fun getSomeResource() = callbackFlow<Resource> {
        someModule.getSomeData(
            listener = object : SomeListener {
                override fun onSuccess(response: SomeResponse) {
                    trySend(Resource.Success(response))
                }

                override fun onProgressChanged(progress: Int) {
                    trySend(Resource.Progress(progress))
                }

                override fun onFailed(error: ErrorResponse) {
                    trySend(Resource.Error(error))
                }
            }
        )
        awaitClose { }
    }
}

callbackFlow를 이용하여, 해당 빌더 안에 listener를 상속받고, 각 상태에 따라서 모델을 생성해 flow에 데이터를 trySend를 통해 흘려 보내줍니다.

마지막으로 callbackFlow를 사용하는 경우 awaitClose 구문을 사용하지 않는 경우 block 내의 코드가 실행된 이후 즉시 종료되면서 원하는 대로 동작하지 않으며, 메모리 릭이 발생할 수 있으며, 런타임에서 Exception을 발생하기 때문에, 이를 방어하기 위해 block 마지막 부분에 awaitClose 를 추가합니다.

// 3. ViewModel과 같은 곳에서 사용
viewModelScope.launch {
    someRepository.getSomeResource().collectLatest { resource ->
        when (resource) {
            is Success -> {
                // 성공 값 처리
            }
            
            is Progress -> {
                // Progress 처리
            }
            
            is Error -> {
                // ErrorResponse 처리
            }
        }
    }

Data 레이어에 callbackFlow를 통해 구현을 잘 마쳤다면, 이제 해당 로직을 ViewModel과 같은 곳에서 사용하면 됩니다. 반환 방식이 Flow이기 때문에 collect하기 위해서는 Coroutine Scope 위에서 호출되어야 합니다. 안드로이드 ViewModel에서는 viewModelScope를 지원하기 때문에 해당 로직을 사용하면 됩니다.

참고

https://developer.android.com/kotlin/flow?hl=ko#callback

https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/callback-flow.html

https://medium.com/@appdevinsights/callbackflow-in-kotlin-b830a1498946

profile
안드로이드 개발자

0개의 댓글