본문 바로가기

iOS공통

[Swift] 이미지 캐싱을 통한 로딩속도 최적화

 

안녕하세요

 

최근 프로젝트에 적용한 이미지 캐싱방법에 대해 회고해 보려고합니다.

 

우선 문제는 다음과 같았습니다.

 

크기가 큰 이미지의 경우 로딩속도가 생각보다 오래걸렸습니다.

 

화면상에 존재하는 이미지는 하나뿐이지만,

 

사용자가 리스트 형태로 주어지는 경우 반복적인 로딩은 유저경험을 크게 저하시킬 것이 분명했습니다.

 

따라서 이미지를 캐싱하기로 했습니다.

 

 

아래영상은 이미지 로딩 속도입니다.

 

이미지 로딩 시간(대략 5초)

 

KingFisher를 사용하지 않은 이유

 

이미지 캐싱을 위해 KingFisher라이브러리를 많이 사용합니다. 

 

 

현재 개발중인 서비스는 iOS와 AOS에서 모두 동작합니다.

 

 

따라서 두 플랫폼에서 업로드한 이미지를 플랫폼에 상관없이 보여주기위해 파일 포맷을 통일하기로 했습니다.

(iOS에 가장 최적화된 형식인 HEIC타입의 경우 안드로이드에서는 사용불가하다.)

 

 

PNG타입의 경우 두 플랫폼에서 모두 사용가능하지만, 이미지 파일 용량이 다소 큰 편입니다.

(사진 한장이 20MB를 초과하는 일이 잦았다.)

 

S3에 업로드된 이미지

 

따라서 가장 범용적으로 사용되지만 용량이 PNG보다는 낮은 JPEG를 선택하고 했습니다.

 

 

그러던 중 압축시 화질손실이 JPEG보다 적지만 더 높은 압축률을 보이는 WebP형식을 발견하였습니다.

 

 

따라서 WebP파일을 사용하기로 결정했습니다. 하지만 iOS에서는 WebP파일을 형식을 지원하지 않았습니다.

 

 

따라서 써드파티를 사용하여 Data -> UIImage전환을 따로 관리해야 했습니다.

 

 

해당과정을 직접 관리하기 위해 이미지 캐싱 객체를 직접만들었습니다.

 

(나중에 알게된 사실이지만, KingFisher에 WebP를 지원하는 라이브러리가 존재합니다.)

 

 

 

캐싱 플로우

 

캐싱은 총 2계층에서 발생합니다. 디스크 캐싱과 메모리 케싱입니다.

 

캐싱 플로우

 

 

메모리 캐싱 NSCache

 

메모리 캐싱의 경우 NSCache를 사용했습니다.

 

NSCache는 메모리 해제를 자동으로 해준다는 장점이 있습니다.

 

홀드할 수 있는 최대 객체의수, 자동해제를 시작하는 객체의 수

 

 

NSCache의 경우 Key와 Value타입으로 모두 class타입을 사용해야 합니다.

 

해당 방식이 다소 의아했지만 메모리관리상 참조타입이 이점이 있어 그렇다고 합니다.

 

예를들어 Value가 외부에서 더이상 참조되지 않는 경우 삭제대상이 되는 구조입니다.

 

즉, 메모리를 적시에 해제함으로써 효율을 올리기 위해서 입니다.

 

 

메모리 캐싱 코드

 

 

먼저, NSCache타입을 정의합니다. Key타입으로 NSString을 사용한 이유는 이미지 url을 식별자로 사용하기 위함입니다.

 

private let imageMemoryCache: NSCache<NSString, UIImage> = .init()

 

엥? 그러면 그냥 String을 쓰면되잖아?

 

 

앞서 말씀드렸듯, NSCache의 경우 Key타입이 class여야합니다.

 

 

위 이유도 있지만 NSCache의 경우 참조 비교로 키값을 찾음으로 클래스타입만 사용해서는 원하는 동작을 이끌어낼 수 없습니다.

 

 

NSString의 경우 놀랍게도 생성시 전달한 문자열이 같은 경우 다수의 공간에서 같은 인스턴스를 공유하는 방식으로 최적화 되어 있습니다.

 

 

따라서 NSString이 NSCache에 Key값으로 유효한 상태라면 String문자열로 접근이 가능합니다.

 

 

아래 코드는 메모리 캐싱정보를 확인하는 코드입니다.

 

 

URL을 사용하여 Key로 사용하는 것을 확인할 수 있습니다.

 

메모리 캐싱 정보를 화인하는 코드

 

 

LRU 방식을 사용한 캐시 정보 추적

 

코드를 보시면 updateLastReadTime함수를 확인할 수 있습니다.

 

 

해당 함수는 디스크 캐싱을 위한 장치입니다.

 

 

디스크 캐싱의 경우 NSCache처러 자동관리가 이루어지지 않기때문에

 

 

특정 파일수를 초과할 경우 수동으로 매모리를 해제해야 합니다.

 

 

이 방식을 LRU방식을 사용하여 구현하였으며, 캐시가 히트할 때마다 시간을 업데이트 하는 방식으로 동작합니다.

 

 

특정 캐시에대한 접근 시간 저장은 UserDefaults가 담당합니다.

 

 

캐싱장보는 ImageCacheInfo타입으로 저장됩니다. WebP로 저장된 경우 추가적인 처리가 필요함으로 파일포맷도 함께 저장합니다.

ImageCacheInfo

 

 

해당 객체들은 URL을 키로한 딕셔너리에 저장되며 열람할 때 ImageCacheInfo로 디코딩 됩니다.

(디코딩시간은 체감할 수 없을 정도로 빨랐다.)

캐싱 정보 불러오기

 

 

