iOS 개발에서 네트워크 통신을 할 경우 URLSession이라는 객체를 사용한다. 프레임워크 사용 없는 순정 코드와 Combine을 사용한 코드, async/await를 사용한 코드 등을 각각 작성하며 차이를 체감하고 익혀보려 한다. 오늘은 completionHandler를 사용한 가장 기본적인 코드를 작성해볼 것이다.
JSONPlaceholder라는 무료 API 통신 데이터 제공 사이트를 사용할 건데, 그 중에서도 /todos로 끝나는 URL로 통신하면 다음과 같은 JSON 응답 데이터를 받는다.

struct Todo: Decodable, Hashable {
let id: Int
let userId: Int
let title: String
let completed: Bool
}
JSON 데이터에 대응되는 형태의 Todo 모델을 정의했다. Hashable을 채택한 이유는 내가 데이터와 바인딩 될 view를 테이블 뷰로 만들었는데, UITableViewDiffableDataSource의 Item 타입으로 지정되는 모델은 Hashable을 채택해야하기 때문이다. UI 코드는 다루지 않겠다.
네트워크 통신을 할 때 발생할 수 있는 오류 상황에 대한 case와 디버깅 메시지를 작성했다. 모든 오류 상황에 CustomError 타입으로 발생시키려고 한다.
enum CustomError: Error {
case invalidURL
case invalidResponse
case badResponse(Int)
case noData
case decodingFailed
case etc(Error)
var debugMessage: String {
switch self {
case .invalidURL:
"[Error] invalid URL"
case .invalidResponse:
"[Error] casting response to HTTPResponse failed"
case .badResponse(let statusCode):
"[Error] response not good (statusCode: \(statusCode))"
case .noData:
"[Error] response doesn't contain any data"
case .decodingFailed:
"[Error] decoding failed"
case .etc(let error):
"[Error] \(error.localizedDescription)"
}
}
}
네트워크 통신은 비동기 동작이기 때문에, 함수 실행이 끝난 뒤에 응답 처리가 필요하므로 @escaping 클로저(completionHandler) 사용이 필요하다. 아래 코드를 보면 확인할 수 있지만 통신 요청부터 응답, 디코딩까지 모든 과정을 하나의 함수에 담았다. 이 과정에서 한번이라도 오류가 발생하면 실패기 때문에 todos는 nil, error는 CustomError case를 할당한 채 클로저가 반환된다. 모든 오류 체크를 통과하고 디코딩까지 성공하면 [Todo] 형태의 데이터와 nil인 error를 담은 completionHandler가 반환될 것이다. 각 코드에 대한 설명은 주석을 보면 된다.
func fetchTodos(completionHandler: @escaping (_ todos: [Todo]?, _ error: CustomError?) -> Void) {
let urlString = "https://jsonplaceholder.typicode.com/todos"
// 1. URL 유효성 체크
guard let url = URL(string: urlString) else {
return completionHandler(nil, CustomError.invalidURL)
}
// 2. 네트워크 요청(Request)
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
// 3. 요청 실패인 경우 error 반환
return completionHandler(nil, CustomError.etc(error))
} else {
// 4. response를 HTTPURLResponse로 타입 캐스팅 (실패 시 error 반환)
guard let response = response as? HTTPURLResponse else {
return completionHandler(nil, CustomError.invalidResponse)
}
// 5. 응답 코드 체크 (200번대여야 통신 성공, 실패 시 error 반환)
guard (200..<300).contains(response.statusCode) else {
return completionHandler(nil, CustomError.badResponse(response.statusCode))
}
// 6. data가 nil이 아닌지 체크 (실패 시 error 반환)
guard let data = data else {
return completionHandler(nil, CustomError.noData)
}
// 7. 디코딩 (실패 시 error 반환)
guard let decodedData = try? JSONDecoder().decode([Todo].self, from: data) else {
return completionHandler(nil, CustomError.decodingFailed)
}
// 8. 디코딩 성공한 [Todo] 형태의 데이터 반환
return completionHandler(decodedData, nil)
}
}
.resume()
}
통신 결과 처리는 메인 스레드에서 이루어지도록 구현했다.
class TodoViewController: UIViewController {
// ... //
private func bind() {
viewModel.fetchTodos() { [weak self] todos, error in
// completionHandler를 통해 error가 반환되었을 경우 - 디버깅 메시지 호출
if let error = error {
// 메인 스레드에서 실행
DispatchQueue.main.async {
print(error.debugMessage)
}
}
// completionHandler를 통해 디코딩 성공한 데이터가 반환되었을 경우 - view와 바인딩
guard let todos = todos else { return }
// 메인 스레드에서 실행
DispatchQueue.main.async {
self?.todoTableView.reloadData(todos: todos)
}
}
}
}
03) 메소드 정의에서 코드 블록 내 주석 3. 요청 실패인 경우를 발생시켜보겠다. 방법은 간단하다. 맥북 와이파이를 끈 다음에 실행하면 된다.

