날씨 앱 만들기

동그라미·2025년 1월 3일
6
post-thumbnail

날씨 앱 만들기


URLSession 을 이용해서 서버에서 날씨 데이터를 가져와 띄우는 간단한 날씨 앱을 개발해봅니다.


아래 데이터들을 화면에 노출합니다.

  • 현재 기온
  • 최소 기온
  • 최고 기온
  • 날씨 이미지
  • UITableView 를 사용한 5일 간 예보

간단한 날씨 앱을 개발하기 위한 공부들을 해봅시다.


Open Weather API


Open Weather API

  • Open 소스 API 란 모두가 사용할 수 있게 공공적으로 열어놓은 API 를 말합니다.
  • Open 소스 API 중 날씨 데이터를 제공하는 Open Weather API 를 사용합니다.
    링크텍스트

(1) 회원 가입


Open Weather API 를 사용 하기 위해, 먼저 회원가입을 해야합니다.

  • API 사용 목적을 설문 상 물어볼텐데 적당히 값을 입력하고 회원가입 완료.

  • 회원가입이 완료되면 My API Keys 에서 API Key 를 확인 가능.
  • API Key 란 API 명세에 넣어야하는 고유한 값.

(2) API 파악


Guide 로 가서 API 가이드 읽어보기

https://api.openweathermap.org/data/2.5/weather?lat={lat}&lon={lon}&appid={API key}&units=metric

  • ? 뒤의 key-value 는 쿼리 파라미터.

URLSession 으로 날씨 앱 만들기


💻 URLSession 을 사용해서 날씨 데이터를 불러와 띄워봅시다.

  1. API 명세를 파악하고, JSON 데이터를 가져오기 위한 Codable 구조체를 선언합니다.
  2. URLSession 의 URL 에 사용할 API 의 URL 주소를 입력합니다.
  3. URLSession.dataTask 를 통해 네트워크 통신을 수행하고, 성공적으로 데이터를 받아왔다면 데이터를 뷰(UILabel, UITableView)에 적용합니다.

- CurrentWeatherResult.swift

import Foundation

struct CurrentWeatherResult: Codable {
    let weather: [Weather]
    let main: WeatherMain
}

struct Weather: Codable {
    let id: Int
    let main: String
    let description: String
    let icon: String
}

struct WeatherMain: Codable {
    let temp: Double
    let temp_min: Double
    let temp_max: Double
    let humidity: Int
}

- ViewController.swift

import UIKit
import SnapKit

class ViewController: UIViewController {

    // 테이블 뷰에 넣을 데이터 소스.
    private var dataSource = [ForecastWeather]()
    
    // URL 쿼리에 넣을 아이템들
    // 서울역 위경도
    private let urlQueryItems: [URLQueryItem] = [
        URLQueryItem(name: "lat", value: "37.5"),
        URLQueryItem(name: "lon", value: "126.9"),
        URLQueryItem(name: "appid", value: "3d1be1b2d3419223212333eb2388ba4a"),
        URLQueryItem(name: "units", value: "metric")
    ]
    
