-Today's Learning Content-

  • UIKit의 동기, 비동기 작업

1. 오늘의 Error

내용 정리

강의를 모두 본 후 강의에서 제시한 '날씨 앱'을 완성하여 퀄리티 업을 시도하던 중 마주한 문제에 대해...

1) 처음보는 에러

강의를 보며 제작한 날씨 앱의 퀄리티를 올리기 위해 다양한 작업을 시도하던 중, 테이블 뷰에 날짜, 기온만 표시되는 것이 아니라 날씨도 표시되면 좋겠다고 생각했다. 그래서 실제 아이폰 앱을 참고하여 날씨 상태를 표현하는 아이콘을 넣어보기로 했는데...

날씨 정보를 가져오는 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을 가지고 UIImageViewimage를 넣기 위한 작업을 진행한다.

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의 이미지를 업데이트 시켜주었다. 이렇게 하고 빌드를 하니...

오..!!

오...?

빌드는 되었는데 이상한 에러가 발생했다.
게다가...

테이블뷰 스크롤이 굉장히 버벅인다...
노란색이랑 빨간색 에러는 봤어도 보라색은 처음인데... 어떤 에러일까??

2) 보라색 에러는 뭐지?

처음보는 에러를 발견하고 이런 에러는 어떤 경우에 발생하는지 먼저 찾아봤다. 그랬더니 보라색 에러는 주로 다음과 같은 상황에서 발생한다고 한다.

  1. UI 업데이트를 메인 스레드가 아닌 백그라운드 스레드에서 수행할 때
    • 원인: UI 작업(예: UILabel.text 설정 등)은 반드시 메인 스레드에서 수행해야 한다. 이를 백그라운드 스레드에서 실행할 경우 보라색 경고가 발생한다.
    • 해결방법: 백그라운드 스레드에서 UI 작업을 실행했다면, 메인 스레드로 전환해야 한다.
  2. 특정 작업이 메인 스레드에서 실행되어야 함에도 그렇지 않은 경우
    • 원인: 메인 스레드에서 실행되어야 하는 작업이 백그라운드에서 호출될 때도 보라색 경고가 발생할 수 있다.
    • 예시: 네트워크 요청 후 바로 UI를 변경하려고 할 때
  3. Main Thread Checker가 활성화되어 발생하는 경고
    • 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을 추천해줬으니 그걸 사용해서 한번 해결해보자!

3) 에러 해결하기-1

강의에서 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()

이렇게 작성하고 빌드를 했더니 이제 보라색 에러는 사라졌다.
그럼 빌드 화면은 어떨까?

아까와 달리 스크롤이 부드럽게 잘 움직인다.
문제 해결!!

그럼 왜 이런 문제가 발생한걸까?

4) 동기와 비동기

Swift에는 동기(Synchronous)와 비동기(Asynchronous)라는 개념이 있다. 각각의 정의는 다음과 같다.

1️⃣ 동기(Synchronous)

  • 정의: 작업을 순차적으로 실행하며, 하나의 작업이 끝날 때까지 다음 작업은 시작되지 않는다.
  • 특징:
    - 작업이 완료될 때까지 호출한 코드가 대기(block) 상태
    - 작업이 오래 걸리면 프로그램 전체가 느려짐
    - 주로 순서가 중요한 작업에서 사용
// 동기 작업 예시
print("Start")
doSomethingSynchronously()
print("End") // doSomethingSynchronously()가 끝난 후 실행

2️⃣ 비동기(Asynchronous)

  • 정의: 작업을 백그라운드에서 실행하며, 작업의 완료 여부와 상관없이 다음 작업이 바로 시작됨
  • 특징:
    - 호출한 코드는 바로 다음 작업으로 넘어가고, 작업이 완료되면 별도의 콜백이나 핸들러에서 처리됨
    - UI 반응성을 유지하면서 백그라운드에서 무거운 작업을 수행할 수 있음
