Android 비동기 및 비차단 API 가이드라인

비차단 API는 작업을 요청한 다음 호출 스레드에 제어를 다시 양보하여 요청된 작업이 완료되기 전에 다른 작업을 실행할 수 있도록 합니다. 이러한 API는 요청된 작업이 진행 중이거나 작업이 진행되기 전에 I/O 또는 IPC 완료, 경합이 심한 시스템 리소스의 가용성 또는 사용자 입력이 필요할 수 있는 경우에 유용합니다. 특히 잘 설계된 API는 진행 중인 작업을 취소하고 원래 호출자를 대신하여 작업이 실행되는 것을 중지하여 작업이 더 이상 필요하지 않을 때 시스템 상태와 배터리 수명을 보존하는 방법을 제공합니다.

비동기 API는 차단되지 않는 동작을 달성하는 한 가지 방법입니다. 비동기 API는 작업이 완료되거나 작업 진행 중 다른 이벤트가 발생할 때 알림을 받는 연속 또는 콜백 형식을 허용합니다.

비동기 API를 작성하는 데는 두 가지 주요 동기가 있습니다.

  • N번째 작업이 N-1번째 작업이 완료되기 전에 시작되어야 하는 여러 작업을 동시에 실행합니다.
  • 작업이 완료될 때까지 호출 스레드가 차단되지 않도록 합니다.

Kotlin은 정지 함수를 기반으로 빌드된 일련의 원칙과 API인 구조화된 동시 실행을 적극적으로 권장합니다. 구조화된 동시 실행은 코드의 동기 및 비동기 실행을 스레드 차단 동작에서 분리합니다. 정지 함수는 비차단동기식입니다.

정지 함수:

  • 호출 스레드를 차단하지 말고 다른 곳에서 실행되는 작업의 결과를 기다리는 동안 실행 스레드를 구현 세부정보로 양보하세요.
  • 동기적으로 실행되며 차단되지 않는 API 호출자가 API 호출로 시작된 차단되지 않는 작업과 동시에 계속 실행할 필요가 없습니다.

이 페이지에서는 비차단 및 비동기 API를 사용할 때 개발자가 안전하게 가질 수 있는 최소 기준을 자세히 설명하고, Android 플랫폼 또는 Jetpack 라이브러리에서 Kotlin 또는 Java 언어로 이러한 기준을 충족하는 API를 작성하는 방법을 일련의 레시피로 보여줍니다. 확신이 서지 않는 경우 개발자 기대치를 새로운 API 노출 영역의 요구사항으로 간주하세요.

비동기 API에 대한 개발자 기대치

다음 기대치는 달리 명시되지 않는 한 정지되지 않는 API의 관점에서 작성되었습니다.

콜백을 허용하는 API는 일반적으로 비동기식입니다.

API가 인플레이스로만 호출되도록 문서화되지 않은 콜백을 허용하는 경우(즉, API 호출 자체가 반환되기 전에 호출 스레드에 의해서만 호출됨) API는 비동기식으로 간주되며 해당 API는 다음 섹션에 문서화된 다른 모든 요구사항을 충족해야 합니다.

항상 제자리에서만 호출되는 콜백의 예는 반환되기 전에 컬렉션의 각 항목에서 매퍼 또는 술어를 호출하는 고차 맵 또는 필터 함수입니다.

비동기 API는 최대한 빨리 반환해야 합니다.

개발자는 비동기 API가 차단되지 않고 작업 요청을 시작한 후 빠르게 반환되기를 기대합니다. 언제든지 비동기 API를 호출해도 안전해야 하며 비동기 API를 호출해도 프레임이 끊기거나 ANR이 발생해서는 안 됩니다.

