본문 바로가기

iOS

소깡이의 URLSession과 친해지기 프로젝트

728x90

극 I지만 .. 쟈도 I인 것 같으니 .. 먼저 다가가보겠어요

 

먼저 로또의 정보를 GET하는 API 연결을 해보자.

 

API 연결하는 것에 순서가 딱 정해져 있는 것은 아니지만 (나는) 보통 아래의 순서로 진행한다. ⬇️

1. 모델 구조 만들기

2. APIManager 만들기

3. 서버 통신

4. 서버 통신 후 응답 메시지로 UI/레이아웃 갱신 및 분기처리 

 

1. 모델 구조 만들기

https://quicktype.io/

 

Convert JSON to Swift, C#, TypeScript, Objective-C, Go, Java, C++ and more • quicktype

{ "people": [ { "name": "Atticus", "high score": 100 }, { "name": "Cleo", "high score": 900 }, { "name": "Orly" }, { "name": "Jasper" } ] } Provide sample JSON files, URLs, JSON schemas, or GraphQL queries.

quicktype.io

만약 JSON 구조를 알고 있다면, 위 사이트를 통해서 구조체를 쉽게 만들 수 있음 .. 근데 이거 그대로 사용하면 안되고 한번 확인 필수 !!

 

{"totSellamnt":101799373000,"returnValue":"success","drwNoDate":"2022-04-16","firstWinamnt":2220348512,"drwtNo6":38,"drwtNo4":26,"firstPrzwnerCo":11,"drwtNo5":35,"bnusNo":42,"firstAccumamnt":24423833632,"drwNo":1011,"drwtNo2":9,"drwtNo3":12,"drwtNo1":1}

 

 

서버에서 위와 같이 응답 데이터를 받는 것을 확인할 수 있고,

 

struct Lotto: Codable {
    let totSellamnt: Int
    let returnValue, drwNoDate: String
    let firstWinamnt, drwtNo6, drwtNo4, firstPrzwnerCo: Int
    let drwtNo5, bnusNo, firstAccumamnt, drwNo: Int
    let drwtNo2, drwtNo3, drwtNo1: Int
}

Swift에서 사용할 데이터 구조로 바꾸면 위와 같이 바꿀 수 있다.

 

2. APIManager 만들기

이제 서버 통신을 위한 API를 만들어보자.

 

전체적인 흐름은 다른 서버통신과 같다. (어렵다고 생각하지 말 것.)

  • URL 선언하고 
  • HTTP 메서드 확인한 다음
  • Request에 맞는 값들을 넣어주고 
  • 서버 통신을 한 뒤,
  • 데이터를 받고
  • 데이터를 필요한 곳에 사용할 수 있도록 탈출 클로저 형태로 만들어준다.

 

class LottoAPIManager {
    
    static func requestLotto(drwNo: Int, completionHandler: @escaping (Lotto?, APIError?) -> ()) {
        guard let url = URL(string: "https://www.dhlottery.co.kr/common.do?method=getLottoNumber&drwNo=\(drwNo)") else { return }
        
        URLSession.shared.dataTask(with: url) { data, response, error in
            DispatchQueue.main.async {
                guard error == nil else {
                    print("Failed Request")
                    completionHandler(nil, .failedRequest)
                    return
                }

                guard let data = data else {
                    print("No Data Returned")
                    completionHandler(nil, .noData)
                    return
                }

                guard let response = response as? HTTPURLResponse else {
                    print("Unable Response")
                    completionHandler(nil, .invalidResponse)
                    return
                }

                guard response.statusCode == 200 else {
                    print("Failed Response")
                    completionHandler(nil, .failedRequest)
                    return
                }

                do {
                    let result = try JSONDecoder().decode(Lotto.self, from: data)
                    completionHandler(result, nil)

                } catch let error {
                    completionHandler(nil, .invalidData)
                    print(error)
                }
            }

        }.resume()
    }
}

 

위에서부터 보면, 기본적인(= 단순한) 서버 통신을 하기 때문에 shared를 사용해서 서버통신을 하면 된다.

