내용 정리
강의를 모두 본 후 강의에서 제시한 '날씨 앱'을 완성하여 퀄리티 업을 시도하던 중 마주한 문제에 대해...
강의를 보며 제작한 날씨 앱의 퀄리티를 올리기 위해 다양한 작업을 시도하던 중, 테이블 뷰에 날짜, 기온만 표시되는 것이 아니라 날씨도 표시되면 좋겠다고 생각했다. 그래서 실제 아이폰 앱을 참고하여 날씨 상태를 표현하는 아이콘을 넣어보기로 했는데...
날씨 정보를 가져오는 API에서 각 날짜별 날씨에 대한 아이콘은 weather
라는 배열 안에 담겨있었다.
때문에 JSON
으로 변환할 타입을 수정할 필요가 있었다.
그래서 위의 코드를 아래와 같이 바꿨다
struct ForecastWeatherResult: Codable {
let list: [ForecastWeather]
}
struct ForecastWeather: Codable {
let main: WeatherMain
let dtTxt: String
let weather: [ForecastWeatherInWeather]
enum CodingKeys: String, CodingKey {
case main
case dtTxt = "dt_txt"
case weather
}
}
struct ForecastWeatherInWeather: Codable {
let icon: String
}
내가 필요한 것은 icon
에 대한 값 뿐이기 때문에 일부러 icon
만 작성했다.
그럼 이제 이 데이터를 받아와야 하는데... 이 아이콘이 들어가는 부분은 테이블뷰의 셀이다. 때문에 셀의 UI를 수정하고 UI를 설정하는 메소드를 수정할 필요가 있었다.
우선 기존 테이블뷰는 셀에 UILabel
을 두 개 정의해둔 상태였는데, UIImageView
를 추가해야 해서 UIStackView
를 사용해서 UI를 하나로 묶어 주었다.
private let cellStackView = UIStackView()
private let weatherIcon = UIImageView()
private let dtTxt = UILabel()
private let tempLabel = UILabel()
// 생략...
// 테이블뷰 셀의 UI들을 세팅하는 메소드
func configUI() {
contentView.backgroundColor = .clear
self.addSubview(cellStackView)
setupIconView()
setupLabelView()
setupStackView()
setupUILayout()
}
이렇게 .horizontal
한 스택뷰를 만들어 날짜, 아이콘, 기온이 한 줄로 표시될 수 있게 설정하였다.
다음으로는 아이콘에 대한 데이터를 받아와 UIImageView
에 적용을 해야 했는데...
/// 셀의 UI 값을 할당하는 메소드
/// - Parameter forecastWeather: 서버에서 받아온 데이터
public func cofigCell(forecastWeather: ForecastWeather) {
self.dtTxt.text = convertDtTxtFormat(forecastWeather.dtTxt)
self.tempLabel.text = "\(Int(round(forecastWeather.main.temp)))°C"
}
위의 코드는 ViewController
에서 테이블뷰 셀의 데이터소스 메소드에서 호출하여 테이블뷰의 셀 UI를 설정하는 메소드이다.
지금은 날짜와 기온을 표시하는 레이블의 값을 변경하는 코드만 작성되어 있는데, 여기에 아이콘의 값을 바꿔주는 코드를 작성하면 될 것 같다.
그 전에, icon에 대한 데이터는 URL
형태로 들어오기 때문에 이를 Data
로 변환하여 UIImage(data:)
형태로 이미지를 만들어 줄 필요가 있었다. 때문에 URL
에 대한 검증을 먼저 시도하기로 했다.
guard let imageUrl =
URL(string: "https://openweathermap.org/img/wn/\(forecastWeather.weather[0].icon)@2x.png") else {
print("이미지 URL이 잘못되었습니다.")
return
}
URL
이 많이 길어서 코드가 길어지지만... 위의 코드는 guard
문을 통해 URL
이 유효한지 확인하고 유효하지 않으면 return
하는 코드이다.
그래서 만약 URL
이 잘못되었다면 아이콘은 표시되지 않을 것이다.
다음으로는 위에서 만들어진 안전한 imageUrl
을 가지고 UIImageView
에 image
를 넣기 위한 작업을 진행한다.
if let data = try? Data(contentsOf: imageUrl) {
if let image = UIImage(data: data) {
DispatchQueue.main.async {
self.weatherIcon.image = image
}
}
}
위의 코드는 if-let
을 이용하여 imageUrl
을 데이터화 시키고, 데이터화 된 값을 UIImage(data:)
로 이미지화 시키는 코드이다.
그리고 UI 업데이트는 메인 쓰레드에서 이루어져야 하기 때문에 DispatchQueue.main.async
를 사용하여 icon의 이미지를 업데이트 시켜주었다. 이렇게 하고 빌드를 하니...
오..!!
오...?
빌드는 되었는데 이상한 에러가 발생했다.
게다가...
테이블뷰 스크롤이 굉장히 버벅인다...
노란색이랑 빨간색 에러는 봤어도 보라색은 처음인데... 어떤 에러일까??
처음보는 에러를 발견하고 이런 에러는 어떤 경우에 발생하는지 먼저 찾아봤다. 그랬더니 보라색 에러는 주로 다음과 같은 상황에서 발생한다고 한다.
원인
: UI 작업(예: UILabel.text
설정 등)은 반드시 메인 스레드에서 수행해야 한다. 이를 백그라운드 스레드에서 실행할 경우 보라색 경고가 발생한다.해결방법
: 백그라운드 스레드에서 UI 작업을 실행했다면, 메인 스레드로 전환해야 한다.원인
: 메인 스레드에서 실행되어야 하는 작업이 백그라운드에서 호출될 때도 보라색 경고가 발생할 수 있다.예시
: 네트워크 요청 후 바로 UI를 변경하려고 할 때Main Thread Checker
: iOS 12부터 도입된 기능으로, 메인 스레드에서 UI 업데이트가 아닌 작업이 감지되면 경고를 표시한다.흠... 그렇군...
그럼 내 에러는 어떤 경우인걸까? 우선 에러 메세지를 살펴보자.
Synchronous URL loading of https://openweathermap.org/img/wn/01n@2x.png should not occur on this application's main thread as it may lead to UI unresponsiveness. Please switch to an asynchronous networking API such as URLSession.![](https://velog.velcdn.com/images/crois0509/post/be1b24a0-2c69-4929-aab7-3216fb990293/image.png)
에러문구가 참 길기도 하다... 직역하면 아래와 같다.
UI 응답이 없을 수 있으므로
이 응용 프로그램의 메인 스레드에서 https://openweathermap.org/img/wn/01n @2x.png의
동기식 URL 로드가 발생해서는 안 됩니다.
URLsession과 같은 비동기식 네트워킹 API로 전환하세요.
그래 전혀 모르겠다.
다만, 마지막에 lease switch to an asynchronous networking API such as URLSession. 라는 문구가 있기 때문에 여기 집중해 보면, 비동기식 네트워킹 API로 방법을 바꾸라는 의미 같다.
그렇다면 나는 위의 예시에서 2번, 특정 작업이 메인 스레드에서 실행되어야 함에도 그렇지 않은 경우에 해당하는 것 같다. 왜냐하면 나도 image의 URL
을 요청하고 곧바로 UI를 업데이트 하려고 했기 때문이다.
그럼 이제 에러를 해결해야 하는데... 여기서 URLSession
을 추천해줬으니 그걸 사용해서 한번 해결해보자!
강의에서 URLSession
을 다룬 적이 있기 때문에 구현은 그렇게 어렵지 않다. 내가 아는 방법은 dataTask
를 이용하는 방법 뿐이니 이걸 사용해보자.
먼저 URLSession
을 사용하여 dataTask
메소드를 실행하고, 매개변수로 이미지의 URL
을 넣는다.
let session = URLSession(configuration: .default)
session.dataTask(with: imageUrl) { (data, _, error) in
// 데이터가 없거나 에러가 있다면 return
guard let data, error == nil else {
print("데이터 로드 실패")
return
}
// 데이터를 image로 작성
if let image = UIImage(data: data) {
DispatchQueue.main.async {
self.weatherIcon.image = image
}
}
}.resume()
이렇게 작성하고 빌드를 했더니 이제 보라색 에러는 사라졌다.
그럼 빌드 화면은 어떨까?
아까와 달리 스크롤이 부드럽게 잘 움직인다.
문제 해결!!
그럼 왜 이런 문제가 발생한걸까?
Swift에는 동기(Synchronous)와 비동기(Asynchronous)라는 개념이 있다. 각각의 정의는 다음과 같다.
1️⃣ 동기(Synchronous)
// 동기 작업 예시
print("Start")
doSomethingSynchronously()
print("End") // doSomethingSynchronously()가 끝난 후 실행
2️⃣ 비동기(Asynchronous)
// 비동기 작업 예시
print("Start")
doSomethingAsynchronously() {
print("Async Task Completed")
}
print("End") // doSomethingAsynchronously() 호출 직후 실행
그렇구나... 그럼 나는 왜 에러가 발생한걸까? 그것은 바로 동기 작업을 메인 스레드에서 실행하려고 했기 때문이다.
아까 작성한 코드를 보자
if let data = try? Data(contentsOf: imageUrl) {
if let image = UIImage(data: data) {
DispatchQueue.main.async {
self.weatherIcon.image = image
}
}
}
여기서 동기 작업은 무엇일까?
바로 Data(contentsOf:)
이다.
Data(contentsOf:)
메소드는 동기적으로 URL에서 데이터를 로드한다. 때문에 이 작업이 메인 스레드에서 실행되면 네트워크 요청이 완료될 때까지 UI가 멈추게 된다. 그래서 보라색 에러가 발생하면서 이 작업을 비동기적으로 실행하도록 권장하는 것이다.
그래서 URLSession
을 쓰면 비동기적으로 네트워크 작업을 수행할 수 있기 때문에 URLSession
을 사용하여 비동기적 작업으로 수정하면 에러가 사라지고 UI가 정상적으로 로드 되는 것이다.
다만 이 때 주의할 점은 UI의 업데이트는 반드시 메인스레드에서 이루어져야 한다는 점이다.
DispatchQueue.main.async {
self.weatherIcon.image = image
}
위의 코드에서 DispatchQueue.main.async
의 역할은 코드 블록 내부의 코드가 메인스레드에서 수행할 작업이라고 명시적으로 선언하는 것이다. 이렇게 하면 URLSession
의 작업이 비동기적으로 수행되더라도 UI를 업데이트 하는 위의 코드는 메인스레드에서 이루어지기 때문에 문제가 발생하지 않는다.
UIKit에서 메인스레드의 역할은 아래와 같다.
그럼 동기 작업을 메인 스레드에서 진행하면 안되는 이유는 뭘까?
그 이유는 아래와 같다.
DispatchQueue.global().async {
// 무거운 작업 수행 (예: 네트워크 요청)
let result = doHeavyTask()
DispatchQueue.main.async {
// 메인 스레드에서 UI 업데이트
self.label.text = result
}
}
결국 메인스레드에서 동기 작업을 하면 안되는 이유는 UI가 멈출 위험이 있기 때문이라고 할 수 있다.
때문에 비동기 작업을 통해 백그라운드에서 무거운 작업을 처리하고, 결과만 메인 스레드에서 UI 업데이트에 활용해야 한다.
이를 통해 앱의 반응성과 사용자 경험을 향상시킬 수 있다.
아까는 Xcode에서 URLSession
을 추천해줬기 때문에 그걸 사용했는데 다른 방법으로도 해결할 수 있지 않을까?
마침 강의에서 Alamofire
에 대해 배웠으니까 이걸 사용해서 해결해보자. 왜 Alamofire
를 사용하냐면, 서드파티 라이브러리인 Alamofire
의 내부도 결국 URLSession
으로 이루어져 있고, 훨씬 간결하게 끝낼 수 있기 때문이다.
AF.request(imageUrl).responseData { response in
if let data = response.data, let image = UIImage(data: data) {
DispatchQueue.main.async {
self.weatherIcon.image = image
}
}
}
Alamofire
를 사용하니 URLSession
을 사용할 때보다 훨씬 간결하고 짧게 코드를 작성할 수 있었다.
과연 빌드는 잘 될까?
물론 잘 된다. 당연히 에러도 없다.
Xcode는 URLSession
을 사용하여 해결하는 것을 추천했지만, 결국 에러 자체는 네트워킹 작업을 비동기적으로 처리할 것을 요구한 것이기 때문에 이와 같은 처리만 해준다면 다른 코드도 상관이 없다.
Alamofire
를 사용해서 간단히 해결했지만, 사실 더 간단한 해결 방법도 있다.
DispatchQueue.global().async {
if let data = try? Data(contentsOf: imageUrl) {
if let image = UIImage(data: data) {
DispatchQueue.main.async {
self.weatherIcon.image = image
}
}
}
}
위의 코드는 가장 처음에 작성해서 에러가 난 코드를 그대로 사용하지만, DispatchQueue.global().async
코드를 통해 내부 코드가 비동기적으로 수행된다고 명시적으로 표현하고 있다.
다만, UI를 업데이트 하는 코드만 DispatchQueue.main.async
로 감싸 메인 스레드에서 수행되는 코드라고 표현했다.
코드 자체는 Alamofire
를 사용하는 방법이 짧을 수 있지만, 라이브러리를 추가해야 하는 점 때문에 상황에 따라 적합하지 않을 수 있다고 생각한다.
하지만, DispatchQueue.global().async
는 라이브러리를 적용하지 않아도 사용이 가능하고 사용 방법도 어렵지 않기 때문에 비교적 간단하다고 생각했다.
분명 더 많고 좋은 방법이 있을 것이기 때문에 조금 더 찾아보고 공부해 본다면 관련된 코드를 작성할 때 큰 도움이 될 것이다.
오늘은 강의에서 만든 날씨앱을 발전시키다가
처음 보는 오류를 경험하고 이를 해결하는 과정을 겪었다.
네트워크와 관련된 내용은 여전히 너무 어렵고
알아야 하는 내용이 많은 것 같다...
이번에는 동기와 비동기에 대해 더 깊이 알아볼 수 있는 기회가 되어서
유익한 시간이었다고 생각한다.
이상하다 내가 한건 칙칙이 날시였는데.. 여긴 왜 이쁘죠