Swift async/await

2021년 8월 3일 수정

Swift Concurrency(동시성)

Swift는 전통적(?)으로 GCD(Grand Central Dispatch)라 불리우는 병렬 프로그래밍 패러다임을 제공해왔다. 하지만 이 방식은 디스패치 큐를 활용한 현재로 치자면 좀 구식인 스타일이라 코딩하기 약간 까다로운 편이다.

이를 개선하기 위한 여러 요구가 있어왔다. 그리고 Swift 5.5 부터 새로운 비동기 프로그래밍 패턴으로 동시성(Concurrency) 개념이 지원되기 시작하면서 (이제서야) asyncawait 키워드가 도입되었다.

이미 Javascript Promise를 통해 asyncawait 키워드는 잘 알려져있는데 Swift의 그것도 사실상 거의 동일한 개념으로 사용할 수 있다.

Xcode 13이 아직 정식 릴리즈 전이라 내용이 지속적으로 바뀔 수도 있음에 유의하자.

async

async 키워드는 비동기 태스크를 정의한다. 다르게 표현해서 async 마크가 표기된 함수는 비동기로 동작하는 함수가 된다.

func someWork() async throws -> String {
    ...
}

위 예제처럼 async 마크 위치는 함수 매개변수 선언 뒤이고 throws 의 앞이다. 여기서 throws 는 반드시 필요한 것이 아니라 그저 위치 설명을 위해서 추가한 것이니 오해는 하지 말자.

async 는 프로퍼티에도 마크하는 것이 가능하다.

struct SomeStruct {
    var value: String {
        get async throws {
            ...
        }
    }
}

동시에 프로퍼티에도 throws 를 표기할 수 있게 되었기도 하다.

await

await 는 비동기 함수의 실행을 동기하는 명령이다. 즉 현재 스레드에서 해당 비동기 함수의 실행이 끝날 때까지 대기한다는 말이다.

func doWhatWithSomeWork() async {
    ...
    try? await someWork()
    ...
}

await 는 블로킹(blocking)을 유발하므로 async 로 마크된 함수 등 비동기 컨텍스트 내에서만 쓸 수 있다. 당연하게도 await 중인 함수의 실행이 완료될 때까지 코드의 실행은 중간에 멈추게(blocking) 된다.

await 를 사용할 수 있는 곳은 여러 곳이 있는데 위치가 미묘하긴 하다. 예를 들어 for 루프의 경우 공식 홈페이지 소개 내용에 아래와 같은 식의 예제를 소개하고 있다.

import Foundation

let handle = FileHandle.standardInput
for try await line in handle.bytes.lines {
    print(line)
}

아마도 handle.bytes.lines 는 비동기 Getter인 모양이다.

동기 코드 안에서의 비동기

async 는 함수 뿐만 아니라 아니라 블럭 자체에 마크할 수도 있었는데 Xcode Beta 3에서 이 문법이 Deprecated 되었다. 대신 이제는 Task 를 직접 써서 동일한 일을 할 수 있다.

Task {
    try? await someWork()
}

Task의 마지막 매개변수 타입이 async 클로저로 정의되어 있기 때문에 구현부에서 await 를 사용해도 문제가 없다.

그 외에 Task 자체에서는 우선순위(priority)도 결정할 수 있다. 자세한 것은 레퍼런스 매뉴얼을 참고하자.

병렬 실행

한 번에 여러 작업을 동시에 실행하고자 할 때는 아래와 같은 async-let 문법의 코드를 작성할 수 있다.

async let james = someWork(name: "James")
async let conrad = someWork(name: "Conrad")
async let michael = someWork(name: "Michael")

let results = await [james, conrad, michael]

마치 await 배열을 만든 느낌의 코드다. 어쨌거나 진정한 의미의 동시성 코드다.

이 외에도 Task Group을 활용하는 방법도 있다. 아래 예제는 공식 홈페이지의 예제이다.

