Android API 클라이언트 측 캐싱 가이드라인

Android API 호출은 일반적으로 호출당 상당한 지연 시간과 계산이 필요합니다. 따라서 클라이언트 측 캐싱은 유용하고 정확하며 성능이 우수한 API를 설계할 때 중요한 고려사항입니다.

동기

Android SDK에서 앱 개발자에게 노출되는 API는 플랫폼 프로세스의 시스템 서비스에 바인더 IPC 호출을 실행하는 Android 프레임워크의 클라이언트 코드로 구현되는 경우가 많습니다. 이 시스템 서비스의 작업은 일부 계산을 실행하고 결과를 클라이언트에 반환하는 것입니다. 이 작업의 지연 시간은 일반적으로 다음 세 가지 요인에 의해 결정됩니다.

  • IPC 오버헤드: 기본 IPC 호출은 일반적으로 기본 인프로세스 메서드 호출의 지연 시간의 10,000배입니다.
  • 서버 측 경합: 클라이언트의 요청에 응답하여 시스템 서비스에서 실행되는 작업이 즉시 시작되지 않을 수 있습니다(예: 서버 스레드가 이전에 도착한 다른 요청을 처리하는 데 사용 중인 경우).
  • 서버 측 계산: 서버에서 요청을 처리하는 작업 자체에 상당한 작업이 필요할 수 있습니다.

캐시가 다음 조건을 충족하는 경우 클라이언트 측에 캐시를 구현하여 이러한 지연 시간 요소를 모두 제거할 수 있습니다.

  • 올바름: 클라이언트 측 캐시는 서버에서 반환했을 결과와 다른 결과를 반환하지 않습니다.
  • 효과적: 클라이언트 요청이 캐시에서 제공되는 경우가 많습니다(예: 캐시 적중률이 높음).
  • 효율적: 클라이언트 측 캐시는 캐시된 데이터를 간결하게 표현하고 클라이언트의 메모리에 너무 많은 캐시된 결과나 오래된 데이터를 저장하지 않는 등 클라이언트 측 리소스를 효율적으로 사용합니다.

클라이언트에서 서버 결과를 캐싱하는 것을 고려하세요.

클라이언트가 정확히 동일한 요청을 여러 번 수행하고 반환된 값이 시간이 지나도 변경되지 않는 경우 요청 매개변수를 키로 사용하는 캐시를 클라이언트 라이브러리에 구현해야 합니다.

구현에서 IpcDataCache을 사용하는 것이 좋습니다.

public class BirthdayManager {
    private final IpcDataCache.QueryHandler<User, Birthday> mBirthdayQuery =
            new IpcDataCache.QueryHandler<User, Birthday>() {
                @Override
                public Birthday apply(User user) {
                    return mService.getBirthday(user);
                }
            };
    private static final int BDAY_CACHE_MAX = 8;  // Maximum birthdays to cache
    private static final String BDAY_API = "getUserBirthday";
    private final IpcDataCache<User, Birthday> mCache
            new IpcDataCache<User, Birthday>(
                BDAY_CACHE_MAX, MODULE_SYSTEM, BDAY_API,  BDAY_API, mBirthdayQuery);

    /** @hide **/
    @VisibleForTesting
    public static void clearCache() {
        IpcDataCache.invalidateCache(MODULE_SYSTEM, BDAY_API);
    }

    public Birthday getBirthday(User user) {
        return mCache.query(user);
    }
}

전체 예시는 android.app.admin.DevicePolicyManager를 참고하세요.

IpcDataCache는 메인라인 모듈을 비롯한 모든 시스템 코드에서 사용할 수 있습니다. 거의 동일하지만 프레임워크에만 표시되는 PropertyInvalidatedCache도 있습니다. 가능하면 IpcDataCache를 사용하세요.

서버 측 변경사항에 따라 캐시 무효화

