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 테스트에는 클라이언트 코드에 사용된 캐싱에 관한 특별한 지식이 필요하지 않습니다.
캐시 적중 및 캐시 부적중 연구
IpcDataCache 및 PropertyInvalidatedCache은 실시간 통계를 인쇄할 수 있습니다.
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"];
}
}