await withTaskGroup(of: Data.self) { taskGroup in
    let photoNames = await listPhotos(inGallery: "Summer Vacation")
    for name in photoNames {
        taskGroup.async { await downloadPhoto(named: name) }
    }
}

동적으로 병렬 태스크를 구성해야 한다면 유용한 방식인 것 같다. 이 외에도 부모-자식 태스크를 구성하는 등 다양한 용도로 사용되니 일단 이름은 알아두면 좋을 것 같다.

액터(Actors)

동시성 지원과 함께 액터라 불리는 새로운 타입이 추가되었다. Class와 비슷하게 특수 구조체를 설계할 수 있는 기능으로 액터의 인스턴스 역시 동일하게 레퍼런스 타입이다.

그런데 액터의 특수성으로 스레드 안정성을 보장하기 위해 동시에 하나의 Task 에서만 접근을 보장한다는 점이 있다.

actor TemperatureLogger {
    let label: String
    var measurements: [Int]
    private(set) var max: Int

    init(label: String, measurement: Int) {
        self.label = label
        self.measurements = [measurement]
        self.max = measurement
    }

    func update(with measurement: Int) {
        measurements.append(measurement)
        if measurement > max {
            max = measurement
        }
    }
}

클래스를 대체한다고 적긴 했지만 그렇다고 클래스와 완전히 동일한 것은 또 아니다. 예를 들어 var 로 선언된 가변형 프로퍼티의 수정(mutate)은 외부와 격리된다. 그리고 액터의 프로퍼티와 메서드를 외부에서 참조하려면 반드시 await 로 접근해야 한다.

let logger = TemperatureLogger(label: "Temp", measurement: 0)

// 참조할 수 있는 프로퍼티나 메서드는 await로만 접근 가능
let max = await logger.max
await logger.update(with: 2)

// 가변형 프로퍼티 수정은 외부와 격리되기 때문에 아래 코드는 오류 발생:
// Actor-isolated property 'measurements' can only be mutated from inside the actor
await logger.measurements.append(1)

당연하게도 액터 내부에서는 자신의 프로퍼티나 메서드를 참조하는 데 await 가 필요없다. 어찌 보면 당연한 디자인 같다.

결론적으로 비동기 코드 안에서 자주 참조될 공유 객체 모델이라면 이런 액터로 구현하면 딱 맞는 형태일 것 같다. 클래스는 애초에 레퍼런스 타입이라 동시에 여러 스레드에서 하나의 메모리에 접근해서 동시에 수정을 가하는 문제가 많았기 때문에 이런 특수 타입으로 공유 객체의 접근을 보장하려는 목적으로 등장한 것 같다.

그렇다면 구조체(struct)는 왜 이런 게 없는가 생각된다면 일단 Swift의 값(Value) 타입이란 게 뭔지부터 다시 생각해보는 것이 좋을 것 같다. 값 타입은 인스턴스 공유가 애초에 불가능하게 설계되어 있다.

Xcode Beta의 Playground에서 문제점

플레이그라운드에서 코드를 시험해 볼 때 문제를 좀 겪었다. 아래와 같은 이슈다.

error: cannot find 'async' in scope

이 외에도 await 라던가 혹은 새로 추가된 Task 도 찾을 수 없다는 오류가 났었다.

매뉴얼에 명시된 대로라면 이들은 모두 표준 라이브러리에 포함되기 때문에 별도의 패키지를 임포트할 필요는 없는게 맞다. 하지만 현재 베타에는 아래와 같이 동시성 모듈을 임포트해야 한다.

import _Concurrency

이후 위와 같은 오류는 더이상 발생하지 않았다.

이 외에도 플레이그라운드에서 여러 비동기 코드를 테스트하는 데 어려움이 많았다. 차라리 일반 프로젝트로 시험해 보는 게 스트레스를 받지 않는 것 같아서 더 좋을 것 같다.

관련 링크