관리 메뉴

스윞한 개발자

Image Cache에 대한 고찰 💭 본문

Swift 이론

Image Cache에 대한 고찰 💭

스윞남 2025. 1. 9. 13:57
728x90
반응형
SMALL

안녕하세요!

 

지난 포스팅에서 Kingfisher의 이미지 캐싱과 커스텀 이미지 캐싱에 대해 정리해 보았는데, 문제점을 발견해 그에 관해 분석해 보고 파생될 수 있는 다른 문제점들에 대해 해결책을 생각해 보려고 합니다.

 

 

오늘 포스팅에서는 쟁점이 되는 목차들을 생각해 보았습니다.

 

1. 캐시에 저장된 이미지를 보여줄때 -> 서버 상의 이미지가 달라졌을 때

저번 포스팅에서 Kingfisher에 캐싱 기능에 대해 정리를 하고 커스텀 캐시 코드를 작성해 보았습니다. 제가 가져오는 getImage 메서드는 싱글톤으로 구현했고 이미지 URL이 캐시에 존재하면 서버와의 통신을 하지 않았습니다. 구현을 마치고 캐싱 과정이 잘 되는지 확인했고 멋지게,, 포스팅을 했다고 생각하고 있었습니다. 

근데,,, 곰곰이 생각해 보니,,

 

 

 

 

사진에 대해 서버와의 통신 후, 한 번이라도 캐시에 저장된 경우 무조건 Hit가 발생한다는 사실을 깨달았습니다. 서버상에 이미지가 바뀌게 된다면 새로 로드해야 될 상황에서 저는 이 캐싱기능에만 집중하여, 저장만 되어있다면 무조건 이미지를 반환시켜 주었습니다. 만약 서버에서 새로운 이미지로 바뀐다 하더라도 저는 항상 과거의 이미지만 보여주고 있게 되겠죠..ㅎㅎ

 

Kingfisher에서도 하이브리드 캐싱 기법으로 메모리나 디스크에 캐싱을 저장시키지만 정작 제가 구현했을 때는 retrieveImage로 저장된 이미지만 로드하고 있었습니다. 이렇게 된다면 디스크에 저장된 일주일 동안은 이미지가 바뀌지 않는 문제가 발생할 것입니다.

 

그래서! 캐싱 기능에서 캐시에 Hit 된다고 하더라도 로컬 데이터와 서버 데이터가 일치하는지를 확인하는 과정을 추가해줘야 했습니다. 저는 이 과정을 너무 어렵게 생각하고 있었는데, 여러 사례들을 살펴보니 단순한 구조를 가지고 있었습니다.

 

제일 단순한 방법으로는 주기적으로 캐시를 삭제 혹은 앱을 끄면 캐시를 삭제하는 방법이 있었습니다. 또 유효한 데이터인지 확인하기 위해 서버와의 통신을 먼저 한 후, 캐시를 업데이트시켜주는 방식을 많이 적용하고 있었습니다. 

 

# 로컬 - 서버 간 데이터 동기화 (ETag - Entity Tag)

결론적으로는, 서버에서 현재 클라이언트에 가진 이미지가 최신 이미지인지 확인하는 과정이 필요했습니다. 이 문제를 해결하는 방법으로 웹 브라우저 캐싱에서 많이 사용하는 ETag를 생각해 보았습니다. ETag는 서버와 리소스의 버전을 식별하는 데 사용하는 식별자입니다. 서버에서는 데이터가 변경될 때마다 ETag 값을 생성하여 응답 헤더에 포함시켜 줍니다. 

 

 

클라이언트는 서버로부터 이미지를 받을 때 ETag 값을 함께 캐싱하고, If-None-Match 헤더에 ETag 값을 포함시켜 서버에 전송하게 된다면, 서버에서 해당 이미지가 최신 리소스인지 판별을 해주게 됩니다.

 