    private let titleLabel: UILabel = {
        let label = UILabel()
        label.text = "서울특별시"
        label.textColor = .white
        label.font = .boldSystemFont(ofSize: 30)
        return label
    }()
    private let tempLabel: UILabel = {
        let label = UILabel()
        label.textColor = .white
        label.font = .boldSystemFont(ofSize: 50)
        return label
    }()
    private let tempMinLabel: UILabel = {
        let label = UILabel()
        label.textColor = .white
        label.font = .boldSystemFont(ofSize: 20)
        return label
    }()
    private let tempMaxLabel: UILabel = {
        let label = UILabel()
        label.textColor = .white
        label.font = .boldSystemFont(ofSize: 20)
        return label
    }()
    private let tempStackView: UIStackView = {
        let stackView = UIStackView()
        stackView.axis = .horizontal
        stackView.spacing = 20
        stackView.distribution = .fillEqually
        return stackView
    }()
    private let imageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFit
        imageView.backgroundColor = .black
        return imageView
    }()
    private lazy var tableView: UITableView = {
        let tableView = UITableView()
        tableView.backgroundColor = .black
        // delegate: "대리자. 대신 수행해주는 사람." tableView 의 여러가지 속성 세팅을 이 ViewController 에서 대신 세팅해주겠다.
        tableView.delegate = self
        // dataSource: 테이블 뷰에 넣을 데이터를 이 ViewController 에서 세팅해주겠다.
        tableView.dataSource = self
        // 테이블 뷰에 테이블 뷰 셀 등록.
        tableView.register(TableViewCell.self, forCellReuseIdentifier: TableViewCell.id)
        return tableView
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        fetchCurrentWeatherData()
        fetchForecastData()
        configureUI()
    }
    
    // 서버 데이터를 불러오는 메서드
    private func fetchData<T: Decodable>(url: URL, completion: @escaping (T?) -> Void) {
        let session = URLSession(configuration: .default)
        session.dataTask(with: URLRequest(url: url)) { data, response, error in
            guard let data = data, error == nil else {
                print("데이터 로드 실패")
                completion(nil)
                return
            }
            // http status code 성공 범위.
            let successRange = 200..<300
            if let response = response as? HTTPURLResponse, successRange.contains(response.statusCode) {
                guard let decodedData = try? JSONDecoder().decode(T.self, from: data) else {
                    print("JSON 디코딩 실패")
                    completion(nil)
                    return
                }
                completion(decodedData)
            } else {
                print("응답 오류")
                completion(nil)
            }
        }.resume()
    }
    
    // 서버에서 현재 날씨 데이터를 불러오는 메서드
    private func fetchCurrentWeatherData() {
        var urlComponents = URLComponents(string: "https://api.openweathermap.org/data/2.5/weather")
        urlComponents?.queryItems = self.urlQueryItems
        
        guard let url = urlComponents?.url else {
            print("잘못된 URL")
            return
        }
        
        fetchData(url: url) { [weak self] (result: CurrentWeatherResult?) in
            guard let self, let result else { return }
            // UI 작업은 메인 쓰레드에서 작업
            DispatchQueue.main.async {
                self.tempLabel.text = "\(Int(result.main.temp))°C"
                self.tempMinLabel.text = "최소: \(Int(result.main.temp_min))°C"
                self.tempMaxLabel.text = "최고: \(Int(result.main.temp_max))°C"
            }
            guard let imageUrl = URL(string: "https://openweathermap.org/img/wn/\(result.weather[0].icon)@2x.png") else { return }
    
            // image 를 로드하는 작업은 백그라운드 쓰레드 작업
            if let data = try? Data(contentsOf: imageUrl) {
                if let image = UIImage(data: data) {
                    // 이미지뷰에 이미지를 그리는 작업은 UI 작업이기 때문에 다시 메인 쓰레드에서 작업.
                    DispatchQueue.main.async {
                        self.imageView.image = image
                    }
                }
            }
        }
    }

    // 서버에서 5일 간 날씨 예보 데이터를 불러오는 메서드
    private func fetchForecastData() {
        var urlComponents = URLComponents(string: "https://api.openweathermap.org/data/2.5/forecast")
        urlComponents?.queryItems = self.urlQueryItems
        
        guard let url = urlComponents?.url else {
            print("잘못된 URL")
            return
        }
        
        fetchData(url: url) { [weak self] (result: ForecastWeatherResult?) in
            guard let self, let result else { return }
            
            // result 콘솔에 찍어보기
            for forecastWeather in result.list {
                print("\(forecastWeather.main)\n\(forecastWeather.dtTxt)\n\n")
            }
            
            // UI 작업은 메인 쓰레드에서
            DispatchQueue.main.async {
                self.dataSource = result.list
                self.tableView.reloadData()
            }
        }
    }
    
    private func configureUI() {
        view.backgroundColor = .black
        [
            titleLabel,
            tempLabel,
            tempStackView,
            imageView,
            tableView
        ].forEach { view.addSubview($0) }
        
        [
            tempMinLabel,
            tempMaxLabel
        ].forEach { tempStackView.addArrangedSubview($0) }
        
        titleLabel.snp.makeConstraints {
            $0.centerX.equalToSuperview()
            $0.top.equalToSuperview().offset(120)
        }
        
        tempLabel.snp.makeConstraints {
            $0.centerX.equalToSuperview()
            $0.top.equalTo(titleLabel.snp.bottom).offset(10)
        }
        
        tempStackView.snp.makeConstraints {
            $0.centerX.equalToSuperview()
            $0.top.equalTo(tempLabel.snp.bottom).offset(10)
        }
        
        imageView.snp.makeConstraints {
            $0.centerX.equalToSuperview()
            $0.width.height.equalTo(160)
            $0.top.equalTo(tempStackView.snp.bottom).offset(20)
        }
        
        tableView.snp.makeConstraints {
            $0.top.equalTo(imageView.snp.bottom).offset(30)
            $0.leading.trailing.equalToSuperview().inset(20)
            $0.bottom.equalToSuperview().inset(50)
        }
    }
}

extension ViewController: UITableViewDelegate {
    // 테이블 뷰 셀의 높이 크기 지정.
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        40
    }
}

extension ViewController: UITableViewDataSource {
    // 테이블 뷰의 indexPath 마다 테이블 셀 지정.
    // indexPath = 테이블 뷰의 행과 섹션을 지정. 여기서 섹션은 사용하지 않고 행만 사용함.
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: TableViewCell.id) as? TableViewCell else { return UITableViewCell() }
        cell.configureCell(forecastWeather: dataSource[indexPath.row])
        return cell
    }
    // 테이블 뷰 섹션에 행이 몇 개 들어가는가. 여기서 섹션은 없으니 그냥 총 행 개수를 입력하면 된다.
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        dataSource.count
    }
}

- ForecastWeatherResult.swift

import Foundation

struct ForecastWeatherResult: Codable {
    let list: [ForecastWeather]
}

struct ForecastWeather: Codable {
    let main: WeatherMain
    let dtTxt: String
    
