스파르타 코딩 클럽 - iOS 스타터 6기/본 캠프

52. 스파르타 코딩 클럽 - 봄여어름갈겨어울 #2 (날씨앱)

seongpil Heo 2025. 5. 21. 21:50

 

 

51. 스파르타 코딩 클럽 - 봄여어름갈겨어울 #1 (날씨앱)

🌤️ 봄여어름갈겨어울 (심화 팀 프로젝트)최종 프로젝트 전 마지막 팀 프로젝트인 심화 주차 팀 프로젝트가 오늘 시작되었다.기간은 5월 20일(화)부터 5월 28일(수)까지 주말 제외 일주일이다.

coding-pill.tistory.com

🌤️  봄여어름갈겨어울 (심화 팀 프로젝트)

[ 목차 ]

1. 오늘 작업 내용

2. Trouble Shooting

3. TIL


  👨🏻‍💻  오늘 작업 내용

[ 1. API gitignore를 위한 xcconfig 파일 작성 ]

 

48. 스파르타 코딩 클럽 - xcconfig 파일을 사용하여 Github로 부터 API 키 감추기

xcconfig 파일을 통해 Github ignore 설정하기[ 1. xcconfig 파일 생성 ] [ New File from Templete... 클릭 ] → [Other 부분에 Configuration Settings File 클릭 ] [ 2. API 키 작성하기 ]만든 xcconfig 파일에 사용하는 API 키를

coding-pill.tistory.com

방법은 위의 링크를 참고

 

[ 1-1. PR Merged ]

Github PR

 

[ 2. API Response를 위한 Data Model 작성 ]

[ 2-1. 현재 날씨 Data Model ]

현재 날씨 Data
import Foundation

// MARK: - WeatherResponse
struct WeatherResponse: Codable {
    let coord: Coord // 위도, 경도
    let weather: [Weather] // 날씨 정보
    let base: String // 날씨 데이터의 기준
    let main: Main // 주요 기상 정보
    let visibility: Int // 가시 거리 (미터)
    let wind: Wind // 바람 정보
    let clouds: Clouds // 구름 정보
    let dt: Int // 데이터 계산 시간
    let sys: Sys // 국가 및 일출/일몰 정보
    let timezone, id: Int // 시간대 오프셋
    let name: String // 도시 이름
    let cod: Int // 응답 코드 (200이면 정상 응답)
}

// MARK: - Clouds
struct Clouds: Codable {
    let all: Int // 구름 양 (%) - 하늘의 n%가 구름으로 덮여 있음
}

// MARK: - Coord
struct Coord: Codable {
    let lon, lat: Double // 위도, 경도
}

// MARK: - Main
struct Main: Codable { // 날씨 상태 배열
    let temp, feelsLike, tempMin, tempMax: Double // temp = 현재 기온
    let pressure, humidity, seaLevel, grndLevel: Int // pressure = 기압, humidity = 습도

    enum CodingKeys: String, CodingKey {
        case feelsLike = "feels_like" //  체감 온도
        case tempMin = "temp_min" // 최저 기온
        case tempMax = "temp_max" // 최고 기온
        case seaLevel = "sea_level" // 해수면 기준 기압
        case grndLevel = "grnd_level" // 지면 기준 기압
    }
}

// MARK: - Sys
struct Sys: Codable {
    let country: String // 국가 코드
    let sunrise, sunset: Int // 일출초 일몰 시간
}

// MARK: - Weather
struct Weather: Codable {
    let id: Int // 날씨 상태 코드
    let main: String // 날씨 상태 (예: 구름, 비, 맑음 등)
    let description: String // 날씨 상세 설명
    let icon: String // 날씨 아이콘
}

// MARK: - Wind
struct Wind: Codable {
    let speed: Double // 바람 속도 (m/s)
    let deg: Int // 풍향
    let gust: Double // 돌풍 풍속 (m/s)
}

 

[ 2-2. 5일간 3시간 간격의 날씨 예보 Data Model ]