dataTask로 데이터를 서버 통신 후 응답 메시지를 받게 되는데 guard 문을 통해서 에러 처리를 한다.

 

위에서부터 모든 에러 처리에 하나도 걸리지 않는다면, 이상 없이 데이터를 받아온 것이고,

이를 JSONDecoder()를 통해 위에서 만들어 놓은 데이터 모델 형태로 바꿔준다. (JSON -> Swift 구조로)

 

그리고 이 결과를 서버 통신을 호출한 곳에서 반환 값으로 사용할 수 있도록 탈출 클로저로 담아서 보낸다. 

이 때, 에러가 있다면 해당 에러가 어떤 것인지 확인할 필요도 있고 + 사용자에게 알려줄 필요도 있으므로 탈출 클로저에 같이 담아서 보낸다.

 

@escaping (Lotto?, APIError?) -> ()

그래서 위와 같은 형태로 데이터가 반환될텐데, 여기서 APIError는 애플에서 기본적으로 제공하는 것이 아니라

애플에서 제공하는 에러 처리인 Error를 확장해서 case를 나눠준 열거형을 사용한 것이다. ⬇️

enum APIError: Error {
    case invalidResponse
    case noData
    case failedRequest
    case invalidData
}

 

3. 서버 통신 & 4. 갱신코드 

그리고 위에서 만들어 놓은 APIManager를 서버 통신이 필요한 곳에서 호출한다.

// MARK: - Network

extension ViewController {
    private func callRequestLotto() {
        LottoAPIManager.requestLotto(drwNo: 1011) { lotto, error in
            guard let lotto = lotto else {
                return
            }

            self.label.text = "\(lotto.drwNoDate)"
            dump(lotto)
        }
    }
}

이렇게 호출해서 원하는 값을 사용하면 된다.

 

🤔 어? 근데 UI 관련 코드인데 DispatchQueue.main에서 처리하지 않나?

맞음. UI와 관련된 코드/UI 갱신 코드는 메인쓰레드에서 관리 해야 한다.

 

위의 ViewController 코드만 보면 메인 쓰레드를 사용하지 않는 것 같지만, APIManager에서 서버통신을 할 때 DispatchQueue.main 쓰레드에서 작업을 하고 있는 것을 알 수 있고, 이로 인해 그 이후의 데이터를 받고 이를 UI에 업데이트 하는 과정 역시 메인 쓰레드에서 진행되고 있다.

 


이렇게는 조금 .. 간단한 .. ? 서버 통신 코드였는데 .. 이를 좀 더 일반화해보자.

 

앱에서 하나의 API만 연결하는 경우는 드물고 여러 ViewController에서 서버 통신을 진행하게 된다.

그 때마다 API를 만들어서 호출을 하게 될텐데 .. 코드를 작성하면 알겠지만 (그리고 대충 감으로도 알겠지만) 중복되는 코드가 많아진다는 것을 확인할 수 있다. 

 

엑스코드는 강아지가 아닌데 .. 꼭 반복되는 코드를 줄여야할까요?

ㄴ 네. 

ㄴ ㅇㅋㅇㅋ.

 

어떻게 줄이는가 하면 .. 방법은 쉽다.

반복되는 코드를 하나로 모아주면 된다.

 

URLSession을 확장시켜보자.