많은 작업과 수명 주기 신호는 플랫폼이나 라이브러리에 의해 필요에 따라 트리거될 수 있으며 개발자가 코드의 모든 잠재적 호출 사이트에 관한 전역 지식을 보유하도록 기대하는 것은 지속 가능하지 않습니다. 예를 들어 앱 콘텐츠를 채워 사용 가능한 공간 (예: RecyclerView)을 채워야 하는 경우 View 측정 및 레이아웃에 대한 응답으로 동기 트랜잭션에서 FragmentManagerFragment를 추가할 수 있습니다. 이 프래그먼트의 onStart 수명 주기 콜백에 응답하는 LifecycleObserver는 여기에서 일회성 시작 작업을 실행할 수 있으며 이는 끊김 현상이 없는 애니메이션 프레임을 생성하기 위한 중요한 코드 경로에 있을 수 있습니다. 개발자는 이러한 종류의 수명 주기 콜백에 응답하여 모든 비동기 API를 호출해도 프레임이 끊기는 원인이 되지 않는다고 항상 확신해야 합니다.

이는 반환 전에 비동기 API에서 실행하는 작업이 매우 가벼워야 함을 의미합니다. 요청 및 연결된 콜백의 레코드를 만들고 작업을 실행하는 실행 엔진에 등록하는 것이 최대입니다. 비동기 작업을 등록하는 데 IPC가 필요한 경우 API 구현은 이 개발자 기대치를 충족하는 데 필요한 조치를 취해야 합니다. 여기에는 다음 중 하나 이상이 포함될 수 있습니다.

  • 기본 IPC를 단방향 바인더 호출로 구현
  • 등록을 완료하는 데 경합이 심한 잠금을 가져올 필요가 없는 시스템 서버로 양방향 바인더 호출
  • IPC를 통해 차단 등록을 실행하기 위해 앱 프로세스의 작업자 스레드에 요청 게시

비동기 API는 void를 반환해야 하며 잘못된 인수에 대해서만 예외를 발생시켜야 합니다.

비동기 API는 요청된 작업의 모든 결과를 제공된 콜백에 보고해야 합니다. 이를 통해 개발자는 성공 및 오류 처리를 위한 단일 코드 경로를 구현할 수 있습니다.

비동기 API는 null 인수를 확인하고 NullPointerException을 발생시키거나 제공된 인수가 유효한 범위 내에 있는지 확인하고 IllegalArgumentException을 발생시킬 수있습니다. 예를 들어 0~1f 범위의 float를 허용하는 함수의 경우 매개변수가 이 범위 내에 있는지 확인하고 범위를 벗어나면 IllegalArgumentException를 발생시킬 수 있습니다. 또는 짧은 String가 영숫자 전용과 같은 유효한 형식에 부합하는지 확인할 수 있습니다. (시스템 서버는 앱 프로세스를 신뢰해서는 안 됩니다. 시스템 서비스는 시스템 서비스 자체에서 이러한 검사를 복제해야 합니다.)

다른 모든 오류는 제공된 콜백에 보고해야 합니다. 여기에는 다음이 포함되지만 이에 국한되지는 않습니다.

  • 요청된 작업의 재시도 불가능한 실패
  • 작업을 완료하는 데 필요한 승인 또는 권한이 누락된 경우의 보안 예외
  • 작업 실행 할당량이 초과됨
  • 앱 프로세스가 작업을 수행할 만큼 '포그라운드'가 아님
  • 필수 하드웨어 연결이 해제됨
  • 네트워크 오류
  • 채팅 일시 차단
  • 바인더 종료 또는 사용할 수 없는 원격 프로세스

비동기 API는 취소 메커니즘을 제공해야 합니다.

비동기 API는 호출자가 더 이상 결과에 관심이 없음을 실행 중인 작업에 나타내는 방법을 제공해야 합니다. 이 취소 작업은 다음 두 가지를 알려야 합니다.

호출자가 제공한 콜백에 대한 하드 참조를 해제해야 합니다.

비동기 API에 제공된 콜백에는 대형 객체 그래프에 대한 하드 참조가 포함될 수 있으며 해당 콜백에 대한 하드 참조를 보유하는 진행 중인 작업으로 인해 이러한 객체 그래프가 가비지 수집되지 않을 수 있습니다. 취소 시 이러한 콜백 참조를 해제하면 작업이 완료될 때보다 훨씬 빨리 이러한 객체 그래프가 가비지 수집 대상이 될 수 있습니다.