import Foundation

// MARK: - WeatherForecast
struct WeatherForecast: Codable {
    let cod: String
    let message, cnt: Int
    let list: [ForecastList]
    let city: ForecastCity
}

// MARK: - City
struct ForecastCity: Codable {
    let id: Int
    let name: String
    let coord: ForecastCoord
    let country: String
    let population, timezone, sunrise, sunset: Int
}

// MARK: - Coord
struct ForecastCoord: Codable {
    let lat, lon: Double
}

// MARK: - List
struct ForecastList: Codable {
    let dt: Int // 데이터 시각 (유닉스 타임스탬프)
    let main: MainClass // 기온 등 주요 정보
    let weather: [ForecastWeather] // 날씨 정보 배열
    let clouds: ForecastClouds // 구름 정보
    let wind: ForecastWind // 바람 정보
    let visibility: Int? // 가시 거리 (미터 옵셔널)
    let pop: Double // 강수 확률 (0.0 ~ 1.0)
    let rain: Rain? // 강우량 정보 (옵셔널)
    let sys: ForecastSys // 일/밤 구분
    let dtTxt: String // 날짜/시간 (예: "2025-05-20 12:00:00")

    enum CodingKeys: String, CodingKey {
        case dt, main, weather, clouds, wind, visibility, pop, rain, sys
        case dtTxt = "dt_txt"
    }
}

// MARK: - Clouds
struct ForecastClouds: Codable {
    let all: Int // 구름 양 (%)
}

// MARK: - MainClass
struct MainClass: Codable {
    let temp, feelsLike, tempMin, tempMax: Double
    let pressure, seaLevel, grndLevel, humidity: Int
    let tempKf: Double

    enum CodingKeys: String, CodingKey {
        case temp // 현재 온도
        case feelsLike = "feels_like" // 체감 온도
        case tempMin = "temp_min" // 최저 온도
        case tempMax = "temp_max" // 최고 온도
        case pressure // 기압 (hPa)
        case seaLevel = "sea_level" // 해수면 기준 기압
        case grndLevel = "grnd_level" // 지면 기준 기압
        case humidity // 습도 (%)
        case tempKf = "temp_kf" // 온도 보정값
    }
}

// MARK: - Rain
struct Rain: Codable {
    let the3H: Double // 3시간 동안의 강수량 mm

    enum CodingKeys: String, CodingKey {
        case the3H = "3h" // 키가 3h로 시작해서 the3H로 매핑
    }
}

// MARK: - Sys
struct ForecastSys: Codable {
    let pod: Pod // 낮 / 밤 정보
}

enum Pod: String, Codable {
    case d = "d" // 낮
    case n = "n" // 밤
}

// MARK: - Weather
struct ForecastWeather: Codable {
    let id: Int // 날씨 상태
    let main: MainEnum // 날씨 주 분류 (예 : Clear, Rain등)
    let description, icon: String // 날씨 설명, 날씨 아이콘
}

enum MainEnum: String, Codable {
    case clear = "Clear" // 맑음
    case clouds = "Clouds" // 구름낀 (흐림)
    case rain = "Rain" // 비
}

// MARK: - Wind
struct ForecastWind: Codable {
    let speed: Double // 풍속 (m/s)
    let deg: Int // 풍향 (도)
    let gust: Double // 돌풍 속도 (m/s)
}

 

[ 2-3. PR Merged ]

 

[ 3. 네트워크 통신을 위한 NetworkManager 작성 ]

[ 3-1. closure 버전으로 작성 ]

import Foundation
import Alamofire
import UIKit

class NetworkManager {
    static let shared = NetworkManager()
    private init() {}
    
    private lazy var apiKey: String? = {
        guard let filePath = Bundle.main.path(forResource: "Info", ofType: "plist"),
              let plist = NSDictionary(contentsOfFile: filePath),
              let key = plist["OpenWeatherApiKey"] as? String else {
            print("❌ Info.plist에서 OpenWeatherApiKey를 불러오지 못했습니다.")
            return nil
        }
        return key
    }()
    
