💻 개발/오늘의 삽질

[iOS / Swift] URL Encoding = nil...? (URL 인코딩이 되지 않을 때)

고도고도 2022. 9. 22. 22:29

공공데이터 포털에서 한국환경공단에서 제공하는 대기질 정보를 통해서 간단하게 측정소별 대기질 정보를 확인할 수 있는 앱을 만들어보려고 했다. 

 

한국환경공단_에어코리아_측정소정보

대기질 측정소 정보를 조회하기 위한 서비스로 TM 좌표기반의 가까운 측정소 및 측정소 목록과 측정소의 정보를 조회할 수 있다. ※ 운영계정으로 사용하고자 할 경우 에어코리아 OpenAPI 사용자

www.data.go.kr

 

구현하려는 앱은 총 3개의 ViewController로,

  • 지역명을 검색할 수 있는 페이지
  • 해당 지역의 측정소 목록을 보여주는 페이지
  • 특정 측정소의 대기질의 상세정보를 보여주는 페이지

로 구성되어 있다.

 

 

우선 지역명을 검색할 수 있는 페이지인 ViewController부터 살펴보자.

//
//  ViewController.swift
//  Basic_07
//
//  Created by 고도 on 2022/09/14.
//

import UIKit

class ViewController: UIViewController {
    @IBOutlet weak var cityNameTextField: UITextField!
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    @IBAction func tapFindButton(_ sender: UIButton) {
        guard let viewController = self.storyboard?.instantiateViewController(withIdentifier: "RegionTableViewController") as? RegionTableViewController else { return }
        viewController.city = self.cityNameTextField.text ?? "대전"
        
        self.navigationController?.pushViewController(viewController, animated: true)
    }
}

push 방식으로 화면 이동을 구현했으며, 검색하기 버튼을 클릭하면 이동하는 페이지인 RegionTableViewController에 입력한 지역명을 전달하기 위해 ViewController를 인스턴스화하고 값을 전달하는 방식으로 구현했다.

 

다음은 해당 지역의 측정소 목록이 출력되는 RegionTableViewController이다.

//
//  RegionTableViewController.swift
//  Basic_07
//
//  Created by 고도 on 2022/09/14.
//

import UIKit

class RegionTableViewController : UIViewController {
    var city: String = ""
    var cities: [Item] = []
    
    let apiKey: String = "...생략"
    
    @IBOutlet weak var regionTableView: UITableView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.regionTableView.dataSource = self
        self.regionTableView.delegate = self
        
        self.getData(city: city)
    }
    
    func getData(city: String) {
        let session: URLSession = URLSession(configuration: .default)
        let addr: String = "https://apis.data.go.kr/B552584/MsrstnInfoInqireSvc/getMsrstnList?serviceKey=\(apiKey)&returnType=json&numOfRows=100&pageNo=1&addr=\(city)"      
        
        // URL로 변환
        guard let url = URL(string: addr) else { return }
        debugPrint(url)
    
        session.dataTask(with: url) { data, response, error in
            if let error {
                print(String(describing: error))
            }
            
            if let data = data {
                do {
                    let userResponse = try JSONDecoder().decode(UserResponse.self, from: data)
                    self.cities = userResponse.response.body.items
                    DispatchQueue.main.async {
                        self.regionTableView.reloadData()
                    }
                } catch(let error) {
                    print(String(describing: error))
                }
            }
        }.resume()
    }
}

// Cell 선택 시 동작
extension RegionTableViewController : UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        guard let viewController = self.storyboard?.instantiateViewController(withIdentifier: "RegionWeatherViewController") else { return }
        self.navigationController?.pushViewController(viewController, animated: true)
    }
}

// Cell 세부 정보
extension RegionTableViewController : UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return self.cities.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "RegionCell", for:indexPath) as? RegionCell else { return UITableViewCell() }
        cell.getData(city: self.cities[indexPath.row])
        return cell
    }
}

간단하게 설명하자면 하단의 extension은 UITableView를 사용하기 위해 채택한 Delegate와 DataSource이다. ViewController가 viewDidLoad 되면 getData 함수를 호출하여 API를 통해 서버에서 데이터를 받아오는데 이 부분에서 문제가 발생했다.

 

String을 URL로 변환해주는 guard let 구문에서 return으로 빠지는 것이었다. return으로 인해 함수가 종료되면서 debugPrint 이하로는 실행되지 않았다.

 

1. 다른 방식으로 URL을 생성

처음에는 문제가 무엇인지 파악할 수 없었다. 변환되기 전 addr 변수를 출력하고 POSTMAN에서 직접 호출에 봤는데 정상적으로 호출되고 있었다. nil이 리턴되면서 자연스레 return으로 빠지는 것인데 원인을 파악할 수 없었다... 🤔

 

기존의 addr 대신, 짧은 문자열을 넣어봤는데 debugPrint 이후 코드들이 정상적으로 실행되면서 addr이 너무 긴 탓인가? 쿼리와 파라미터까지 한 번에 때려박은 탓인가에 대한 고민을 했다.

 

