iOS 프로젝트/Jeongfisher

[iOS] Jeongfisher 3. JFImageDownloader, 중복 Request 처리

유정주 2023. 8. 22. 12:43
반응형
 

GitHub - jeongju9216/Jeongfisher: 유정주의 이미지 캐시 라이브러리

유정주의 이미지 캐시 라이브러리. Contribute to jeongju9216/Jeongfisher development by creating an account on GitHub.

github.com

 

JFImageDownloader

JFImageDownloader는 URL을 이용해 이미지를 다운로드하는 클래스입니다.

메모리 캐시와 디스크 캐시에 데이터가 없을 때 JFImageDownloader를 이용해 네트워킹을 하는 거죠.

 

이미지 다운로더를 만들면서 힘들었던 것은 중복 Reqeust 처리입니다.

동일한 URL로 여러 번 Reqeust를 하면 비효율적이겠죠?

그래서 URL을 딕셔너리에 저장해 두고 중복 Request를 하지 않도록 구현했는데 참 고생이 많았네요 ㅠㅠ

이번 포스팅에서는 이 과정을 작성해보려고 합니다.

 

JFImageDownloader는 개선 전과 개선 후로 나눠서 작성해 보겠습니다.

개선 전, 후 모두 딕셔너리를 이용해 중복을 처리했습니다.

딕셔너리 동시성 문제를 해결하기 위한 방법이 다른데요.

요약하면 개선 전에는 completionHandler, DispatchQueue barrier를 이용했고,

개선 후에는 actor, Task를 이용했습니다.

지금부터 차근차근 내용을 적어볼게요.

 

 

개선 전

개선 전에는 DispatchQueue를 이용해 동시성 문제를 해결했습니다.

 

딕셔너리 Read와 Write는 아래같은 상황에서 발생합니다.

  • Write: 새로운 Request를 딕셔너리에 저장할 때, 완료된 Request를 딕셔너리에서 제거할 때
  • Read: URL 체크

Read와 Write를 나눠서 처리해줬는데요.

Write는 async + barrier를 사용해 데이터 레이스를 방지하면서 다음 코드를 진행했고,

Read는 sync로 수행해서 정확한 값을 얻어오도록 구현했습니다.

 

열심히 구현해 봤지만...

아쉽게도 기능이 제대로 동작하지 않았습니다.

 

동일한 URL이 동시에 Request가 되면 첫 번째 Request만 처리되었기 때문입니다.

예를 들어, 10개의 UIImageView가 동일한 URL을 Request 하면 1번 UIImageView에만 이미지가 설정되고

나머지 UIImageView에는 이미지 설정이 되지 않았습니다.

(이건 그냥 제가 못한 거겠죠 하하;;)

 

또 한 가지 문제는 딕셔너리에 add, remove를 할 때마다 DispatchQueue를 사용해서 코드 복잡성이 증가했습니다.

이런 문제들을 어떻게 해결할 수 있을지 고민했습니다.

 

 

해결 방법

먼저 DispatchQueue를 어떻게 없앨 수 있을지 고민했습니다.

딕셔너리의 동시성 문제를 해결하기 위해 DispatchQueue를 도입했으니까

다른 방식으로 동시성 문제를 해결할 수 있을지 고민했죠.

 

결국 actor를 적용해보기로 결정했습니다.

actor를 처음 공부했을 때는 너무 어려웠는데 이번 기회에 적용해 보자! 결심했습니다.

그리고 Swift Concurrency를 적극적으로 도입하자는 초심(?)을 떠올리고 actor가 잘 어울리겠다 싶었습니다.

 

추가로 completionHandler는 Task와 async/await으로 변경했고,

Task의 상태를 알기 위해 Enum의 연관값을 활용했습니다.

 

 

중복 Request 처리 방법

자세한 방법은 따로 포스팅을 할 예정이고,

여기서는 reqeust 부분만 살펴보죠.

 

먼저 Enum부터 보겠습니다.

private enum DownloadEntry {
    case inProgress(Task<JFImageData, Error>)
    case complete(JFImageData)
}

private var cache: [URL: DownloadEntry] = [:]
private var count: [URL: Int] = [:]

DownloadEntry는 Task의 상태를 알기 위해 도입했습니다.

inProgress 케이스는 연관값으로 Task를 가지고, complete는 결과 데이터 타입을 가집니다.

 

cache 딕셔너리는 Enum 데이터를 관리해서 중복 Request인지 판단합니다.

let task = Task {
    try await download(from: url, etag: etag)
}

cache[url] = .inProgress(task)

do {
    let jfImageData = try await task.value
    cache[url] = .complete(jfImageData)
    return jfImageData
} catch {
    cache[url] = nil
    throw error
}

만약 중복이 아니라면 Task를 생성해서 딕셔너리에 넣습니다.

그리고 Task가 완료되면 complete로 상태를 변경합니다.

 

만약 중복이라면 Task를 꺼내서 상태를 확인합니다.

//이미 같은 URL 요청이 들어온 경우 Task 완료 대기
if let cached = cache[url] {
    switch cached {
    case .inProgress(let task):
        return try await task.value
    case .complete(let jfImageData):
        return jfImageData
    }
}

진행 중이라면 task value를 대기하고, 완료되었다면 연관값을 바로 반환합니다.

 

 

개선 후 느낀 점

가장 크게 체감한 부분은 기능이었습니다 ㅋㅋ;

드디어 원하는 대로 동작을 하는 겁니다 ㅎㅎ!

동일한 Request가 들어오면 첫 번째 Request 결과를 대기했다가 반환할 수 있게 되었습니다.

 

또 많은 completionHandler를 없앨 수 있었습니다.

let (data, response) = try await URLSession.shared.data(for: request)

특히 URLSession의 data(for:) 메서드를 사용하니 다운로드 코드가 너무 깔끔해졌습니다.

async/await이 코드 가독성에 큰 기여를 한다는 것을 다시 한번 느낄 수 있었습니다.

 

마지막으로 actor를 활용해서 동시성 문제를 해결하니 DispatchQueue를 없앨 수 있었습니다.

actor를 이용하니 간편하게 thread safe한 코드를 작성할 수 있었습니다.

그리고 직접 actor를 사용해 보니 그렇게 어렵진 않더라고요!

물론 제대로 쓰는 건 깊은 지식과 경험이 필요하겠죠?

열심히 노력해야겠습니다.

 

감사합니다.


아직은 초보 개발자입니다.

더 효율적인 코드 훈수 환영합니다!

공감 댓글 부탁드립니다.

반응형