안드로이드 앱의 API 응답 캐싱
데이터를 캐시하는 목적은 다양할텐데, 모바일 앱에선 대게 빠르게 화면을 보여주기 위해 이전 응답을 저장해두었다가 보여주는 용도로 사용한다. 안드로이드 앱에서 API 응답을 캐싱하는 방법을 간단히 살펴보자. 캐시는 어디에 정보를 저장하는지, 아키텍처의 어느 계층에서 캐시를 하는지에 따라 세분화 할 수 있다. 저장 위치에 따른 구분으론 앱이 살아있는 동안에만 캐시하는 메모리 캐시와 디스크 캐시로 나뉠 수 있고, 계층에 따른 구분으론 네트워크 캐시와 애플리케이션 캐시로 나뉠 수 있다. 참, 애플리케이션 캐시는 그냥 내가 지어봤다. 나의 결론부터 얘기해보면 다음과 같다. 네트워크 캐시보다는 애플리케이션 캐시를 사용하자. 네트워크 캐시는 용도가 꽤 한정적이다. 디스크 캐시와 메모리 캐시는 용도에 따라 구분하면 되는데, 디스크 캐시의 내용을 다시 메모리 캐시할 필요는 거의 없다. 메모리 캐시할 땐 DTO 객체를 캐시하자. 캐시는 만료 처리를 잘 해야 한다. 이 때 AutoService 등이 도움이 된다. 네트워크 캐시 아마 대부분 Okhttp 를 이용해 api 를 호출하고 응답을 받을 것이다. Okhttp도 Cache 기능이 있고, 이를 잘 활용할 수 있다면 앱 개발자 입장에선 가장 편리하게 캐시의 이점을 누릴 수 있다. 하지만 사용에 제약이 많아 도움이 되는 경우가 적다. Okhttp 캐시는 cache-control 헤더를 기반으로 동작한다. 참조 코드도 나와있는데, 많은 api 응답이 즉시성을 요구하기 때문에 잘 활용하지 않는다. 써 보고 싶다면 백엔드 개발자와 잘 협의를 해서, response 에도 제대로 된 cache header가 설정되어야 한다. 하지만 okhttp의 network interceptor를 이용해 response 의 cache header도 조작할 수 있으므로, 앱만 의지가 있다면 활용할 수 있다. 같은 API라도 언제는 캐시하고, 언제는 캐시하지 않는 등의 조정이 필요하다. 기본적으론 캐시를 사용하는데, 특정 상황에서만 캐시를 사용하지 않도록 cache-control 헤더를 제어하면 된다. retrofit 을 쓴다면 header를 인자로 받던지, 별개의 함수를 만들고, 함수에 커스텀한 처리를 넣어두면 된다. interface BalanceService { // case 1: cache control 헤더를 인자로 받기 @GET(...) suspend fun fetchBalance( @Header("Cache-Control") String cacheControlHeader) : BalanceDto // case 2: custom annotation 등을 이용해 api 를 분리하기 @GET(...) @Cache(true) // 이런 annotation 은 없다. 직접 만들어서 설정하자. suspend fun fetchBalanceWithCache() : BalanceDto @GET(...) @Cache(false) suspend fun fetchBalanceWithoutCache() : BalanceDto } 그런데 여기까지 오면 굳이 이렇게 네트워크 캐시를 써야 하나? 싶은 마음이 든다. 가끔 앱의 전역 설정같이 잘 바뀌지 않는 api 응답 같은 경우엔 활용해봄직 하지만, 전체 api 중에 그런 성격의 api 는 대부분 많지 않을것이라 생각한다. 그 얼마 안되는 경우를 해결하기 위해 이렇게 기술적으로 복잡하게 들어가느니, 차라리 그냥 애플리케이션 캐시로 직접 관리하는 게 편한 경우가 많다. 물론 굉장히 네트워크가 좋지 않은 환경까지 대응한다면 앱 전반적으로 api 캐싱을 고려해야 하니 이런 경우라면 필요하겠으나, 대한민국 사용자를 대상으로 하는 앱이라면 굳이 이렇게까지 복잡하게 만들 필요가 있을까 싶다. 이 네트워크 캐시가 빛을 발할 만한 부분이 있는데, 네트워크 이미지 캐시이다. 대부분의 이미지의 URI는 고정되어 있기 때문에 해당 URI의 내용이 캐싱되어 있으면 잘 hit할 것이라 기대할 수 있다. 하지만 이 역시 많은 이미지 로더들이 자체적인 캐시 처리를 하기 때문에 별로 의미가 없다. coil 문서 를 보면 기본적으로 cache-control 헤더값과 무관하게 모든 응답을 디스크 캐시에 기록한다고 적혀있다. 또한 okhttp의 cache 설정이 아닌, 자체 cache 구현을 활용한다. 따라서 위에서 언급한 네트워크 캐시를 안써도 coil 은 알아서 네트워크 이미지 캐시를 잘 한다. 내 경험 상 네트워크 캐시는 별로 쓸 일이 없었다. 그 이유는 다음과 같다. api 응답 캐시 용도로는 개별 응답의 캐시 컨트롤이 쉽지 않아 사용하기 불편하다. 이미지 캐시 용도로는 이미 이미지 로더가 자체적인 캐시를 만들어뒀기 때문에 안쓴다. 캐시 관리할 대상 api 가 많지 않다면, 애플리케이션 캐시로 직접 관리하는게 프로젝트 유지보수하기 편하다. 알아야 할 내용(네트워크 캐시)이 줄어드니깐. 애플리케이션 캐시 한땀 한땀 손으로 제어하는 애플리케이션 캐시로 가 보자. 데이터를 어디에 저장하는지에 따라 메모리 캐시와 디스크 캐시로 구분할 수 있을텐데, 디스크 캐시 디스크 캐시는 다시 DataStore 를 이용한 파일 저장과 Room 을 이용한 DB 저장으로 구분할 수 있겠다. 예전엔 DB 쓰기가 좀 껄끄러웠는데 이젠 Room 덕분에 DB를 아주 편하게 사용할 수 있다. 나는 대게 갯수를 한정할 수 없는 데이터는 DB에, 한정된 개수의 데이터는 DataStore 에 저장한다. 디스크 캐시의 내용을 다시 메모리에 캐시해야 할까? 난 불필요하다고 생각한다. 왠지 접근할 때 마다 IO가 발생할테니 이를 다시 메모리캐시하는게 좋을 것 같아 보인다. 하지만 DataStore는 자체적으로 읽어온 내용을 메모리에 캐시한다. Room 은 저채적인 메모리 캐시가 없지만, Room 이 접근하는 Sqlite 가 내부에 캐시를 한다. 따라서 괜히 복잡하게 메모리 캐시를 추가할 이유가 없다. 메모리 캐시 가장 빈번하게 활용하는 형태가 api 의 응답을 메모리에 들고 있는 메모리 캐시일 것이다. 메모리 캐시에 뭘 저장할지도 결정을 해야 한다. 크게 3가지 선택지가 있을것이다. json (응답 자체) : 응답으로 받는 json 자체를 저장 dto : json 을 객체로 매핑한 dto 객체를 저장 도메인 객체: dto를 다시 앱 로직에 맞춰 매핑한 도메인 객체를 저장 일단 retrofit 등을 쓴다면 json 을 구경할 일이 없을거라, json은 탈락이다. 그럼 dto 를 저장할건지, 도메인 객체를 저장할 건지 고민이 된다. gemini 등에 물어봐도 이에 대한 완전한 결론은 잘 보이지 않는다. 하지만 앱에서 api 응답을 캐싱할 용도로는 dto 저장이 적합해보인다. 설계 측면에서 메모리 캐시는 api 응답을 담아두는 그릇일 뿐이며, 도메인보다는 데이터 레이어에서 해결할 내용에 가깝다. 도메인 객체를 만들어내기 위해 여러개의 dto 들을 조합해야 하는 경우엔 좀 고민이 되긴 하지만, 그런 경우에도 결국 개별 dto 들을 캐싱한 후 도메인 객체는 매번 새로 만들어내는 게 코드를 관리하기 더 좋다고 생각한다. 대부분 메모리 캐시는 간단한 맵의 형태일텐데, dto 를 저장할 경우엔 캐시 키를 구성하기도 훨씬 편하다. 그냥 api 요청 인자가 키가 되기 때문이다. ChatGPT선생님은 아래와 같이 말씀하신다. 캐시와 네트워크 요청 간 교통정리 대부분의 캐시 활용 유스케이스는 다음과 같다. 캐시를 찔러본다. hit 했으면 캐시로 응답 hit 하지 못했다면 네트워크 요청을 보내고, 응답으로 캐시를 갱신 이 과정을 실제 코딩한다면 어떻게 할지도 생각해볼 거리가 있다. retrofit 을 이용해 구현한 대부분의 안드로이드 앱은 대강 다음 형태로 구현되어 있을것이다. 계좌 잔고를 캐싱하는 예시를 생각해보자. interface BalanceRepository { suspend fun getBalance(type: String) : Balance } class BalanceRepositoryImpl( private val balanceService: BalanceService) : BalanceRepository { override suspend fun getBalance(type: String) = service.fetchBalance(type).toBalance() } interface BalanceService { @GET(...) fun fetchBalance(@Query("type") type: String) : BalanceDto } 여기 메모리 캐시를 집어넣으면 대강 다음과 같은 모습이 될 것이다. class BalanceRepositoryImpl(...) : BalanceRepository { private val balanceCache = mutableMapOf() override suspend fun getBalance(type: String) = balanceCache.getOrPut(type) { service.fetchBalance(type) }.toBalance() } 귿이 이 코드를 더 복잡하게 할 필요는 없어보인다. 캐시 동기화 정도의 개선은 생각해 볼만 하겠다. 디스크 캐시의 경우엔 몇 가지 고민이 더 생긴다. 잔고를 디스크에 넣는게 말이 안되지만 datastore 에 저장하겠다고 가정해보자. 위에 디스크캐시 부분에서 언급했듯이, 디스크캐시 내용을 다시 메모리캐시에 올릴 이유는 없다. datastore 에 shared preference 기반으로 json 전체를 string 으로 저장했다고 가정해본다. 그렴 코드가 좀 더 늘어난다. interface BalanceStore() { ... } class BalanceStoreImpl(private val store: DataStore) : BalanceStore { override fun fetchBala

데이터를 캐시하는 목적은 다양할텐데, 모바일 앱에선 대게 빠르게 화면을 보여주기 위해 이전 응답을 저장해두었다가 보여주는 용도로 사용한다. 안드로이드 앱에서 API 응답을 캐싱하는 방법을 간단히 살펴보자.
캐시는 어디에 정보를 저장하는지, 아키텍처의 어느 계층에서 캐시를 하는지에 따라 세분화 할 수 있다. 저장 위치에 따른 구분으론 앱이 살아있는 동안에만 캐시하는 메모리 캐시와 디스크 캐시로 나뉠 수 있고, 계층에 따른 구분으론 네트워크 캐시와 애플리케이션 캐시로 나뉠 수 있다. 참, 애플리케이션 캐시는 그냥 내가 지어봤다.
나의 결론부터 얘기해보면 다음과 같다.
- 네트워크 캐시보다는 애플리케이션 캐시를 사용하자. 네트워크 캐시는 용도가 꽤 한정적이다.
- 디스크 캐시와 메모리 캐시는 용도에 따라 구분하면 되는데, 디스크 캐시의 내용을 다시 메모리 캐시할 필요는 거의 없다.
- 메모리 캐시할 땐 DTO 객체를 캐시하자.
- 캐시는 만료 처리를 잘 해야 한다. 이 때 AutoService 등이 도움이 된다.
네트워크 캐시
아마 대부분 Okhttp 를 이용해 api 를 호출하고 응답을 받을 것이다. Okhttp도 Cache 기능이 있고, 이를 잘 활용할 수 있다면 앱 개발자 입장에선 가장 편리하게 캐시의 이점을 누릴 수 있다. 하지만 사용에 제약이 많아 도움이 되는 경우가 적다.
Okhttp 캐시는 cache-control
헤더를 기반으로 동작한다. 참조 코드도 나와있는데, 많은 api 응답이 즉시성을 요구하기 때문에 잘 활용하지 않는다. 써 보고 싶다면 백엔드 개발자와 잘 협의를 해서, response 에도 제대로 된 cache header가 설정되어야 한다. 하지만 okhttp의 network interceptor를 이용해 response 의 cache header도 조작할 수 있으므로, 앱만 의지가 있다면 활용할 수 있다.
같은 API라도 언제는 캐시하고, 언제는 캐시하지 않는 등의 조정이 필요하다. 기본적으론 캐시를 사용하는데, 특정 상황에서만 캐시를 사용하지 않도록 cache-control
헤더를 제어하면 된다. retrofit 을 쓴다면 header를 인자로 받던지, 별개의 함수를 만들고, 함수에 커스텀한 처리를 넣어두면 된다.
interface BalanceService {
// case 1: cache control 헤더를 인자로 받기
@GET(...)
suspend fun fetchBalance( @Header("Cache-Control") String cacheControlHeader) : BalanceDto
// case 2: custom annotation 등을 이용해 api 를 분리하기
@GET(...)
@Cache(true) // 이런 annotation 은 없다. 직접 만들어서 설정하자.
suspend fun fetchBalanceWithCache() : BalanceDto
@GET(...)
@Cache(false)
suspend fun fetchBalanceWithoutCache() : BalanceDto
}
그런데 여기까지 오면 굳이 이렇게 네트워크 캐시를 써야 하나? 싶은 마음이 든다. 가끔 앱의 전역 설정같이 잘 바뀌지 않는 api 응답 같은 경우엔 활용해봄직 하지만, 전체 api 중에 그런 성격의 api 는 대부분 많지 않을것이라 생각한다. 그 얼마 안되는 경우를 해결하기 위해 이렇게 기술적으로 복잡하게 들어가느니, 차라리 그냥 애플리케이션 캐시로 직접 관리하는 게 편한 경우가 많다. 물론 굉장히 네트워크가 좋지 않은 환경까지 대응한다면 앱 전반적으로 api 캐싱을 고려해야 하니 이런 경우라면 필요하겠으나, 대한민국 사용자를 대상으로 하는 앱이라면 굳이 이렇게까지 복잡하게 만들 필요가 있을까 싶다.
이 네트워크 캐시가 빛을 발할 만한 부분이 있는데, 네트워크 이미지 캐시이다. 대부분의 이미지의 URI는 고정되어 있기 때문에 해당 URI의 내용이 캐싱되어 있으면 잘 hit할 것이라 기대할 수 있다. 하지만 이 역시 많은 이미지 로더들이 자체적인 캐시 처리를 하기 때문에 별로 의미가 없다. coil 문서 를 보면 기본적으로 cache-control 헤더값과 무관하게 모든 응답을 디스크 캐시에 기록한다고 적혀있다. 또한 okhttp의 cache 설정이 아닌, 자체 cache 구현을 활용한다. 따라서 위에서 언급한 네트워크 캐시를 안써도 coil 은 알아서 네트워크 이미지 캐시를 잘 한다.
내 경험 상 네트워크 캐시는 별로 쓸 일이 없었다. 그 이유는 다음과 같다.
- api 응답 캐시 용도로는 개별 응답의 캐시 컨트롤이 쉽지 않아 사용하기 불편하다.
- 이미지 캐시 용도로는 이미 이미지 로더가 자체적인 캐시를 만들어뒀기 때문에 안쓴다.
- 캐시 관리할 대상 api 가 많지 않다면, 애플리케이션 캐시로 직접 관리하는게 프로젝트 유지보수하기 편하다. 알아야 할 내용(네트워크 캐시)이 줄어드니깐.
애플리케이션 캐시
한땀 한땀 손으로 제어하는 애플리케이션 캐시로 가 보자. 데이터를 어디에 저장하는지에 따라 메모리 캐시와 디스크 캐시로 구분할 수 있을텐데,
디스크 캐시
디스크 캐시는 다시 DataStore 를 이용한 파일 저장과 Room 을 이용한 DB 저장으로 구분할 수 있겠다. 예전엔 DB 쓰기가 좀 껄끄러웠는데 이젠 Room 덕분에 DB를 아주 편하게 사용할 수 있다. 나는 대게 갯수를 한정할 수 없는 데이터는 DB에, 한정된 개수의 데이터는 DataStore 에 저장한다.
디스크 캐시의 내용을 다시 메모리에 캐시해야 할까? 난 불필요하다고 생각한다. 왠지 접근할 때 마다 IO가 발생할테니 이를 다시 메모리캐시하는게 좋을 것 같아 보인다. 하지만 DataStore는 자체적으로 읽어온 내용을 메모리에 캐시한다. Room 은 저채적인 메모리 캐시가 없지만, Room 이 접근하는 Sqlite 가 내부에 캐시를 한다. 따라서 괜히 복잡하게 메모리 캐시를 추가할 이유가 없다.
메모리 캐시
가장 빈번하게 활용하는 형태가 api 의 응답을 메모리에 들고 있는 메모리 캐시일 것이다. 메모리 캐시에 뭘 저장할지도 결정을 해야 한다. 크게 3가지 선택지가 있을것이다.
- json (응답 자체) : 응답으로 받는 json 자체를 저장
- dto : json 을 객체로 매핑한 dto 객체를 저장
- 도메인 객체: dto를 다시 앱 로직에 맞춰 매핑한 도메인 객체를 저장
일단 retrofit 등을 쓴다면 json 을 구경할 일이 없을거라, json은 탈락이다. 그럼 dto 를 저장할건지, 도메인 객체를 저장할 건지 고민이 된다.
gemini 등에 물어봐도 이에 대한 완전한 결론은 잘 보이지 않는다. 하지만 앱에서 api 응답을 캐싱할 용도로는 dto 저장이 적합해보인다. 설계 측면에서 메모리 캐시는 api 응답을 담아두는 그릇일 뿐이며, 도메인보다는 데이터 레이어에서 해결할 내용에 가깝다. 도메인 객체를 만들어내기 위해 여러개의 dto 들을 조합해야 하는 경우엔 좀 고민이 되긴 하지만, 그런 경우에도 결국 개별 dto 들을 캐싱한 후 도메인 객체는 매번 새로 만들어내는 게 코드를 관리하기 더 좋다고 생각한다. 대부분 메모리 캐시는 간단한 맵의 형태일텐데, dto 를 저장할 경우엔 캐시 키를 구성하기도 훨씬 편하다. 그냥 api 요청 인자가 키가 되기 때문이다. ChatGPT선생님은 아래와 같이 말씀하신다.
캐시와 네트워크 요청 간 교통정리
대부분의 캐시 활용 유스케이스는 다음과 같다.
- 캐시를 찔러본다.
- hit 했으면 캐시로 응답
- hit 하지 못했다면 네트워크 요청을 보내고, 응답으로 캐시를 갱신
이 과정을 실제 코딩한다면 어떻게 할지도 생각해볼 거리가 있다. retrofit 을 이용해 구현한 대부분의 안드로이드 앱은 대강 다음 형태로 구현되어 있을것이다. 계좌 잔고를 캐싱하는 예시를 생각해보자.
interface BalanceRepository {
suspend fun getBalance(type: String) : Balance
}
class BalanceRepositoryImpl( private val balanceService: BalanceService) : BalanceRepository {
override suspend fun getBalance(type: String) = service.fetchBalance(type).toBalance()
}
interface BalanceService {
@GET(...)
fun fetchBalance(@Query("type") type: String) : BalanceDto
}
여기 메모리 캐시를 집어넣으면 대강 다음과 같은 모습이 될 것이다.
class BalanceRepositoryImpl(...) : BalanceRepository {
private val balanceCache = mutableMapOf<String,BalanceDto>()
override suspend fun getBalance(type: String) = balanceCache.getOrPut(type) {
service.fetchBalance(type)
}.toBalance()
}
귿이 이 코드를 더 복잡하게 할 필요는 없어보인다. 캐시 동기화 정도의 개선은 생각해 볼만 하겠다.
디스크 캐시의 경우엔 몇 가지 고민이 더 생긴다. 잔고를 디스크에 넣는게 말이 안되지만 datastore 에 저장하겠다고 가정해보자. 위에 디스크캐시 부분에서 언급했듯이, 디스크캐시 내용을 다시 메모리캐시에 올릴 이유는 없다. datastore 에 shared preference 기반으로 json 전체를 string 으로 저장했다고 가정해본다. 그렴 코드가 좀 더 늘어난다.
interface BalanceStore() {
...
}
class BalanceStoreImpl(private val store: DataStore<Preferences>) : BalanceStore {
override fun fetchBalance( type: String) : Flow<BalanceDto?> = store.data.map { it[type]?.toDto() }
override suspend fun updateBalance( type: String, dto: BalanceDto) = store.edit {
it[type] = dto.toJson()
}
}
class BanalceRepositoryImpl( val balanceStore: BalanceStore, val balanaceService: BalanceService) : BanalceRepository {
override suspend fun getBalance(type: String) : Balance {
// 실제론 코드 더 잘 짜야함!
return runBlocking { balanceStore.fetchBalance(type).first() } ?: {
val balance = baservice.fetchBalance(type)
balanceStore.updateBalance( type, balance)
balance
}
}
}
이름을 좀 더 추상화해서 BalanceRemoteDataSource
, BalanceLocalDataSource
등으로 만들 수도 있겠다. 그런데 앱 전체적으로 데이터 캐싱하는 대상은 일부분일텐데, RemoteDataSource
가 등장하는 순간, LocalDataSource
가 없는 대부분의 api 들도 다 RemoteDataSource
라고 이름붙여야 할까 고민이 된다. 이건 득실을 따져봐야겠지만, 나는 괜히 이름만 길어지는 것 같아 굳이 그렇게 까지 만든 필요는 없다고 생각한다.
캐시 만료/ 무시 처리
캐시를 만드는 순간, 만료 혹은 무시(하고 네트워크 요청) 처리를 신경 써 줘야 한다. 두 기능이 다 필요한 경우도 있겠지만, 대부분은 둘 중 하나의 기능만 필요했다. 예를 들어 로그인한 사용자의 user id 는 대게 만료만 필요하고 (로그아웃), 계좌 잔액은 무시만 필요하다.
무시는 아직까지 repository 함수에 forceUpdate 정도의 인자를 받는 것 외에 더 깔끔한 방법은 모르겠다. 메모리 캐시에 무시 처리를 한다면 이렇게 만들 수 있겠다.
class BalanceRepositoryImpl(...) : BalanceRepository {
private val balanceCache = mutableMapOf<String,BalanceDto>
// 실무에선 이렇게 암호같이 짜지 말자
override suspend fun getBalance(type: String, forceRefresh: Boolean) =
{
balanceCache[type]?.takeUnless { forceRefresh } ?: {
service.fetchBalance(type).also { balanceCache[type] = it }
}
}.toBalance()
}
만료는 좀 더 복잡하다. 예를 들어 사용자가 로그아웃 할 때 모든 사용자 id 캐시를 디스크/ 메모리에서 삭제해야 한다면 어떻게 하면 좋을까? 이 경우 한군데라도 만료를 빼 먹으면 꽤 골치아픈 버그가 생긴다. 현재 내가 생각하는 가장 좋은 구현은 AutoService 를 이용해 특정 애너테이션이 붙은 클래스들을 ServiceLoader 로 수집해와서, 이들에게 만료 로직을 호출하는 방식이다. 대강 이런 식이 될 것이다.
interface UserIdAware {
fun expireUserId()
}
@AutoService(UserIdAware::class.java)
class UserIdCache() : UserIdAware {
var userId: String? = null
fun expireUserId() {
userId = null
}
}
class LogoutUsecase( val userIdAwares: List<UserIdAware>) {
operator fun invoke() {
...
userIdAwares.forEach { it.expireUserId() }
}
}
정리
안드로이드의 API 응답 캐싱에서, 일관적으로 모든 api 에 캐싱을 적용하겠다면 네트워크 캐싱을 적용해야겠지만, 일부 api 만 캐싱한다면 애플리케이션 레벨에서 캐싱을 하는 게 유지보수하기 더 좋다고 생각한다. 메모리캐시를 사용할 경우엔 DTO 객체를 저장하는 걸 고려해보자. 캐시 만료를 잘 하기 위해선 AutoService 등의 활용도 고민해보자.