iOS 개발자야, 아저씨 말씀 잘 듣고 completionHandler 대응 잘하고 건강하고 행복하게 오래오래 살아야한다~

Heedon Ham·2023년 8월 17일
0

iOS 이것 저것

목록 보기
12/17
post-thumbnail

비동기도 정리해야 하고, closure도 정리해야하지만 뭔가 뇌리에 강하게 박힌 에피소드라 퇴색되기 전에 일단 작성해본다.

API call을 여러번 사용하기

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들...
}

작동 순서는 다음과 같이 작성했다.

  • 왼쪽 화살표: 함수 호출
  • 오른쪽 화살표: completionHandler 활용

beforeUsingDispatchGroup

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 두둥등장

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

과정은 다음과 같이 나타낼 수 있다.

usingDispatchGroup

따라서 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()
        }
    }
}

여기까지 작성해서 해결되었으면 좋았겠지만...


네트워크 call은 무조건 성공하지 않는다

블랙미러 시리즈를 예시로 선택해봤다.
(정규 시즌 6개에 스페셜 1개 포함 총 7개, 각 시즌 당 3~6화 존재)

blackMirrorLogo

season_Number로 3을 넣어서 Codable struct를 구성, 이후 fetchEpisodeList를 수행하면 이상하게 시즌 7개 중 3개만 가져올 수 있다고 알림을 준다.

cannotGetWholeEpisode

위에서 설명한 dispatchGroup은 해당 task가 끝날 때마다 leave 메서드를 호출해서 task수를 하나씩 줄여나간다. task가 0이 된 것을 확인하면 notify 메서드로 작업 완료 이후 수행할 코드를 구현할 수 있다.

그런데 아무리 작업을 해도 notify 메서드가 호출되지 않는다. enter와 leave 순서가 틀리지 않았기에 결국 직접 count 변수로 하나씩 찍어보았다.

countVariableForDispatchGroup

데이터를 가져올 수 없던 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)
        }
    }
}

과정을 정리하면 다음과 같이 나타낼 수 있다.

noCompletionHandlerForError

네트워크 call에 실패하면 completionHandler로 전달해주지 않기에 leave 메서드가 호출될 수 없고, 결국 notify 호출도 불가능하다.


completionHandler for all

따라서 에러 상황에서도 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:) 를 활용하면 다음과 같이 작업을 할 것이다.

  1. episodeList를 얻기 위한 Alamofire request 메서드가 global queue에 맡겨짐
  2. global queue는 일을 하고 있지 않는 thread ("thread A")에게 Alamofire request 메서드를 담당하게 함
  3. Alamofire request 메서드는 concurrent 비동기 처리이므로 "thread A"는 다시 global queue에게 메서드를 맡김
  4. 다시 global queue에게 맡겨진 Alamofire request는 일을 하고 있지 않는 다른 thread에게 맡겨짐 + 동시에 "thread A"는 비동기 처리이므로 바로 작업 완료되었음을 DispatchGroup에게 알림
  5. Alamofire request는 2번째 맡겨진 thread에서 처리 중, 언제 끝날지는 추적이 불가능해서 모름 + 동시에 DispatchGroup은 작업이 완료된 줄 알고 notify 메서드 호출
  6. 실제 가져온 episodeList 데이터는 없지만 이미 reloadData 메서드를 호출

따라서 global.async 방식으로 수행하는 작업들은 group에 등록하기 위해 다시 global.async로 처리하면 언제 끝나게 될 지 추적할 방법이 없다.

그래서 직접 관리하는 enter와 leave 메서드를 사용해야 한다.

profile
dev( iOS, React)

0개의 댓글