iOS 프로젝트/카멜레온

[iOS] 카멜레온 개발 일지 - 4 (API 통신과 URLSession, completionHandler)

유정주 2022. 6. 20. 22:47
반응형

안녕하세요. 개발하는 정주입니다.

오늘은 카멜레온 개발 일지 - 4에 대해 포스팅하려고 합니다.
서버와 API 통신을 하는 내용입니다.
URLSession만을 사용했고 URLSession이 무엇인지에 대한 것은 따로 포스팅할 계획입니다.
라이브러리를 사용하지 않은 이유, URLSession만 쓰면서 발생한 문제(?)를 다뤄보겠습니다!

 

+) 관련 내용을 공부하다보니 제가 느낀 문제점은 completionHandler로 비동기 처리를 하면서 발생한 것도 많았습니다.

그래서 이번 포스팅은 URLSession과 completionHandler의 단점 정도로 받아들여주시면 감사하겠습니다.

조금 더 개념이 잡히면 정리해서 다시 써야겠군요... ㅠㅠ

* 해당 포스팅은 대략적인 개발 일지로 자세한 내용은 필요시에만 따로 포스팅합니다.

 

 

Alamofire를 쓰지 않은 이유

카멜레온 프로젝트에서는 Alamofire를 사용하지 않고 URLSession만을 이용했습니다.

처음부터 Alamofire를 이용해 개발하면 URLSession으로 개발하는 방법을 모르기 때문에
기본기가 다져지지 않을 것 같았습니다.
혹여나 나중에 Alamofire에 문제가 생기면 많이 난감해지기도 합니다.

기본기를 다지기 위해!라는 생각 하나로 URLSession만을 이용해 개발한 것이죠 ㅎ
지금 생각해보면 참 잘한 선택인 거 같아요. (물론 URLSession이 만만해졌다는 건 아닙니다 ㅎ;)

 

 

사용한 API 종류

카멜레온에 들어가는 API 종류는 크게 세 가지입니다.

  1. GET
  2. POST
  3. multipart/form-data

1, 2번은 다들 익숙하실 것이고 3번은 낯설 수 있습니다.
사진을 서버로 보낼 때 사용하고 POST의 Content-Type을 multipart/form-data로 설정합니다.
파일 데이터를 업로드할 때 쓰는 것으로 이해하면 될 듯합니다.

따라서 GET 메서드, POST 메서드, POST 메서드 중 multipart/form-data로 크게 세 가지를 사용합니다.
각각 대표적인 사용처만 하나씩 살펴보겠습니다.

 

 

GET

GET은 데이터를 요청할 때 서버로 요청하는 Header의 URL에 데이터를 넣어 전달합니다.
카멜레온에서는 GET에서 데이터를 전달하지는 않습니다.

 

GET 구현

GET을 요청하는 코드를 살펴봅시다.

더보기
private func requestGet(url: String, completionHandler: @escaping (Bool, Any) -> Void) {
    guard let url = URL(string: url) else {
        completionHandler(false, "Error: cannot create URL")
        return
    }

    var request = URLRequest(url: url)
    request.httpMethod = "GET"
    request.setValue(authorization, forHTTPHeaderField: authorizationHeaderKey)

    URLSession.shared.dataTask(with: request) { data, response, error in
        guard error == nil else {
            completionHandler(false, "Error: error calling GET -> \(error!)")
            return
        }

        guard let data = data else {
            completionHandler(false, "Error: Did not receive data")
            return
        }

        guard let response = response as? HTTPURLResponse, (200 ..< 300) ~= response.statusCode else {
            completionHandler(false, "Error: HTTP request failed: \((response as! HTTPURLResponse).statusCode)")
            return
        }

        if let output = try? JSONDecoder().decode(Response.self, from: data) {
            completionHandler(output.result == self.okString, output)
        } else {
            ...
        }
    }.resume()
}

 

생각보다 길지만 예외처리가 절반입니다! ㅎㅎ (로그 및 설명에 불필요한 코드는 줄였습니다.)

  1. URL 확인
  2. URLRequest 생성
  3. URLRequest의 Method를 GET으로 설정
  4. 필요시 Auth 키 추가
  5. request 후 data, response, error 대기
  6. response 디코딩 후 전달

여기서 error가 존재하는지, data가 존재하는지, 정상 response가 왔는지, response가 디코딩이 되는지를 확인하여
문제가 있다면 completionHandler에 false와 에러 내용을 전달합니다.
문제가 없다면 response의 result가 ok인지와 디코딩 결과를 전달합니다.

guard 문을 각각 처리한 이유는 어떤 문제가 발생했는지 더 정확히 알기 위해서입니다.
한 번에 처리하면 코드는 짧아지겠지만 Bool값이 false일 때 디버깅이 힘들 수 있습니다.
어차피 메서드로 만들어 한 번만 작성할 것이므로 코드가 길어지더라도 따로 따로 처리해주었습니다.

completionHandler로 전달된 내용은 각 호출 부분에서 후처리를 해줍니다.

 

GET 사용 예시

카멜레온에서는 버전을 가져올 때 GET을 사용합니다.