그래서 addr을 다른 방법으로 구현했다. 지금처럼 String을 한 번에 URL로 변환하는 방법URLComponents와 URLQueryItem을 활용하여 URL을 생성하는 방법이 있다. 후자의 방법으로 리펙토링을 진행했다.

// ########## 변경 전 ##########
let session: URLSession = URLSession(configuration: .default)
let addr: String = "https://apis.data.go.kr/B552584/MsrstnInfoInqireSvc/getMsrstnList?serviceKey=\(apiKey)&returnType=json&numOfRows=100&pageNo=1&addr=\(city)"

guard let url: URL = domain?.url else { return }

// ########## 변경 후 ##########
var baseURL: URLComponents? = URLComponents(string: "https://apis.data.go.kr/B552584/MsrstnInfoInqireSvc/getMsrstnList")
let returnType: URLQueryItem = URLQueryItem(name: "returnType", value: "json")
let numOfRows: URLQueryItem = URLQueryItem(name: "numOfRows", value: "100")
let pageNo: URLQueryItem = URLQueryItem(name: "pageNo", value: "1")
let addr: URLQueryItem = URLQueryItem(name: "addr", value: city)
let serviceKey: URLQueryItem = URLQueryItem(name: "serviceKey", value: apiKey)
 
baseURL?.queryItems = [returnType, numOfRows, pageNo, addr, serviceKey]

guard let encodedUrl = String(baseURL?.url) else { return }

하지만 역시 guard let 구문에서 return으로 빠지면서 함수가 종료됐다.

 

2. 인코딩 문제?

그렇게 삽질을 계속하다가 갑자기 인코딩이라는 단어가 떠올랐다. 매개변수로 전달한 addr을 URL 구조체 내부적으로 인코딩을 진행하고 있지 않을까란 생각으로 검색을 해보았고 아래 공식 문서를 접할 수 있었다.

 

 

Apple Developer Documentation

 

developer.apple.com

 

문자열에서 대체되지 않은 문자를 전달하여 이를 바탕으로 인코딩을 진행하는 함수였다. 공식 문서에 나와있는 것처럼 urlQueryAllowed을 매개변수로 전달하고 다시 한번 url을 출력해보았다.

guard let encodedStr = addr.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return }
guard let url = URL(string: encodedStr) else { return }

하지만 변환은 됐지만 API 호출에서 오류가 발생하길래 변환된 url을 비교해봤다.

// 정상적인 url
https://apis.data.go.kr/B552584/MsrstnInfoInqireSvc/getMsrstnList?serviceKey=DlsG82zYpBjL3dL6XB52XaI9n%2FLX37nb5v%2BjUrn9IxLT%2Fe78qeC6ChO9heFQeJwv%2BpclYR8ux0Q4e1stnKHE2Q%3D%3D&returnType=json&numOfRows=100&pageNo=1&addr=%EB%8C%80%EC%A0%84

// 변환된 url
https://apis.data.go.kr/B552584/MsrstnInfoInqireSvc/getMsrstnList?serviceKey=DlsG82zYpBjL3dL6XB52XaI9n%252FLX37nb5v%252BjUrn9IxLT%252Fe78qeC6ChO9heFQeJwv%252BpclYR8ux0Q4e1stnKHE2Q%253D%253D&returnType=json&numOfRows=100&pageNo=1&addr=%EB%8C%80%EC%A0%84

약간의 차이가 보이는가? 변환된 url은 %가 한번 더 인코딩 되어 %25로 변환된 것을 볼 수 있다.

 

사실 이 부분에 대해서는 공식문서에서도 다루고 있었다.

%로 인코딩된 문자열에서 이 메소드를 호출하면 %가 한 번 더 인코딩되므로 주의하라는 것이었다. 그래서 이 문제를 해결하기 위해 인코딩되지 않은 API KEY를 사용해보기로 했다. 왜냐하면 공공데이터 포털에서는 두 가지 종류 키를 제공했기 때문이다.

 

기존에는 위에 인코딩된 API KEY를 사용했는데 이를 디코딩된 API KEY로 변경했다. 하지만 디코딩된 API KEY 역시, URL 변환 과정에서 인코딩이 제대로 되지 않아 역시 API 호출이 정상적으로 진행되지 않았다.

 

결국 간단하지만 강력한 방법을 적용하기로 했다. 바로 replacingOccurrences를 활용하는 것이었다.

let encodedAndReplacedStr = encodedStr.replacingOccurrences(of: "%25", with: "%")

이렇게 해서 문제를 해결...! 할 수 있는 줄 알았지만... 다른 문제가 발생했다. 😭

 

3. SSH 보안 오류?

인증서 관련 오류 같은데 정확히는 모르겠다. 찾아보니까 구 버젼의 Xcode, Swift에서 자주 발생하던 문제 같은데 난 Xcode 14, Swift 5인데? 그러던 중 나와 비슷한 문제를 겪는 분을 발견했다. 이 분 역시 공공데이터 포털에서 제공하는 다른 API를 사용하던 중에 발생한 오류였다. 

 

 

<스위프트 프로젝트> 공공 api 받기

