|

callbackFlow는 언제 써야 할까: 콜백 API를 Flow로 바꾸는 기준

callbackFlow를 Kotlin Flow로 바꾸는 기준을 설명하는 대표 이미지
callbackFlow는 여러 번 값을 보내는 콜백 API를 Flow로 감쌀 때 가장 잘 맞는다

callbackFlow는 콜백 API를 Kotlin Flow로 바꿀 때 쓰는 도구입니다. 하지만 모든 콜백을 callbackFlow로 감싸면 코드가 더 복잡해질 수 있습니다. 핵심 기준은 값이 여러 번 오고, callback 등록과 해제가 필요하며, collector 취소에 맞춰 리소스를 정리해야 하는가입니다.

안드로이드에서는 location listener, connectivity callback, sensor callback, Firebase listener처럼 값이 계속 들어오는 API에서 callbackFlow가 자주 등장합니다. 반대로 한 번 성공하거나 실패하고 끝나는 콜백이면 suspendCancellableCoroutine이 더 단순할 수 있습니다. Flow 자체의 판단 기준은 Flow와 suspend 함수 차이 글과 함께 보면 더 잘 잡힙니다.

callbackFlow 사용 기준 카드 이미지
callbackFlow는 등록과 해제가 필요한 다중 이벤트 콜백을 Flow로 바꿀 때 적합하다

callbackFlow를 써야 하는 상황

callbackFlow는 cold Flow를 만듭니다. 즉, Flow를 만든 순간 바로 콜백이 등록되는 것이 아니라 collect 같은 terminal operator가 실행될 때 block이 호출됩니다. Kotlin 공식 callbackFlow 문서는 callbackFlow가 다른 context나 callback 기반 API에서 값을 보낼 수 있도록 thread-safety와 context preservation을 보장한다고 설명합니다.

  • 값이 한 번이 아니라 여러 번 발생한다
  • register / unregister 구조가 있다
  • 구독자가 없어지면 listener도 해제해야 한다
  • 콜백에서 error, complete, cancel 흐름을 Flow로 전달해야 한다

이 조건을 만족하면 callbackFlow가 자연스럽습니다. 특히 화면이 사라지거나 ViewModel scope가 취소될 때 listener를 같이 정리해야 하는 안드로이드 코드에서 의미가 큽니다.


suspendCancellableCoroutine과의 차이

둘의 차이는 어렵게 외울 필요가 없습니다. 결과가 한 번이면 suspendCancellableCoroutine, 값이 여러 번이면 callbackFlow를 먼저 생각하면 됩니다.

  • suspendCancellableCoroutine: 로그인 결과, 권한 요청 결과, 단발 네트워크 콜백처럼 한 번 끝나는 작업
  • callbackFlow: 위치 업데이트, 센서 값, 연결 상태, 데이터 변경 listener처럼 계속 들어오는 이벤트

물론 실제 API가 애매할 때도 있습니다. 그럴 때는 ‘구독을 시작하고 나중에 명시적으로 해제해야 하는가’를 보면 됩니다. 해제가 필요하다면 callbackFlow 쪽으로 기울 가능성이 큽니다.


callbackFlow 기본 예제

아래 예시는 callback 기반 위치 API를 Flow로 감싸는 형태입니다. 실제 프로젝트에서는 권한, 설정, 예외 처리를 더 넣어야 하지만 구조는 비슷합니다.

fun LocationClient.locationUpdates(): Flow<Location> = callbackFlow {
    val callback = object : LocationCallback {
        override fun onLocationResult(result: LocationResult) {
            result.lastLocation?.let { location ->
                trySend(location).isSuccess
            }
        }
    }

    requestLocationUpdates(callback)

    awaitClose {
        removeLocationUpdates(callback)
    }
}

여기서 가장 중요한 부분은 awaitClose입니다. collect가 취소되거나 Flow가 닫힐 때 removeLocationUpdates를 호출하지 않으면 callback이 계속 살아 있을 수 있습니다. 공식 문서도 awaitClose를 리소스 cleanup 용도로 설명하고, 누수를 막기 위해 중요하다고 말합니다.


trySend와 buffer를 같이 봐야 하는 이유

callback은 값을 빠르게 밀어 넣을 수 있지만, collector는 그 속도를 따라가지 못할 수 있습니다. 그래서 trySend 결과를 무시하지 말고 실패 가능성을 고려해야 합니다. 이벤트가 너무 많다면 buffer, conflated buffer, sampling 같은 전략도 함께 검토해야 합니다.

fun observeEvents(api: EventApi): Flow<Event> = callbackFlow {
    val listener = EventListener { event ->
        trySend(event).onFailure {
            // collector가 취소됐거나 channel이 닫힌 상황을 기록할 수 있다
        }
    }

    api.addListener(listener)
    awaitClose { api.removeListener(listener) }
}.buffer(capacity = Channel.CONFLATED)

최신 값만 중요하다면 Channel.CONFLATED가 더 나을 수 있습니다. 모든 이벤트가 중요하다면 버퍼 크기와 back-pressure 정책을 더 신중하게 잡아야 합니다.


안드로이드에서 자주 하는 실수

  • awaitClose를 빼서 listener가 해제되지 않는다
  • 단발 콜백까지 callbackFlow로 감싸서 코드가 과해진다
  • trySend 실패를 완전히 무시한다
  • callback 등록 함수와 해제 함수가 thread-safe하다고 가정한다
  • Flow로 바꿨다는 이유만으로 lifecycle 수집 문제를 해결했다고 착각한다

callbackFlow는 콜백을 Flow 모양으로 바꾸는 도구이지, 수집 위치와 lifecycle 문제를 자동으로 해결해 주는 도구는 아닙니다. collect 위치는 ViewModel, repeatOnLifecycle, Compose의 collectAsStateWithLifecycle 같은 흐름과 함께 봐야 합니다.


정리

callbackFlow는 여러 번 값이 들어오는 callback API를 Flow로 바꾸는 데 적합합니다. 판단 기준은 간단합니다. 구독을 시작하고, 값이 여러 번 오고, 취소 시 반드시 callback을 해제해야 한다면 callbackFlow를 검토하면 됩니다.

단발 결과에는 suspendCancellableCoroutine을 먼저 보고, callbackFlow를 쓴다면 awaitClose와 buffer 전략까지 같이 설계하는 것이 안전합니다.

함께보면 좋은 글