func getVersion(completionHandler: @escaping (Bool, Any) -> Void) {
    requestGet(url: serverIP + "/version", completionHandler: { [weak self] (result, response) in
        guard let self = self else { return }

        if result || self.retryCount == 3 {
            self.retryCount = 0
            completionHandler(result, response)
        } else {
            self.retryCount += 1
            self.getVersion(completionHandler: completionHandler)
        }
    })
}

위 requestGet 메서드를 호출하여 version 값을 요청합니다.
만약 result가 false라면 최대 3회까지 재요청합니다.

retry는 제 나름대로 작성해봤는데 잘 작성한건지 모르겠습니다 ㅎ;
좀 더 살펴보며 다듬을 예정입니다.

HttpService.shared.getVersion(completionHandler: { [weak self] (result, response) in
    if result {
        self?.setupAppInfo(response: (response as! Response))
        self?.presentNextVC() //버전 세팅 후 홈으로 이동
    } else {
        self?.showErrorAlert(erorr: "서버 통신에 실패했습니다.", action: { _ in
            self?.presentNextVC()
        })
    }
})

전달받은 completionHandler에서 result를 확인하여 true라면 홈 화면으로 이동하고
false 라면 서버 통신에 실패했다는 Alert를 띄웁니다.

 

POST

POST는 데이터를 Http Body에 담아 전달하는 방식입니다.
카멜레온에서는 Json 형식으로 서버에 전달하고 서버로부터 전달 받습니다.
URLSession을 이용해 POST를 어떻게 구현하는지 간단히 살펴봅시다.

POST 구현

더보기
private func requestPost(url: String, param: [String: Any], completionHandler: @escaping (Bool, Any) -> Void) {
    let sendData = try! JSONSerialization.data(withJSONObject: param, options: [])

    guard let url = URL(string: url) else {
        print("Error: cannot create URL")
        return
    }

    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue(authorization, forHTTPHeaderField: authorizationHeaderKey)
    request.addValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = sendData

    URLSession.shared.dataTask(with: request) { (data, response, error) in
        guard error == nil else {
            completionHandler(false, "Error: error calling GET -> \(error!)")
            return
        }

        guard let data = data else {
            completionHandler(false, "Error: Did not receive data")
            return
        }

        guard let response = response as? HTTPURLResponse, (200 ..< 300) ~= response.statusCode else {
            completionHandler(false, "Error: HTTP request failed: \((response as! HTTPURLResponse).statusCode)")
            return
        }

        guard let output = try? JSONDecoder().decode(Response.self, from: data) else {
            completionHandler(false, "Error: JSON Data Parsing failed")
            return
        }

        completionHandler(output.result == self.okString, output)
    }.resume()
}

 

GET과 크게 다른 점은 없습니다.
GET과의 차이점은 Content-Type을 설정하고 httpBody에 데이터를 넣는 것 뿐입니다.

  1. 전달할 데이터 json 데이터로 변환
  2. URL 확인
  3. URLRequest 생성
  4. URLRequest의 Method를 POST로 설정
  5. 필요 시 Auth 키 추가
  6. Content-Type, httpBody 설정
  7. request 후 data, response, error 대기
  8. response 디코딩 후 전달

 

POST 사용 예시

선택한 얼굴의 index를 서버로 전송하는데 POST를 사용합니다.

func sendCheckedFaces(params: [String: Any], completionHandler: @escaping (Bool, Any) -> Void) {
    requestPost(url: serverIP + "/faces", param: params, completionHandler: { [weak self] (result, response) in
        guard let self = self else { return }

        if result || self.retryCount == 3 {
            self.retryCount = 0
            completionHandler(result, response)
        } else {
            self.retryCount += 1
            self.sendCheckedFaces(params: params, completionHandler: completionHandler)
        }
    })
}

reqeustPost( )에 url와 전달할 데이터를 전달합니다.
requestGet( )과 마찬가지로 실패시 3회 재시도합니다.

let jsonData = ["faces": indexArray, "mode": UploadData.shared.convertType] as [String : Any]
HttpService.shared.sendCheckedFaces(params: jsonData) { [weak self] (result, response) in
    if result {
        DispatchQueue.main.async {
            let convertVC = ConvertViewController()
            convertVC.modalPresentationStyle = .fullScreen
            self?.navigationController?.pushViewController(convertVC, animated: true)
        }
    } else {
        self?.showErrorAlert()
    }
}

sendCheckedFaces( )를 호출하는 코드입니다.
전달할 데이터를 Dictionary로 만들어 전달합니다.
completionHandler를 통해 전달 받은 result를 체크하고 분기 작업합니다.
위 코드에서는 result가 true일 때 다음 화면으로 넘어가고 아니라면 에러 Alert를 띄웁니다.

 

 

Multipart

미지막으로 Multipart입니다.
Multipart는 파일을 업로드할 때 사용합니다.
카멜레온에서는 사진을 서버에 업로드할 때 사용합니다.

 

Multipart 구현

