이번에 실습한 내용은 MVC 디자인 패턴, API 통신, 그리고 JSON Parsing 을 이용한 간단 날씨 앱 구현이다. 이때까지 배운 MVC 디자인 패턴을 실제 적용해보고, OpenWeather 라는 사이트의 API 를 이용해 실시간 날씨 정보를 불러오는 것과, 불러온 정보를 JSON Parsing 을 이용해 실제 앱에 표시하는 내용을 배웠다.
우선 StoryBoard 는 위와 같이 구성하였다. 각 object 가 화면 상에서 어떤 부분인지 이해하기 쉽게 빨간색 선으로 표시하였다.
MVC 디자인 패턴을 적용하기 위해 일단 폴더를 위와 같이 나누어주었다.
(앱을 완성하고 찍은 스크린샷이라 모든 파일이 다 이미 있는건 양해 부탁함다)
Model 폴더에는 앱이 "무엇을" 할지를, View 폴더에는 사용자에게 "보여지는" 부분을, 그리고 Controller 폴더에는 Model 이 가지고 있는 데이터를 "어떻게" 할 것인지 명령하는 파일들을 모아두었다.
우선 API 통신을 어떻게 하는지 대략적으로 내용을 배우고 정리해두었는데, 다음 링크를 참조하면 된다.
지금은 영어로만 내용을 정리하였는데 추후에 한글로도 업데이트 할 예정이다.
어쨌든 https://openweathermap.org/api 여기에 접속하여 회원가입을 하면 개인 API Key 를 발급받을 수 있다. 이 고유 Key 값이 있어야 앱 내에서 API 통신이 가능하다.
https://openweathermap.org/current
위 링크로 들어가면 API Call 을 어떻게 하는지 상세히 설명되어 있다. 위 예시는 도시명으로 요청을 보내는 형식인데, url 링크를 보면 물음표(?)가 있는 것을 볼 수 있다. 이 다음부터 쿼리가 시작이 된다. 즉 우리가 프로그래밍을 할 때 함수에 입력값 (파라미터) 을 주는 것처럼 여기에 적절한 입력값을 줘야 정확한 API 통신이 가능하다.
OpenWeather 같은 경우에는 q 에는 도시명, appid 에는 개인 API key 를 작성하도록 명시하고 있다.
만약 대구의 날씨를 알고 싶고, 날씨를 섭씨 온도로 가져오기를 원한다면 위와 같은 형식으로 작성하면 된다. 입력값의 순서는 상관없다. 다만 각 입력값 사이에 엠퍼샌드 (&) 는 정확하게 표시해야 한다. 위 스크린샷에서 units = metric 이라는 입력값도 포함되어 있는데, 이는 온도를 섭씨로 요청하는 것이다. 내가 임의로 정한 것은 아니고 OpenWeather 의 API document 를 보면 저런 방식으로 꼭 요청해야 한다고 적혀있다.
요청이야 보내는 방법이 아주 다양한데, 그것을 앱 개발자가 임의로 입력값을 정할 수는 없다. 각 웹사이트의 API Documents 를 면밀히 살펴보고, 정확하게 보내줘야 한다.
앞서 이야기한 방식대로 요청을 보내면, 위와 같이 JSON 형식으로 데이터가 표시된다. 참고로 기본적으로 저렇게 예쁘게(?) 뜨지는 않으니 Chrome Extension Store 에서 JSON Viewer Pro 를 다운 받으면 된다. 무료다 ㅎㅎ
위 스샷을 보면 이것저것 정보가 굉장히 많은데, 대강 필요한 정보를 보면, weather 배열에 있는 id값, main 배열에 있는 temp 값, 그리고 name 값이다. JSON 은 모두 Key & Value 값으로 이루어져있는데, Value 값이 Int, Double, String 등 다양할 수 있다.
다시 Xcode 로 돌아와 Model 폴더에 WeatherData.swift 라는 파일을 생성한다. 이 파일은 JSON Decoding 을 위한 구조체를 선언할 것이다.
아까 필요한 값이 name, temp, id 값이라 이야기했다. 그런데, temp 값과 id값은 main 과 weather 라는 JSON 배열 안에 들어있는 것을 보았을 것이다. 이 역시 구조체를 선언할 때 고려해야 해서 선언해야 한다.
데이터 타입 역시 OpenWeather 에서 제공하는 형식의 데이터 타입과 완전히 동일해야 한다. 아, Value 값 역시 이름이 완전히 같아야 한다.
또 중요한 것으로는, 각 구조체는 Codable 이라는 프로토콜을 채택해야 한다는 것이다. 그래야 나중에 다른 파일에서 JSON Decoder 인스턴스가 해당 구조체에 맞게 디코딩을 진행한다.
다음은 Model 폴더에서 WeatherManager.swift 파일을 생성해주었다. 지금 만드는 앱에 Delegate Design Pattern도 적용을 하고자 하는데, 이를 위한 프로토콜을 가장 상위에 정의하였다.
protocol WeatherManagerDelegate{
func didUpdateWeather(_ weatherManager: WeatherManager, weather: WeatherModel)
func didFailWithError(error: Error)
}
해당 프로토콜을 준수해야 하는 변수를 WeatherManager 구조체 내에 delegate 라는 이름으로 선언하였다. 이는 나중에 Controller 폴더의 WeatherViewController 에서 WeatherManager 인스턴스를 생성하면, 해당 인스턴스가 프로토콜을 수행할 수 있도록 해준다.
위는 WeatherManager 구조체다. 여기서 대부분의 계산이나 API 통신이 이루어진다.
let weatherURL = "https://api.openweathermap.org/data/2.5/weather?appid=05f127c75b54873b91a8324762be85e4&&units=metric"
var delegate: WeatherManagerDelegate?
func fetchWeather(latitude: CLLocationDegrees, longitude: CLLocationDegrees){
let urlString = "\(weatherURL)&lat=\(latitude)&lon=\(longitude)"
performRequest(with: urlString)
}
func fetchWeather(cityName: String){
let urlString = "\(weatherURL)&q=\(cityName)"
performRequest(with: urlString)
}
우선 weatherURL 상수부터 살펴보자. 사용자 입력값이 있기 전까지 도시명이나 위치를 알 수 없는데 뭘 벌써부터 url 을 선언하나 싶을 수도 있지만, 해당 상수는 바뀌지 않은 부분만 미리 선언한 것이다. 즉, 개인 API key 는 바뀌지 않으니 미리 정의해도 되고, 어차피 섭씨 기준으로 온도를 받아올 것이기 때문에 units = metric 도 미리 상수에 정의해두었다.
fetchWeather ( ) 함수는 2개를 만들었다. 하나는 사용자가 현재 위치를 기준으로 날씨 정보를 받아오고자 할 때를 위한 함수고, 또 하나는 사용자가 검색한 도시를 기준으로 날씨 정보를 받아오고자 할 때를 위한 함수다.
performRequest ( ) 함수가 이제 API 네트워킹이 일어나는 함수다. 우선 Networking 이 일어나기 위해 밟아야하는 Step 4개가 아래와 같다:
우선 코드부터 보자
func performRequest(with urlString: String){
//1. URL 인스턴스 생성
if let url = URL(string: urlString){
//2. URLSession 인스턴스 생성
let session = URLSession(configuration: .default)
//3. URLSession 에게 할 일 주기
let task = session.dataTask(with: url) { (data, response, error) in
if error != nil{
delegate?.didFailWithError(error: error!)
return
}
//Data 타입으로 데이터를 받아옴..일반 string, int 가 아님
if let safeData = data {
if let weather = parseJSON(weatherData: safeData){
delegate?.didUpdateWeather(self, weather: weather)
}
}
}
//4. 할 일 시작
task.resume()
}
}
위 코드의 주석에서 각 Step 를 명시하였다. 3번에서 중요한 내용이 있는데, error != nil, 즉 에러가 있으면 WeatherManager 구조체의 인스턴스 보고 didFailWithError 를 실행하게 하며, 그게 아니면 정상적으로 JSON 데이터를 디코딩하는 단계로 넘어간다.
func parseJSON(weatherData: Data)->WeatherModel? {
let decoder = JSONDecoder() //an object that can decode JSON Objects
do{
let decodedData = try decoder.decode(WeatherData.self, from: weatherData)
let id = decodedData.weather[0].id
let temp = decodedData.main.temp
let name = decodedData.name
let weather = WeatherModel(conditionID: id, cityName: name, temperature: temp)
return weather
}catch{
delegate?.didFailWithError(error: error)
return nil
}
}
parseJSON 함수 내에서 decoder 라는 JSONDecoder 인스턴스를 생성한다.
do-catch 문을 사용했는데, 이는 decoder.decode( ) 를 실행했을 때 오류를 던질 수 있기 때문에 오류를 잡고 파악하기 위해 사용하였다.
현재 앱에서 적절한 내용을 표시하기 위해서는 id, temp, name 값이 필요하고, WeatherData 구조체 형식으로 디코딩되어 decodedData 상수에 모조리 저장되어 있다. 이를 개별적인 상수로 값을 받아오고, 다시 WeatherModel 타입의 상수로 만들어 return 한다.
WeatherModel 파일은 위와 같이 선언하였는데, 이는 앱에서 온도, 도시명, 그리고 알맞은 날씨 image 를 표시하기 위해서 따로 만들어주었다.
temperatureString 은 연산 프로퍼티로 정의하였는데, 앱에서 한자리수 소수점으로 온도를 표시할 것이기 때문에 추가하였다. conditionName 역시 연산 프로퍼티인데, 이 값 역시 API 통신을 통해 받아온다.
OpenWeather API Documents 를 보면 이렇게 ID 로 날씨 상태를 구분한다. ID 200~232 까지는 Thunderstorm 으로 구분되어 있고 300~몇까지는 또 Drizzle 로 구분한다. 물론 세세하게 들어가면 light thunderstorm 도 있고 heavy thunderstorm 도 있지만, 아직 초보 단계니 크게크게 구분을 했다.
위 ID 에 따라 다른 날씨 이미지를 앱에서 표시할 건데, API 통신을 통해서 받아온 ID값이 300~321 사이면 drizzle 아이콘을 사용할 것이다.
바로 이걸 계산하기 위한 연산프로퍼티를 conditionName 으로 선언한 것이다.
//연산 프로퍼티
var conditionName: String{
switch conditionID {
case 200...232:
return "cloud.bolt"
case 300...321:
return "cloud.drizzle"
case 500...531:
return "cloud.rain"
case 600...622:
return "cloud.snow"
case 701...781:
return "cloud.fog"
case 800:
return "sun.max"
case 801...804:
return "cloud.bolt"
default:
return "cloud"
}
}
이렇게 ParseJSON 함수에서 필요한 데이터를 모두 계산하고, delegate에 데이터를 모두 넘긴다.
if let safeData = data {
if let weather = parseJSON(weatherData: safeData){
delegate?.didUpdateWeather(self, weather: weather)
}
}
마지막으로 WeatherViewController 파일이다.
우선 사용자의 위치와 관련된 일을 처리하기 위해서는 CoreLocation 라이브러리를 import 해야 한다.
앞서 정의한 WeatherManager 구조체의 인스턴스 역시 당연히 생성해야 하며, CLLocationManager 클래스의 인스턴스도 생성해야 각종 위치 관련된 함수를 사용할 수 있다.
override func viewDidLoad() {
super.viewDidLoad()
locationManager.delegate = self
weatherManager.delegate = self
searchTextField.delegate = self
locationManager.requestLocation()
locationManager.requestWhenInUseAuthorization() //shows a pop-up for permission
}
viewDidLoad() 함수에서는 위와 같이 작성하였다. locationManager 의 delegate 과 weatherManager 의 delegate을 현재 클래스로 지정하고, 앱 내에서 사용하는 searchTextField 의 delegate 도 현재 클래스도 선언한다. 이렇게 해야 채택한 프로토콜을 준수할 수 있다.
locationManager.requestLocation( ) 은 사용자의 현 위치를 request 하여, CLLocationManager 내에 정의되어 있는 프로토콜 메서드 func locationManager(_: CLLocationManager, didUpdateLocations locations: [CLLocation]) 을 호출한다.
위 함수 역시 우리가 별도로 정의해야 하는데, 이는 가독성을 위해 ViewController 폴더 아래에 extension 을 통해 구현하였다.
locationManager.requestWhenInUseAuthorization() 는 위치를 사용해도 되는지 아래와 같은 팝업을 띄운다.
팝업을 띄워도 개발자가 중간에 별도로 메시지를 설정할 수 있다.
Info.plist 에서 Privacy - Location When In Use Usage Description 키를 추가하여 value 쪽에 원하는 메시지를 입력하면 된다.
다음으로는 가독성을 위해서 WeatherViewController 의 extension을 정의하였다.
첫 번째 extension 은 WeatherViewController 가 UITextFieldDelegate 을 준수하기 위한 extension 이다.
두 번째 extension은 내가 따로 정의한 WeatherManagerDelegate 프로토콜을 준수하기 위해 작성한 extension이다. didUpdateWeather 함수가 호출되면, API 통신을 통해 받아온 정보를 모두 앱에 표시할 수 있게 정의하였다.
마지막 extension 은 CLLocationManagerDelegate 프로토콜을 준수하기 위한 extension이다. 사용자가 현재 위치를 검색하면, 첫 번째 함수가 호출되어, 현재 위치 (latitude, longitude) 를 기준으로 도시 및 온도 정보를 받아온다.
이렇게 해서 간단한 날씨 앱을 구현했다. 내가 나중에 보고 참고하려고 블로그 글을 작성하는 것이다보니 글을 읽는 다른 분들이 이해하기에는 힘들 것 같긴하다.
혹시나 자세한 보충 설명이 필요하시면 댓글 달아주세요! 부족하지만 아는 선에서 최대한 답변드리겠습니다.