bind() 함수 내 print(error.debugMessage) 코드에 의해 출력된 에러 메시지를 확인할 수 있다.
urlString을 ""로 해놓으면 String → URL 캐스팅에 실패하여 CustomError.invalidURL이 발생하고 https://를 지우면 CustomError.etc가 발생하는데 디버깅 메시지에는 unsupported URL이라고 뜬다.
이번에는 https://jsonplaceholder.typicode.com/todos에서 끝에 s를 지워보겠다.

statusCode가 좋지 않을 때 발생하도록 정의한 CustomError.badResponse에 걸리는 것을 확인할 수 있다.
디코딩 에러를 발생시키려면 Model을 이상하게 하면 된다. Todo의 프로퍼티 중 id를 idd로 바꿔보았다.

디코딩 에러가 발생했다. 내가 직접 만든 에러를 하나하나 출력해보니까 재밌었다.
이렇게 정의한 1개의 if문과 5개의 guard문을 모두 통과하여 디코딩까지 성공하면 비로소 view와 데이터가 바인딩된다.
JSONDecoder()를 통해 디코딩하는 메소드 decode의 경우 throws 함수기 때문에 try 키워드가 필요하다. 이 때 guard문과 try?를 쓰는 대신 do try-catch문으로 리팩토링 해보았다.
// before
guard let decodedData = try? JSONDecoder().decode([Todo].self, from: data) else {
// 디코딩 실패 시 에러 반환
return completionHandler(nil, CustomError.decodingFailed)
}
// guard 문을 통과하면 성공 데이터 반환
return completionHandler(nil, CustomError.decodingFailed)
// after
do {
let decodedData = try JSONDecoder().decode([Todo].self, from: data)
// 성공 데이터 반환
return completionHandler(decodedData, nil)
} catch {
// 실패 시 에러 반환
return completionHandler(nil, CustomError.decodingFailed)
}
completionHandler의 타입을 보자.
@escaping (_ todos: [Todo]?, _ error: CustomError?) -> Void)
[Todo]와 CustomError가 옵셔널인 이유는 성공이면 error가 nil, 실패면 todos가 nil이기 때문이다. 그래서 bind() 함수에서 결과 처리를 위해 if let error = error와 guard let todos = todos를 해야만 했다.
이 코드들을 더 깔끔하게 처리할 수 있게 해주는 Swift의 타입이 있다. 바로 Result다. Result는 바로 성공/실패 분기 처리를 위해 태어난 친구다. 클로저와 escaping 클로저도 이미 Swift스러운 문법이지만, Result는 더더욱 Swift스러운 친구다. 그것도 최신형으로. Result 타입은 Swift 5에 도입되었다고 한다.
fetchTodos를 리팩토링한 코드를 보고 직접 비교해보자.
func fetchTodos(completionHandler: @escaping (Result<[Todo], CustomError>) -> Void) {
// ... 자세한 코드는 생략하고 반환 부분만 비교 ... //
do {
let decodedData = try JSONDecoder().decode([Todo].self, from: data)
return completionHandler(.success(decodedData))
} catch {
return completionHandler(.failure(CustomError.decodingFailed))
}
}
바로 위에서 do-catch문으로 리팩토링한 코드와 비교해보면, 성공/실패일 때 반대 데이터에 nil을 할당하는 대신 .success 또는 .failure로 결과 케이스를 보내는 것을 볼 수 있다. nil일지도 모르는 성공/실패 경우로 인해 옵셔널 타입으로 선언할 필요가 없어졌다.
그리고 Result 타입의 정의부를 보면 제네릭을 통해 성공 데이터 타입 [Todo]와 실패 에러 타입 CustomError를 명시하고 있다. 따라서 decodedData와 CustomError.decodingFailed를 각각 성공/실패 케이스에 할당할 수 있는 것이다.
여기서 끝이 아니고, bind() 코드 또한 매우 가독성이 좋아지는 것을 볼 수 있다.
private func bind() {
viewModel.fetchTodos { [weak self] result in
switch result {
case .success(let todos):
DispatchQueue.main.async {
self?.todoTableView.reloadData(todos: todos)
}
case .failure(let error):
DispatchQueue.main.async {
print(error.debugMessage)
}
}
}
}
Result가 enum이기 때문에 switch문으로 깔끔하게 처리할 수 있다.

https://developer.apple.com/documentation/swift/result
여기까지 정리해보며 느낀점은 역시 순정 코드가 제일 (어렵고 길고) 재밌다는 것이다. 그리고 정확히 같은 동작이어도 여러가지 다른 방법으로 구현할 수 있다는 것도 매우 흥미롭다. 그래서 이 시리즈를 시작하게 되었다. 다음에는 Combine을 사용하여 구현해볼 것이다.