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
을 사용하여 구현해볼 것이다.