Skip to content

Google GeoCoding을 이용하여 산 이름으로 (위도,경도) 찾기

yjwyjwyjw edited this page Dec 1, 2021 · 5 revisions

JSON 가내 수공업 후기

클론 대상인 올라 에서는 100대 명산만을 제공하고 있는데, 이번 프로젝트에서 올라와의 주요 차별점 중 하나가 표시하는 산의 갯수가 더 많다는 것이었다. 더욱 더 많은 산 위치를 표시하기 위한 API를 찾아 본 결과, 대한민국의 산 정보 3300여개를 보유한 JSON 파일을 찾는데 성공했다. 하지만 위치(위도와 경도)와 산 이름이 같이 표기되지 않아 어떻게든 위치 정보를 직접 만들어야 했다!

방법은 크게 4가지가 있었다.

  1. 하나하나 위도와 경도를 수작업으로 입력
  • 시간이 많이 들고 작업 할 수 있는 양이 한정적이다. 무엇보다 프로그래머 답지 못하다.
  1. CoreLocation 안에 탑재된 GeoCoding 사용
  • 간편하긴 하지만 한국의 산 이름으로만 검색 했을 시 아주 유명한 산이 아니면 정확도가 매우 떨어졌고, 리퀘스트 제한이 심했다. 애플 가이드라인에 따르면 유저 액션 하나당 리퀘스트 하나, 혹은 1분에 하나를 권장하고 있다. 특정 상황이 아니면 쓰기 힘들것으로 보인다.
  1. 네이버 Map API GeoCoding
  • 국내 사이트에서 만든것이라 정확도가 높을것이라 기대했으나, 정확한 주소 (ex. OO리 OO읍 무슨무슨 산)를 요구했다. 산 API에 소재지 정보를 포함하고 있긴 했지만, 정확한 검색은 어렵다고 판단해 패스했다.

결국, OO산 이라고 대충 검색해도 상당한 정확도를 자랑하는 Google GeoCoding을 선택하게 되었다.

구현

구현 과정은 대략 다음과 같다.

다운로드한 JSON 파일을 읽기 -> 산 이름으로 Google GeoCoding 리퀘스트 보내기 -> 응답에서 위도와 경도 추출 -> 산정보와 위치정보를 합쳐 새로운 데이터로 저장

문제점

하지만 예상치 못한 문제들이 있었는데, 다음과 같다.

  1. 같은 산이 여러 지역에 걸쳐 있을 수 있었다. 예를들어 가리산의 경우 강원도와 경기도에 걸쳐 존재하는 산이기 때문에 이름은 '가리산'으로 같아도 실질적으로 강원도의 가리산 / 경기도의 가리산 으로 분류가 되고 실제로 JSON 파일에는 해당 산의 정보가 같은 이름, 다른 소재지/고도/설명으로 각각 들어가 있다. 리퀘스트를 산의 이름만으로 보내고 가장 정확도가 높은 첫번째 응답만을 받기 때문에 같은 이름의 산 정보가 여러개 있을시 같은 위치에 마커가 2번 찍히게 되었다.
  2. JSON 원본에 자체적으로 오류가 있는 산들이 있어 위치좌표가 중국, 일본, 미국 샌프란시스코에 찍혀있는 경우도 있어 이를 필터링 해야 했다. 또한 산이 아닌 봉우리, 공원 같은 지형도 있어 이를 걸러낼 필요가 있었다.
  3. 산 데이터가 무슨 이유에선지 파싱에 실패했다.

해결

  1. 검색어를 소재지 + 산이름 으로 하여 해결했다. 검색어가 지저분해져 걱정했는데, 구글 검색 알고리즘은 거의 항상 결과를 찾아냈다.
  2. 응답에 딸려오는 지역 코드를 검사하여 국외는 필터링했다. 또한 응답의 장소 분류를 natural_feature로 한정하는것으로 자연지물인 산을 보다 높은 정확도로 찾을 수 있었다.
  3. 응답에 plusCode 라는 구글이 자체 정의 한 객체가 있는데, 응답에 따라 있을때도 있고 없을때도 있었다. 응답 객체에서 plusCode를 옵셔널로 선언하여 해결했다.

