해당 글은 주관적인 의견이 많이 들어가고, 잘못된 내용이 있을 수도 있습니다 :)
코드는 아래 링크로 첨부해두겠습니다
이번 주제는 서버에서 etag, last-modified 등의 캐시 처리를 지원해주지 않을 때, 안드로이드에서 간단히 처리할 수 있는 방법에 대해 알아보려고 합니다.
*서버에서 캐시 처리를 위한 정보(Cache-Control) 를 잘 내려준다면, OkHttp 를 이용하여 CacheDirectory 만 생성해주면 알아서 처리해주기 때문에 해당 포스트를 읽지 않아도 됩니다.
먼저 안드로이드에서 캐시하면 가장 바로 떠오르는 것중 하나가 Room, sqlite, realm 과 같은 로컬 데이터 베이스를 사용하는 것 입니다.
해당 방식의 처리도 좋지만, Table 과 Entity 를 정의해야 한다는 불편함이 존재 합니다. 또한 응답이 변경되어 Entity 가 바뀌면 이전 데이터를 사용하기 위해 마이그레이션 처리해야할 수도 있습니다. (물론 경우에 따라 fallbacktodestructivemigration 과 같이 테이블을 제거하고 다시 생성할 수도 있습니다) 처리 할 수 는 있지만, 우리는 더 쉬운 방법을 원합니다.
보통 안드로이드에서 네트워크 통신을 사용하는 경우, Retrofit, OkHttp 를 사용하는 케이스가 많습니다(구글 예제에서도 Retrofit 을 사용중) 그리고 OkHttp 에서는 파일 캐시를 지원합니다. 따라서 우리는 OkHttp 에서 제공하는 캐시 기능과 함께 이를 어노테이션으로 사용할 수 있도록 구현해볼 예정입니다.(feat. Coil 에서도 OkHttp 의 Cache 를 이용하였지만, 최근에는 자체 Cache 로 분리되었습니다)
OkHttp 의 CacheControl 클래스에서 noStore, noCache, maxAge, maxStale, minFresh 와 같은 메서드를 제공해줍니다.
각각 메서드의 역할을 간단하게 알아보면
noStore : 어떠한 응답도 캐시에 저장하지 않습니다.
noCache : 네트워크 응답만을 사용하겠다는 의미 입니다.
코드로는 아래와 같이 정의 되어 있습니다.
@JvmField
val FORCE_NETWORK = Builder()
.noCache()
.build()
OkHttp 라이브러리에서는 아래 처럼 정의해서 사용하고 있습니다.
private val CACHE_CONTROL_FORCE_NETWORK_NO_CACHE = CacheControl.Builder().noCache().noStore().build()
private val CACHE_CONTROL_NO_NETWORK_NO_CACHE = CacheControl.Builder().noCache().onlyIfCached().build()
maxAge : 캐시된 데이터의 유효 기간을 정해줍니다.
maxStale : 캐시된 데이터가 만료 이후 사용할 수 있는 기한을 정해줍니다. (보통 age, stale 이 동시에 사용되고, stale 기간이 있어도 이미 만료되어 age 에 의해 데이터를 가져올 수도 있습니다.)
minFresh : 새로운 데이터를 받아오는 최소한의 기간을 정해줍니다. 해당 기간이 넘어가면, 캐시된 데이터는 사용되지 않습니다.
설명이 너무 간단해서 이해가 어려울 수 있어서, 좋은 예시를 링크 걸어두겠습니다
이제 본격적으로 구현에 들어가겠습니다.
1. 먼저 Cache Directory 를 만들어야 합니다.
val cache = Cache(context.cacheDir, (5 * 1024 * 1024).toLong()) // 5MB
val client = builder.cache(cache).build() // 캐시 세팅
return Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL)
.client(client) // 클라이언트 세팅
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
2. OkHttp 에서 제공하는 Interceptor 를 생성 합니다. (Interceptor 에 대해 모른다면 링크를 참고 해주세요)
주의 해야할 점은 우리는 서버 디펜던시 없이 우리만의 정책을 가지고 캐시를 처리하려고 하니, 혹시나 서버에서 Cache-control 이나 pragma 값을 내려준다면 명시적으로 제거해줍니다.
class CacheInterceptor @Inject constructor() : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()
val cacheControl = CacheControl.Builder()
.maxStale(1, TimeUnit.HOURS)
.build()
request = request.newBuilder()
.removeHeader(HEADER_PRAGMA) // PRAGMA 제거
.removeHeader(HEADER_CACHE_CONTROL) // Cache-Control 제거
.cacheControl(cacheControl)
.build()
return chain.proceed(request)
}
companion object {
// https://github.com/square/okhttp/blob/e1d5d5f5206064efebe5ef6842cdfa4b94745805/okhttp/src/commonMain/kotlin/okhttp3/internal/-CacheControlCommon.kt#L142 참고
private const val HEADER_CACHE_CONTROL = "Cache-Control"
private const val HEADER_PRAGMA = "Pragma"
}
}
이로써 구현이 끝났습니다.
앞으로 CacheInterceptor 를 사용하는 Retrofit 의 Client 는 응답을 받고 이후 한시간 까지 Cache Directory 에서 파일 캐시를 사용합니다.
하지만 모든 API 에 해당 처리를 하고 싶지 않고, Annotation 을 활용한다면 사용하는 입장에서는 더욱 편리하니 좀더 보완해보겠습니다.
1. Annotation 클래스를 추가해줍니다. 저는 Cacheable 로 만들었고, 캐시 시간과 단위를 매개변수로 받을 수 있도록 처리하였습니다.
@MustBeDocumented
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class Cacheable(val value: Int, val timeUnit: TimeUnit)
2. Annotation 을 사용해줍니다.
@GET("3/genre/movie/list")
@Cacheable(1, TimeUnit.HOURS)
fun getGenreList(): Call<GenreListDto>
이제 Retrofit 을 사용할 때, Annotation 을 가져올 수 있도록 처리만 하면 됩니다.
다행히 Retrofit 에서는 Invocation 이라는 클래스를 제공하고 있습니다
간략하게 훓어보면 Retrofit service 가 호출되면, 해당 클래스의 Method 와 Argument 를 Capture(읽어오기) 할 수 있다고 하네요.
친절하게 샘플 코드도 제공하고 있습니다.
3. 해당 코드를 참고하여 Annotation 을 읽을 수 있는 테스트 코드를 작성합니다
class CustomAnnotationTest {
@Test(expected = Exception::class)
@Throws(Exception::class)
fun `Cacheable 어노테이션 동작 테스트`() {
val httpLoggingInterceptor =
HttpLoggingInterceptor { message: String? -> Timber.i(message) }
httpLoggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
val client: OkHttpClient = OkHttpClient.Builder()
.addInterceptor(httpLoggingInterceptor)
.addInterceptor(
// Interceptor for UnitTest
Interceptor { chain ->
// Then
val request: Request = chain.request()
assertEquals(GET::class.java.simpleName, request.method)
assertEquals("$BASE_URL/$GENRE_URL", request.url.toString())
val invocation = request.tag(Invocation::class.java)
assertNotNull(invocation)
val annotation: Cacheable? =
requireNotNull(invocation).method().getAnnotation(Cacheable::class.java)
assertNotNull(annotation)
assertEquals(
Cacheable::class.java.simpleName,
requireNotNull(annotation).annotationClass.simpleName
)
assertEquals(annotation.value, 1)
assertEquals(annotation.timeUnit, TimeUnit.HOURS)
throw Exception()
})
.build()
// Given
val json = Json {
isLenient = false
ignoreUnknownKeys = true
coerceInputValues = true
}
// Given
val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.client(client)
.build()
val moveApi: MovieApi = retrofit.create(MovieApi::class.java)
// When
moveApi.getGenreList().execute()
}
companion object {
private const val GENRE_URL = "3/genre/movie/list"
}
}
(다행히 테스트를 통과했습니다)
Robolectric 혹은 UnitTest 가 아닌 AndroidTest 를 이용하면 아래와 같이 좀더 우아하게 처리도 가능합니다.
val requestUrl = Uri.parse(BASE_URL)
.buildUpon()
.appendPath(GENRE_URL)
.build()
.toString()
assertEquals(requestUrl, request.url.toString())
4. 이제 Annotation 을 가져오는 방법을 알았으니, 위의 CacheInterceptor 를 수정해줍니다.
class CacheInterceptor @Inject constructor() : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()
request.tag(Invocation::class.java)?.method()
?.getAnnotation(Cacheable::class.java)
?.let { cacheable: Cacheable ->
val cacheControl = CacheControl.Builder()
.maxStale(cacheable.value, cacheable.timeUnit)
.build()
request = request.newBuilder()
.removeHeader(HEADER_PRAGMA) // PRAGMA 제거
.removeHeader(HEADER_CACHE_CONTROL) // Cache-Control 제거
.cacheControl(cacheControl)
.build()
}
return chain.proceed(request)
}
companion object {
// https://github.com/square/okhttp/blob/e1d5d5f5206064efebe5ef6842cdfa4b94745805/okhttp/src/commonMain/kotlin/okhttp3/internal/-CacheControlCommon.kt#L142 참고
private const val HEADER_CACHE_CONTROL = "Cache-Control"
private const val HEADER_PRAGMA = "Pragma"
}
}
비로소 구현이 끝났습니다.
이제 Cacheable Annotation 을 이용하여 원하는 서비스에 편하게 처리할 수 있습니다 :)
해당 Cache 가 어떤식으로 처리되는지 궁금하다면 OkHttp 의 CacheStrategy 클래스를 보면 됩니다
// No cached response.
// 저장된 캐시가 없는 경우
if (cacheResponse == null) {
return CacheStrategy(request, null)
}
// Drop the cached response if it's missing a required handshake.
// 저장된 캐시의 핸드 쉐이크가 없는 경우
if (request.isHttps && cacheResponse.handshake == null) {
return CacheStrategy(request, null)
}
// If this response shouldn't have been stored, it should never be used as a response source.
// This check should be redundant as long as the persistence store is well-behaved and the
// rules are constant.
// 캐시로 사용할 수 없는 경우, 내부 구현체를 보면 리다이렉트 되었을 때,
// 헤더의 Expires 와 Cache-Control 의 maxAgeSeconds, isPublic, isPrivate 등의 값을 보며 체크 합니다.
// 자세한 부분은 코드를 체크해주세요
if (!isCacheable(cacheResponse, request)) {
return CacheStrategy(request, null)
}
// noCache 를 세팅했거나 If-Modified-Since, If-None-Match 의 값이 Null 이 아닌 경우
val requestCaching = request.cacheControl
if (requestCaching.noCache || hasConditions(request)) {
return CacheStrategy(request, null)
}
위의 4가지 케이스에서는 CacheResponse 를 null 로 반환 합니다. (코드를 쭉 보다보면 null 로 반환 케이스가 또 존재합니다)
이후에는 저장된 캐시의 Cache-Control 을 보며 하나하나 확인하는데 재미있는 점은,
val oneDayMillis = 24 * 60 * 60 * 1000L
if (ageMillis > oneDayMillis && isFreshnessLifetimeHeuristic()) {
builder.addHeader("Warning", "113 HttpURLConnection \"Heuristic expiration\"")
}
하루가 지났지만, 저장된 캐시를 사용하는 경우에는, 휴리스틱(어림짐작)하게 해당 캐시가 만료되었다는 Warning 헤더를 추가해주고 있습니다.
따라서 HttpLoggingInterceptor 에서 Warning 을 볼 수 있습니다.
우리가 작성한 Cacheable Annotation 에 의해 CacheResponse 가 반환되는 부분인 해당 부분입니다.
어떤식으로 마무리 해야할지 모르겠지만,
다음에는 더 유익한 정보를 가져올 수 있도록 하겠습니다.
그럼 20000
해당 코드를 적용한 Repository PR : https://github.com/Nanamare/MovieComposeLaboratory/pull/12
도움이 아주 많이 된 Reference : https://medium.com/swlh/annotation-based-offline-caching-in-retrofit-d7dbd775ac74
'Android > Today I Learned' 카테고리의 다른 글
네트워크 요청 실패했는데, RunCatching onSuccess 가 호출? (0) | 2022.06.23 |
---|---|
주관적인 Compose 사용 후기 (4) | 2022.06.11 |
Material library 1.5.0 로 올리니 크래시가?! (1) | 2022.03.11 |
Compose 버튼 사이즈 관련 Tips (1) | 2022.03.01 |
Compose Navigation - viewmodel 사용할 때 주의할 점 (0) | 2021.12.25 |