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를 사용해 보니 그렇게 어렵진 않더라고요!
물론 제대로 쓰는 건 깊은 지식과 경험이 필요하겠죠?
열심히 노력해야겠습니다.
감사합니다.
아직은 초보 개발자입니다.
더 효율적인 코드 훈수 환영합니다!
공감과 댓글 부탁드립니다.