서버에서 반환된 값이 시간이 지남에 따라 변경될 수 있는 경우 변경사항을 관찰하는 콜백을 구현하고 클라이언트 측 캐시를 적절하게 무효화할 수 있도록 콜백을 등록하세요.

단위 테스트 사례 간 캐시 무효화

단위 테스트 모음에서는 실제 서버가 아닌 테스트 더블에 대해 클라이언트 코드를 테스트할 수 있습니다. 그렇다면 테스트 사례 간에 클라이언트 측 캐시를 지워야 합니다. 이는 테스트 사례를 상호 폐쇄적으로 유지하고 한 테스트 사례가 다른 테스트 사례를 방해하지 못하도록 하기 위한 것입니다.

@RunWith(AndroidJUnit4.class)
public class BirthdayManagerTest {

    @Before
    public void setUp() {
        BirthdayManager.clearCache();
    }

    @After
    public void tearDown() {
        BirthdayManager.clearCache();
    }

    ...
}

내부적으로 캐싱을 사용하는 API 클라이언트를 실행하는 CTS 테스트를 작성할 때 캐시는 API 작성자에게 노출되지 않는 구현 세부정보이므로 CTS 테스트에는 클라이언트 코드에 사용된 캐싱에 관한 특별한 지식이 필요하지 않습니다.

캐시 적중 및 캐시 부적중 연구

IpcDataCachePropertyInvalidatedCache은 실시간 통계를 인쇄할 수 있습니다.

adb shell dumpsys cacheinfo
  ...
  Cache Name: cache_key.is_compat_change_enabled
    Property: cache_key.is_compat_change_enabled
    Hits: 1301458, Misses: 21387, Skips: 0, Clears: 39
    Skip-corked: 0, Skip-unset: 0, Skip-bypass: 0, Skip-other: 0
    Nonce: 0x856e911694198091, Invalidates: 72, CorkedInvalidates: 0
    Current Size: 1254, Max Size: 2048, HW Mark: 2049, Overflows: 310
    Enabled: true
  ...

필드

히트:

  • 정의: 요청된 데이터가 캐시 내에서 성공적으로 발견된 횟수입니다.
  • 의미: 효율적이고 빠른 데이터 검색을 나타내며 불필요한 데이터 검색을 줄입니다.
  • 수가 많을수록 일반적으로 좋습니다.

삭제:

  • 정의: 무효화로 인해 캐시가 삭제된 횟수입니다.
  • 삭제 이유:
    • 무효화: 서버의 데이터가 오래되었습니다.
    • 공간 관리: 캐시가 가득 찼을 때 새 데이터를 위한 공간을 확보합니다.
  • 수가 많으면 데이터가 자주 변경되고 비효율적일 수 있습니다.

누락:

  • 정의: 캐시가 요청된 데이터를 제공하지 못한 횟수입니다.
  • 원인:
    • 비효율적인 캐싱: 캐시가 너무 작거나 올바른 데이터를 저장하지 않습니다.
    • 자주 변경되는 데이터
    • 첫 번째 요청
  • 수가 많으면 캐싱 문제가 있을 수 있습니다.

건너뛰기:

  • 정의: 캐시를 사용할 수 있었음에도 불구하고 전혀 사용되지 않은 인스턴스입니다.
  • 건너뛰기 이유:
    • 코르킹: Android 패키지 관리자 업데이트와 관련하여 부팅 중 호출량이 많기 때문에 의도적으로 캐싱을 사용 중지합니다.
    • 설정되지 않음: 캐시가 있지만 초기화되지 않았습니다. 넌스가 설정되지 않았습니다. 즉, 캐시가 무효화된 적이 없습니다.
    • 우회: 캐시를 건너뛰기 위한 의도적인 결정입니다.
  • 수가 많으면 캐시 사용에 비효율성이 있을 수 있습니다.

