메모리 캐싱에 대한 내용을 작성하고 거의 반년만에 쓰는 이미지 캐싱에 대한 내용입니다.. :) 틀린 내용이 있을 수도 있으니 언제든 피드백 주세요!
[Swift] iOS의 메모리 캐시 NSCache | NSMutableDictionary객체와의 차이점, NSCopying, 깊은복사/얕은복사
캐싱에 대한 내용을 늦게 다시 쓰는 만큼 디스크 캐싱 & Etag기반으로 서버와 리소스 동기화 체크 & instruments로 로딩 속도 분석에 내용까지 함께 작성해보려 한다. 이번을 계기로 OSLog의 os_signpost를 사용해서 instruments를 통해 성능을 테스트해 보았고 실제 이미지 로드 속도가 얼마나 나아질 수 있는지에 대한 실질적인 지표를 보아서 의미 있었다.
그리고 앞으로는 이렇게 정량적 지표를 근거로 성능 개선에 대한 코드를 짜야겠다는 생각을 하게 되었네요 ☺️
📍 ETag기반 이미지 캐싱을 통해 달성하고 싶은 것
- 네트워크 부하 감소 : remoteDB에 저장된 이미지 데이터를 불러오는 네트워킹에 대한 리소스 사용을 최소화
- 로드 시간 단축 : 결과적으로 이미지 로드 시간을 단축해서 사용자 경험 개선
- Etag : ETag 기반의 이미지 캐싱 기능을 사용해서 같은 주소의 이미지가 변동된 것을 서버와 동기화
📍 Etag는 왜 필요?
ETag는 Entity Tag 로서 리소스를 식별하는 식별자. 서버상에서 이미지 즉, 리소스가 바뀌었다면 클라이언트에서 캐싱해 놓은 이미지 데이터가 아니기 때문에 서버에서 다시 데이터를 받아서 로드시켜 줘야겠죠?
만약 서버에서 이미지가 바뀔 때 ETag가 아닌 url자체가 바뀐다면 ETag를 고려해주지 않아도 된다는 뜻일 것이다. 그러니 그냥 바뀐 url로 다시 이미지 데이터 받아서 띄워주고 캐싱해 놓으면 된다. 근데! 서버에서 Etag를 지원하는데( 이미지가 바뀌었을 때 url이 바뀌는 게 아니라 etag가 바뀌는 경우) ETag를 고려하지 않고 캐싱을 구현한다면 클라이언트에서 캐싱해 놓은 이전 이미지를 띄우기 때문에 변경된 이미지가 반영이 안되는 불상사가 발생하고, 디스크에 캐싱이라도 해놓았다면 앱을 삭제하기 전까지 캐싱해놓은 이전 이미지를 활용하기 때문에 사용자 경험에 크게 문제가 된다.
📍 이미지 캐시 매니저를 직접 구현
▶️ 이미지 캐시 매니저를 직접 구현해 준 이유
흔히 쓰이는 swift의 이미지 관리 라이브러리인 Kingfisher에도 캐싱에 대한 기능이 최적화되어 있지만, 서버의 이미지가 바뀌었는가의 여부에 따라 이미지를 로드해 주는 Etag에 대한 지원은 해주지 않는 것으로 보였다. 커뮤니티 상에서도 etag를 고려해주어야 하는 경우에는 .forchRefresh와 같은 options를 따로 지정해서 캐싱을 하지 않음으로써 우회적으로 해결하는 방법이 올라와 있었다. 그리고 304 코드에 대한 처리는 라이브러리 패키지에서 따로 보지 못했기 때문..!
참고 : https://github.com/onevcat/Kingfisher/discussions/1632#discussioncomment-10556439 / https://swiftpackageindex.com/onevcat/kingfisher/master/documentation/kingfisher/commontasks_cache#Skipping-cache-searching-force-downloading-image-again
그래서 ETag를 비교해서 이미지가 변경되었을 때(statusCode 200)에만 서버에서 data 받고, 이미지가 변경되지 않아 서버와 동일할 때(statusCode 304)에는 기존에 캐싱해 놨던 데이터를 반환하도록 구현해 보았다.
▶️ 이미지 캐싱 플로우
메모리 캐시 조회 -> 디스크 캐시 조회
-> 메모리 혹은 디스크 이 중에 저장해 놓은 데이터(etag, 이미지 url 등)가 있다면 헤더에 'If-None-Match'를 넣어서 서버에 요청
-> 304에러가 날 경우는 Not Modified 즉, 리소스에 변경이 일어나지 않았다는 의미이므로 기존에 캐싱해 둔 데이터 리턴 / 200일 경우에는 새로 온 데이터를 리턴
▶️ 이미지 캐시 매니저 코드
아직 구조적으로는 수정이 필요한 코드여서 부끄럽지만 실제 프로젝트에서 사용했던 예시가 될 수 있는 코드는 여기서 보실 수 있습니다!
📍 성능 테스트를 위한 기반
이렇게 구현하였다면 로드 시간이 실제적으로 줄어들었는지를 확인해야 한다. OSLog 프레임워크와 os_signpost api, 그리고 xcode 측정 도구인 instrument를 사용해 보았다.
▶️ 성능 테스트를 위해 구현한 뷰
연습용 api 로 많이 쓰이는 포켓몬 오픈 api를 사용했다.
▶️ 목데이터로 사용한 이미지 리스트
다행히 테스트용으로 사용하려는 포켓몬 이미지 url에 대해 etag를 지원해 주어서 테스트해 볼 수 있었다.
포켓몬 이미지 url 리스트
let pokemonImageUrlList = [
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/1.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/2.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/3.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/4.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/5.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/6.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/8.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/10.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/11.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/12.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/13.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/14.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/15.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/16.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/17.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/18.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/19.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/20.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/21.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/22.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/23.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/24.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/25.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/26.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/27.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/28.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/29.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/30.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/31.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/32.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/33.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/34.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/35.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/36.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/37.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/38.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/39.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/40.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/41.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/42.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/43.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/44.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/45.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/46.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/47.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/48.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/49.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/50.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/51.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/52.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/53.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/54.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/55.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/56.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/57.png",
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/58.png",
]
▶️ BasicAsyncImage
SwiftUI에서 제공하는 AsyncImage 사용 -> 항상 네트워킹을 통해 로드
import SwiftUI
struct BaisicAsyncImage: View {
var url : String?
var width : CGFloat = 80
var height : CGFloat = 80
var radius : CGFloat = 4
var body: some View {
VStack {
if let url {
AsyncImage(url: URL(string: url)){ phase in
if let image = phase.image {
let _ = logEnd()
image // Displays the loaded image.
.resizable()
} else if phase.error != nil {
defaultContent // Indicates an error.
} else {
ProgressView() // Acts as a placeholder.
}
}
} else {
defaultContent
}
}
.frame(width : width, height : height)
.background(.gray)
.cornerRadius(radius)
.scaledToFit()
}
private var defaultContent : some View {
Color.gray
.overlay {
Image(systemName: "photo")
.foregroundStyle(.white)
}
}
}
▶️ CacheAsyncImage
구현한 ImageCacheManager를 사용해서 이미지 로드
import SwiftUI
struct CacheAsyncImage: View {
var url : String?
var width : CGFloat = 80
var height : CGFloat = 80
var radius : CGFloat = 4
@StateObject var vm = CacheAsyncImageViewModel()
var body: some View {
VStack {
if let imageData = vm.imageData, let uiImage = UIImage(data: imageData) {
let image = Image(uiImage: uiImage)
image
.resizable()
}else {
defaultContent
}
}
.frame(width : width, height : height)
.background(.gray)
.cornerRadius(radius)
.scaledToFit()
.onAppear{
vm.loadImage(url: url ?? "")
}
}
private var defaultContent : some View {
Color.gray
.overlay {
Image(systemName: "photo")
.foregroundStyle(.white)
}
}
}
final class CacheAsyncImageViewModel : ObservableObject {
private var cancellables = Set<AnyCancellable>()
@Published var imageData : Data?
func loadImage (url : String) {
ImageCacheManager.shared.getImageData(urlString: url)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] completion in
print("📍---completion---", completion)
guard let self else { return }
switch completion {
case .failure:
self.imageData = nil
case .finished:
break
}
}, receiveValue: { [weak self]value in
guard let self ,let value else {return }
print("📍---value---", value)
self.imageData = value
})
.store(in: &cancellables)
}
}
📍 성능테스트
▶️ 성능 테스트 조건
- 공정한 평가를 위해 앱 삭제 및 재설치 후 측정
- 캐싱 전 + 후에 대한 이미지 로드 시간의 '평균'을 기준으로 함
▶️ OSLog & os_signpost
각 이미지의 이미지 로드 시간이 얼마나 걸리는지 로그를 기록하기 위한 코드! OSLog의 category로 관련 작업을 그룹화하고,os_signpost를 통해 개발자가 코드의 특정 지점을 로그 항목으로 표시할 수 있다.
logStart와 logEnd 메서드를 각각 아래처럼 정의해 놓고 BasicAsyncImage, CacheAsyncImage 각각에 이미지 로드를 시작하는 시점, 이미지 데이터를 받아서 로드가 끝나는 시점 실행시켜 준다.
let logger = OSLog(subsystem: "com.example.ImageCacheTest", category: "Performance")
func logStart() {
os_signpost(.begin, log: logger, name: "Image Load", "Start loading image")
}
func logEnd() {
os_signpost(.end, log: logger, name: "Image Load", "Finished loading image")
}
▶️ Instruments
instrument 사용해서 os_signpost가 어떻게 기록되었는지 보자
이렇게 세팅해 놓은 상태에서 레코딩 시작
▶️ BasicAsyncImage에서의 로그
평균 로드 시간 약 872ms
▶️ CacheAsyncImage에서의 로그
평균 로드 시간 약 201ms
logStart 지점을 커스텀 이미지 뷰(BasicAsyncImage, CacheAsyncImage) 구조체의 init 시점으로도 해보고, onAppear 시점으로도 해보았는데 로드 시간에 4배 정도 차이가 난다는 점은 동일했다.
처음엔 어차피 etag를 확인하기 위해 네트워킹을 매번 해야 하니까 매번 이미지 데이터 가져오는 거랑 똑같이 리소드 들지 않음?? 이라는 의문이 있었다.
하지만, 304 Not Modified 상태 코드를 받을 경우 data는 들어오지 않음 (0byte)
→ 이 경우에는 Header만 들어오기 때문에 200으로 데이터를 받아올 때보다 가벼운 작업(response 축소)
→ 서버 부하 감소 / 네트워킹 응답 처리 감소
→ 결과적으로는 이미지 로드 시간을 줄여줄 수 있음 (평균 이미지 로드 시간 감소)
캐싱을 구현해서 실제로 이미지 로드 시간을 단축시켜 줌으로써 사용자 경험에 이점이 될 수 있다는 것을 실감했고, 다만, 캐싱을 해서 메모리를 차지하는 용량이 늘어나게 되면 사용자 경험에 해가 될 수 있는 부분이 생기기 때문에 LRU(Least Recently Used), LFU(Least Frequently Used) 같은 정책들을 사용해서 용량 관리를 하는 것이 좋다. 이 부분에 대해서는 기회가 될 때 다시 한번 글을 작성해 보는 걸로!
'iOS' 카테고리의 다른 글
Github Actions를 통한 다국어 자동화 (2) | Submodule 최신화, Slack Webhook (2) | 2025.02.24 |
---|---|
GitHub Actions를 통한 다국어 자동화 (1) | Git Submodule, Lokalise (0) | 2025.02.18 |
[iOS] App Thinning 앱 씨닝과 Slicing, On-Demand Resource, Bitcode (4) | 2024.06.08 |
[iOS] image asset의 크기 1x ,2x, 3x 사용하는 이유 | scale factor, 해상도(Resolution), pixel, point (0) | 2024.05.22 |
[iOS] 앱의 시작지점 @main (1) | 2024.02.11 |