토이프로젝트에서 이미지를 PhotosPicker로 받아서 화면에 보여줄 때, 받은 PhotosPickerItem -> Data -> UIImage -> Image로 바꿔주는 convertImage 함수를 작성할 때 몇가지 궁금증이 생겼다.
사진이 nil일 수 있으니까 selectedItem, postImage를 optional 값을 지정하는 것은 알겠다. 그런데 guard let 말고 그냥 !를 붙여서 강제 unwrapping 할 수 있는데 굳이,,? 그리고 if let으로 해도 되지 않을까?
왜 어떤 함수 앞에는 try를 붙여서 error handling을 해야할까? 그 특징적인 함수가 있을까? 이것도 마찬가지로 try!로 강제하면 안될까?
왜 convertImage를 async 키워드를 붙여서 비동기적으로 해야할까? 뭔가 async, await, Task가 한 세트인 것은 알겠는데 이유가 있을까?
이러한 궁금증이 생겨서 optional, error-handling, concurrency에 대해 정리를 해보았다.
Optional은 값이 nil일 수도 있는 것을 나타낼 때 사용한다. Optional 값을 사용하기 위해서는 optional-unwrapping 과정이 필요하다.
let optionalString: String? = nil
let result = optionalString ?? "default"
print(result) // default
let optionalInt: Int? = 10
let result = optionalInt!
print(result) // 10
let nilValue: Int? = nil
let crash = nilValue! // 런타임 에러 발생, 앱이 강제 종료됨
let optionalString: String? = "hello"
if let unwrappedString = optionalString {
print("not nil: \(unwrappedString)") // "not nil: hello"
} else {
print("nil")
}
let nilString: String? = nil
if let unwrappedNil = nilString {
print("not nil: \(unwrappedNil)")
} else {
print("nil") // 출력: "nil"
}
func greetUser(_ name: String?) {
guard let unwrappedName = name else {
print("nil")
return
}
print("Hello, \(unwrappedName)!")
}
greetUser("Jun") // "Hello, Jun!"
greetUser(nil) // "nil"
PhotosPickerItem을 컴퓨터가 이해할 수 있는 데이터로 바꿔줄 때 사용하는 loadTransferable 함수를 cmd + 클릭을 하면 다음과 같은 사진을 볼 수 있다.
async throws라고 되어있는데, 일단 throws만 보면 된다. throws는 "에러가 발생할 수 있으니 그에 대해 대처할 수 있는 코드를 작성해줘"라고 알려주는 키워드이다. 가령 예시처럼 사진을 불러오는데 오류가 발생하거나, url을 통해 데이터를 불러올 때 에러가 발생할 때 앱이 죽지 않고 대처할 수 있게 해준다.
에러를 대처할 수 있는 방법은 아래와 같다.
do {
let data = try fetchData()
print("데이터: \(data)")
} catch {
print("에러 발생: \(error)")
}
gurad let data = try? fetchData() else { print("데이터 로드 실패") return }
let data = try! fetchData()
print("데이터: \(data)")
func performTask() throws {
let data = try fetchData()
print("data: \(data)")
}
do {
try performTask()
} catch {
print("error: \(error)")
}
iOS에서 메인 스레드는 중요한 역할을 하니 화면을 그리는 역할만 해야한다. 오래걸리거나, 무거운 일은 다른 스레드로 넘겨줘야 메인 스레드에 부하가 안걸린다.
가령, 그것을 도와주는 것이 AsyncImage이다. ScrollView에서 AsyncImage로 이미지을 100개 불러오면 화면을 그리는 메인 스레드는 scrollView를 그려서 사용자가 스크롤 할 수 있고, 다른 스레드가 이미지를 다 그려서 AsyncImage로 보여주면 그때 사용자가 화면에서 볼 수 있다
import SwiftUI
struct ContentView: View {
let imageUrlString = "sampleURL"
var body: some View {
VStack {
Image(systemName: "square.and.arrow.up")
.resizable()
.frame(width: 200, height: 200)
ScrollView {
ForEach(0..<5) { _ in
// AsyncImage(url: URL(string: imageUrlString))
let data = try! Data(contentsOf: URL(string: imageUrlString)!)
Image(uiImage: UIImage(data: data)!)
}
}
}
}
}
#Preview {
ContentView()
}
만약 위 코드로 사진을 불러오면 화면이 그냥 멈춰버린다. 왜냐하면 화면을 그리는 것 뿐아니라 사진을 가져오는 통신까지 매인 스레드가 하게 코드를 짰기 때문이다. 이때 만큼은 메인 스레드가 가장 중요하게 하는 일이 사진을 서버로부터 가져오는 일이 되기 때문에 화면을 그리는 등의 중요한 일을 미루는 것이다.
따라서 오래걸리거나 무거운 작업을 할 때는 메인 스레드로 넘기는 synchronous적인(동기적) 방법보다, 다른 스레드로 넘기는 asynchronous적인 방법으로 코드를 작성해야한다
asynchronous적(비동기적)으로 코드를 작성할 때는 두가지가 중요한데, 첫번째는 어떻게 다른 스레드로 넘기느냐 이고, 두번째는 어떤 함수가 오래걸리고 무거운지를 알아야 한다.
비동기적으로 실행하고 싶은 부분을 Task로 감싸주면, 무거운 작업이나 오래 걸리는 작업을 비동기적으로 처리할 수 있다. 이렇게 되면 메ㅔ인 스레드가 다른 중요한 일을 하면서 백그라운드에서 작업을 진행할 수 있다.
import UIKit
print("시작")
print("1.thread:", Thread.current)
Task {
print("2.thread:", Thread.current)
var number = 0
for i in 0...200000 {
// number = number + i
number += i
}
print("결과:", number)
}
print("끝")
print("3.쓰레드:", Thread.current)
코드를 보면 시작과 끝은 메인 스레드이고, Task로 감싼 반복문은 3번 스레드인 것을 확인할 수 있다. Task로 감싸게 되면 컴퓨터에게 복잡한 일을 할 것인데, 메인 스레드 말구 다른 스레드에게 넘겨줘라는 뜻이다. 시작과 메인 스레드가 찍히고, 3번 스레드가 일하는 중이라는 것이 찍히고, 끝과 메인 스레드가 찍히고, 3번 스레드가 하던 무거운 작업인 반복문이 끝나면 결과가 찍힌다. 즉, 3번 스레드가 무거운 작업을 할 동안 메인 스레드는 자기 일을 하는 것이다.
위의 loadTransferable 함수 설명을 다시 보면, async 키워드가 있는 것을 확인할 수 있다. async 키워드는 무거운 작업 또는 오래 걸리는 작업임을 알려준다. 가령, 네트워크 요청, 파일 읽기/쓰기, 이미지 다운로드 및 처리할 때가 있다. 이러한 경우 비동기적으로 처리해야 메인 스레드에 부하가 안걸린다.
async 키워드를 가진 함수는 await, Task와 함께 사용된다.
Task 생성
await로 대기
await를 통해 특정 비동기 작업이 완료될 때까지 대기하게 한다.
즉 async함수는 await 키워드를 앞에 적어줘서 오래 걸리는 함수임을 알려주고, Task안에 적어서 다른 스레드로 보내는 방식으로 사용한다.
Task {
print("시작")
await Task.sleep(2_000_000_000) // 비동기작업 시작
print("끝")
}
위의 코드를 보며 다시 설명을 하려고 한다.
Task: 비동기 작업을 실행할 수 있는 스코프를 정의한다. Task는 비동기 작업을 메인 스레드가 아닌 다른 스레드에서 실행할 수 있도록 감싸주는 역할을 한다. 여기서는 2초 동안 비동기적으로 대기하는 작업을 정의하고 있다.
print("시작"): 첫 번째 print는 메인 스레드에서 바로 실행된다. 즉, Task는 메인 스레드에서 시작되지만 내부에서 비동기 작업을 처리할 수 있게 해준다.
await Task.sleep(2_000_000_000): Task.sleep은 비동기 함수로, 2초 동안 대기하는 작업을 처리한다. await 키워드를 사용하여 이 작업이 완료될 때까지 대기하게 된다. 2초가 지나면 Task.sleep이 완료되고, 다음 줄의 코드가 실행된다.
2_000_000_000 나노초는 2초를 의미한다. Task.sleep은 시간을 나노초 단위로 받아들인다.
중요하게도, await를 사용했기 때문에 2초 동안 메인 스레드가 멈추는 것이 아니라, 다른 작업이 계속해서 진행될 수 있도록 도와준다.
print("끝"): 2초 대기 후, Task 내부의 작업이 완료되고 마지막 print("끝")이 실행된다. 여기서 비동기 작업이 끝났음을 알 수 있다.
위 코드의 convertImage를 다시 보자.
convertImage 함수에서는 PhotosPickerItem을 받아와서 이를 처리하기 위해 optional unwrapping을 수행한다. 이 때, guard let을 사용하는 이유는 다음과 같다.
guard let은 값이 nil일 경우 else 블록으로 넘어가며, 이후의 코드를 실행하지 않도록 한다. 따라서 함수의 흐름을 더 명확하게 유지할 수 있다. 예를 들어, guard let item = item else { return }에서 item이 nil일 경우 함수가 바로 종료된다.
if let도 가능하지만, 사용 시 코드를 더 복잡하게 만들 수 있다. 만약 nil일 경우에만 특정 코드를 실행하게 하려면, 모든 경우에 대해 else 블록을 작성해야 하므로 가독성이 떨어질 수 있다.
강제 unwrapping은 nil이 아닐 것이라고 확신할 때 사용할 수 있으나, 만약 nil일 경우 앱이 강제 종료된다. 따라서 안전한 코드를 작성하기 위해서는 피하는 것이 좋다.
❗️ 결론적으로, guard let을 사용함으로써 코드의 안전성과 가독성을 동시에 확보할 수 있다.
loadTransferable 함수는 async throws를 사용하고 있는데, 이는 다음과 같은 특징이 있다.
함수가 실행되는 도중에 에러가 발생할 가능성이 있음을 나타내며, 호출하는 쪽에서 이 에러를 처리할 수 있도록 요구한다.
try 키워드는 에러가 발생할 수 있는 함수에 대해 사용되며, do-catch 문과 함께 에러를 처리해야 한다. 예를 들어, guard let data = try? await item.loadTransferable(type: Data.self) else { return }와 같이 사용하면, 에러 발생 시 nil을 반환하고 이어서 처리할 수 있다.
try!는 에러가 절대 발생하지 않음을 확신할 때 사용되지만, 만약 에러가 발생하면 앱이 강제 종료된다. 이는 안전하지 않은 방식으로, 주의해서 사용해야 한다.
❗️ 결론적으로, error handling은 안정성과 코드의 예측 가능성을 높이기 위한 중요한 기능이며, try, try?, try! 각각의 사용 용도가 다르므로 적절히 선택해야 한다.
convertImage 함수는 비동기 처리를 위해 async 키워드가 붙어 있다. 비동기 처리를 해야 하는 이유는 다음과 같다.
iOS에서는 메인 스레드가 UI 업데이트와 같은 중요한 작업을 처리하기 때문에, 무거운 작업은 다른 스레드에서 처리해야 한다. 이를 통해 앱의 응답성을 유지할 수 있다.
async 키워드는 해당 함수가 비동기 작업을 포함하고 있음을 나타내며, await 키워드를 통해 비동기 작업이 완료될 때까지 대기한다.
Task는 비동기 작업을 실행할 수 있는 스코프를 정의하며, 이 내부에서 다른 스레드에서 비동기적으로 작업을 수행할 수 있게 해준다.
예를 들어, await convertImage(item: newValue)는 newValue에 대한 이미지 변환이 완료될 때까지 기다리며, 메인 스레드가 블로킹되지 않도록 돕는다.
❗️ 결론적으로, 비동기 처리는 UI가 원활하게 작동하도록 하면서 백그라운드에서 필요한 작업을 수행할 수 있게 한다.
참고 자료 및 출처