무효화:

  • 정의: 캐시된 데이터를 오래되었거나 만료된 것으로 표시하는 프로세스입니다.
  • 중요성: 시스템이 최신 데이터로 작동하여 오류와 불일치를 방지한다는 신호를 제공합니다.
  • 일반적으로 데이터를 소유한 서버에 의해 트리거됩니다.

현재 크기:

  • 정의: 캐시에 있는 현재 요소 수입니다.
  • 중요도: 캐시의 리소스 사용률과 시스템 성능에 미치는 잠재적 영향을 나타냅니다.
  • 값이 높을수록 일반적으로 캐시에서 더 많은 메모리를 사용합니다.

최대 크기:

  • 정의: 캐시에 할당된 최대 공간입니다.
  • 중요도: 캐시의 용량과 데이터 저장 기능을 결정합니다.
  • 적절한 최대 크기를 설정하면 캐시 효율성과 메모리 사용량의 균형을 맞출 수 있습니다. 최대 크기에 도달하면 가장 최근에 사용되지 않은 요소를 삭제하여 새 요소를 추가하므로 비효율적일 수 있습니다.

최고 수위:

  • 정의: 캐시가 생성된 이후 도달한 최대 크기입니다.
  • 중요성: 최대 캐시 사용량과 잠재적 메모리 압력에 관한 유용한 정보를 제공합니다.
  • 최고점을 모니터링하면 잠재적인 병목 현상이나 최적화할 영역을 파악할 수 있습니다.

오버플로:

  • 정의: 캐시가 최대 크기를 초과하여 새 항목을 위한 공간을 확보하기 위해 데이터를 삭제해야 한 횟수입니다.
  • 중요도: 데이터 삭제로 인한 캐시 압력 및 잠재적 성능 저하를 나타냅니다.
  • 오버플로 수가 많으면 캐시 크기를 조정하거나 캐싱 전략을 재평가해야 할 수 있습니다.

동일한 통계는 버그 신고에서도 확인할 수 있습니다.

캐시 크기 조정

캐시에는 최대 크기가 있습니다. 최대 캐시 크기를 초과하면 항목이 LRU 순서로 삭제됩니다.

  • 항목을 너무 적게 캐싱하면 캐시 적중률에 부정적인 영향을 미칠 수 있습니다.
  • 항목을 너무 많이 캐싱하면 캐시의 메모리 사용량이 증가합니다.

사용 사례에 적합한 균형을 찾으세요.

중복 클라이언트 호출 제거

클라이언트는 짧은 시간 동안 서버에 동일한 쿼리를 여러 번 실행할 수 있습니다.

public void executeAll(List<Operation> operations) throws SecurityException {
    for (Operation op : operations) {
        for (Permission permission : op.requiredPermissions()) {
            if (!permissionChecker.checkPermission(permission, ...)) {
                throw new SecurityException("Missing permission " + permission);
            }
        }
        op.execute();
  }
}

이전 호출의 결과를 재사용하는 것이 좋습니다.

public void executeAll(List<Operation> operations) throws SecurityException {
    Set<Permission> permissionsChecked = new HashSet<>();
    for (Operation op : operations) {
        for (Permission permission : op.requiredPermissions()) {
            if (!permissionsChecked.add(permission)) {
                if (!permissionChecker.checkPermission(permission, ...)) {
                    throw new SecurityException(
                            "Missing permission " + permission);
                }
            }
        }
        op.execute();
  }
}

최근 서버 응답의 클라이언트 측 메모이제이션 고려

클라이언트 앱이 API의 서버가 의미 있는 새로운 응답을 생성할 수 있는 속도보다 빠른 속도로 API를 쿼리할 수 있습니다. 이 경우 효과적인 접근 방식은 타임스탬프와 함께 클라이언트 측에서 마지막으로 확인한 서버 응답을 메모이제이션하고, 메모이제이션된 결과가 충분히 최근인 경우 서버를 쿼리하지 않고 메모이제이션된 결과를 반환하는 것입니다. API 클라이언트 작성자는 메모이제이션 기간을 결정할 수 있습니다.

