[iOS] URLSession 코드 비교 시리즈 01 : completionHandler

Emily·2025년 1월 30일
2

URLSession

목록 보기
1/3

iOS 개발에서 네트워크 통신을 할 경우 URLSession이라는 객체를 사용한다. 프레임워크 사용 없는 순정 코드와 Combine을 사용한 코드, async/await를 사용한 코드 등을 각각 작성하며 차이를 체감하고 익혀보려 한다. 오늘은 completionHandler를 사용한 가장 기본적인 코드를 작성해볼 것이다.

JSONPlaceholder라는 무료 API 통신 데이터 제공 사이트를 사용할 건데, 그 중에서도 /todos로 끝나는 URL로 통신하면 다음과 같은 JSON 응답 데이터를 받는다.

01) Decodable 모델 정의

struct Todo: Decodable, Hashable {
    let id: Int
    let userId: Int
    let title: String
    let completed: Bool
}

JSON 데이터에 대응되는 형태의 Todo 모델을 정의했다. Hashable을 채택한 이유는 내가 데이터와 바인딩 될 view를 테이블 뷰로 만들었는데, UITableViewDiffableDataSourceItem 타입으로 지정되는 모델은 Hashable을 채택해야하기 때문이다. UI 코드는 다루지 않겠다.

02) CustomError 정의

네트워크 통신을 할 때 발생할 수 있는 오류 상황에 대한 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)"
        }
    }
}

03) fetchTodos 메소드 정의

네트워크 통신은 비동기 동작이기 때문에, 함수 실행이 끝난 뒤에 응답 처리가 필요하므로 @escaping 클로저(completionHandler) 사용이 필요하다. 아래 코드를 보면 확인할 수 있지만 통신 요청부터 응답, 디코딩까지 모든 과정을 하나의 함수에 담았다. 이 과정에서 한번이라도 오류가 발생하면 실패기 때문에 todosnil, errorCustomError case를 할당한 채 클로저가 반환된다. 모든 오류 체크를 통과하고 디코딩까지 성공하면 [Todo] 형태의 데이터와 nilerror를 담은 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()
}

04) fetchTodos 메소드 호출

통신 결과 처리는 메인 스레드에서 이루어지도록 구현했다.

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)
            }
        }
    }
}

05) 오류 발생 시키기 01 - 요청 실패

03) 메소드 정의에서 코드 블록 내 주석 3. 요청 실패인 경우를 발생시켜보겠다. 방법은 간단하다. 맥북 와이파이를 끈 다음에 실행하면 된다.

bind() 함수 내 print(error.debugMessage) 코드에 의해 출력된 에러 메시지를 확인할 수 있다.

06) 오류 발생 시키기 02 - 응답 에러

urlString""로 해놓으면 String → URL 캐스팅에 실패하여 CustomError.invalidURL이 발생하고 https://를 지우면 CustomError.etc가 발생하는데 디버깅 메시지에는 unsupported URL이라고 뜬다.

이번에는 https://jsonplaceholder.typicode.com/todos에서 끝에 s를 지워보겠다.

statusCode가 좋지 않을 때 발생하도록 정의한 CustomError.badResponse에 걸리는 것을 확인할 수 있다.

07) 오류 발생 시키기 03 - 디코딩 에러

디코딩 에러를 발생시키려면 Model을 이상하게 하면 된다. Todo의 프로퍼티 중 ididd로 바꿔보았다.

디코딩 에러가 발생했다. 내가 직접 만든 에러를 하나하나 출력해보니까 재밌었다.

이렇게 정의한 1개의 if문5개의 guard문을 모두 통과하여 디코딩까지 성공하면 비로소 view와 데이터가 바인딩된다.

08) do-catch문으로 리팩토링하기

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)
}

09) Result 타입으로 리팩토링 하기

completionHandler의 타입을 보자.

@escaping (_ todos: [Todo]?, _ error: CustomError?) -> Void)

[Todo]CustomError가 옵셔널인 이유는 성공이면 error가 nil, 실패todos가 nil이기 때문이다. 그래서 bind() 함수에서 결과 처리를 위해 if let error = errorguard 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를 명시하고 있다. 따라서 decodedDataCustomError.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)
            }
        }
    }
}

Resultenum이기 때문에 switch문으로 깔끔하게 처리할 수 있다.

https://developer.apple.com/documentation/swift/result

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

profile
iOS Junior Developer

0개의 댓글