    private var urlQueryItems: [URLQueryItem] {
        return [
            URLQueryItem(name: "lat", value: "37.5"),
            URLQueryItem(name: "lon", value: "126.9"),
            URLQueryItem(name: "appid", value: apiKey),
            URLQueryItem(name: "units", value: "metric"),
        ]
    }
    
    // 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)
        }
    }
    
    // 서버에서 현재 날씨 데이터를 불러오는 메서드.
    private func fetchCurrentWeatherData(completion: @escaping (Result<(WeatherResponse, String), AFError>) -> Void) {
        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<WeatherResponse, AFError>) in
            guard let self else { return }
            
            switch result {
                
            // 네트워크 통신 성공시
            case .success(let weatherResponse):
                let imageUrl = "https://openweathermap.org/img/wn/\(weatherResponse.weather[0].icon)@2x.png"
                completion(.success((weatherResponse, imageUrl)))
                
            // 네트워크 통신 실패시
            case .failure(let error):
                print("데이터 로드 실패: \(error)")
                completion(.failure(error))
            }
        }
    }
    
    // 서버에서 5일 간 날씨 예보 데이터를 불러오는 메서드
    private func fetchForeCastData(completion: @escaping (Result<WeatherForecast, AFError>) -> Void) {
        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<WeatherForecast, AFError>) in
            guard let self else { return }
            switch result {
                
            // 네트워크 통신 성공시
            case .success(let weatherForecast):
                completion(.success(weatherForecast))
                
            // 네트워크 통신 실패시
            case .failure(let error):
                print("데이터 로드 실패: \(error)")
                completion(.failure(error))
            }
        }
    }
    
}

 

 

[ 3-2. RxSwift(Single) 버전으로 작성 ]

import Foundation
import Alamofire
import UIKit
import RxSwift

class NetworkManager {
    static let shared = NetworkManager()
    private init() {}
    
    private lazy var apiKey: String? = {
        guard let filePath = Bundle.main.path(forResource: "Info", ofType: "plist"),
              let plist = NSDictionary(contentsOfFile: filePath),
              let key = plist["OpenWeatherApiKey"] as? String else {
            print("❌ Info.plist에서 OpenWeatherApiKey를 불러오지 못했습니다.")
            return nil
        }
        return key
    }()
    
    private var urlQueryItems: [URLQueryItem] {
        return [
            URLQueryItem(name: "lat", value: "37.5"),
            URLQueryItem(name: "lon", value: "126.9"),
            URLQueryItem(name: "appid", value: apiKey),
            URLQueryItem(name: "units", value: "metric"), // 섭씨로 데이터 받기
        ]
    }
    
    // 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)
        }
    }
    
    private func fetchCurrentWeatherData() -> Single<(WeatherResponse, String)> {
        return Single.create { single in
            var urlComponents = URLComponents(string: "https://api.openweathermap.org/data/2.5/weather")
            urlComponents?.queryItems = self.urlQueryItems
            
            guard let url = urlComponents?.url else {
                print("잘못된 URL")
                single(.failure(AFError.invalidURL(url: "")))
                return Disposables.create()
            }
            
            self.fetchDataByAlamofire(url: url) { [weak self] (result: Result<WeatherResponse, AFError>) in
                guard let self else { return }
                
                switch result {
                    
                // 네트워크 통신 성공시
                case .success(let weatherResponse):
                    let imageUrl = "https://openweathermap.org/img/wn/\(weatherResponse.weather[0].icon)@2x.png"
                    single(.success((weatherResponse, imageUrl))) // 성공시 날씨 정보와 아이콘 이미지 url을 방출
                    
                // 네트워크 통신 실패시
                case .failure(let error):
                    print("데이터 로드 실패: \(error)")
                    single(.failure(error)) // 실패시 에러 방출
                }
            }
            return Disposables.create() // Single 종료
        }
    }
    
    // 서버에서 5일 간 날씨 예보 데이터를 불러오는 메서드
    private func fetchForeCastData() -> Single<WeatherForecast> {
        return Single.create { single in
            var urlComponents = URLComponents(string: "https://api.openweathermap.org/data/2.5/forecast")
            urlComponents?.queryItems = self.urlQueryItems
            
            guard let url = urlComponents?.url else {
                print("잘못된 URL")
                single(.failure(AFError.invalidURL(url: "")))
                return Disposables.create() // error를 방출하고 종료
            }
            
            self.fetchDataByAlamofire(url: url) { [weak self] (result: Result<WeatherForecast, AFError>) in
                guard let self else { return }
                
                switch result {
                    
                // 네트워크 통신 성공시
                case .success(let weatherForecast):
                    single(.success(weatherForecast)) // 결과를 방출
                    
                // 네트워크 통신 실패시
                case .failure(let error):
                    print("데이터 로드 실패: \(error)")
                    single(.failure(error)) // 에러 방출
                }
            }
            return Disposables.create() // Single 종료
        }
    }
    
    
}

 


  Trouble Shooting

