Android API 호출은 일반적으로 호출당 상당한 지연 시간과 계산이 필요합니다. 따라서 유용하고 정확하며 성능이 우수한 API를 설계할 때는 클라이언트 측 캐싱을 중요하게 고려해야 합니다.
동기
Android SDK에서 앱 개발자에게 노출되는 API는 종종 Android 프레임워크의 클라이언트 코드로 구현되며, 이 코드는 플랫폼 프로세스의 시스템 서비스에 바인더 IPC를 호출합니다. 이 코드의 작업은 일부 계산을 실행하고 결과를 클라이언트로 반환하는 것입니다. 이 작업의 지연 시간은 일반적으로 다음 세 가지 요인에 의해 결정됩니다.
- 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
...
필드
조회수:
- 정의: 요청된 데이터가 캐시에서 성공적으로 발견된 횟수입니다.
- 중요도: 데이터를 효율적이고 빠르게 검색하여 불필요한 데이터 검색을 줄입니다.
- 일반적으로 숫자가 클수록 좋습니다.
지우기:
- 정의: 무효화로 인해 캐시가 삭제된 횟수입니다.
- 삭제 이유:
- 무효화: 서버의 오래된 데이터입니다.
- 공간 관리: 캐시가 가득 찼을 때 새 데이터를 위한 공간을 확보합니다.
- 개수가 많으면 데이터가 자주 변경되고 비효율적일 수 있습니다.
누락:
- 정의: 캐시에서 요청된 데이터를 제공하지 못한 횟수입니다.
- 원인:
- 비효율적인 캐싱: 캐시가 너무 작거나 올바른 데이터를 저장하지 않음
- 자주 변경되는 데이터
- 최초 요청
- 수가 많으면 잠재적인 캐싱 문제가 있을 수 있습니다.
건너뛰기:
- 정의: 캐시를 사용할 수 있었음에도 전혀 사용되지 않은 인스턴스입니다.
- 건너뛰는 이유:
- Corking: Android 패키지 관리자 업데이트에만 해당하며, 부팅 중에 호출 수가 많아 캐싱을 의도적으로 사용 중지합니다.
- 설정되지 않음: 캐시가 있지만 초기화되지 않았습니다. nonce가 설정되지 않았습니다. 즉, 캐시가 무효화된 적이 없습니다.
- 우회: 캐시를 건너뛰기로 의도적으로 결정한 경우입니다.
- 수가 많으면 캐시 사용에 비효율성이 있을 수 있습니다.
무효화:
- 정의: 캐시된 데이터를 오래되었거나 비활성 상태로 표시하는 프로세스입니다.
- 중요도: 시스템이 최신 데이터를 사용하고 있음을 나타내는 신호를 제공하여 오류와 불일치를 방지합니다.
- 일반적으로 데이터를 소유한 서버에서 트리거합니다.
현재 크기:
- 정의: 캐시의 현재 요소 수입니다.
- 중요도: 캐시의 리소스 사용률과 시스템 성능에 미칠 수 있는 영향을 나타냅니다.
- 값이 클수록 일반적으로 캐시에서 더 많은 메모리가 사용됩니다.
최대 크기:
- 정의: 캐시에 할당된 최대 공간입니다.
- 중요도: 캐시의 용량과 데이터를 저장하는 기능을 결정합니다.
- 적절한 최대 크기를 설정하면 캐시 효율성과 메모리 사용량 간의 균형을 유지하는 데 도움이 됩니다. 최대 크기에 도달하면 가장 오래전에 사용된 요소를 제거하여 새 요소가 추가되므로 비효율성을 나타낼 수 있습니다.
최고 수위:
- 정의: 캐시가 생성된 이후 도달한 최대 크기입니다.
- 중요도: 최대 캐시 사용량과 잠재적인 메모리 압력에 관한 유용한 정보를 제공합니다.
- 최대치를 모니터링하면 잠재적인 병목 현상이나 최적화할 영역을 파악하는 데 도움이 됩니다.
오버플로:
- 정의: 캐시가 최대 크기를 초과하여 새 항목을 위한 공간을 확보하기 위해 데이터를 제거해야 했던 횟수입니다.
- 중요도: 데이터 제거로 인한 캐시 압력 및 잠재적인 성능 저하를 나타냅니다.
- 오버플로 수가 많으면 캐시 크기를 조정하거나 캐싱 전략을 재평가해야 할 수 있습니다.
동일한 통계는 버그 신고에서도 확인할 수 있습니다.
캐시 크기 조정
캐시의 최대 크기는 정해져 있습니다. 최대 캐시 크기를 초과하면 항목이 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
서버 쿼리 대신 클라이언트 측 codegen 고려
빌드 시 서버에서 쿼리 결과를 알 수 있는 경우 빌드 시 클라이언트에서도 알 수 있는지 고려하고 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"];
}
}