// 비동기 작업 예시
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를 업데이트 하는 위의 코드는 메인스레드에서 이루어지기 때문에 문제가 발생하지 않는다.

5) UIKit과 메인스레드

UIKit에서 메인스레드의 역할은 아래와 같다.

메인스레드(Main Thread)

  • UIKit에서 UI 관련 작업은 메인 스레드(또는 UI 스레드)에서 실행된다.
  • 메인 스레드는 사용자 인터페이스(UI) 업데이트, 터치 이벤트 처리 등을 담당한다.

그럼 동기 작업을 메인 스레드에서 진행하면 안되는 이유는 뭘까?
그 이유는 아래와 같다.

메인스레드에서 동기 작업을 하면 안되는 이유

  1. UI 응답성 저하:
    • 동기 작업은 완료될 때까지 다음 코드의 실행을 막기 때문에, 오래 걸리는 작업을 메인 스레드에서 수행하면 UI가 멈추게 된다.
    • 사용자가 앱을 터치하거나 스크롤할 때 아무런 만응이 없는 프리징(Freezing) 상태가 발생한다.
  2. 데드락(Deadlock) 위험
    • 동기 작업이 메인 스레드에서 다른 동기 작업을 호출하면 서로 기다리다가 영원히 끝나지 않는 상태가 될 수 있다.
  3. 백그라운드 작업 필요
    • 네트워크 호출, 파일 I/O, 복잡한 계산과 같은 작업은 백그라운드 스레드에서 수행해야 한다.
    • 작업이 완료된 후 메인 스레드에서 결과를 UI에 업데이트 해야 한다.
  4. 동기와 비동기 작업 예시(GCD)
DispatchQueue.global().async {
    // 무거운 작업 수행 (예: 네트워크 요청)
    let result = doHeavyTask()
    
    DispatchQueue.main.async {
        // 메인 스레드에서 UI 업데이트
        self.label.text = result
    }
}

결국 메인스레드에서 동기 작업을 하면 안되는 이유는 UI가 멈출 위험이 있기 때문이라고 할 수 있다.
때문에 비동기 작업을 통해 백그라운드에서 무거운 작업을 처리하고, 결과만 메인 스레드에서 UI 업데이트에 활용해야 한다.
이를 통해 앱의 반응성과 사용자 경험을 향상시킬 수 있다.

6) 에러 해결하기-2

아까는 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을 사용하여 해결하는 것을 추천했지만, 결국 에러 자체는 네트워킹 작업을 비동기적으로 처리할 것을 요구한 것이기 때문에 이와 같은 처리만 해준다면 다른 코드도 상관이 없다.

7) 에러 해결하기-3

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는 라이브러리를 적용하지 않아도 사용이 가능하고 사용 방법도 어렵지 않기 때문에 비교적 간단하다고 생각했다.

분명 더 많고 좋은 방법이 있을 것이기 때문에 조금 더 찾아보고 공부해 본다면 관련된 코드를 작성할 때 큰 도움이 될 것이다.

-Today's Lesson Review-

오늘은 강의에서 만든 날씨앱을 발전시키다가
처음 보는 오류를 경험하고 이를 해결하는 과정을 겪었다.
네트워크와 관련된 내용은 여전히 너무 어렵고
알아야 하는 내용이 많은 것 같다...
이번에는 동기와 비동기에 대해 더 깊이 알아볼 수 있는 기회가 되어서
유익한 시간이었다고 생각한다.
profile
이유있는 코드를 쓰자!!

4개의 댓글

comment-user-thumbnail
2024년 12월 4일

이상하다 내가 한건 칙칙이 날시였는데.. 여긴 왜 이쁘죠

1개의 답글
comment-user-thumbnail
2024년 12월 7일

와.. 저 오늘 UIImageView.image must be used from main thread only 라는 보라색 오류가 떴었는데, 이 블로그 보고 원인 파악 하고 해결 했숩니다. !!!!! 보라색 에러 신기해요

1개의 답글