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

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

비동기 API는 비차단 동작을 실행하는 한 가지 방법입니다. 비동기 API는 작업이 완료될 때 또는 작업 진행 중에 다른 이벤트가 발생할 때 알림을 받는 일종의 연속 또는 콜백을 허용합니다.

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

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

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 측정값 및 레이아웃에 응답하여 동기식 트랜잭션에서 FragmentFragmentManager에 추가할 수 있습니다. 이 프래그먼트의 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에서 실행됩니다. 일시중지 함수는 반환하거나 발생하기 전에 모든 관련 작업을 완료해야 하며 콜백 매개변수를 제자리에서만 호출해야 하므로 이러한 콜백은 연결된 디스패처를 사용하여 호출하는 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의 로컬 변수로 존재할 수 있습니다.

코루틴을 관리하는 클래스는 닫기 및 취소 메서드를 노출해야 함

코루틴을 구현 세부정보로 실행하는 클래스는 진행 중인 동시 작업을 깔끔하게 종료하는 방법을 제공해야 하며, 이를 통해 제어되지 않는 동시 작업이 상위 범위로 유출되지 않습니다. 일반적으로 이는 제공된 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)

    // ...
}