비동기도 정리해야 하고, closure도 정리해야하지만 뭔가 뇌리에 강하게 박힌 에피소드라 퇴색되기 전에 일단 작성해본다.
TMDB API 중 TV series의 Season과 Episode 리스트를 받아오기 위해 call을 2개를 연속으로 써야 하는 상황이었다.
episode를 받아오기 위해선 series_id와 season_number가 필요하다.
https://api.themoviedb.org/3/tv/{series_id}/season/{season_number}
struct SeasonDetail: Codable {
//...중략...
let episodes: [Episode]
}
struct Episode: Codable {
//...관련 property들...
}
작동 순서는 다음과 같이 작성했다.
for문 내 season 개수만큼 비동기로 episode call을 하기에 for loop block 밖에 reloadData를 작성할 수 없다. 네트워크 call을 기다리지 않고 바로 reload할테니까.
이 과정의 문제점은 매번 episode list를 가져올 때마다 reloadData 메서드를 호출해야 하는 점이다.
만약 season이 20개가 넘는 장수 시리즈에 각 시즌마다 에피소드가 30화 이상이라면? 확률은 적지만 reload 와중에 화면이 깜빡일 수 있는 UX적 단점이 존재한다.
한 season의 episodeList를 가져오면 completionHandler로 다시 연결해서 다음 season의 episodeList 가져오는 식의 비동기지만 사실상 동기처럼 꼬리에 꼬리를 무는 구조로도 작성할 수 있지만 이 역시 개수가 늘어나면 callback 지옥이 펼쳐질 것이다.
//callback의 callback의 callback...
var episodeCount = 1
dataManager.callRequest(season: seasonNumber, episode: episodeCount) {
episodeCount += 1
dataManager.callRequest(season: seasonNumber, episode: episodeCount) {
episodeCount += 1
dataManager.callRequest(season: seasonNumber, episode: episodeCount) {
//...마지막 episode까지...
}
}
}
DispatchGroup
A group of tasks that you monitor as a single unit.
Groups allow you to aggregate a set of tasks and synchronize behaviors on the group. You attach multiple work items to a group and schedule them for asynchronous execution on the same queue or different queues. When all work items finish executing, the group executes its completion handler.
비동기 처리하는 작업들을 한데 모아놓은 집합체이다.
위에서 각 fetchEpisodeList 호출이 언제 끝날지 모르기에 무턱대로 for loop 밖에서 reloadData를 호출할 수 없다고 언급했다.
그렇지만 우리는 언제일지는 모르겠지만 episodeList를 가져오는 모든 네트워크 call이 다 끝나는 그 때 reloadData를 호출하면 된다고 알고 있다.
이 때를 알기위해 DispatchGroup을 활용한다.
Updating the Group Manually
- func enter()
Explicitly indicates that a block has entered the group.- func leave()
Explicitly indicates that a block in the group finished executing.
enter 메서드로 새로운 task가 비동기로 처리되고 있음을 알리며, leave 메서드로 해당 task가 종료되었음을 알려준다.
notify(qos:flags:queue:execute:)
Schedules the submission of a block with the specified attributes to a queue when all tasks in the current group have finished executing.
- qos
The quality of service class for the work to be performed.- flags
Options for how the work is performed.
For possible values, see DispatchWorkItemFlags.- queue
The queue to which the supplied block is submitted when the group completes.- work
The work to be performed on the dispatch queue when the group is completed.
This function schedules a notification block to be submitted to the specified queue when all blocks associated with the dispatch group have completed. If the group is empty (no block objects are associated with the dispatch group), the notification block object is submitted immediately. When the notification block is submitted, the group is empty.
dispatchGroup에 등록된 모든 task가 끝나서 empty인 경우 호출되는 notify 메서드로 그 이후 수행하려는 작업을 closure로 작성할 수 있다.
Alamofire를 활용하는 경우, response 메서드는 모두 main queue를 활용한다.
// Response Handler - Unserialized Response
func response(queue: DispatchQueue = .main,
completionHandler: @escaping (AFDataResponse<Data?>) -> Void) -> Self
// Response Serializer Handler - Serialize using the passed Serializer
func response<Serializer: DataResponseSerializerProtocol>(queue: DispatchQueue = .main,
responseSerializer: Serializer,
completionHandler: @escaping (AFDataResponse<Serializer.SerializedObject>) -> Void) -> Self
// Response Data Handler - Serialized into Data
func responseData(queue: DispatchQueue = .main,
dataPreprocessor: DataPreprocessor = DataResponseSerializer.defaultDataPreprocessor,
emptyResponseCodes: Set<Int> = DataResponseSerializer.defaultEmptyResponseCodes,
emptyRequestMethods: Set<HTTPMethod> = DataResponseSerializer.defaultEmptyRequestMethods,
completionHandler: @escaping (AFDataResponse<Data>) -> Void) -> Self
// Response String Handler - Serialized into String
func responseString(queue: DispatchQueue = .main,
dataPreprocessor: DataPreprocessor = StringResponseSerializer.defaultDataPreprocessor,
encoding: String.Encoding? = nil,
emptyResponseCodes: Set<Int> = StringResponseSerializer.defaultEmptyResponseCodes,
emptyRequestMethods: Set<HTTPMethod> = StringResponseSerializer.defaultEmptyRequestMethods,
completionHandler: @escaping (AFDataResponse<String>) -> Void) -> Self
// Response Decodable Handler - Serialized into Decodable Type
func responseDecodable<T: Decodable>(of type: T.Type = T.self,
queue: DispatchQueue = .main,
dataPreprocessor: DataPreprocessor = DecodableResponseSerializer<T>.defaultDataPreprocessor,
decoder: DataDecoder = JSONDecoder(),
emptyResponseCodes: Set<Int> = DecodableResponseSerializer<T>.defaultEmptyResponseCodes,
emptyRequestMethods: Set<HTTPMethod> = DecodableResponseSerializer<T>.defaultEmptyRequestMethods,
completionHandler: @escaping (AFDataResponse<T>) -> Void) -> Self
과정은 다음과 같이 나타낼 수 있다.
따라서 notify의 queue를 main으로 설정하면 다음과 같이 코드를 작성할 수 있다.
let dispatchGroup = DispatchGroup()
func configSeasonWithEpisodeDetail() {
dataManager.fetchSeasonList(seriesId: seriesId) {
//[Season]을 모두 저장한 뒤 각 season의 [Episode] 구하기
for season in self.dataManager.getSeasonList() {
//task 시작
self.dispatchGroup.enter()
self.dataManager.fetchEpisodeList(seriesId: self.seriesId, seasonNumber: season.seasonNumber) {
//[Episode] 가져오는 task 완료
self.dispatchGroup.leave()
}
}
//for문 내의 모든 task가 종료 시 호출
self.dispatchGroup.notify(queue: .main) {
self.tvCollectionView.reloadData()
}
}
}
여기까지 작성해서 해결되었으면 좋았겠지만...
블랙미러 시리즈를 예시로 선택해봤다.
(정규 시즌 6개에 스페셜 1개 포함 총 7개, 각 시즌 당 3~6화 존재)
season_Number로 3을 넣어서 Codable struct를 구성, 이후 fetchEpisodeList를 수행하면 이상하게 시즌 7개 중 3개만 가져올 수 있다고 알림을 준다.
위에서 설명한 dispatchGroup은 해당 task가 끝날 때마다 leave 메서드를 호출해서 task수를 하나씩 줄여나간다. task가 0이 된 것을 확인하면 notify 메서드로 작업 완료 이후 수행할 코드를 구현할 수 있다.
그런데 아무리 작업을 해도 notify 메서드가 호출되지 않는다. enter와 leave 순서가 틀리지 않았기에 결국 직접 count 변수로 하나씩 찍어보았다.
데이터를 가져올 수 없던 4개만큼 count가 남아있었다.
NetworkManager에서 AlamoFire의 responseDecodable 결과인 response가 error인 경우에 관련된 completionHandler를 설정해주지 않았다.
//headers: accessToken 포함해야 함
func fetchSeasonDetail(seriesId: Int, seasonNumber: Int, completionHandler: @escaping ([Episode]) -> ()) {
let url = EnumUrl.requestUrl + "\(seriesId)/season/\(seasonNumber)"
AF.request(url, method: .get, headers: headers).validate().responseDecodable(of: SeasonDetail.self) { response in
switch response.result {
case .success(let value):
comopletionHandler(value.episodes)
case .failure(let error):
print("Error: ", error.localizedDescription)
}
}
}
과정을 정리하면 다음과 같이 나타낼 수 있다.
네트워크 call에 실패하면 completionHandler로 전달해주지 않기에 leave 메서드가 호출될 수 없고, 결국 notify 호출도 불가능하다.
따라서 에러 상황에서도 completionHandler를 전달해주는 모델로 작성해야 한다.
completionHandler를 2개로 작성할 수도 있고, Result 타입으로도 활용할 수 있지만 여기선 completionHandler 2개로 작성해보자.
func fetchSeasonDetail(seriesId: Int, seasonNumber: Int, success: @escaping ([Episode]) -> (), failure: @escaping (AFError) -> ()) {
let url = EnumUrl.requestUrl + "\(seriesId)/season/\(seasonNumber)"
AF.request(url, method: .get, headers: headers).validate().responseDecodable(of: SeasonDetail.self) { response in
switch response.result {
case .success(let value):
success(value.episodes)
case .failure(let error):
print("Error: ", error.localizedDescription)
failure(error)
}
}
}
이렇게 하면, sucess와 failure 모두 dataManager로 전달할 수 있다.
그러면 dataManager에선 해당 case에 따라 다르게 대응을 할 수 있다.
func fetchEpisodeList(seriesId: Int, seasonNumber: Int, completionHandler: @escaping () -> ()) {
networkManager.fetchSeasonDetail(type: type, seriesId: seriesId, seasonNumber: seasonNumber) { episodeList in
//success인 경우
self.seasonEpisodeDictionary[seasonNumber] = episodeList
completionHandler()
} failure: { error in
//data 못 가져온 경우, 빈 배열을 dictionary에 할당
self.seasonEpisodeDictionary[seasonNumber] = []
print("No data in season \(seasonNumber)")
completionHandler()
}
}
이러면 ViewController에서도 leave 메서드가 온전하게 작동하면서 notify 메서드 호출까지 잘 이뤄진다.
에러 케이스 대응의 중요성을 크게 느꼈고, 막연했던 DispatchGroup 활용도 생각보다 가벼워서 뭔가 얻어가는 것이 많은 에피소드였다.
참고) 굳이 enter()
와 leave()
를 사용하는 이유
Alamofire request 메서드는 global queue에서 async로 수행한다.
DispatchGroup에 등록하기 위해 다시 한번 global().async(group:)
를 활용하면 다음과 같이 작업을 할 것이다.
따라서 global.async 방식으로 수행하는 작업들은 group에 등록하기 위해 다시 global.async로 처리하면 언제 끝나게 될 지 추적할 방법이 없다.
그래서 직접 관리하는 enter와 leave 메서드를 사용해야 한다.