코드는 아래와 같다. QuickType과 Paw에서 자동생성된 코드도 이용했다.

//
//  main.swift
//  MountainGenerator
//
//  Created by Jiwon Yoon on 2021/11/25.
//
//  Mac Command Line Tool

import Foundation

var processedMountains = Set<MountainWithLocation>()

func readJSON() -> [MountainInfo] {
    let data = try? Data(contentsOf: URL(fileURLWithPath: "/Users/jiwonyoon/Desktop/Mountains.json"))
    let decoded = try? JSONDecoder().decode([MountainInfo].self, from: data!)
    
    return decoded ?? []
}

// 국내에 없거나, 자연 지형(natural feature)이 아니면 산 데이터로 인정 하지 않음
func isValidMountain(response: GeoCodeDTO?) -> Bool {
    guard let response = response,
          let addressComponents = response.results.first?.addressComponents,
          let types = response.results.first?.types else {
              return false
          }
    var isInKorea = false
    let isNaturalFeature = types.contains("natural_feature")
    
    for addressComponent in addressComponents {
        if addressComponent.longName == "대한민국" || addressComponent.shortName == "KR" {
            isInKorea = true
            break
        }
    }
    return isInKorea && isNaturalFeature
}

func sendRequest(for mountain: MountainInfo) {
    let sessionConfig = URLSessionConfiguration.default
    let session = URLSession(configuration: sessionConfig, delegate: nil, delegateQueue: nil)
    
    guard var URL = URL(string: "https://maps.googleapis.com/maps/api/geocode/json") else { return }
    let URLParams = [
        "address": "\(mountain.mountainRegion) \(mountain.mountainName)",
        "key": "비밀임",
        "language": "ko",
    ]
    URL = URL.appendingQueryParameters(URLParams)
    var request = URLRequest(url: URL)
    request.httpMethod = "GET"
    
    let task = session.dataTask(with: request, completionHandler: { (data: Data?, response: URLResponse?, error: Error?) -> Void in
        if let data = data, (error == nil) {
            let dto = try? JSONDecoder().decode(GeoCodeDTO.self, from: data)
            // 검색해도 위치정보가 나오지 않을 경우.
            guard let latitude = dto?.results.first?.geometry.location.lat,
                  let longitude = dto?.results.first?.geometry.location.lng,
                  isValidMountain(response: dto) else {
                      print("not a valid Mountain!")
                      return
                  }
            
            let mountainWithLocation = MountainWithLocation(mountain: mountain, latitude: latitude, longitude: longitude)
            processedMountains.insert(mountainWithLocation)
            print("\(mountainWithLocation.mountain.mountainName), \(mountainWithLocation.latitude), \(mountainWithLocation.longitude)")
            
        }
        else {
            // Failure
            print("URL Session Task Failed: %@", error!.localizedDescription);
        }
    })
    task.resume()
    session.finishTasksAndInvalidate()
}


let mountains = readJSON()
print(mountains.count)

for mountain in mountains {
    print("requesting: \(mountain.mountainName)")
    sendRequest(for: mountain)
    // 너무 리퀘스트를 동시에 보내면 구글 API가 응답하지 않음!
    sleep(1)
}
let mountainsWithLocation = Array(processedMountains)
//이후 json 파일로 저장

let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
if let jsonData = try? encoder.encode(mountainsWithLocation),
   let jsonString = String(data: jsonData, encoding: String.Encoding.utf8) {
    
    let pathWithFileName = URL(string: "/Users/jiwonyoon/Desktop/")?.appendingPathComponent("MountainsWithLocation.json")
    try jsonString.write(to: pathWithFileName!, atomically: true, encoding: .utf8)
}
//
//  GeoCoderResponse.swift
//  MountainGenerator
//
//  Created by Jiwon Yoon on 2021/11/25.
//

import Foundation