이미지 URL에 버전을 포함해서 업데이트 시 변경하는 방식도 존재하지만,, 더 복잡해져 잘 쓰이지 않는다고 합니다. 이 외에도 주기적으로 갱신하는 방법(Pull Model), 푸시 알림(WebSocket, Cloud Messaging),  이미지 서명(timeStamp) 등도 있지만 가장 많이 사용되는 방법이 ETagLast-Modified를 사용하는 방법입니다.

 

Kingfisher 라이브러리를 사용할 때도 이런 상황이 발생하면 어떡하지?라는 생각에 여러 자료들을 찾아보았는데,,, Kingfisher는 이런 경우 ETag만을 나타내는 로직은 공식적으로는 지원해주지 않는다고 합니다. 그래서 Kingfisher는 커스텀 메서드를 만들어 최신화된 이미지인지 확인 후 캐시에 업데이트해줄 수 있는 로직으로 구성해 볼 수 있습니다.

 

# 업데이트 시 생각해 볼 사항

최신화된 이미지를 적용시킬 때 -> 캐시를 업데이트할 때! 사용자 스크롤 할 때의 위치를 인식하고 사용자가 보이지 않을 때 업데이트를 해준다던지 등의 측면에 대해 여러 상황을 고려하며 구현을 해야 할 것 같습니다.

 

2. 레이스 컨디션(경쟁 상태)

* 이미지를 가져올 때는 발생하지 않음?

final class ImageCacheManager {
    static let shared = ImageCacheManager()

    private var cache = [String: String]()
    private let lock = NSLock()

    private init() {}

    func getImage(for name: String) -> String? {
        lock.lock()
        defer { lock.unlock() }
        return cache[name]
    }

    func setImage(_ image: String, for name: String) {
        lock.lock()
        defer { lock.unlock() }
        cache[name] = image
    }
}

 

제가 캐시를 구현한 코드를 살펴보면, lock을 이용해 경쟁상태에 대해 관리를 해주었습니다. 제가 "이미지를 가져올때는 lock을 걸지 않아도 된다?"는 피드백을 받았습니다.  스레드 안전하지 않기 때문에 저는 lock으로 경쟁상태를 관리해주고자 하였었습니다.

 

단순히 이미지를 가져올때 공유자원(getImage)을 여러 스레드에서 접근하게 된다면 단순히 이미지라는 공유자원을 가져오는 것이기 때문에 문제가 발생하지 않습니다.

 

 

❌ 문제 시나리오

  1. 스레드 A가 getImage(for:)를 호출하여 캐시에서 데이터를 읽는 중입니다.
  2. 스레드 B가 setImage(_:for:)를 호출하여 동일한 키의 데이터를 수정하려고 합니다.
  3. 두 작업이 동시에 실행되면 딕셔너리 내부의 데이터가 예상치 못하게 손상될 수 있다? 

한 스레드에서는 이미지를 저장(setImage)하고자 하고 한 이미지는 이 이미지를 가져(getImage) 오고자 한다면, 이미지를 가져오는 과정에서 새로 저장된 이미지를 가져올 수도 혹은 최신화되지 않은 이미지를 가져올 수 도 있습니다. 그렇기 때문에, 이미지 저장(setImage) 메서드에서는 lock 걸어 관리를 해 줄 필요가 있습니다!

 

✅ Lock 사용 시 해결

lock.lock()과 lock.unlock()을 사용하면 하나의 스레드가 데이터를 읽거나 수정하는 동안 다른 스레드는 대기하게 되어 위와 같은 문제를 방지할 수 있습니다.

 

이미지 캐시를 기점으로 다양한 공부를 할 수 있는 것 같습니다. 다음 포스팅에는 더 나아가 동시성, 랜더링 측면에서 더 유익한 내용으로 정리해 보겠습니다! 잘못된 점에 대한 피드백은 언제든 환영입니다! 감사합니다!

 

 

 

728x90
반응형
LIST