음 ... 왜안되지 Codable에 대해서 이해가 필요하다. 뭐 이건 나중으로 미루고 왜 시키는대로 했는데 안되는지 모르겠다. http://apis.data.go.kr/1360000/VilageFcstMsgService/getWthrSituation ?serviceKey=인..

pinelover.tistory.com

 

위 블로그에서 나와있는 방식으로 해결했지만 모든 주소에 대해 허용을 하기에 보안적으로 문제가 발생할 수 있다.

 

우선 임시 방편으로 이렇게 해결했다. 추측으로는 도메인 형태에서 발생하는 문제라고 생각한다. (시간을 갖고 좀 더 찾아봐야겠다.) 

 

최종 코드는 아래와 같다.

//
//  RegionTableViewController.swift
//  Basic_07
//
//  Created by 고도 on 2022/09/14.
//

import UIKit

class RegionTableViewController : UIViewController {
    var cities: [Item] = []
    var city: String = ""
    let apiKey: String = "...생략"
    
    @IBOutlet weak var regionTableView: UITableView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.regionTableView.dataSource = self
        self.regionTableView.delegate = self
        
        self.getData(city: city)
    }
    
    func getData(city: String) {
        let session: URLSession = URLSession(configuration: .default)
        let addr: String = "https://apis.data.go.kr/B552584/MsrstnInfoInqireSvc/getMsrstnList?serviceKey=\(apiKey)&returnType=json&numOfRows=100&pageNo=1&addr=\(city)"
        
        guard let encodedStr = addr.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return }
        let encodedAndReplacingStr = encodedStr.replacingOccurrences(of: "%25", with: "%")
        
        guard let url = URL(string: encodedAndReplacingStr) else { return }
        
        session.dataTask(with: url) { data, response, error in
            if let error {
                print(String(describing: error))
            }
            
            if let data = data {
                do {
                    let userResponse = try JSONDecoder().decode(UserResponse.self, from: data)
                    self.cities = userResponse.response.body.items
                    DispatchQueue.main.async {
                        self.regionTableView.reloadData()
                    }
                } catch(let error) {
                    print(String(describing: error))
                }
            }
        }.resume()
    }
}

// Cell 선택 시 동작
extension RegionTableViewController : UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        guard let viewController = self.storyboard?.instantiateViewController(withIdentifier: "RegionWeatherViewController") else { return }
        self.navigationController?.pushViewController(viewController, animated: true)
    }
}

// Cell 세부 정보
extension RegionTableViewController : UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return self.cities.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "RegionCell", for:indexPath) as? RegionCell else { return UITableViewCell() }
        cell.getData(city: self.cities[indexPath.row])
        return cell
    }
}

 

4. 해결!

우선 내가 원하던 대로 앱이 작동하기 시작했다. 몇 가지 좀 더 찾아봐야겠지만 그래도 해결해서 뿌듯하다. 오늘 삽질을 통해서 알게된 것은 문자열에 %가 있으면 기본 생성자인 URL(string: )으로는 URL을 생성할 수 없고, .addPercentEncoding을 통해 URL을 생성해야한다는 것이다.

// ########## 1. nil이 return ##########
  let baseURL: String = "https://www.naver.com%"
  guard let encodedURL: URL = URL(string: baseURL) else { return }
  debugPrint(encodedURL)
  
// ########## 2. 정상적으로 문자열이 return ##########
  let baseURL: String = "https://www.naver.com%"
  guard let baseAndPercentEncodingURL = baseURL.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return }
  guard let encodedURL: URL = URL(string: baseAndPercentEncodingURL) else { return }
  print(encodedURL)

또한 위 방식대로 진행하면 %가 한번 더 인코딩 되어 %25가 된다는 것이다.

 

사실 이번에 사용한 공공데이터 API는 전에 안드로이드 개발 당시에 사용해본 경험이 있는 API였다. 사용 경험이 있었기에 이번에 적용한 것이었는데 당시에는 문제 없이 정상적으로 호출됐었다. iOS가 확실히 보안적으로 더 까다로운 것 같다. 관련해서 좀 더 찾아봐야겠다.

 

🔗 참고

 

[iOS] iOS9 App Transport Security 설정법

iOS9으로 업데이트 되면서, HTTP로 접속을 하거나, 인증되지 않은 HTTPS 즉, 정상적인 SSL이 아닌 곳으로 이동이나 webView를 띄우면 아래와 같은 에러가 나게 됩니다. NSURLSession/NSURLConnection HTTP load fa..

blowmj.tistory.com

 

iOS ) DecodingError

안녕하세요 :) Zedd입니다. 날씨가 정말 선선해져따..... 세상의 모든 비염 분들..이겨냅시다.................... 암튼 오늘은 DecodingError를 공부해보려고 합니다. DecodingError가 모냐면.. 말그대로 Decod..

zeddios.tistory.com

 

[SWIFT] URL encoding (URL nil이 될 때 - addingPercentEncoding)

URL에 한국어를 넣거나 다른 특수문자들을 넣어야 될 때가 있는데요. 이럴 때, URL(string: someStr)을 이용하면 nil 값으로 변형되는 때가 있습니다. 저도 예전에 카카오 오픈 API를 이용해서 장소를 검

dongminyoon.tistory.com