예를 들어 앱은 그려진 모든 프레임에서 통계를 쿼리하여 사용자에게 네트워크 트래픽 통계를 표시할 수 있습니다.

@UiThread
private void setStats() {
    mobileRxBytesTextView.setText(
        Long.toString(TrafficStats.getMobileRxBytes()));
    mobileRxPacketsTextView.setText(
        Long.toString(TrafficStats.getMobileRxPackages()));
    mobileTxBytesTextView.setText(
        Long.toString(TrafficStats.getMobileTxBytes()));
    mobileTxPacketsTextView.setText(
        Long.toString(TrafficStats.getMobileTxPackages()));
}

앱은 60Hz로 프레임을 그릴 수 있습니다. 하지만 TrafficStats의 클라이언트 코드는 초당 최대 한 번 서버에 통계를 쿼리하고 이전 쿼리 후 1초 이내에 쿼리된 경우 마지막으로 확인된 값을 반환할 수 있습니다. API 문서에서 반환된 결과의 최신성에 관한 계약을 제공하지 않으므로 허용됩니다.

participant App code as app
participant Client library as clib
participant Server as server

app->clib: request @ T=100ms
clib->server: request
server->clib: response 1
clib->app: response 1

app->clib: request @ T=200ms
clib->app: response 1

app->clib: request @ T=300ms
clib->app: response 1

app->clib: request @ T=2000ms
clib->server: request
server->clib: response 2
clib->app: response 2

서버 쿼리 대신 클라이언트 측 코드 생성 고려

빌드 시 서버에서 쿼리 결과를 알 수 있는 경우 빌드 시 클라이언트에서도 알 수 있는지 고려하고 API를 클라이언트 측에서 완전히 구현할 수 있는지 고려하세요.

기기가 시계인지 (즉, 기기가 Wear OS를 실행하는지) 확인하는 다음 앱 코드를 고려해 보세요.

public boolean isWatch(Context ctx) {
    PackageManager pm = ctx.getPackageManager();
    return pm.hasSystemFeature(PackageManager.FEATURE_WATCH);
}

이 기기 속성은 빌드 시간에, 특히 이 기기의 부팅 이미지를 위해 프레임워크가 빌드된 시간에 알려져 있습니다. hasSystemFeature의 클라이언트 측 코드는 원격 PackageManager 시스템 서비스를 쿼리하는 대신 알려진 결과를 즉시 반환할 수 있습니다.

클라이언트에서 서버 콜백 중복 삭제

마지막으로 API 클라이언트는 API 서버에 콜백을 등록하여 이벤트 알림을 받을 수 있습니다.

앱이 동일한 기본 정보에 대해 여러 콜백을 등록하는 것은 일반적입니다. 서버가 IPC를 사용하여 등록된 콜백마다 클라이언트에 한 번씩 알리는 대신 클라이언트 라이브러리는 서버와 함께 IPC를 사용하는 등록된 콜백 하나를 보유한 다음 앱에서 등록된 각 콜백에 알려야 합니다.

digraph d_front_back {
  rankdir=RL;
  node [style=filled, shape="rectangle", fontcolor="white" fontname="Roboto"]
  server->clib
  clib->c1;
  clib->c2;
  clib->c3;

  subgraph cluster_client {
    graph [style="dashed", label="Client app process"];
    c1 [label="my.app.FirstCallback" color="#4285F4"];
    c2 [label="my.app.SecondCallback" color="#4285F4"];
    c3 [label="my.app.ThirdCallback" color="#4285F4"];
    clib [label="android.app.FooManager" color="#F4B400"];
  }

  subgraph cluster_server {
    graph [style="dashed", label="Server process"];
    server [label="com.android.server.FooManagerService" color="#0F9D58"];
  }
}