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 ]
[ 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 종료
}
}
'스파르타 코딩 클럽 - iOS 스타터 6기 > 본 캠프' 카테고리의 다른 글
54. 스파르타 코딩 클럽 - 봄여어름갈겨어울 #4 (날씨앱) (0) | 2025.05.23 |
---|---|
53. 스파르타 코딩 클럽 - 봄여어름갈겨어울 #3 (날씨앱) (0) | 2025.05.22 |
51. 스파르타 코딩 클럽 - 봄여어름갈겨어울 #1 (날씨앱) (0) | 2025.05.20 |
50. 스파르타 코딩 클럽 - BookSearchApp #2 (0) | 2025.05.16 |
49. 스파르타 코딩 클럽 - BookSearchApp #1 (4) | 2025.05.15 |