실제 업무에서 발생했던 이벤트를 nowinandroid 프로젝트에 비유해서 표현하였습니다.
tl;dr
- 이미지 로더로 Coil 을 사용한다면, respectCacheHeaders 의 값을 서버 환경에 맞게 잘 설정해줘야 합니다.
- 잘못될 경우, Disk 에 저장만하고, 사용하지 않고 있을 수도 있습니다.
- Glide 에 OkHttp 를 integration 해서 사용한다면 같은 문제가 발생할 수 있습니다.
동료가 Coil 을 이용해서 개발중, 이미지가 너무 늦게 보이는 것 같다는 이야기하였고 실제로 URL 형식의 이미지를 로드하는데, 특정 이미지의 경우 Disk Hit 이 되지 않고, 항상 Network Hit 이 되는 경우가 있었습니다. 다만 Glide 는 Disk Hit 이 잘 되고 있었습니다.
(뒤에서 이야기 하겠지만, Glide 도 특정 상황에서 발생할 수 있습니다.)
먼저 Coil 의 기본 캐시 저장소에 원하는 데이터가 저장 되어 있는지 체크 하였고, 실제로 원하는 데이터가 잘 들어있음을 확인했습니다.
혹시나 이미지 URL 이 동적으로 변경하는 것은 아닌가 싶어서, image key(url hash) 도 확인해서 변경이 되지 않고 있는 부분도 확인했습니다.
결과적으로 Coil 의 구현체를 확인해야 해결될 것 같아서 빠르게 HttpUriFetcher.kt(최신 버전에서는 NetworkFetcher 로 Rename 되었습니다) 로 이동하였습니다.
코드를 디버깅 하며 문제를 해결하였는데, 알게된 부분은 아래와 같습니다.
1. Coil 은 OkHttp 를 내장해서 지원하고 있습니다. 따라서 HTTP cache header 도 라이브러리 내에서 지원하고 있습니다.
2. Glide 는 HTTP cache header 을 지원하지 않습니다. (다만 OkHttp 를 integration 해서 사용할 수 있도록 제공합니다.)
그리고 이 차이에서 위의 문제가 발생했습니다.
Coil 에서는 이미지를 로드할 때 사용하는 옵션을 제공하는 ImageLoaderOptions 클래스를 제공하는데, respectCacheHeaders 라는 속성이 제공되고 기본값이 true 로 설정되어 있습니다. Client, Server 사이의 요청, 응답 사이에 HTTP cache header 와 관련된 정보가 존재하면 이를 존중하겠다는 의미 입니다. (존중 하겠다는 의미는 Disk 에 이미지가 있어도, Request 를 서버로 요청해서 서버에서의 이미지 변경 상태를 보겠다 라는 의미이고 즉 "새로운 요청을 보내겠다"는 의미입니다.)
관련 코드를 보면, respectCacheHeaders 가 true 인 경우 newRequest(새로운 요청) 과 CacheResponse 을 서로 Compute (비교 및 계산) 해서 유효한 경우에만 Disk Cache 를 사용하고, 아니라면 이후에 Slow path 인 network 로 요청을 하게 됩니다. false 인 경우는 cache header 를 보지 않고, 항상 Disk 에서 가져오게 됩니다.
결국 문제는 cache header 로 인해 발생하고 있는 것으로 좁혀졌습니다.
위에서 봤던 newRequest 와 CacheResponse 를 compute 하는 부분에서 차이가 발생했는데, 코드가 조금 길어서 링크로 먼저 첨부해둡니다. Cache 처리가 되기 위해서는 아래의 if 문을 통과해야하는데, minFreshMillis, freshMillis 와 maxStaleMillis 의 값이 서버에서 내려오지 않았고, 결과적으로 0L 값이 세팅되면서 통과히지 못하고 있었습니다.
if (!responseCaching.noCache && ageMillis + minFreshMillis < freshMillis + maxStaleMillis) {
return CacheStrategy(null, cacheResponse)
}
각 용어에 대한 간략한 설명은 아래 적어둡니다.
- ageMillis:
- 캐시된 응답이 생성된 후 경과한 시간(밀리초).
- minFreshMillis:
- 응답이 최소 이 기간 동안 신선해야 함을 요구하는 시간(밀리초).
- freshMillis:
- 캐시된 응답의 유효 신선도 기간(밀리초).
- maxStaleMillis:
- 클라이언트가 허용하는 최대 오래된 기간(밀리초).
반면에 잘 동작했던 URL 은 max-age 값이 내려왔고, freshMillis 에 세팅되면서 if 문이 true 가 되어 원하는 조건이 만들어져 Disk Cache 를 사용하고 있었습니다.
if (requestCaching.maxAgeSeconds != -1) {
freshMillis = minOf(freshMillis, SECONDS.toMillis(requestCaching.maxAgeSeconds.toLong()))
}
문제가 된 Flow 는 아래와 같습니다.
- Client 가 Server 에 이미지 첫 요청
- Server 가 Client 에게 200 응답과 함께 body 에 이미지 내려줌(이때 Client 는 Disk 에 Write)
- 이후 Client 가 같은 이미지를 로드한다면, respectCacheHeaders 값이 true 이기 때문에 이전에 받았던 etag 를 header 에 넣어서 Server 로 요청
- Server 에서 etag 를 보고 변경이 없기 때문에 304 Not Modified 상태 코드와 함께 Body 는 Empty 로 내려줌(변경된 부분이 없기 때문에 Client 것을 써도 된다는 의미이고 이미지를 다운받지 않기 때문에 해당 시간만큼 절약하기를 희망)
- Client 가 이전에 저장한 CacheResponse 에 Cache 값(ageMillis, minFreshMillis, freshMillis, maxStaleMillis)들을 비교 하지만 서버에서 max-age 를 함께 주지 않아서 Cache 의 결과는 항상 invalid 한 상태를 반환
- Client 는 캐시가 만료되었다고 판단하고 다시 새로운 요청으로 이미지를 그려냄
위에서 이야기 했지만 정상동작(Disk Hit 하는 경우)에는 304 Not Modified 에서 max-age, max-stale, expired 등의 값을 Server 에서 내려줬고 이를 Client 가 활용할 수 있었습니다.
해당 문제를 수정하면서, 대략적으로만 알고 있었던 Http Caching 에 대해 좀더 부분적으로 이해할 수 있었습니다.
그럼 좋은 주말 보내시고 20000
참고 자료
https://datatracker.ietf.org/doc/rfc9111/
https://datatracker.ietf.org/doc/html/rfc7234
'Android > Today I Learned' 카테고리의 다른 글
findViewTreeLifecycleOwner == null 인 경우? (0) | 2022.09.09 |
---|---|
네트워크 요청 실패했는데, RunCatching onSuccess 가 호출? (0) | 2022.06.23 |
주관적인 Compose 사용 후기 (4) | 2022.06.11 |
서버 디펜던시 없이 네트워크 작업 캐시 구현하기(feat.OkHttp) (0) | 2022.04.06 |
Material library 1.5.0 로 올리니 크래시가?! (1) | 2022.03.11 |