호출자를 위해 작업을 실행하는 실행 엔진이 해당 작업을 중지할 수 있습니다.

비동기 API 호출로 시작된 작업은 전력 소비 또는 기타 시스템 리소스에 높은 비용이 발생할 수 있습니다. 호출자가 이 작업이 더 이상 필요하지 않다고 신호를 보낼 수 있는 API는 추가 시스템 리소스를 소비하기 전에 작업을 중지할 수 있습니다.

캐시되거나 고정된 앱에 대한 특별 고려사항

콜백이 시스템 프로세스에서 시작되고 앱에 전달되는 비동기 API를 설계할 때는 다음을 고려하세요.

  1. 프로세스 및 앱 수명 주기: 수신자 앱 프로세스가 캐시된 상태일 수 있습니다.
  2. 캐시된 앱 고정기: 수신자 앱 프로세스가 고정될 수 있습니다.

앱 프로세스가 캐시된 상태로 전환되면 활동 및 서비스와 같은 사용자에게 표시되는 구성요소를 적극적으로 호스팅하지 않는다는 의미입니다. 앱은 다시 사용자에게 표시될 수 있으므로 메모리에 유지되지만 그동안 작업을 실행해서는 안 됩니다. 대부분의 경우 앱이 캐시된 상태로 전환되면 앱 콜백 디스패치를 일시중지하고 앱이 캐시된 상태에서 종료되면 다시 시작하여 캐시된 앱 프로세스에서 작업을 유도하지 않아야 합니다.

캐시된 앱이 고정될 수도 있습니다. 앱이 고정되면 CPU 시간을 0으로 수신하고 작업을 전혀 할 수 없습니다. 해당 앱에 등록된 콜백에 대한 모든 호출은 앱이 고정 해제될 때 버퍼링되고 전달됩니다.

앱이 고정 해제되고 버퍼링된 트랜잭션을 처리할 때쯤이면 앱 콜백이 오래되었을 수 있습니다. 버퍼는 유한하며 오버플로되면 수신자 앱이 비정상 종료됩니다. 오래된 이벤트로 앱이 과부하되거나 버퍼가 오버플로되지 않도록 프로세스가 고정된 동안에는 앱 콜백을 디스패치하지 마세요.

검토 중:

  • 앱의 프로세스가 캐시되는 동안 앱 콜백의 디스패치를 일시중지하는 것을 고려해야 합니다.
  • 앱 프로세스가 고정되어 있는 동안에는 앱 콜백의 디스패치를 일시중지해야 합니다(MUST).

상태 추적

앱이 캐시된 상태로 전환되거나 캐시된 상태에서 종료되는 시점을 추적하려면 다음 단계를 따르세요.

mActivityManager.addOnUidImportanceListener(
    new UidImportanceListener() { ... },
    IMPORTANCE_CACHED);

앱이 고정되거나 고정 해제되는 시점을 추적하려면 다음 단계를 따르세요.

IBinder binder = <...>;
binder.addFrozenStateChangeCallback(executor, callback);

앱 콜백 디스패치 재개 전략

앱이 캐시된 상태 또는 고정된 상태로 전환될 때 앱 콜백의 디스패치를 일시중지하는지 여부와 관계없이 앱이 해당 상태를 종료하면 앱이 콜백을 등록 해제하거나 앱 프로세스가 종료될 때까지 앱이 해당 상태를 종료한 후 앱의 등록된 콜백의 디스패치를 재개해야 합니다.

예를 들면 다음과 같습니다.

IBinder binder = <...>;
bool shouldSendCallbacks = true;
binder.addFrozenStateChangeCallback(executor, (who, state) -> {
    if (state == IBinder.FrozenStateChangeCallback.STATE_FROZEN) {
        shouldSendCallbacks = false;
    } else if (state == IBinder.FrozenStateChangeCallback.STATE_UNFROZEN) {
        shouldSendCallbacks = true;
    }
});