struct GeoCodeDTO: Codable {
    let results: [Result]
    let status: String
    
    enum CodingKeys: String, CodingKey {
        case results = "results"
        case status = "status"
    }
}

// MARK: - Result
struct Result: Codable {
    let addressComponents: [AddressComponent]
    let formattedAddress: String
    let geometry: Geometry
    let placeID: String
    let plusCode: PlusCode?
    let types: [String]

    enum CodingKeys: String, CodingKey {
        case addressComponents = "address_components"
        case formattedAddress = "formatted_address"
        case geometry
        case placeID = "place_id"
        case plusCode = "plus_code"
        case types
    }
}

// MARK: - AddressComponent
struct AddressComponent: Codable {
    let longName, shortName: String
    let types: [String]

    enum CodingKeys: String, CodingKey {
        case longName = "long_name"
        case shortName = "short_name"
        case types
    }
}

// MARK: - Geometry
struct Geometry: Codable {
    let location: Location
    let locationType: String
    let viewport: Viewport

    enum CodingKeys: String, CodingKey {
        case location
        case locationType = "location_type"
        case viewport
    }
}

// MARK: - Location
struct Location: Codable {
    let lat, lng: Double
}

// MARK: - Viewport
struct Viewport: Codable {
    let northeast, southwest: Location
}

// MARK: - PlusCode
struct PlusCode: Codable {
    let compoundCode, globalCode: String

    enum CodingKeys: String, CodingKey {
        case compoundCode = "compound_code"
        case globalCode = "global_code"
    }
}
//
//  MountainModel.swift
//  MountainGenerator
//
//  Created by Jiwon Yoon on 2021/11/25.
//

import Foundation

struct MountainInfo: Codable {
    let mountainName, mountainRegion, mountainHeight, mountainShortDescription: String
    
    enum CodingKeys: String, CodingKey {
        case mountainName = "MNTN_NM"
        case mountainRegion = "MNTN_LOCPLC_REGION_NM"
        case mountainHeight = "MNTN_HG_VL"
        case mountainShortDescription = "DETAIL_INFO_DTCONT"
    }
}

struct MountainWithLocation: Codable, Hashable {
    let mountain: MountainInfo
    let latitude: Double
    let longitude: Double
    
    enum CodingKeys: String, CodingKey {
        case mountain = "mountain"
        case latitude = "latitude"
        case longitude = "longitude"
    }
    // 위도와 경도가 같을시 같은 데이터로 취급
    func hash(into hasher: inout Hasher) {
        hasher.combine(latitude)
        hasher.combine(longitude)
    }
    
    static func ==(lhs: MountainWithLocation, rhs: MountainWithLocation) -> Bool {
        return lhs.longitude == rhs.longitude && lhs.latitude == rhs.latitude
    }
}
//
//  Extensions.swift
//  MountainGenerator
//
//  Created by Jiwon Yoon on 2021/11/25.
//

import Foundation

protocol URLQueryParameterStringConvertible {
    var queryParameters: String {get}
}

extension Dictionary : URLQueryParameterStringConvertible {
    /**
     This computed property returns a query parameters string from the given NSDictionary. For
     example, if the input is @{@"day":@"Tuesday", @"month":@"January"}, the output
     string will be @"day=Tuesday&month=January".
     @return The computed parameters string.
    */
    var queryParameters: String {
        var parts: [String] = []
        for (key, value) in self {
            let part = String(format: "%@=%@",
                String(describing: key).addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!,
                String(describing: value).addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!)
            parts.append(part as String)
        }
        return parts.joined(separator: "&")
    }
    
}

extension URL {
    /**
     Creates a new URL by adding the given query parameters.
     @param parametersDictionary The query parameter dictionary to add.
     @return A new URL.
    */
    func appendingQueryParameters(_ parametersDictionary : Dictionary<String, String>) -> URL {
        let URLString : String = String(format: "%@?%@", self.absoluteString, parametersDictionary.queryParameters)
        return URL(string: URLString)!
    }
}
Clone this wiki locally