    enum CodingKeys: String, CodingKey {
        case main
        case dtTxt = "dt_txt"
    }
}

- TableCell.swift

import UIKit

final class TableViewCell: UITableViewCell {
    
    static let id = "TableViewCell"
    
    private let dtTxtlabel: UILabel = {
        let label = UILabel()
        label.backgroundColor = .black
        label.textColor = .white
        return label
    }()
    
    private let templabel: UILabel = {
        let label = UILabel()
        label.backgroundColor = .black
        label.textColor = .white
        return label
    }()
    
    // TableView 의 style 과 id 로 초기화할때 사용하는 코드.
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        configureUI()
    }
    
    private func configureUI() {
        contentView.backgroundColor = .black
        [
            dtTxtlabel,
            templabel
        ].forEach { contentView.addSubview($0) }
        dtTxtlabel.snp.makeConstraints {
            $0.leading.equalToSuperview().inset(20)
            $0.centerY.equalToSuperview()
        }
        templabel.snp.makeConstraints {
            $0.trailing.equalToSuperview().inset(20)
            $0.centerY.equalToSuperview()
        }
    }
    
    public func configureCell(forecastWeather: ForecastWeather) {
        dtTxtlabel.text = forecastWeather.dtTxt
        templabel.text = "\(forecastWeather.main.temp)°C"
    }
    
    // 인터페이스 빌더를 통해 셀을 초기화 할 때 사용하는 코드. 여기서는 fatalError 를 통해 명시적으로 인터페이스 빌더로 초기화 하지 않음을 나타냄.
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

Alamofire 로 날씨 앱 만들기


  • Alamofire 는 Swift 의 HTTP 네트워킹 라이브러리.
  • 내부적으로 URLSession 을 사용.
  • URLSession 을 한단계 감싸서 네트워크 코드 사용성에 편의를 제공.
  • AF.request(url) 메서드를 통해서 네트워크 통신 수행.
  • AF.request(url).responseDecodable(of: xxx) 를 통해서 네트워크 통신과 동시에 response 를 디코딩 가능.
  • Result<T, AFError> 타입으로 response 반환

- 위 URLSession 으로 작성했었던 fetchData() 메서드는, Alamofire 로 작성하면 아래와 같습니다.

import Alamofire

// 서버 데이터를 불러오는 메서드
private func fetchDataByAlamofire<T: Decodable>(url: URL, completion: @escaping (Result<T, AFError>) -> Void) {
    AF.request(url).responseDecodable(of: T.self) { response in
        completion(response.result)
    }
}
  • URLSession 과 비교했을 때
    • successRange 를 정의해주지 않아도, Alamofiresuccessfailure 여부를 판단.
    • responseDecodable 메서드를 통해서 JSONDecoder().decode() 과정 생략.
    • 코드 간소화.

- Alamofire 를 사용한 Current Weather 와 ForecastWeather fetch 메서드.

// 서버에서 현재 날씨 데이터를 불러오는 메서드
private func fetchCurrentWeatherData() {
    var urlComponents = URLComponents(string: "https://api.openweathermap.org/data/2.5/weather")
    urlComponents?.queryItems = self.urlQueryItems
    
    guard let url = urlComponents?.url else {
        print("잘못된 URL")
        return
    }
    
    fetchDataByAlamofire(url: url) { [weak self] (result: Result<CurrentWeatherResult, AFError>) in
        switch result {
        case .success(let result):
            DispatchQueue.main.async {
                self?.tempLabel.text = "\(Int(result.main.temp))°C"
                self?.tempMinLabel.text = "최소: \(Int(result.main.temp_min))°C"
                self?.tempMaxLabel.text = "최고: \(Int(result.main.temp_max))°C"
            }
            
            guard let imageUrl = URL(string: "https://openweathermap.org/img/wn/\(result.weather[0].icon)@2x.png") else {
                return
            }
            
            // Alamofire 를 사용한 이미지 로드
            AF.request(imageUrl).responseData { response in
                if let data = response.data, let image = UIImage(data: data) {
                    DispatchQueue.main.async {
                        self?.imageView.image = image
                    }
                }
            }
        case .failure(let error):
            print("데이터 로드 실패: \(error)")
        }
    }
}

// 서버에서 5일 간 날씨 예보 데이터를 불러오는 메서드
private func fetchForecastData() {
    var urlComponents = URLComponents(string: "https://api.openweathermap.org/data/2.5/forecast")
    urlComponents?.queryItems = self.urlQueryItems
    
    guard let url = urlComponents?.url else {
        print("잘못된 URL")
        return
    }
    
    fetchDataByAlamofire(url: url) { [weak self] (result: Result<ForecastWeatherResult, AFError>) in
        guard let self else { return }
        switch result {
        case .success(let result):
            DispatchQueue.main.async {
                self.dataSource = result.list
                self.tableView.reloadData()
            }
        case .failure(let error):
            print("데이터 로드 실패: \(error)")
        }
    }
}
profile
맨날 최선을 다하지는 마러라. 피곤해서 못산다.

0개의 댓글