또는 타겟 프로세스가 고정될 때 콜백이 타겟 프로세스에 전달되지 않도록 하는 RemoteCallbackList를 사용할 수 있습니다.

예를 들면 다음과 같습니다.

RemoteCallbackList<IInterface> rc =
        new RemoteCallbackList.Builder<IInterface>(
                        RemoteCallbackList.FROZEN_CALLEE_POLICY_DROP)
                .setExecutor(executor)
                .build();
rc.register(callback);
rc.broadcast((callback) -> callback.foo(bar));

callback.foo()는 프로세스가 고정되지 않은 경우에만 호출됩니다.

앱은 콜백을 사용하여 수신한 업데이트를 최신 상태의 스냅샷으로 저장하는 경우가 많습니다. 앱이 남은 배터리 비율을 모니터링하는 가상 API를 고려해 보세요.

interface BatteryListener {
    void onBatteryPercentageChanged(int newPercentage);
}

앱이 고정될 때 여러 상태 변경 이벤트가 발생하는 시나리오를 고려해 보세요. 앱이 고정 해제되면 가장 최근 상태만 앱에 전달하고 오래된 다른 상태 변경사항은 삭제해야 합니다. 이 전송은 앱이 고정 해제될 때 즉시 이루어져야 앱이 '따라잡을' 수 있습니다. 다음과 같이 달성할 수 있습니다.

RemoteCallbackList<IInterface> rc =
        new RemoteCallbackList.Builder<IInterface>(
                        RemoteCallbackList.FROZEN_CALLEE_POLICY_ENQUEUE_MOST_RECENT)
                .setExecutor(executor)
                .build();
rc.register(callback);
rc.broadcast((callback) -> callback.onBatteryPercentageChanged(value));

경우에 따라 앱이 고정 해제된 후 동일한 값을 알리지 않도록 앱에 제공된 마지막 값을 추적할 수 있습니다.

상태는 더 복잡한 데이터로 표현될 수 있습니다. 앱이 네트워크 인터페이스에 관해 알림을 받도록 하는 가상의 API를 고려해 보세요.

interface NetworkListener {
    void onAvailable(Network network);
    void onLost(Network network);
    void onChanged(Network network);
}

앱에 대한 알림을 일시중지할 때는 앱이 마지막으로 본 네트워크 및 상태 집합을 기억해야 합니다. 재개 시 손실된 이전 네트워크, 사용 가능해진 새 네트워크, 상태가 변경된 기존 네트워크를 이 순서대로 앱에 알리는 것이 좋습니다.

콜백이 일시중지된 동안 사용할 수 있게 되었다가 손실된 네트워크를 앱에 알리지 않습니다. 앱은 정지된 동안 발생한 이벤트의 전체 계정을 수신해서는 안 되며 API 문서에서는 명시적 수명 주기 상태 외부에서 중단되지 않은 이벤트 스트림을 제공한다고 약속해서는 안 됩니다. 이 예에서 앱이 네트워크 가용성을 지속적으로 모니터링해야 하는 경우 캐시되거나 고정되지 않도록 하는 수명 주기 상태를 유지해야 합니다.

검토 시 일시중지 후 알림을 다시 시작하기 전에 발생한 이벤트를 병합하고 등록된 앱 콜백에 최신 상태를 간결하게 전달해야 합니다.

개발자 문서 고려사항

비동기 이벤트의 전송은 발신자가 이전 섹션에 표시된 대로 일정 기간 전송을 일시중지했거나 수신자 앱이 이벤트를 적시에 처리할 만큼 충분한 기기 리소스를 수신하지 않았기 때문에 지연될 수 있습니다.

개발자가 앱에 이벤트가 통지된 시간과 이벤트가 실제로 발생한 시간 사이의 시간을 가정하지 않도록 합니다.

API 정지에 대한 개발자 기대사항