위의 LottoAPIManager에서 중복되게 사용될 코드를 살펴보면

    static func requestLotto(drwNo: Int, completionHandler: @escaping (Lotto?, APIError?) -> ()) {
        guard let url = URL(string: "https://www.dhlottery.co.kr/common.do?method=getLottoNumber&drwNo=\(drwNo)") else { return }
        
        URLSession.shared.dataTask(with: url) { data, response, error in ✅

            DispatchQueue.main.async { 
                guard error == nil else { ✅ 
                    print("Failed Request")
                    completionHandler(nil, .failedRequest)
                    return
                }

                guard let data = data else { ✅
                    print("No Data Returned")
                    completionHandler(nil, .noData)
                    return
                }

                guard let response = response as? HTTPURLResponse else { ✅
                    print("Unable Response") 
                    completionHandler(nil, .invalidResponse)
                    return
                }

                guard response.statusCode == 200 else { ✅
                    print("Failed Response")
                    completionHandler(nil, .failedRequest)
                    return
                }

                do {
                    let result = try JSONDecoder().decode(Lotto.self, from: data)
                    completionHandler(result, nil)

                } catch let error {
                    completionHandler(nil, .invalidData)
                    print(error)
                }
            }

        }.resume() ✅

 

이렇게 비슷한 성격의 서버 통신을 하는 경우에는 URLSession에서 shared를 사용해 dataTask로 서버와 통신하는 것이 유사하다.

 

그러므로 URLSession을 확장해서 코드를 합쳐보자.

static func request<T: Codable>(_ session: URLSession = .shared, endpoint: URLRequest,  completionHandler: @escaping (T?, APIError?) -> ()) {
        session.customDataTask(endpoint) { data, response, error in
        
        ...
        
        }
}

 

먼저 이렇게 URLSession의 경우는 파라미터로 받되, 기본적으로는 .shared로 설정을 한다.

 

그리고 각 URL 마다 요청해야하는 값들이 다를 수 있기 때문에 URLRequest도 파라미터로 받아서 일반화한다.

 func customDataTask(_ endpoint: URLRequest, completionHandler: @escaping completionHandler) -> URLSessionDataTask {
     let task = dataTask(with: endpoint, completionHandler: completionHandler)
     task.resume()
     
     return task
 }

해당 URL로 서버 통신을 시작하는데, 기본적으로 suspended 상태이므로 resume으로 재개한다.

 

_

전체 코드를 살펴보면

import Foundation

extension URLSession {
    
    ✅ typealias completionHandler = (Data?, URLResponse?, Error?) -> Void
    
    func customDataTask(_ endpoint: URLRequest, ✅ completionHandler: @escaping completionHandler) -> URLSessionDataTask {
        let task = dataTask(with: endpoint, ✅ completionHandler: completionHandler)
        task.resume()
        
        return task
    }
    
    static func request<T: Codable>(_ session: URLSession = .shared, endpoint: URLRequest,  ✔️ completionHandler: @escaping (T?, APIError?) -> ()) {
        session.customDataTask(endpoint) { ✅ data, response, error in
            DispatchQueue.main.async {
                guard error == nil else {
                    print("Failed Request")
                    ✔️ completionHandler(nil, .failedRequest)
                    return
                }
                
                guard let data = data else {
                    print("No Data Returned")
                    ✔️ completionHandler(nil, .noData)
                    return
                }
                
                guard let response = response as? HTTPURLResponse else {
                    print("Unable Response")
                    ✔️ completionHandler(nil, .invalidResponse)
                    return
                }
                
                guard response.statusCode == 200 else {
                    print("Failed Response")
                    ✔️ completionHandler(nil, .failedRequest)
                    return
                }
                
                do {
                    let result = try JSONDecoder().decode(T.self, from: data)
                    ✔️ completionHandler(result, nil)
                    
                } catch let error {
                    ✔️ completionHandler(nil, .invalidData)
                    print(error)
                }
            }
        }
    }
}

✅ 로 표시를 한, completionHandler는 dataTask 메서드 호출 이후 data, response, error를 반환하는 탈출 클로저이다.

이 반환값을 바탕으로 에러 처리를 해서 원하는 데이터 모델과 에러 종류를 다시 ✔️ 로 표시한 탈출 클로저로 반환해서 ViewController(혹은 ViewModel)에서 서버 통신을 하고 반환값으로 사용하는 것이다. 

 

추가로, (T?, APIError?) -> Void 형태의 탈출 클로저임을 알 수 있는데,

여기서 데이터와 에러가 모두 옵셔널로 처리된 이유는 

- 데이터가 제대로 들어온 경우에는 에러가 nil로

- 에러인 경우에는 데이터가 nil로 처리되기 때문이다.

 


아직 조금 .. 어색해도 기본적인 흐름/구조는 비슷하기 때문에 .. 많이 해보면서 익숙해지는 것이 답이라고 생각한다 ..

아자자 .. !