불러온 딕셔너리를 업데이트(최신 시간으로)하고 다시 디스크에 저장합니다.

updateLastReadTime함수

 

 

디스크 캐싱

 

UserDefaults의 경우 크기가 큰 파일이 아닌 작은 파일을 위한 용도로 주로 사용됨으로

 

 

디스크 캐싱 방법으로 FileManager를 사용했습니다.

 

 

디스크에 존재하는 이미지 파일을 확인하기 위해

 

 

아래의 함수를 사용하여 url을 기반으로 이미지 파일의 경로를 생성합니다.

 

이미지 파일 경로 생성

 

파일 시스템의 경우 앱마다 목적에 맞는 디렉토리가 이미 존재하고 있습니다. 

 

 

따라서 이미 생성되어있는 Cache디렉토리에 Image디렉토리를 추가하고

 

 

해당 경로에 url을 기반으로 파일이름을 설정하고 생성하였습니다.

(url의 경우 파일이름이 될 수 없는 특수문자를 포함할 수 있어 검열이 필요)

 

 

디스크 캐싱의 경우도 메모리 캐싱과 마찬가지로 캐싱 접근 시간을 업데이트 합니다.

 

여기에 추가로 디스크 캐싱 정보를 메모리 캐시에 올리는 작업이 추가됩니다.

 

디스크 캐싱확인

 

 

디스크에도 캐싱정보가 없는 경우

 

이미지를 바탕으로 데이터를 다운로드 합니다.

 

그 후 순차적으로 디스크 캐싱, 캐싱정보를 업데이트, 그리고 메모리 캐싱을 업데이트 합니다.

 

이미지 다운로드

 

 

LRU 방법을 사용한 디스크 캐싱 관리

디스크에 캐싱된 파일수가 50개를 초과하는 경우 가장 접근률이 떨어지는 상위 10개의파일을 삭제하는 매커니즘입니다.

 

이미지 디스크 저장 함수

 

 

 

 

 

이미지 다운로드를 비동기로 처리하기위해 해당 함수의 반환형을 RxSwift.Single로 지정하였습니다.

Single

 

 

Data를 이미지로

 

WebP형식의 경우 아래와 같이 예외처리를 해주었습니다.

 

UIImage의 경우 JPEG, PNG, GIF등 형식에 대해서 UIImage변환을 지원합니다. 

(해당 프로젝트의 경우 위 4가지 형식만을 업로드 가능타입으로 지정)

createUIImage함수

 

 

 

파일매니저 동시성 문제해결

 

NSCache와 UserDefualts의 경우 내부적으로 Thread-Safe가 보장됩니다.

 

 

하지만 파일매니저의 경우 보장되지 않습니다.

 

 

만약 하나의 화면에 다수의 이미지가 존재할 경우 동시성 문제가 발생할 수 있다고 생각했습니다.

 

 

파일매니저에 접근하는 코드를 직렬 큐에서 관리하기위해 각과정을 세분화 하였습니다.

 

 

중간에 이미지를 다운로드하는 행위에 대해서만 동시성 스케쥴러가 적용되고

 

 

파일매니저 접근 코드에 대해선 직렬 스케쥴러를 사용하도록 설정했습니다.

 

 

(RxSwift의 ObserveOn SubscribeOn에 대해 궁금하다면 이 글을 확인해 주세요!)

 

세분화된 과정

 

 

결과

결과적으로 이미지 캐싱을 통해 로딩속도에 의한 유저경험을 개선하였습니다.

 

한번 로딩된 경우 사실상 로딩이 느껴지지 않습니다.

 

메모리 캐싱의 경우 깜빡이지도 않는 것을 확인할 수 있습니다.

 

 

이미지 로딩속도 케싱시 체감못할 수준

 

회고

구현과정에서 3가지 저장소를 사용해야 했습니다.

 

메모리 캐싱을 위해 NSCache

 

디스크 캐싱을 위해 FileManager

 

캐싱정보 저장을 위해 UserDefualts

 

캐싱정보를 저장하는 UserDefulats의 경우 커스텀타입을 인코딩하여 저장하고 열람하는 경우 다시 디코딩을 진행해야 합니다.

 

CoreData(iOS 로컬 DB)를 사용하면 엔티티를 곧바로 활용함으로써

 

해당과정이 불필요하다고 생각합니다.

 

디스크 캐싱과 캐싱정보 저장소를 통일할 수 있다는 장점역시 가질 수 있었습니다.

(두 엔티티간 관계를 맻을 수 있기에 더욱 효율적이라고 판단 됨)

 

뿐만아니라 동시성 문제역시 CoreData를 사용할 경우 더 쉽게 핸들링 할 수 있습니다.

 

앞서 제시된 코드에서 확인할 수 있듯 스케쥴러를 각각 지정해야하는 까다로움이 있었습니다.

 

다음에 진행하는 프로젝트의 경우 로컬DB를 활용하는 방안으로 시스템을 구축해보려고합니다. 👆

 

 

번외: 캐싱 객체는 클린 아키텍처 관점에서 어떤 계층에 위치해야 할까?

클린아키텍처 관점으로 해당 객체는 데이터 계층에 위치하는 것이 맞다고 판단했습니다.

 

 

UIImage(NSCache에 사용하기 위해)를 반환 형식으로 사용했기에 Domain에 인터페이스를 따로 만들지 않고

 

 

인터페이스와 구현체 모두 Data레이어에 구현하였습니다.

 

 

지금 생각해보면 Domain, Data 계층을 매개하는 역할을 하는 Repository의 역할과는 무관함으로 Repository가 아닌 다른 네이밍을 사용하는 게 더 좋았을 것이라고 생각합니다.