Kotlin의 구조화된 동시 실행에 익숙한 개발자는 모든 중단 API에서 다음 동작을 기대합니다.

정지 함수는 반환하거나 예외를 발생시키기 전에 연결된 모든 작업을 완료해야 합니다.

차단되지 않는 작업의 결과는 일반 함수 반환 값으로 반환되고 오류는 예외를 발생시켜 보고됩니다. (이는 콜백 매개변수가 불필요하다는 의미인 경우가 많습니다.)

정지 함수는 콜백 매개변수를 인플레이스로만 호출해야 함

정지 함수는 반환되기 전에 항상 연결된 작업을 모두 완료해야 하므로 정지 함수가 반환된 후 제공된 콜백이나 다른 함수 매개변수를 호출하거나 이에 대한 참조를 유지해서는 안 됩니다.

콜백 매개변수를 허용하는 정지 함수는 달리 문서화되지 않는 한 컨텍스트를 유지해야 함

정지 함수에서 함수를 호출하면 호출자의 CoroutineContext에서 실행됩니다. suspend 함수는 반환되거나 예외가 발생하기 전에 연결된 모든 작업을 완료해야 하고 콜백 매개변수를 인플레이스로만 호출해야 하므로 기본 기대치는 이러한 콜백도 연결된 디스패처를 사용하여 호출 CoroutineContext에서 또한 실행된다는 것입니다. API의 목적이 호출 CoroutineContext 외부에서 콜백을 실행하는 것이라면 이 동작을 명확하게 문서화해야 합니다.

정지 함수는 kotlinx.coroutines 작업 취소를 지원해야 함

제공되는 모든 정지 함수는 kotlinx.coroutines에 정의된 대로 작업 취소와 협력해야 합니다. 진행 중인 작업의 호출 작업이 취소되면 호출자가 최대한 빨리 정리하고 계속할 수 있도록 함수가 최대한 빨리 CancellationException로 재개되어야 합니다. 이는 suspendCancellableCoroutinekotlinx.coroutines에서 제공하는 기타 일시중지 API에 의해 자동으로 처리됩니다. 라이브러리 구현은 기본적으로 이 취소 동작을 지원하지 않으므로 일반적으로 suspendCoroutine를 직접 사용하면 안 됩니다.

백그라운드 (기본 스레드 또는 UI 스레드 아님)에서 차단 작업을 실행하는 정지 함수는 사용된 디스패처를 구성하는 방법을 제공해야 합니다.

스레드를 전환하기 위해 차단 함수가 완전히 일시 중지되도록 하는 것은 권장되지 않습니다.

정지 함수를 호출하면 개발자가 작업을 실행할 자체 스레드나 스레드 풀을 제공하도록 허용하지 않고 추가 스레드가 생성되어서는 안 됩니다. 예를 들어 생성자는 클래스 메서드의 백그라운드 작업을 실행하는 데 사용되는 CoroutineContext를 허용할 수 있습니다.

차단 작업을 실행하기 위해 디스패처로 전환하기 위해서만 선택적 CoroutineContext 또는 Dispatcher 매개변수를 허용하는 함수는 대신 기본 차단 함수를 노출하고 호출 개발자가 withContext에 대한 자체 호출을 사용하여 선택한 디스패처로 작업을 전달하도록 권장해야 합니다.

코루틴을 실행하는 클래스

코루틴을 실행하는 클래스에는 이러한 실행 작업을 수행하는 CoroutineScope이 있어야 합니다. 구조화된 동시 실행 원칙을 준수하면 범위를 획득하고 관리하기 위한 다음 구조 패턴이 적용됩니다.

동시 작업을 다른 범위로 실행하는 클래스를 작성하기 전에 다음 대체 패턴을 고려하세요.

class MyClass {
    private val requests = Channel<MyRequest>(Channel.UNLIMITED)

    suspend fun handleRequests() {
        coroutineScope {
            for (request in requests) {
                // Allow requests to be processed concurrently;
                // alternatively, omit the [launch] and outer [coroutineScope]
                // to process requests serially
                launch {
                    processRequest(request)
                }
            }
        }
    }