NetworkManager을 작성할 때 @escaping을 사용한 closure 방식으로 코드를 작성했었는데

이번 팀 프로젝트는 MVVM 패턴과 RxSwift를 사용해서 진행하기 때문에 RxSwift 방식으로 NetworkManager을 작성하고 싶었다.

 

하지만 내 코드에 Rx를 사용하려고 했지만 어떻게 적용할지 어려움이 있어서

튜터님께 방문했다.

 

튜터님이 fetchCurrentWeatherData() 메서드를 먼저 Single을 사용해서 리펙토링 해주셨고,

func fetchCurrentWeatherData() -> Single<(WeatherResponse, String)> {
    return Single.create { single in
        var urlComponents = URLComponents(string: "https://api.openweathermap.org/data/2.5/weather")
        urlComponents?.queryItems = self.urlQueryItems

        guard let url = urlComponents?.url else {
            print("잘못된 URL")
            single(.failure(AFError.invalidURL(url: "")))
            return Disposables.create()
        }

        self.fetchDataByAlamofire(url: url) { (result: Result<WeatherResponse, AFError>) in

            switch result {

            // 네트워크 통신 성공시
            case .success(let weatherResponse):
                let imageUrl = "https://openweathermap.org/img/wn/\(weatherResponse.weather[0].icon)@2x.png"
                single(.success((weatherResponse, imageUrl))) // 성공시 날씨 정보와 아이콘 이미지 url을 방출

            // 네트워크 통신 실패시
            case .failure(let error):
                print("데이터 로드 실패: \(error)")
                single(.failure(error)) // 실패시 에러 방출
            }
        }
        return Disposables.create() // Single 종료
    }
}

 

나는 튜터님이 리펙토링 해주신 코드를 보고 fetchForeCastData() 메서드를 Single을 사용해서 리펙토링 해보았다.

// 서버에서 5일 간 날씨 예보 데이터를 불러오는 메서드
func fetchForeCastData() -> Single<WeatherForecast> {
    return Single.create { single in
        var urlComponents = URLComponents(string: "https://api.openweathermap.org/data/2.5/forecast")
        urlComponents?.queryItems = self.urlQueryItems

        guard let url = urlComponents?.url else {
            print("잘못된 URL")
            single(.failure(AFError.invalidURL(url: "")))
            return Disposables.create() // error를 방출하고 종료
        }

        self.fetchDataByAlamofire(url: url) { (result: Result<WeatherForecast, AFError>) in

            switch result {

            // 네트워크 통신 성공시
            case .success(let weatherForecast):
                single(.success(weatherForecast)) // 결과를 방출

            // 네트워크 통신 실패시
            case .failure(let error):
                print("데이터 로드 실패: \(error)")
                single(.failure(error)) // 에러 방출
            }
        }
        return Disposables.create() // Single 종료
    }
}