func requestMultipartForm(url: String, params: [String: Any], media: MediaFile, completionHandler: @escaping (Bool, Any) -> Void) {
    ...
    
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.addValue(authorization, forHTTPHeaderField: authorizationHeaderKey)
    request.addValue("multipart/form-data; boundary=\(authorization)", forHTTPHeaderField: "Content-Type")

    let data = createUploadBody(params: params, media: media)

    URLSession.shared.uploadTask(with: request, from: data) { (data, response, error) in
        ...
        
        completionHandler(output.result == self.okString, output)
    }.resume()
}

중복되는 부분은 생략했습니다.
Multipart도 Method는 POST입니다.
대신 multipart/form-data를 Content-type으로 설정하는 것이죠.
또한 Multipart는 data body 형식을 맞춰 생성해야 합니다.

private func createUploadBody(params: [String: Any], media: MediaFile) -> Data {
    let lineBreak = "\r\n"
    let boundaryPrefix = "--\(authorization)\(lineBreak)".data(using: .utf8)!
    let endBoundary = "--\(authorization)--\(lineBreak)".data(using: .utf8)!

    var body = Data()

    body.append(boundaryPrefix)

    let contentsType = (UploadData.shared.uploadType == .Photo) ? "image/\(media.extension)" : "video/\(media.extension)"
    if let imageData = media.data {
        body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(media.filename)\"\(lineBreak)".data(using: .utf8)!)
        body.append("Content-Type: \(contentsType)\(lineBreak + lineBreak)".data(using: .utf8)!)
        body.append(imageData)
        body.append(lineBreak.data(using: .utf8)!)
    }

    body.append(endBoundary)

    return body
}

형식이 조금만 달라도 통신이 안 되더라고요...
이 형식 맞추는 것이 첫 번째 고비였을 정도입니다 ㅎㅎ;
이미지라는 것을 표시하는 image 뒤에 이미지의 확장자를 붙여 Content-Type을 설정합니다.
영상이라면 video/확장자로 설정하면 됩니다.
마지막으로 Image 데이터를 body에 넣어주면 끝입니다.

이렇게 생성한 body를 uploadTask의 from 인자로 설정해 서버에 전달합니다.

GET, POST와 달리 귀찮은 작업이 굉장히 많았네요 ㅜㅠ;;;

 

 

URLSession만 쓰면서 힘들었던 점

URLSession와 completionHandler로 비동기 처리를 하면서

힘들었던 점은 API 통신을 동기적으로 하고 싶을 때 클로저가 중첩되는 것이었습니다.

예를 들어, 카멜레온 앱에서는 이런 동기적 로직이 존재합니다.

  1. [POST] 서버의 잔여 데이터 삭제
  2. [POST] 사진 업로드
  3. [GET] 얼굴 Detection 결과 요청

이 로직은 반드시 동기적으로 처리되야 하며 이를 코드로 표현하면 아래와 같습니다.

//잔여 데이터 삭제
HttpService.shared.deleteFiles(completionHandler: { [weak self] (result, response) in
    ...

    //이미지 파일 업로드
    HttpService.shared.uploadMedia(params: [:], media: mediaFile, completionHandler: { [weak self] (result, response) in
        ...

        //3초에 한 번씩 호출하면서 classifier가 완료되었는지 확인함
        HttpService.shared.getFaces(waitingTime: 3, completionHandler: { [weak self] (result, response) in
            ...
        })
    })
})

클로저가 중첩되니 코드가 길어져 로직을 한 번에 파악하기도 힘들었고 참조에 대한 문제도 걱정되었습니다.

 

종합하면 아래와 같이 정리할 수 있겠네요.

  • closure가 중첩된다.
  • completionHandler에 대한 호출 실수가 있을 수 있다.
  • 오류 처리가 어렵다.
  • 비동기 호출의 흐름이 복잡해진다.

 

지옥의 중첩 클로저는 async & await를 이용해 리팩토링 할 예정입니다.

비동기 코드를 동기 코드인 것처럼 작성할 수 있기 때문에 코드의 구조가 직관적으로 변합니다.

쉽지 않은 개념이기 때문에 조금 더 공부를 하고 진행을 할 것 같네요... ㅠㅠ

Alamofire를 이용하면 편리하게 동기 처리가 가능하고 무척이나 길었던 에러 핸들링도 간편해진다고 합니다.
코드가 짧아지고 직관적으로 변한다는 장점이 있습니다.
서버 통신에 Alamofire도 사용해보고 코드 길이 비교 포스팅을 해보는 것도 재밌을 것 같네요.

 

 

마무리

오늘은 서버 통신에 대한 개발일지를 작성해보았습니다.
처음으로 해보는 서버 통신이었기 때문에 어려움도 많았고 여러분이 보기에 허술한 부분도 많을 것입니다.
이제 종강도 했으니 다른 분의 코드를 보며 코드에 대한 안목을 기르고 리팩토링 해보려고 합니다.
지금도 최대한 깔끔하게 작성하려고 했는데 훨씬 깔끔해지도록 열심히 해보겠습니다 ㅎㅎ

감사합니다.


아직은 초보 개발자입니다.
더 효율적인 코드 훈수 환영합니다!
공감 댓글 부탁드립니다.



반응형