    fun submitRequest(request: MyRequest) {
        requests.trySend(request).getOrThrow()
    }
}

동시 작업을 실행하기 위해 suspend fun를 노출하면 호출자가 자체 컨텍스트에서 작업을 호출할 수 있으므로 MyClassCoroutineScope를 관리할 필요가 없습니다. 요청 처리를 직렬화하는 것이 더 간단해지고 상태는 추가 동기화가 필요한 클래스 속성 대신 handleRequests의 지역 변수로 존재하는 경우가 많습니다.

코루틴을 관리하는 클래스는 close 및 cancel 메서드를 노출해야 합니다.

구현 세부정보로 코루틴을 실행하는 클래스는 제어되지 않는 동시 작업이 상위 범위로 누출되지 않도록 이러한 진행 중인 동시 작업을 깔끔하게 종료하는 방법을 제공해야 합니다. 일반적으로 이는 제공된 CoroutineContext의 하위 Job를 만드는 형태를 취합니다.

private val myJob = Job(parent = `CoroutineContext`[Job])
private val myScope = CoroutineScope(`CoroutineContext` + myJob)

fun cancel() {
    myJob.cancel()
}

사용자 코드가 객체에서 실행 중인 미해결 동시 작업의 완료를 기다릴 수 있도록 join() 메서드가 제공될 수도 있습니다. (여기에는 작업을 취소하여 실행되는 정리 작업이 포함될 수 있습니다.)

suspend fun join() {
    myJob.join()
}

터미널 작업 이름 지정

아직 진행 중인 객체 소유의 동시 작업을 깔끔하게 종료하는 데 사용되는 이름은 종료가 발생하는 방식의 동작 계약을 반영해야 합니다.

진행 중인 작업이 완료될 수 있지만 close() 호출이 반환된 후 새 작업이 시작되지 않을 수 있는 경우 close()를 사용합니다.

진행 중인 작업이 완료되기 전에 취소될 수 있는 경우 cancel()를 사용합니다. cancel() 호출이 반환된 후에는 새 작업을 시작할 수 없습니다.

클래스 생성자는 CoroutineScope가 아닌 CoroutineContext를 허용합니다.

객체가 제공된 상위 범위로 직접 실행할 수 없는 경우 CoroutineScope의 적합성이 생성자 매개변수로 분류됩니다.

// Don't do this
class MyClass(scope: CoroutineScope) {
    private val myJob = Job(parent = scope.`CoroutineContext`[Job])
    private val myScope = CoroutineScope(scope.`CoroutineContext` + myJob)

    // ... the [scope] constructor parameter is never used again
}

CoroutineScope는 불필요하고 오해의 소지가 있는 래퍼가 되며, 일부 사용 사례에서는 생성자 매개변수로 전달하기 위해서만 생성된 후 폐기될 수 있습니다.

// Don't do this; just pass the context
val myObject = MyClass(CoroutineScope(parentScope.`CoroutineContext` + Dispatchers.IO))

CoroutineContext 매개변수는 기본적으로 EmptyCoroutineContext입니다.

선택사항인 CoroutineContext 매개변수가 API 표면에 표시되면 기본값은 Empty`CoroutineContext` 센티널이어야 합니다. 이렇게 하면 호출자의 Empty`CoroutineContext` 값이 기본값을 수락하는 것과 동일한 방식으로 처리되므로 API 동작을 더 잘 구성할 수 있습니다.

class MyOuterClass(
    `CoroutineContext`: `CoroutineContext` = Empty`CoroutineContext`
) {
    private val innerObject = MyInnerClass(`CoroutineContext`)

    // ...
}

class MyInnerClass(
    `CoroutineContext`: `CoroutineContext` = Empty`CoroutineContext`
) {
    private val job = Job(parent = `CoroutineContext`[Job])
    private val scope = CoroutineScope(`CoroutineContext` + job)

    // ...
}