Hacking with Swift 사이트의 강좌 번역본입니다.
원문 : https://www.hackingwithswift.com/articles/233/whats-new-in-swift-5-5
What’s new in Swift 5.5
이 글에서 코드 샘플로 각 변경사항을 살펴볼 것이므로, 각각에 대해 실제로 어떻게 동작하는지 확인할 수 있습니다. 시작하기 전에 2가지 중요한 경고가 있습니다.
- 이렇게 많은 대규모의 Swift Evolution 제안이 밀접한 관계를 가진것은 처음 있는 일이므로, 이러한 변경사항들을 결합하도록(cohesive flow) 구성하려 노력했지만, 동시성 작업의 일부는 여러 제안을 읽고난 후에야 제대로 이해할수 있습니다.
- 중요한 부분은 여전히 Swift Evolution을 통해서 유지되고 있고, 최신 Swift 5.5 스냅샷도 있지만 WWDC21 전후에 더 발전할 수 있습니다. 이 글은 상황이 정리가되면 틀림없이 바뀔 것입니다.
팁: 코드 샘플을 직접 사용해보고 싶으면, 다운로드 할 수 있습니다.
Async/await
SE-0296는 Swift에서 비동기(async) 함수를 도입해서 복잡한 비동기 코드를 거의 동기식으로 실행할 수 있습니다. 이는 2 단계로 수행합니다: 새로운 async 키워드로 비동기 함수를 표시하고나서, C#이나 JavaScripty와 같은 다른 언어와 비슷하게, await 키워드를 사용해서 호출합니다.
async/await가 언어에 어떻게 도움이 되는지 알아보기 위해서, 이전의 동일한 문제를 어떻게 해결했는지 살펴보는 것이 도움이 됩니다. 완료 핸들러(Completion handlers)는 Swift 코드에서 일반적으로 함수가 반환된 후에 다시 보내는데 사용되지만, 보다시피 까다로운(tricky)구문을 사용했습니다.
예를들어, 만약 서버에 100,000개의 날씨 기록을 가져오는(fetched) 코드를 작성하는 경우에, 시간에 따라 평균 온도를 계산하고나서, 그 평균 결과를 서버에 업로드하는 것을, 다음과 같이 작성할 수 있습니다.
func fetchWeatherHistory(completion: @escaping ([Double]) -> Void) {
// Complex networking code here; we'll just send back 100,000 random temperatures
DispatchQueue.global().async {
let results = (1...100_000).map { _ in Double.random(in: -10...30) }
completion(results)
}
}
func calculateAverageTemperature(for records: [Double], completion: @escaping (Double) -> Void) {
// Sum our array then divide by the array size
DispatchQueue.global().async {
let total = records.reduce(0, +)
let average = total / Double(records.count)
completion(average)
}
}
func upload(result: Double, completion: @escaping (String) -> Void) {
// More complex networking code; we'll just send back "OK"
DispatchQueue.global().async {
completion("OK")
}
}
여기에서 네트워크 부분은 관련이 없기 때문에, 실제 네트워크 코드를 가짜 값으로 대채(substituted)했습니다. 중요한 것은 각 기능을 실행하는데 시간이 걸릴수 있으므로, 함수의 실행을 막고 값을 직접 반환하는 대신에 준비가 되었을때에만 완료 클로져를 사용해서 무언가를 다시 보냅니다.
그 코드를 사용할때, 하나씩 연결(chain)해서 호출해야 하며, 다음과 같이 연결을 계속하기 위해 완료 클로져를 제공합니다.
fetchWeatherHistory { records in
calculateAverageTemperature(for: records) { average in
upload(result: average) { response in
print("Server response: \(response)")
}
}
}
이러한 접근법의 문제점을 확인하기를 희망합니다.
- 해당 함수가 완료 핸들러(handler)를 한 번 이상 호출하거나, 호출하는 것을 아예 잊어버릴수 있습니다.
- 매개변수 구분 @escaping (String) -> Void는 읽기 어려울 수 있습니다.
- 호출하는 사이트에서 각 완료 핸들러에 대해 들여쓰기된 코드와 함께 이른바(so-called) 운명의 피라미드로 끝납니다.
- Swift 5.0이 Result 타입을 추가할때까지, 완료 핸들러를 사용해서 오류를 다시 보내는 것이 어렵습니다.
Swift 5.5 부터, 다음과 같이 완료 핸들러에 의존하지 않고 비동기적으로 값을 반환하는 것 처럼 표시해서 함수를 정리할 수 있습니다.
func fetchWeatherHistory() async -> [Double] {
(1...100_000).map { _ in Double.random(in: -10...30) }
}
func calculateAverageTemperature(for records: [Double]) async -> Double {
let total = records.reduce(0, +)
let average = total / Double(records.count)
return average
}
func upload(result: Double) async -> String {
"OK"
}
이 경우에 이미 비동기적으로 값을 반환하는 것과 관련된 많은 구문을 제거했지만, 호출 사이트에서는 더 깔끔해집니다.
func processWeather() async {
let records = await fetchWeatherHistory()
let average = await calculateAverageTemperature(for: records)
let response = await upload(result: average)
print("Server response: \(response)")
}
보시다시피, 모든 클로져(closures)와 들여쓰기(indenting)가 사라졌으며, 이를 직선 코드라고 부르기도 합니다 - await 키워드를 제외하고, 동기식 코드처럼 보입니다.
비동기 함수가 동작하는 방법에 대해 몇 가지 간단하고 구체적인 규칙이 있습니다.
- 동기(synchronous) 함수는 간단하게 비동기(async) 함수를 직접 호출 할 수 없습니다. - 말이 안되므로, Swift는 오류를 던질 것입니다.
- 비동기 함수는 다른 비동기 함수들을 호출 할 수 있지만, 필요에 따라 일반 동기함수도 호출 할 수 있습니다.
- 같은 방법으로 호출된 비동기와 동기 함수가 있는 경우에, Swift는 현재 컨텍스트(context)와 일치하는 것을 선호할 것입니다 - 호출 사이트가 현재 비동기이면 Swift는 비동기 함수를 호출하고, 그렇지 않으면(otherwise) 동기 함수를 호출할 것입니다.
라이브러리 작성자가 비동기 함수 이름을 특별히 지정하지 않고 코드의 동기, 비동기 버젼을 모두 제공 할 수 있기 때문에 마지막 부분이 중요합니다.
async/await의 추가는 try/catch와 완벽하게 어울리며, 비동기 함수와 초기화가 필요에 따라 오류를 던질 수 있음을 의미합니다. 여기에서 유일한 조건(proviso)은 Swift가 해당 키워드에 대해 특정 순서를 적용하고, 그 순서는 호출한 사이트와 함수간에 반대(reversed)입니다.
예를들어, 서버에서 사용자를 가져와서 디스크에 저장하는 함수가 있을 수 있으며, 둘 다 오류가 발생해서 실패할 수 있습니다.
enum UserError: Error {
case invalidCount, dataTooLong
}
func fetchUsers(count: Int) async throws -> [String] {
if count > 3 {
// Don't attempt to fetch too many users
throw UserError.invalidCount
}
// Complex networking code here; we'll just send back up to `count` users
return Array(["Antoni", "Karamo", "Tan"].prefix(count))
}
func save(users: [String]) async throws -> String {
let savedUsers = users.joined(separator: ",")
if savedUsers.count > 32 {
throw UserError.dataTooLong
} else {
// Actual saving code would go here
return "Saved \(savedUsers)!"
}
}
보다시피, 두 함수 모두 async throws로 표시되어 있습니다 - 그것들은 비동기 함수이고, 오류를 던질 수 있습니다.
그것들을 호출할때 키워드 순서를 다음과 같이 await try 대신 try await로 바꿉니다.
func updateUsers() async {
do {
let users = try await fetchUsers(count: 3)
let result = try await save(users: users)
print(result)
} catch {
print("Oops!")
}
}
따라서, 함수 정의에서는 비동기(asynchronous), 오류던지기(throwing) 이지만, 호출하는 사이트에서는 오류 던지기(throwing), 비동기(asynchronous) 입니다. - 스택 해제(unwinding a stack: 객체 제거하고 메모리에서 해제)이라 생각하면 됩니다. try await는 await try 보다 더 자연스럽게 읽을수 있을뿐 아니라, 실제로 발생하는 것을 더 잘 반영합니다: 작업이 완료되기를 기다리고, 완료 될때 오류를 던질수 있습니다.
Swift 자체적으로 async/await를 사용하고, Swift 5.0에서 도입된 Result 타입은 완료 핸들러를 개선하는 것이 주요 이점 중 하나였기때문에 훨씬 덜 중요해졌습니다. Result가 쓸모없다는 것은 아닙니다. 나중에 평가할 수 있도록 작업 결과를 저장하는 가장 좋은 방법입니다.
중요
함수를 비동기로 만든다고해서 다른 코드와 함께 마술처럼 동시에 실행되는 것은 아니며, 여러 비동기 함수를 호출하도록 지정하지 않는 한 순차적으로 실행한다는 것을 의미합니다.
지금까지 살펴본 모든 async 함수는 의도적으로 다른 비동기 함수에 의해 호출되었습니다: Swift Evolution 제안은 실제로 동기 컨텍스트에서 비동기식 코드를 실행하는 방법을 제공하지는 않습니다. 대신, 이 기능은 별도의 구조적인 동시성(Structed Concurrency) 제안에 정의되어 있으며, Foundation에서 주요 업데이트로 볼수 있기를 바랍니다.
Async/await: sequences
Se-0298에서 새로운 AsyncSequence 프로토콜을 사용해서 값의 비동기 시퀀스를 반복하는 기능을 도입했습니다. 한번에 모든 값을 미리 계산하는 것보다 사용할 수 있게 되면 순서대로 처리할때 유용합니다 - 아마도 계산하는데 시간이 걸리거나 사용할 수 없기 때문입니다.
AsyncSequence를 사용하는 것은 타입이 AsyncSequence와 AsyncIterator를 준수하고 next() 메소드가 async로 표시되야 하는 것을 제외하면, Sequence를 사용하는 것과 거의 동일합니다. 시퀀스가 끝날때, Sequence와 마찬가지로 next()에서 nil을 다시 보내야 합니다.
예를들어, 1에서 시작해서 호출될때마다 두배가 되는 DoubleGenerator 시퀀스를 만들수 있습니다.
struct DoubleGenerator: AsyncSequence {
typealias Element = Int
struct AsyncIterator: AsyncIteratorProtocol {
var current = 1
mutating func next() async -> Int? {
defer { current &*= 2 }
if current < 0 {
return nil
} else {
return current
}
}
}
func makeAsyncIterator() -> AsyncIterator {
AsyncIterator()
}
}
** 팁 **
코드에서 보여지는 모든 곳에서 async 를 제거하게 되면, 같은 일을 하는 유효한 Sequence를 가집니다 - 그만큼 이 둘이 비슷합니다.
비동기 시퀀스가 있으면, 다음과 같이 비동기 컨텍스트에서, for await를 사용해서 값을 반복할 수 있습니다.
func printAllDoubles() async {
for await number in DoubleGenerator() {
print(number)
}
}
또한 ASyncSequence 프로토콜은 map(), compactMap(), allSatisfy()와 같은, 여러가지 일반적인 메소드의 기본 구현을 제공합니다. 예를들어, 다음과 같은 특정 숫자를 출력하는지 확인할 수 있습니다.
func containsExactNumber() async {
let doubles = DoubleGenerator()
let match = await doubles.contains(16_777_216)
print(match)
}
다시 한번 말하지만, 이 기능은 비동기 컨텍스트에서 사용해야 합니다.
Effectful read-only properties
SE-0310에서 Swift의 읽기전용 프로퍼티를 업그레이드 해서 async와 throws 키워드를 지원하고 개별적으로 또는 함께 제공함으로써 유연성이 크게 향상되었습니다.
이를 입증하기(demonstrate) 위해서, 앱 리소스에 있는 파일의 내용을 불러오는 BundleFile 구조체를 만들수 있습니다. 파일이 없거나, 있지만 읽을수 없거나, 읽을 수는 있지만 읽는데 시간이 많이 걸릴수 있기 때문에, 다음과 같이 컨텐츠 프로퍼티(property)를 비동기로 표시할 수 있습니다.
enum FileError: Error {
case missing, unreadable
}
struct BundleFile {
let filename: String
var contents: String {
get async throws {
guard let url = Bundle.main.url(forResource: filename, withExtension: nil) else {
throw FileError.missing
}
do {
return try String(contentsOf: url)
} catch {
throw FileError.unreadable
}
}
}
}
contents는 async와 throwing 모두 가능하기에, 이를 읽을때 try await를 사용해야 합니다.
func printHighScores() async throws {
let file = BundleFile(filename: "highscores")
try await print(file.contents)
}
Structured concurrency
SE-0304는 Swift에서 동시 작업을 실행하고, 취소하고, 모니터링하는 다양한 접근방법을 도입했고, async/sync와 async sequences 작업을 만들어졌습니다.
보다 쉬운 데모를 위해서, 사용할 수 있는 여러 예제 함수 입니다. - 특정 위치의 날씨를 가져오는 것을 시뮬레이션하는 async 함수, 어떤 숫자가 피보나치 수열에서 특정 위치에 있는지 계산하는 동기 함수.
enum LocationError: Error {
case unknown
}
func getWeatherReadings(for location: String) async throws -> [Double] {
switch location {
case "London":
return (1...100).map { _ in Double.random(in: 6...26) }
case "Rome":
return (1...100).map { _ in Double.random(in: 10...32) }
case "San Francisco":
return (1...100).map { _ in Double.random(in: 12...20) }
default:
throw LocationError.unknown
}
}
func fibonacci(of number: Int) -> Int {
var first = 0
var second = 1
for _ in 0..<number {
let previous = first
first = second
second = previous + first
}
return first
}
구조화된 동시성에 의해 도입된 가장 간단한 비동기 접근방법은 @main 속성을 사용해서 비동기 컨텍스트로 바로 이동하는 기능이며, 다음과 같이 main() 메소드를 async로 표시하기만 하면 됩니다.
@main
struct Main {
static func main() async throws {
let readings = try await getWeatherReadings(for: "London")
print("Readings are: \(readings)")
}
}
구조적인 동시성(structured concurrency) 도입의 주요 변화는 Task와 TaskGroup 2개의 새로운 타입이며, 동시 작업을 개별적(individually) 또는 조정된(coordinated) 방법으로 실행할 수 있습니다.
가장 간단한 형태로, 새로운 Task 객체를 만들고 실행하려는 작업을 전달해서 동시 작업을 시작할 수 있습니다. 이것은 백그라운드 스레드에서 즉시 실행되고, 완료된 값이 돌아올때까지 await를 사용할 수 있습니다.
따라서, 시퀀스의 처음 50개의 숫자를 계산하기 위해서, 백그라운드 스레드에서 fibonacci(of:) 를 여러번 호출할 수 있습니다.
func printFibonacciSequence() async {
let task1 = Task { () -> [Int] in
var numbers = [Int]()
for i in 0..<50 {
let result = fibonacci(of: i)
numbers.append(result)
}
return numbers
}
let result1 = await task1.value
print("The first 50 numbers in the Fibonacci sequence are: \(result1)")
}
보사시피, Swift가 task를 반환할 것을 알수있도록, Task { () -> [int] in를 명시적으로 작성해야 했지만, task 코드가 더 간단하면 필요하지 않습니다. 예를들어, 이렇게 작성하고 정확히 같은 결과를 얻을 수 있습니다.
let task1 = Task {
(0..<50).map(fibonacci)
}
다시말해, task가 생성되자마자 동작하기 시작하고, printFibonacciSequence() 함수는 피보나치(Fibonacci) 숫자가 계산되는 동안에 어떤 스레드에서든지 계속 실행될 것입니다.
** 팁 **
task의 동작은 task를 나중에 저장하지 않고 바로 실행하기 때문에 non-escaping 클로져이며, 이는 클래스 또는 구조체에서 Task 를 사용하는 경우에 프로퍼티나 메소드를 사용하기 위해 self를 사용할 필요가 없다는 것을 의미합니다.
마지막 숫자를 읽을때, await task1.value는 task의 출력이 준비될때까지 printFibonacciSequence()의 실행을 일시 중지하고, 그 시점에 반환할 것입니다. task에서 반환하는게 무엇이든 실제로 중요하지 않다면 - 코드를 언제든지 시작하고 종료하길 원하는 경우 - task를 어디에도 저장할 필요가 없습니다.
잡히지 않는(uncaught) 오류를 던지는 task 동작의 경우에, task의 values 프로퍼티를 읽으면 자동으로 오류를 던질것입니다. 따라서, 두가지 작업을 동시에 실행하고나서 모두 완료될때까지 기다리는 함수를 작성할 수 있습니다.
func runMultipleCalculations() async throws {
let task1 = Task {
(0..<50).map(fibonacci)
}
let task2 = Task {
try await getWeatherReadings(for: "Rome")
}
let result1 = await task1.value
let result2 = try await task2.value
print("The first 50 numbers in the Fibonacci sequence are: \(result1)")
print("Rome weather readings are: \(result2)")
}
Swift는 high, default, low, background의 내장된 task 우선순위(priorities)를 제공합니다. 위 코드는 특별히 설정하지 않아서 default이지만, Task(priority: .hight) 와 같이 사용자화할 수 있습니다. 애플(Apple) 플랫폼에서만 작성하는 경우에, high에 userInitiated를, low에 utility으로 좀 더 익숙한 우선순위를 사용할 수 있지만, userInteractive는 메인 스레드용으로 예약되어 있기 때문에 사용할 수 없습니다.
동작을 실행하는 것뿐만아니라, Task는 코드 실행하는 방법을 제어하기 위한 유용한 정적 메소드를 제공합니다.
- Task.sleep()를 호출하면 현재 task는 지정된 나노초만큼 잠들게(sleep) 될 것입니다. 더 좋은 것이 나올때까지, 1초를 의미하는 1_000_000_000으로 작성합니다.
- Task.checkCancellation()을 호출하면 cancel() 메소드를 호출해서 이 task를 취소하도록 요청했는지 여부를 확인하고, 그런 경우에 CancellationError 던질 것입니다.
- Task.yield()를 호출하면 대기중인 task에게 약간의 시간을 주기 위해서 현재 task을 몇 초동안 일시정지할 것이며, 이는 반복문에서 집중적인 작업을 하는 경우에 특히 중요합니다.
다음에 오는 예제에서 task를 1초동안 멈추고(sleep) 나서 완료하기 전에 취소(cancel)하는 sleeping과 cancellation 모두 확인 할 수 있습니다.
func cancelSleepingTask() async {
let task = Task { () -> String in
print("Starting")
try await Task.sleep(nanoseconds: 1_000_000_000)
try Task.checkCancellation()
return "Done"
}
// The task has started, but we'll cancel it while it sleeps
task.cancel()
do {
let result = try await task.value
print("Result: \(result)")
} catch {
print("Task was cancelled.")
}
}
코드에서, Task.checkCancellatiion()은 그 task가 취소된 것을 깨닫고(realize) 바로 CancellationError을 던지지만, task.value를 읽기 전까지는 도달하지 않습니다.
** 팁 **
task의 성공과 실패 값을 포함하는 Result 값을 얻기 위해 task.result를 사용합니다. 예를들어, 위 코드에서 Result를 얻게 될 것입니다. 성공 또는 실패 사례를 처리하기 위해, try 호출하는 것은 필요하지 않습니다.
좀 더 복잡한 작업을 위해서 task groups*을 생성합니다. - 완료된 값을 만들어내기 위해 함께 동작하는 task의 콜렉션(collection)
프로그래머가 위험한 방법으로 task group를 사용하는 위험을 출이기 위해서, 간단한 public 초기화를 제공하지 않습니다. 대신, withTaskGroup()와 같은 함수를 사용해서 task group을 생성합니다: 원하는 작업의 본문(body)으로 호출하면 작업하기 위한 task group 인스턴스를 전달할 것입니다. group내에서 addTask() 메소드를 사용해서 작업을 추가할 수 있고, 바로 실행을 시작할 것입니다.
** 중요 **
withTaskGroup()의 본문(body) 외부에서 task group을 복사하려고 하면 안됩니다 - 컴파일러는 멈출수 없지만, 스스로 문제를 만들게 됩니다.
task group 작업의 간단한 예제를 봅시다 - 작업 순서가 중요하다는 것을 알기 위해서 다음과 같은 상황을 시도해 보세요.
func printMessage() async {
let string = await withTaskGroup(of: String.self) { group -> String in
group.addTask { "Hello" }
group.addTask { "From" }
group.addTask { "A" }
group.addTask { "Task" }
group.addTask { "Group" }
var collected = [String]()
for await value in group {
collected.append(value)
}
return collected.joined(separator: " ")
}
print(string)
}
하나의 완료된 문자열을 만들도록 설계된 task group을 생성하고나서 task group의 addTask() 메소드를 사용해서 여러개의 클로져를 큐에 넣습니다. 각 클로져는 하나의 문자열을 반환하고, 문자열 배열로 수집된 하나의 문자열로 결합하고 출력을 위해 반환됩니다.
** 팁 **
task group에 있는 모든 task는 반환하는 데이터 타입이 같아야 하므로, 복잡한 작업에 대해 원하는 값을 정확히 얻기 위해 연관된 값을 포함한 열거형을 반환해야 할 수 있습니다. 더 간단한 대안으로는 별도의 Async Let Bindings 제안에서 도입된 것입니다.
addTask()에 대한 각 호출은 문자열이 되는한 어떤 함수도 될 수 있습니다. 하지만 task groups가 반환하기 전에 모든 하위 task가 완료하는 것을 자동으로 기다리며, 코드가 실행될때 하위 task는 어떤 순서로든 완료될 수 있기 때문에 출력이 뒤섞일 것입니다. - 예를들어, Hello A Task Group A와 마찬가지로 Hello From Task Group A가 될 수 있습니다.
task group이 오류를 던질 수 있는 코드를 실행하는 경우에, group 내부에서 오류를 직접 처리하거나 group의 외부에서 처리 하도록 할 수 있습니다. 후자(latter)의 경우에 다른 함수 withTrowingTaskGroup() 를 사용해서 처리되며, 모든 오류를 잡아서 처리하지 못하는 경우에 try를 호출해야 합니다.
예를들어, 다음코드는 하나의 group에서 여러 지역의 날씨를 읽어 간단하게 계산하고 모든 지역의 전체(overall) 평균을 반환합니다.
func printAllWeatherReadings() async {
do {
print("Calculating average weather…")
let result = try await withThrowingTaskGroup(of: [Double].self) { group -> String in
group.addTask {
try await getWeatherReadings(for: "London")
}
group.addTask {
try await getWeatherReadings(for: "Rome")
}
group.addTask {
try await getWeatherReadings(for: "San Francisco")
}
// Convert our array of arrays into a single array of doubles
let allValues = try await group.reduce([], +)
// Calculate the mean average of all our doubles
let average = allValues.reduce(0, +) / Double(allValues.count)
return "Overall average temperature is \(average)"
}
print("Done! \(result)")
} catch {
print("Error calculating data.")
}
}
이 경우에, 각 addTask() 호출은 전달되는 위치 문자열을 제외하고 같으므로, 반복문에서 addTask()를 호출하기 위해서 for location in [London, Rome, San Francisco] {“와 같이 사용할 수 있습니다.
task groups은 group 내부의 모든 task를 취소하는 cancelAll() 메소드를 가지고 있지만, 나중에 addTask()을 사용하면 group에 계속 작업이 추가 될 것입니다. 대안(alternative)으로는, group이 취소된 경우에 작업을 건너뛰기 위해서 addTaskUnlessCancelled()를 사용할 수 있습니다. - 작업이 성공적으로 추가되었는지 반환된 Boolean을 확인합니다.
async let bindings
SE-0317은 간단한 async let 구문을 사용해서 하위 task들을 생성(create)하고 기다리는(await) 기능을 도입했습니다. 이는 다른 종류의 결과 타입을 처리하는 task group의 대안으로 특히 유용합니다. - 예를들어, group 내의 tasks가 다른 종류의 데이터를 반환하고자 하는 경우.
이를 증명(demonstrate)하기 위해, 3가지 다른 비동기 함수로부터 3가지 다른 타입을 가지는 구조체를 만들수 있습니다.
struct UserData {
let name: String
let friends: [String]
let highScores: [Int]
}
func getUser() async -> String {
"Taylor Swift"
}
func getHighScores() async -> [Int] {
[42, 23, 16, 15, 8, 4]
}
func getFriends() async -> [String] {
["Eric", "Maeve", "Otis"]
}
이러한 3가지 값 모두에서 User 인스턴스를 생성하려는 경우에, async let는 가장 쉬운 방법입니다 - 각 함수를 동시에 실행하고, 3개 모두 완료되길 기다리고나서, 객체를 만들기 위해서 그것들을 이용합니다.
방법은 다음과 같습니다.
func printUserDetails() async {
async let username = getUser()
async let scores = getHighScores()
async let friends = getFriends()
let user = await UserData(name: username, friends: friends, highScores: scores)
print("Hello, my name is \(user.name), and I have \(user.friends.count) friends!")
}
** 중요 **
이미 async 컨텍스트에 있는 경우에는 async let만을 사용할 수 있고, async let의 결과를 명시적으로 기다리지 않는 경우에, Swift는 그 범위를 벗어날때 암시적으로 기다릴 것입니다.
오류를 던지는 함수로 작업할때, async let과 함께 try 를 사용할 필요가 없습니다. - 자동으로 결과를 기다리는 곳으로 뒤로 밀릴수 있습니다. 마찬가지로, await 키워드도 포함되므로, try await someFunction()을 async let과 함께 입력하는 대신에 someFunction()을 작성할 수 있습니다.
이를 증명하기 위해, 피보나치 수열(Fibonacci sequence)에서 재귀적으로 숫자들을 계산하기 위해 비동기 함수를 작성할 수 있습니다. 이 방법은
기억하고(memoization) 있지 않으면 엄청난 양의 작업을 반복하기때문에 형편없이(hopeless) 단순(native)하므로, 힘들어지는 것을 막기 위해 입력을 0에서 22까지의 범위로 제한할 것입니다.
enum NumberError: Error {
case outOfRange
}
func fibonacci(of number: Int) async throws -> Int {
if number < 0 || number > 22 {
throw NumberError.outOfRange
}
if number < 2 { return number }
async let first = fibonacci(of: number - 2)
async let second = fibonacci(of: number - 1)
return try await first + second
}
이 코드에서 fibonacci(of:) 재귀호출하는 것은 암시적으로 try await fibonacci(of:)이지만, 이를 생략하고 다음에 오는 줄을 직접 처리할 수 있습니다.
Continuations for interfacing async tasks with synchronous code
SE-0300은 최신 비동기코드에 기존의 완료 핸들러 스타일의 API를 적용하는데 도움이되는 새로운 함수를 도입했습니다.
예를들어, 다음 함수는 완료 핸들러를 사용해서 비동기적인 값을 반환합니다.
func fetchLatestNews(completion: @escaping ([String]) -> Void) {
DispatchQueue.main.async {
completion(["Swift 5.5 release", "Apple acquires Apollo"])
}
}
async/await를 사용하길 원하는 경우에, 함수를 다시 작성하는게 가능하지만, 가능하지 않은 다양한 이유가 있습니다 - 예를들어, 외부 라이브러리일 수도 있습니다. 예를들어, withCheckedContinuation() 함수는 원하는 코드를 계속 실행할수 있도록 하고, 준비가 될때 resume(returning:)을 호출해서 값을 다시 보냅니다. - 완료 핸들러 클로져의 일부 일지라도
따라서, 두번째 fetchLatestNews() 함수를 비동기 함수로 만들수 있고, 기존의 완료 핸들러 함수를 감쌀수 있습니다.
func fetchLatestNews() async -> [String] {
await withCheckedContinuation { continuation in
fetchLatestNews { items in
continuation.resume(returning: items)
}
}
}
다음과 같이, 비동기 함수에서 원래 함수를 사용할 수 있습니다.
func printNews() async {
let items = await fetchLatestNews()
for item in items {
print(item)
}
}
계속해서(continuation) 확인된(checked)이라는 용어는 Swift가 우리를 대신해서 실시간으로 확인하는 것을 의미합니다: 한 번만 resume()를 호출하나요? 이는 계속해서 재개(resume)하지 않으면 리소스 누수가 발생하기 때문에 중요하지만, 두번 호출하게 되면 문제가 생길 것입니다.
중요
분명히 말하지만, 계속해서 정확하게 한 번만 재개(resume)해야 합니다.
실시간 계속해서 확인하는데 성능상 비용이 들기 때문에, Swift는 사용자 대신 실시간 검사하지 않는 것을 제외하고 정확히 동일하게 작업하는 withUnsafeContinuation() 함수를 제공합니다. 이는 Swift가 계속해서 재개(resume)하는 것을 잊어버리더라도 경고를 하지 않고, 두번 호출하게되면 동작이 정의되지 않습니다.
이러한 두 함수는 같은 방법으로 호출되기 때문에, 쉽게 전환할 수 있습니다. 따라서, 함수를 작성하는 동안 잘못 사용하게 되면 Swift가 경고를 보내고 크래쉬가 발생하는 withCheckedContinuation()을 사용할 것이지만, 실시간 성능 비용에 영향을 받는 경우에 사용할때 withUnsafeContinuation()으로 전환할 수 있습니다.
Actors
SE-0306에서 동시성 환경에서 안전하게 사용할 수 있는 클래스와 유사한 개념인 actors를 도입했습니다. 이느 Swift가 주어진 시간에 오직 하나의 스레드에서만 사용할수 있도록 보장하는것이 가능하기 때문이며, 컴파일러 수준에서 바로 다양한 심각한 버그를 제거하는데 도움이 됩니다.
actors가 해결하는 문제를 설명하기 위해서, 다른 수집가와 카드를 교환할수 있는 RiskyCollector 클래스를 만드는 Swift 코드를 살펴(consider)봅시다.
class RiskyCollector {
var deck: Set<String>
init(deck: Set<String>) {
self.deck = deck
}
func send(card selected: String, to person: RiskyCollector) -> Bool {
guard deck.contains(selected) else { return false }
deck.remove(selected)
person.transfer(card: selected)
return true
}
func transfer(card: String) {
deck.insert(card)
}
}
이 코드는 단일 스레드 환경에서 안전합니다: 덱(deck)에 문제가 있는 카드가 있는지 확인하고 제거하고 다른 수집가의 덱에 추가합니다. 하지만 멀티 스레드 환경에서 잠재적인 경쟁 조건(race condition)이 있으며, 이는 2개의 분리된 코드 부분이 나란히 실행됨에 따라서 코드의 결과가 달라 질수 있는 문제 입니다.
동시에 한번 이상 send(card:to:)를 호출하게 되면, 다음과 같은 일련의 이벤트들이 발생할 수 있습니다.
- 첫 번째 스레드는 카드가 덱에 있는지 확인하고, 계속 됩니다.
- 두 번째 스레드 또한, 카드가 덱에 있는지 확인하고, 계속 됩니다.
- 첫 번째 스레드는 덱에서 카드를 제거 하고 다른 사람에게 전달합니다.
- 두 번째 스레드는 덱에서 카드를 제거하려고 시도하지만 이미 없어졌기때문에 아무일도 발생하지 않습니다. 하지만 여전히 다른 사람에게 카드를 전달 합니다.
이 상황에서 한 게이머는 카드 하나를 잃고, 다른 한명은 카드 2개를 얻고, 그 카드가 Magic the Gathering(매직 더 개더링 카드 게임)의 Black Lotus(희귀 카드 이며, 대략 50만불)인 경우에, 큰 문제가 발생한 것입니다!
Actors는 이 문제를 행위자 격리(actor isolation)으로 해결합니다: 저장 프로퍼티(stored properties)와 메소드는 비동기적으로 수행되지 않는한 actor 객체 외부에서 읽을 수 없고, 저장 프로퍼티는 actor 객체 외부에서 전혀 쓸수(written) 없습니다. 비동기 동작은 성능을 위한 것이 아닙니다; 대신, Swift는 이러한 요청을 경쟁 조건을 피하기 위해서 자동으로 큐에 자동으로 배치합니다.
따라서, RiskyCollector 클래스를 다음과 같이 SafeCollector actor로 다시 작성할 수 있습니다.
actor SafeCollector {
var deck: Set<String>
init(deck: Set<String>) {
self.deck = deck
}
func send(card selected: String, to person: SafeCollector) async -> Bool {
guard deck.contains(selected) else { return false }
deck.remove(selected)
await person.transfer(card: selected)
return true
}
func transfer(card: String) {
deck.insert(card)
}
}
예제에서 주의해야할 것들이 몇가지 있습니다.
- Actors는 새로운 actor 키워드를 사용해서 생성됩니다. 이는 구조체, 클래스, 열거형에 포함하는 Swift의 새로운 일반 타입입니다.
- send() 메소드는 전송을 완료할때까지 기다리는 동안 작업을 보류(suspend)해야하기 때문에 async로 표시됩니다.
- transfer(card:) 메소드는 다른 SafeCollector actor가 요청을 처리할 수 있을대까지 기다려야 하기 때문에, await로 호출해야 하기 때문에, async로 표시하지 않았습니다.
명확히 하면, actor는 자신의 프로퍼티와 메소드를 자유롭게, 비동기적으로 사용할수 있지만, 다른 actor와 상호 작용할때에는 반드시 비동기적으로 수행해야 합니다. 이러한 변화는 Swift는 모든 행위자 격리(actor-isolated) 상태가 동시에 접근하지 못하게 할수 있고, 더 중요한 것은 컴파일시에 안전이 보장되도록 할 수 있습니다.
Actors와 클래스는 몇가지 비슷한 점이 있습니다.
- 둘다 참조 타입이므로, 공유 상태를 사용할 수 있습니다.
- 메소드, 프로퍼티, 초기화, 첨자(subscripts)를 가질수 있습니다.
- 프로토콜을 준수할 수 있고 제네릭일 수 있습니다.
- 정적 프로퍼티와 메소드는 Self에 대한 개념이 없고 격리되지 않았기 때문에 두 타입에서 모두 동일하게 동작합니다.
행위자 격리(actor isolation)외에도, actors와 클래스깐에 중요한 차이점은 2가지가 있습니다.
- Actors는 현재 상속을 지원하지 않으므로, 초기화가 훨씬 간단합니다. - 이는 편리한 초기화(convenience initializers), 오버라이딩(overriding), final 키워드, 등이 필요하지 않습니다. 나중에 바뀌게 될수도 있습니다.
- 모든 actors는 새로운 Actor 프로토콜을 암시적으로 준수합니다: 다른 기본 타입은 이를 사용할 수 없습니다. 코드의 다른 부분을 제한함으로써 actors를 사용해서만 사용할 수 있습니다.
actors와 클래스와 어떻게 다른지를 설명하는 가장 좋은 방법은 다음과 같습니다. actors는 메시지를 전달하지, 메모리가 아닙니다 따라서 하나의 actor가 다른 프로퍼티를 직접 사용하거나 메소드를 호출하는 대신, 데이터를 요청하는 메시지를 보내고 Swift가 실시간으로 안전하게 처리할 수 있도록 합니다.
Global actors
SE-0316는 actors를 사용해서 데이터 경쟁에서 격리(isolated)하기 위해 전역(global) 상태를 허용합니다.
이론상, 많은 전역 actor를 사용할 수 있지만, 적어도 지금은 핵심 이점은 메인스레드에서만 사용해야 하는 프로퍼티와 메소드를 표시하기 위해 사용할 수 있는 @MainActor 전역 actor의 도입입니다.
예를들어, 앱의 데이터 저장소를 처리하는 클래스가 있고, 안전상의 이유로 메인 스레드가 아니면 영구 저장소에 대한 변경을 쓰는 것을 거부(refuse)합니다.
class OldDataController {
func save() -> Bool {
guard Thread.isMainThread else {
return false
}
print("Saving data…")
return true
}
}
이 동작은, DispatchQueue.main을 사용한 것처럼, @MainActor를 사용해서 save()가 항상 메인스레드에서 호출된다되는 것을 보장할수 있습니다.
class NewDataController {
@MainActor func save() {
print("Saving data…")
}
}
그게 전부입니다 - Swift는 데이터 컨트롤러에서 save()를 호출할때마다 메인 스레드에서 동작하도록 해줄 것입니다.
** 주의**
actor를 통해서 작업을 하고 있으므로, await, async let 등을 사용해서 save()를 호출해야 합니다.
@MainActor는 MainActor 구조체를 감싼 전역 actor 래퍼(wrapper) 이며, 정적 run() 메소드를 사용해서 실행할 작업을 예약할 수 있기에 유용합니다. 메인 스레드에서 코드가 실행되고, 선택적으로 결과를 다시 보낼 것입니다.
Sendable and @Sendable closures
SE-0302 보낼수 있는(sendable) 데이터를 추가했으며, 이는 다른 스레드에 안전하게 보낼수 있는 데이터 입니다. 이는 새로운 새로운 Sendable 프로토콜과 함수에 대해 @Sendable 속성을 통해서 이뤄집니다(accomplished).
많은 것들이 스레드간 전송하기에 안전합니다.
- Bool, Int, String 등을 포함해서 Swift의 핵심 값 타입 전부
- 래핑된(wrapped) 데이터가 값 타입인 옵셔널(optionals)
- 정적 라이브러리 모음(Standard library collections)은 Array 또는 Dictionary 과 같은 값 타입을 포함합니다.
- 요소(elements)가 모두 값타입인 튜플(Tuples)
- String.self 같은 메타타입.
Sendable 프로토콜을 준수하도록 업데이트 되었습니다.
사용자정의 타입의 경우에, 만드는 것에 따라 다릅니다.
- Actors는 내부적으로 동기화를 처리하기 때문에 자동으로 Sendable을 준수합니다.
- 사용자정의 구조체와 열거형은 Codable 동작과 비슷하게, Sendable을 준수하는 값만 포함하는 경우에, 자동으로 sendable을 준수할 것입니다.
- 사용자정의 클래스는 NSObject에서 상속받거나, 전혀 받지 않는한, Sendable를 준수할 수 있으며, 모든 프로퍼티는 상수이고 Sendable을 준수하고 추가적인 상속을 멈추기 위해 final로 표시됩니다.
Swift는 동시에 작업하는 것을 표시하기 위해서 함수나 클로져에 @Sendable 속성을 사용하고, 실수(shooting ourself in the foot)를 막기 위해 다양한 규칙을 강요할 것입니다. 예를들어, Task 초기화에 전달하는 연산자는 @Sendable로 표시하며, 이는 Task에 의해 캡쳐된 값이 상수이기 때문에 이런 종류의 코드가 허용되는 것을 의미합니다.
func printScore() async {
let score = 1
Task { print(score) }
Task { print(score) }
}
하지만, 이 코드는 하나가 다른 tasks의 값을 변경하는 동안에 사용할 수 있기 때문에, score가 변수인 경우에 허용되지 않습니다.
캡쳐된 값과 비슷한 규칙을 강요할 수 있으므로, 함수와 클로져를 @Sendable로 표시해야 합니다.
func runLater(_ function: @escaping @Sendable () -> Void) -> Void {
DispatchQueue.global().asyncAfter(deadline: .now() + 3, execute: function)
}
if for postfix member expressions
SE-0308에서 Swift는 후행(postfix) 멤버 표현식에 #if 조건을 사용할 수 있습니다. 이말은 약간 모호하게(obscure) 들리지만, SwiftUI에서 흔히 볼수 있는 문제를 해결합니다 : 이제 뷰에 수정자를 선택적(optionally)으로 추가할 수 있습니다.
예를들어, 이러한 변화는 iOS를 사용하거나 다른 플랫폼을 사용하는지에 따라 2가지 다른 폰트 크기로 텍스트 뷰를 만들 수 있도록 합니다.
Text("Welcome")
#if os(iOS)
.font(.largeTitle)
#else
.font(.headline)
#endif
보기는 힘들지만 원하는곳에 사용할 수 있습니다.
Text("Welcome")
#if os(iOS)
.font(.largeTitle)
#if DEBUG
.foregroundColor(.red)
#endif
#else
.font(.headline)
#endif
원하는 경우에 다양한 후행(postfix) 표현식을 사용할 수 있습니다.
let result = [1, 2, 3]
#if os(iOS)
.count
#else
.reduce(0, +)
#endif
print(result)
기술적으로는 result를 완전히 다른 타입으로 만둘수 있지만, 좋지 않은 생각입니다. .count 대신에 +[4]와 같은 확실히 다른 표현을 사용할 수는 없습니다. - . 시작하지 않는 경우에 후행(postfix) 멤버 표현식이 아닙니다.
Allow interchangeable use of CGFloat and Double types
SE-0307은 작지만 중요한 개선을 도입했습니다: Swift는 필요한 대부분에서 CGFloat와 Doublie간에 암시적으로 변환할 수 있습니다.
가장 단순한 형태로, 다음과 같이, 새로운 Double을 만들기 위해서 CGFloat와 Double를 함께 더하기(add) 할 수 있다는 것을 의미합니다.
let first: CGFloat = 42
let second: Double = 19
let result = first + second
print(result)
Swift는 필요에 따라 암시적인 초기화를 삽입해서 구현가능하고, 가능하면 항상 Double을 선호할 것입니다. 더 중요한 것은, 기존 API로 다시 작성하면 수행되지 않습니다: 기술적으로 SwiftUI에서 scaleEffect()는 여전히 CGFloat으로 동작하지만 Swift는 조용히 Double에 연결(bridges)시킵니다.
Codable synthesis for enums with associated values
SE-0296은 연관된 값(associated values)으로 열거형 작성을 지원하기 위해 Swift의 Codabe 시스템을 업그레이드 했습니다. 이전의 열거형은 RawRepresentable을 지원하는 경우에만 지원되었지만, 이번 확장은 일반 열거형 뿐만아니라 Codable 연관된 값(associated values)의 숫자까지도 지원합니다.
예를들어, 다음과 같이 Weather 열거형을 정의할 수 있습니다.
enum Weather: Codable {
case sun
case wind(speed: Int)
case rain(amount: Int, chance: Int)
}
단순한 하나의 케이스(case), 연관된 값(associated values) 하나를 가진 케이스(case), 연관된 값 두개를 가진 세번째 케이스(case)가 있습니다. - 모두 정수형이지만, 문자열이나 다른 Codable 타입을 사용할 수 있습니다.
열거형을 정의해서, 일기예보를 만드는 날씨 배열을 생성할 수 있고, JSONEncode 또는 비슷한 것을 사용하고 출력가능한 문자열로 결과를 변환합니다.
let forecast: [Weather] = [
.sun,
.wind(speed: 10),
.sun,
.rain(amount: 5, chance: 50)
]
do {
let result = try JSONEncoder().encode(forecast)
let jsonString = String(decoding: result, as: UTF8.self)
print(jsonString)
} catch {
print("Encoding error: \(error.localizedDescription)")
}
화면 뒤에서, 열거형 케이스에 연결된 값을 가지는 결과로 중첩된 구조체를 처리 할 수 있는 여러 CodingKey 열거형을 사용해서 구현되며, 이는 같은 작업을 하기 위해 사용자정의 코딩 메소드를 작성하는것이 조금 더 필요하다는 것을 의미합니다.
lazy now works in local contexts
lazy 키워드는 처음 사용될때에만 계산되는 저장 프로퍼티를 작성할때 쓸수 있지만, Swift 5.5에는 비슷하게 동작는 값을 만들기 위해서 함수 내부에서 lazy를 사용할 수 있습니다.
다음 코드는 동작하는 로컬 lazy을 보여줍니다.
func printGreeting(to: String) -> String {
print("In printGreeting()")
return "Hello, \(to)"
}
func lazyTest() {
print("Before lazy")
lazy var greeting = printGreeting(to: "Paul")
print("After lazy")
print(greeting)
}
lazyTest()
실행하면 Before lazy 와 After lazy 출력을 먼저 보게 될 것이고, In printGreeting() 다음에 Hello, Paul이 나옵니다. - Swift는 print(greeting) 줄을 사용할때 printGreeting(to:) 코드를 실행합니다.
실제로, 이 기능은 조건에 따라 코드를 선택적으로 실행하는데 유용한 방법이 될 것입니다 : 일부 작업의 결과를 늦게(lazily) 준비할 수 있고, 나중에 작업이 필요할 때만, 실제 실행됩니다.
Extend property wrappers to function and closure parameters
SE-0293 함수와 클로저용 매개변수로 사용할수 있도록 프로퍼티 래퍼(property wrappers)를 확장합니다. 이러한 방식으로 전달된 매개변수는 복사본을 가져오지 않는한 여전히 변경할 수 없고, 원하는 경우에 앞쪽 밑줄(leading underscore)을 사용해서 기본(underlying) 프로퍼티 래퍼 타입을 사용할 수 있습니다.
예를들어, 정수를 받고 출력하는 함수를 작성할 수 있습니다.
func setScore1(to score: Int) {
print("Setting score to \(score)")
}
다음과 같이, 호출될때 모든 범위의 값을 전달 할 수 있습니다.
setScore1(to: 50)
setScore1(to: -50)
setScore1(to: 500)
점수가 0…100 범위내에서만 있기를 원하는 경우에 숫자가 생성될때 고정하는(clamps) 간단한 프로퍼티 래퍼를 작성할 수 있습니다.
@propertyWrapper
struct Clamped<T: Comparable> {
let wrappedValue: T
init(wrappedValue: T, range: ClosedRange<T>) {
self.wrappedValue = min(max(wrappedValue, range.lowerBound), range.upperBound)
}
}
이제 이 래퍼를 사용해서 새로운 함수를 작성하고 호출할 수 있습니다.
func setScore2(@Clamped(range: 0...100) to score: Int) {
print("Setting score to \(score)")
}
setScore2(to: 50)
setScore2(to: -50)
setScore2(to: 500)
이전과 같은 입력값으로 setScore2()하면 숫자가 50, 0, 100으로 고정되므로, 다른 결과를 출력하게 될 것입니다.
** 팁 **
함수에 전달된 매개변수는 변하지 않기에 변경될때 래핑된 값을 변경할수 없기 때문에 프로퍼티 래퍼(property wrapper)은 사소한(trivial) 것입니다.- 다시 처리할 필요가 없습니다. 하지만, 필요하면 복잡한 프로퍼티 래퍼를 만들 수 있습니다; 프로퍼티나 지역 변수처럼 동작합니다.
Extending static member lookup in generic contexts
SE-0299에서 Swift는 일반 함수에서 프로토콜 멤버의 정적 멤버를 조회하는 것을 허용하며, 이는 모호하게 들리지만, 실제로 SwiftUI에서의 작지만 중요한 가독성 문제를 수정합니다.
이번에 SwiftUI가 이 변경을 지원하도록 업데이트 되지 않았지만, 계획대로 진행된다면 다음과 같이 작성하는 것을 멈출 수 있습니다.
Toggle("Example", isOn: .constant(true))
.toggleStyle(SwitchToggleStyle())
대신 다음과 같이 작성합니다.
Toggle("Example", isOn: .constant(true))
.toggleStyle(.switch)
이는 Apple이 광범위한 해결책을 마련했기에, 초기 SwiftUI 베타에서는 가능했었지만, 출시 전에 철회되었습니다.
실제 변경되는 것을 확인하기 위해서, 이를 준수하는 여러 구조체가 있는 Theme 프로토콜을 생각해 보세요.
protocol Theme { }
struct LightTheme: Theme { }
struct DarkTheme: Theme { }
struct RainbowTheme: Theme { }
몇가지 테마 종류로 theme() 메소드를 호출할 수 있는 Screen 프로토콜을 정의할수 있습니다.
protocol Screen { }
extension Screen {
func theme<T: Theme>(_ style: T) -> Screen {
print("Activating new theme!")
return self
}
}
그리고 이제 screen 인스턴스를 만들 수 있습니다.
struct HomeScreen: Screen { }
다음은 예전 SwiftUI 코드이고, LightTheme()를 지정함으로써 화면에 밝은 테마를 활성화 할 수 있습니다.
let lightScreen = HomeScreen().theme(LightTheme())
더 쉽게 사용하고 싶다면, 다음과 같이 Theme 프로토콜에 정적인 light 프로퍼티를 추가할 수 있습니다.
extension Theme where Self == LightTheme {
static var light: LightTheme { .init() }
}
하지만, 제네릭 프로토콜의 theme() 메소드를 사용하는것이 문제의 원인이었습니다 : Swift 5.5 전에는 불가능했고 항상 LightTheme()를 사용해야 했습니다. 하지만, Swift 5.5 이후에는 가능합니다.
let lightTheme = HomeScreen().theme(.light)
'Swift > Tip' 카테고리의 다른 글
What’s new in Swift 5.7 (0) | 2022.08.08 |
---|---|
Type의 문자열 이름 사용하기 (0) | 2022.04.01 |
What’s new in Swift 5.6 (0) | 2022.03.21 |
Swift version과 Xcode version (0) | 2022.02.09 |
What’s new in Swift 5.4 (0) | 2021.04.15 |
What’s new in Swift 5.3 (0) | 2021.04.15 |
What’s new in Swift 5.2 (0) | 2021.04.15 |
How to use opaque return types in Swift 5.1 (0) | 2019.12.18 |