동시성(Concurrency)

동시성(Concurrency)

비동기 작업을 수행하는 방법을 다뤄요.

Swift는 비동기·병렬 코드를 구조적으로 작성할 수 있는 지원을 언어 차원에서 갖추고 있어요. 여기서 비동기(asynchronous) 코드란 나중에 다시 이어서 실행될 수 있는 코드예요. 겉보기에 멈췄다가 다시 시작되는 거죠. 다만 어느 순간에 실행되는 코드는 프로그램의 한 조각뿐이라는 점은 같아요. 이렇게 코드를 멈추고 다시 이어가는 능력 덕분에, 네트워크로 데이터를 가져오거나 파일을 파싱하는 오래 걸리는 작업을 진행하면서도 UI를 갱신하는 것 같은 짧은 작업에는 계속 진행 상태를 만들어 줄 수 있어요. 한편 병렬(parallel) 코드는 여러 조각의 코드가 동시에 실행된다는 뜻이에요. 가령 4코어 프로세서를 가진 컴퓨터라면 각 코어가 작업 하나씩을 맡아 코드 4조각을 동시에 돌릴 수 있죠.

병렬·비동기 코드를 쓰는 프로그램은 여러 작업을 동시에 수행하면서도, 외부 시스템을 기다리는 작업은 잠시 멈춰 둬요. 이번 장에서는 비동기 코드와 병렬 코드가 합쳐진 이런 흔한 조합을 통틀어 *동시성(concurrency)*이라고 부를게요.

병렬·비동기 코드가 주는 스케줄링 유연성에도 그만큼의 대가가 따라와요. 동시적 코드를 짜다 보면 어떤 코드가 같은 시점에 실행될지 미리 알 수 없고, 실행 순서도 항상 예측할 수 없거든요. 동시적 코드에서 가장 흔한 문제는 여러 코드 조각이 공유된 가변 상태(mutable state)에 동시에 접근하려 할 때 생겨요. 이를 *데이터 레이스(data race)*라고 해요. 언어 차원의 동시성 지원을 쓰면 Swift가 데이터 레이스를 감지하고 막아 주는데, 대부분의 데이터 레이스는 컴파일 타임 오류로 잡혀요. 다만 일부 데이터 레이스는 코드가 실행되는 중에야 드러나기도 해요. 그런 경우 코드 실행이 중단되죠. 데이터 레이스로부터 보호하려면 이번 장에서 설명할 액터(actor)와 격리(isolation)를 사용하면 돼요.

Note: 예전에 동시적 코드를 작성해 본 적이 있다면 스레드(thread)를 다루는 데 익숙할 거예요. Swift의 동시성 모델은 스레드 위에 세워져 있지만, 우리가 스레드를 직접 다룰 일은 없어요. Swift의 비동기 함수는 실행 중이던 스레드를 양보할 수 있는데, 그러면 첫 번째 함수가 블로킹되어 있는 동안 다른 비동기 함수가 그 스레드에서 실행될 수 있어요. 비동기 함수가 다시 이어질 때 그 함수가 어느 스레드에서 실행될지는 Swift가 어떤 보장도 하지 않아요.

Swift의 언어 지원 없이도 동시적 코드를 작성할 수는 있지만, 그런 코드는 읽기가 훨씬 어렵기 마련이에요. 가령 다음 코드는 사진 이름 목록을 내려받고, 그 목록의 첫 번째 사진을 내려받은 뒤 사용자에게 보여줘요.

listPhotos(inGallery: "Summer Vacation") { photoNames in
    let sortedNames = photoNames.sorted()
    let name = sortedNames[0]
    downloadPhoto(named: name) { photo in
        show(photo)
    }
}

이렇게 단순한 경우조차 코드를 일련의 완료 핸들러(completion handler)로 작성해야 하다 보니, 결국 중첩된 클로저를 쓰게 돼요. 이런 방식은 깊은 중첩이 있는 더 복잡한 코드가 되면 금방 다루기 힘들어져요.

비동기 함수 정의하고 호출하기

비동기 함수(asynchronous function) 또는 *비동기 메서드(asynchronous method)*는 실행 도중에 멈출 수 있는 특별한 함수·메서드예요. 보통의 동기 함수·메서드는 끝까지 실행되거나, 오류를 던지거나, 아예 반환하지 않는 셋 중 하나로 끝나요. 비동기 함수·메서드도 결국 그중 하나로 끝나긴 하지만, 무언가를 기다리는 동안 중간에 멈출 수도 있어요. 비동기 함수·메서드의 본문 안에서는 실행이 멈출 수 있는 지점마다 그 표시를 해 줘야 해요.

함수·메서드가 비동기라는 것을 나타내려면 선언에서 매개변수 뒤에 async 키워드를 써요. 이는 오류를 던지는 함수를 표시할 때 throws를 쓰는 것과 비슷해요. 값을 반환하는 함수·메서드라면 반환 화살표(->) 앞에 async를 써요. 예를 들어 갤러리 안 사진들의 이름을 가져오는 함수는 이렇게 작성할 수 있어요.

func listPhotos(inGallery name: String) async -> [String] {
    let result = // ... some asynchronous networking code ...
    return result
}

비동기이면서 오류를 던지는 함수·메서드라면 throws 앞에 async를 써요.

비동기 메서드를 호출하면 그 메서드가 반환할 때까지 실행이 멈춰요. 호출 앞에 await를 써서 가능한 일시 중단 지점임을 표시해요. 이는 오류가 있을 때 프로그램의 흐름이 바뀔 수 있음을 표시하기 위해 오류를 던지는 함수를 호출할 때 try를 쓰는 것과 같아요. 비동기 메서드 안에서 실행 흐름이 멈출 수 있는 경우는 오직 다른 비동기 메서드를 호출할 때뿐이에요. 일시 중단이 암시적으로나 선제적으로 일어나지는 않는다는 뜻이죠. 즉 모든 가능한 일시 중단 지점이 await로 표시된다는 말이에요. 코드 안의 가능한 일시 중단 지점을 모두 표시해 두면 동시적 코드를 읽고 이해하기 한결 수월해져요.

예를 들어 아래 코드는 갤러리의 모든 사진 이름을 가져온 뒤 첫 번째 사진을 보여줘요.

let photoNames = await listPhotos(inGallery: "Summer Vacation")
let sortedNames = photoNames.sorted()
let name = sortedNames[0]
let photo = await downloadPhoto(named: name)
show(photo)

listPhotos(inGallery:)downloadPhoto(named:) 두 함수 모두 네트워크 요청을 해야 하므로 완료되는 데 비교적 오래 걸릴 수 있어요. 반환 화살표 앞에 async를 써서 둘 다 비동기로 만들면, 이 코드가 사진을 기다리는 동안 앱의 나머지 코드는 계속 실행될 수 있어요.

위 예제가 지닌 동시적 성격을 이해하려면 실행 순서가 하나쯤은 다음과 같다고 생각해 보세요.

  1. 코드는 첫 줄부터 실행되다가 첫 번째 await에 도달해. listPhotos(inGallery:) 함수를 호출하고 그 함수가 반환하기를 기다리며 실행을 멈춰.
  2. 이 코드의 실행이 멈춰 있는 동안, 같은 프로그램의 다른 동시적 코드가 실행돼. 예를 들어 오래 걸리는 백그라운드 작업이 새 사진 갤러리 목록을 계속 갱신하고 있을 수 있지. 그 코드도 await로 표시된 다음 일시 중단 지점에 도달하거나 끝날 때까지 실행돼.
  3. listPhotos(inGallery:)가 반환하면 이 코드는 그 지점부터 실행을 이어가. 반환된 값을 photoNames에 할당해.
  4. sortedNamesname을 정의하는 줄은 보통의 동기 코드야. 이 줄들에는 await로 표시된 게 없으니 가능한 일시 중단 지점도 없어.
  5. 다음 awaitdownloadPhoto(named:) 함수 호출을 표시해. 이 코드는 그 함수가 반환할 때까지 다시 실행을 멈추고, 다른 동시적 코드에게 실행 기회를 줘.
  6. downloadPhoto(named:)가 반환하면 그 반환값을 photo에 할당하고, show(_:)를 호출할 때 인자로 전달해.

await로 표시된 가능한 일시 중단 지점은, 현재의 코드 조각이 비동기 함수·메서드가 반환하기를 기다리는 동안 실행을 멈출 수 있음을 알려줘요. 이를 *스레드 양보(yielding the thread)*라고도 불러요. 겉으로 드러나진 않지만 Swift가 현재 스레드에서 코드 실행을 멈추고 그 대신 다른 코드를 그 스레드에서 실행하기 때문이에요. await가 있는 코드는 실행을 멈출 수 있어야 하므로, 비동기 함수·메서드를 호출할 수 있는 곳은 프로그램 안에서 특정 위치로 한정돼요.

  • 비동기 함수·메서드·프로퍼티의 본문 안에 있는 코드.
  • @main으로 표시된 구조체·클래스·열거형의 정적 main() 메서드 안에 있는 코드.
  • 아래 doc:Concurrency#Unstructured-Concurrency에서 보듯, 구조화되지 않은 자식 태스크 안에 있는 코드.

Task.sleep(for:tolerance:clock:) 메서드는 동시성이 어떻게 동작하는지 배우기 위한 간단한 코드를 쓸 때 유용해요. 이 메서드는 현재 태스크를 주어진 시간만큼 최소한 멈춰 두죠. 다음은 listPhotos(inGallery:) 함수가 sleep(for:tolerance:clock:)을 써서 네트워크 작업을 기다리는 상황을 흉내 낸 버전이에요.

func listPhotos(inGallery name: String) async throws -> [String] {
    try await Task.sleep(for: .seconds(2))
    return ["IMG001", "IMG99", "IMG0404"]
}

위 코드의 listPhotos(inGallery:) 버전은 비동기이면서 동시에 오류를 던져요. Task.sleep(until:tolerance:clock:) 호출이 오류를 던질 수 있기 때문이죠. 이 버전의 listPhotos(inGallery:)를 호출할 때는 tryawait를 함께 써요.

let photos = try await listPhotos(inGallery: "A Rainy Weekend")

비동기 함수는 오류를 던지는 함수와 몇 가지 공통점이 있어요. 비동기·오류를 던지는 함수를 정의할 때는 asyncthrows로 표시하고, 그 함수에 대한 호출은 awaittry로 표시해요. 오류를 던지는 함수가 다른 오류를 던지는 함수를 호출할 수 있듯이, 비동기 함수도 다른 비동기 함수를 호출할 수 있어요.

하지만 아주 중요한 차이가 하나 있어요. 오류를 던지는 코드는 do-catch 블록으로 감싸 오류를 처리할 수도 있고, Result로 오류를 저장해 다른 코드가 처리하게 할 수도 있어요. 이런 방식 덕분에 오류를 던지지 않는 코드에서도 오류를 던지는 함수를 호출할 수 있어요. 예를 들면요.

func availableRainyWeekendPhotos() -> Result<[String], Error> {
    return Result {
        try listDownloadedPhotos(inGallery: "A Rainy Weekend")
    }
}

반면 비동기 코드를 감싸서 동기 코드에서 호출하고 결과를 기다리는 안전한 방법은 없어요. Swift 표준 라이브러리는 의도적으로 이런 안전하지 않은 기능을 빼 두었는데, 직접 구현하려고 하면 미묘한 레이스, 스레딩 문제, 교착 상태(deadlock) 같은 문제로 이어질 수 있기 때문이에요. 기존 프로젝트에 동시적 코드를 추가할 때는 위에서부터 아래로 작업하세요. 구체적으로는 맨 위 계층의 코드부터 동시성을 사용하도록 바꾸고, 그다음 그것이 호출하는 함수·메서드들을, 프로젝트 구조를 한 계층씩 내려가며 바꾸는 거예요. 동기 코드는 비동기 코드를 결코 호출할 수 없으니, 아래에서 위로 올라가는 방식은 애초에 불가능해요.

비동기 시퀀스

앞 절의 listPhotos(inGallery:) 함수는 배열의 모든 요소가 준비된 뒤에 배열 전체를 한 번에 비동기로 반환해요. 또 다른 접근 방식은 *비동기 시퀀스(asynchronous sequence)*를 사용해 컬렉션의 요소를 한 번에 하나씩 기다리는 거예요. 비동기 시퀀스를 순회하는 모습은 이렇죠.

import Foundation

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

위 예제는 보통의 for-in 루프 대신 for 다음에 await를 썼어요. 비동기 함수·메서드를 호출할 때처럼 await는 가능한 일시 중단 지점을 나타내요. for-await-in 루프는 다음 요소가 준비되기를 기다리며 각 반복이 시작될 때마다 실행을 멈출 수 있어요.

보통의 for-in 루프에 자신의 타입을 쓰려면 Sequence 프로토콜에 적합(conformance)을 추가하면 되듯이, for-await-in 루프에 자신의 타입을 쓰려면 AsyncSequence 프로토콜에 적합을 추가하면 돼요.

비동기 함수 병렬로 호출하기

await로 비동기 함수를 호출하면 한 번에 코드 한 조각만 실행돼요. 비동기 코드가 실행되는 동안 호출자는 그 코드가 끝나기를 기다렸다가 다음 줄로 넘어가지요. 예를 들어 갤러리에서 사진 세 장을 가져오려면 downloadPhoto(named:) 함수를 다음처럼 세 번 await 할 수 있어요.

let firstPhoto = await downloadPhoto(named: photoNames[0])
let secondPhoto = await downloadPhoto(named: photoNames[1])
let thirdPhoto = await downloadPhoto(named: photoNames[2])

let photos = [firstPhoto, secondPhoto, thirdPhoto]
show(photos)

이 방식에는 중요한 단점이 있어요. 다운로드가 비동기라 진행되는 동안 다른 작업이 일어날 수 있긴 하지만, downloadPhoto(named:) 호출은 한 번에 하나씩만 실행돼요. 각 사진이 완전히 내려받혀야 다음 사진 다운로드가 시작되죠. 그런데 사실 이 작업들이 서로 기다릴 필요는 없어요. 각 사진은 독립적으로, 심지어 동시에 내려받을 수도 있거든요.

비동기 함수를 호출하면서 주변 코드와 병렬로 실행되게 하려면, 상수를 정의할 때 let 앞에 async를 쓰고, 그 상수를 사용할 때마다 await를 써요.

async let firstPhoto = downloadPhoto(named: photoNames[0])
async let secondPhoto = downloadPhoto(named: photoNames[1])
async let thirdPhoto = downloadPhoto(named: photoNames[2])

let photos = await [firstPhoto, secondPhoto, thirdPhoto]
show(photos)

이 예제에서는 downloadPhoto(named:) 호출 세 개가 모두 이전 호출이 끝나기를 기다리지 않고 시작돼요. 시스템 자원이 충분하다면 동시에 실행될 수도 있죠. 이 함수 호출들 중 어느 것도 await로 표시되지 않는데, 코드가 함수 결과를 기다리려고 멈추지 않기 때문이에요. 대신 실행은 photos가 정의되는 줄까지 계속되고, 그 지점에서 프로그램이 이 비동기 호출들의 결과를 필요로 하게 되므로, await를 써서 사진 세 장이 모두 내려받힐 때까지 실행을 멈춰요.

두 방식의 차이를 이렇게 생각해 볼 수 있어요.

  • 뒤따르는 코드가 그 함수의 결과에 의존할 때는 await로 비동기 함수를 호출해요. 이렇게 하면 순차적으로 수행되는 작업이 만들어져요.
  • 코드에서 나중에야 결과가 필요할 때는 async-let으로 비동기 함수를 호출해요. 이렇게 하면 병렬로 수행될 수 있는 작업이 만들어져요.
  • awaitasync-let 둘 다 멈춰 있는 동안 다른 코드가 실행되게 해 줘요.
  • 두 경우 모두 가능한 일시 중단 지점을 await로 표시해서, 필요하면 비동기 함수가 반환할 때까지 실행이 멈출 것임을 나타내요.

두 방식을 같은 코드 안에서 섞어 쓰는 것도 가능해요.

태스크와 태스크 그룹

*태스크(task)*는 프로그램의 일부로서 비동기로 실행될 수 있는 작업 단위예요. 모든 비동기 코드는 어떤 태스크의 일부로 실행돼요. 태스크 하나는 한 번에 한 가지 일만 하지만, 태스크를 여러 개 만들면 Swift가 그것들을 동시에 실행되도록 스케줄할 수 있어요.

앞 절에서 다룬 async-let 문법은 암시적으로 자식 태스크를 만들어요. 이 문법은 프로그램이 어떤 태스크들을 돌려야 하는지 이미 알고 있을 때 잘 맞죠. 태스크 그룹(즉 TaskGroup의 인스턴스)을 만들어 그룹에 자식 태스크를 명시적으로 추가할 수도 있어요. 그러면 우선순위와 취소(cancellation)를 더 자유롭게 제어할 수 있고, 태스크 개수를 동적으로도 만들 수 있어요.

태스크는 계층 구조로 배열돼요. 주어진 태스크 그룹 안의 각 태스크는 같은 부모 태스크를 갖고, 각 태스크는 자식 태스크를 가질 수 있죠. 태스크와 태스크 그룹 사이의 명시적 관계 때문에 이 방식을 *구조적 동시성(structured concurrency)*이라고 불러요. 태스크 사이의 명시적 부모-자식 관계에는 여러 장점이 있어요.

  • 부모 태스크에서는 자식 태스크들이 끝나기를 기다리는 것을 잊을 수 없어요.
  • 자식 태스크에 더 높은 우선순위를 설정하면 부모 태스크의 우선순위도 자동으로 올라가요.
  • 부모 태스크가 취소되면 그 자식 태스크들도 각각 자동으로 취소돼요.
  • 태스크 지역 값(task-local value)은 자식 태스크로 효율적이고 자동으로 전파돼요.

사진을 내려받는 코드를, 사진이 몇 장이든 처리하는 버전으로 다시 쓰면 이래요.

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

    for await photo in group {
        show(photo)
    }
}

위 코드는 새 태스크 그룹을 만들고, 갤러리의 각 사진을 내려받는 자식 태스크를 만들어요. Swift는 조건이 허락하는 만큼 많은 태스크를 동시에 실행해요. 자식 태스크가 사진 내려받기를 끝내는 즉시 그 사진이 화면에 표시되죠. 자식 태스크가 끝나는 순서는 보장되지 않으므로, 이 갤러리의 사진들은 어떤 순서로든 표시될 수 있어요.

Note: 사진 내려받기 코드가 오류를 던질 수 있다면 withThrowingTaskGroup(of:returning:body:)를 호출하면 돼요.

위 코드에서는 각 사진을 내려받은 뒤 표시하므로 태스크 그룹이 아무 결과도 반환하지 않아요. 결과를 반환하는 태스크 그룹이 필요하면 withTaskGroup(of:returning:body:)에 전달하는 클로저 안에 그 결과를 모으는 코드를 추가해요.

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

    var results: [Data] = []
    for await photo in group {
        results.append(photo)
    }

    return results
}

앞의 예제와 마찬가지로 이 예제도 각 사진마다 자식 태스크를 만들어 내려받아요. 앞의 예제와 달리 for-await-in 루프는 다음 자식 태스크가 끝나기를 기다렸다가 그 태스크의 결과를 결과 배열에 추가하고, 모든 자식 태스크가 끝날 때까지 계속 기다려요. 마지막으로 태스크 그룹은 내려받은 사진 배열을 전체 결과로 반환해요.

태스크 취소

Swift 동시성은 협력적(cooperative) 취소 모델을 사용해요. 각 태스크는 실행 중 적절한 지점에서 자신이 취소되었는지 확인하고, 취소에 알맞게 응답해요. 태스크가 하는 작업에 따라 취소에 응답한다는 것은 보통 다음 중 하나를 뜻해요.

  • CancellationError 같은 오류를 던지기
  • nil이나 빈 컬렉션 반환하기
  • 부분적으로 완료된 작업 반환하기

사진이 크거나 네트워크가 느리면 사진 내려받기가 오래 걸릴 수 있어요. 사용자가 모든 태스크가 끝나기를 기다리지 않고 이 작업을 멈출 수 있게 하려면, 태스크가 취소 여부를 확인하고 취소되었으면 실행을 멈춰야 해요. 태스크가 이렇게 하는 방법은 두 가지예요. Task.checkCancellation() 타입 메서드를 호출하거나, Task.isCancelled 타입 프로퍼티를 읽는 거죠. 태스크가 취소된 상태에서 checkCancellation()을 호출하면 오류를 던져요. 오류를 던지는 태스크는 그 오류를 태스크 밖으로 전파해 태스크의 모든 작업을 멈출 수 있어요. 이 방법은 구현하고 이해하기 쉽다는 장점이 있어요. 더 유연하게 하려면 isCancelled 프로퍼티를 쓰세요. 이 프로퍼티는 태스크를 멈추는 과정에서 네트워크 연결을 닫거나 임시 파일을 지우는 것 같은 정리(clean-up) 작업을 수행할 수 있게 해 줘요.

let photos = await withTaskGroup { group in
    let photoNames = await listPhotos(inGallery: "Summer Vacation")
    for name in photoNames {
        let added = group.addTaskUnlessCancelled {
            Task.isCancelled ? nil : await downloadPhoto(named: name)
        }
        guard added else { break }
    }

    var results: [Data] = []
    for await photo in group {
        if let photo { results.append(photo) }
    }
    return results
}

위 코드는 이전 버전에서 여러 가지를 바꿨어요.

  • 각 태스크는 TaskGroup.addTaskUnlessCancelled(priority:operation:) 메서드로 추가돼요. 취소된 뒤에 새 작업을 시작하는 일을 피하기 위해서죠.
  • addTaskUnlessCancelled(priority:operation:) 호출 뒤마다 코드는 새 자식 태스크가 추가되었는지 확인해요. 그룹이 취소되면 added의 값은 false가 되는데, 그 경우 코드는 추가 사진 내려받기를 시도하는 것을 멈춰요.
  • 각 태스크는 사진 내려받기를 시작하기 전에 취소 여부를 확인해요. 취소되었다면 태스크는 nil을 반환해요.
  • 마지막에 태스크 그룹은 결과를 모을 때 nil 값을 건너뛰어요. nil을 반환하는 방식으로 취소를 처리하면 완료된 작업을 버리지 않고, 태스크 그룹이 캣소 시점에 이미 내려받은 사진이라는 부분 결과를 반환할 수 있어요.

Note: 태스크 밖에서 태스크가 취소되었는지 확인할 때는 타입 프로퍼티 대신 Task.isCancelled 인스턴스 프로퍼티를 쓰세요.

취소에 대한 즉각적인 알림이 필요한 작업에는 withTaskCancellationHandler(operation:onCancel:isolation:) 함수를 사용해요. 예를 들면요.

let task = Task {
    await withTaskCancellationHandler {
        // ...
    } onCancel: {
        print("Canceled!")
    }
}

// ... some time later...
task.cancel()  // Prints "Canceled!"

취소 핸들러를 쓸 때도 태스크 취소는 여전히 협력적이에요. 태스크는 끝까지 실행되거나, 취소 여부를 확인하고 일찍 멈추게 돼요. 취소 핸들러가 시작될 때 태스크가 여전히 실행 중이므로, 태스크와 그 취소 핸들러 사이에 상태를 공유하는 것은 피하세요. 그러면 레이스 조건이 생길 수 있어요.

Task.yield() 메서드를 호출하면 일시 중단 지점을 명시적으로 넣을 수 있어요.

func generateSlideshow(forGallery gallery: String) async {
    let photos = await listPhotos(inGallery: gallery)
    for photo in photos {
        // ... render a few seconds of video for this photo ...
        await Task.yield()
    }
}

비디오를 렌더링하는 코드가 동기적이라면 그 안에는 일시 중단 지점이 없어요. 비디오 렌더링 작업은 또 오래 걸릴 수도 있죠. 그럴 때 Task.yield()를 주기적으로 호출하면 일시 중단 지점을 명시적으로 추가할 수 있어요. 오래 걸리는 코드를 이렇게 구조화하면 Swift가 이 태스크의 진행과 프로그램 안 다른 태스크들의 진행 사이에서 균형을 잡을 수 있게 돼요.

구조화되지 않은 동시성

앞 절들에서 다룬 구조적 동시성 방식에 더해, Swift는 구조화되지 않은 동시성(unstructured concurrency)도 지원해요. 태스크 그룹의 일부인 태스크와 달리, *구조화되지 않은 태스크(unstructured task)*는 부모 태스크가 없어요. 구조화되지 않은 태스크를 프로그램이 필요로 하는 대로 관리할 완전한 자유가 있지만, 그 정확성에 대한 책임도 온전히 당신 몫이에요.

주변 코드와 비슷하게 실행되는 구조화되지 않은 태스크를 만들려면 Task.init(name:priority:operation:) 이니셜라이저를 호출해요. 새 태스크는 기본적으로 현재 태스크와 같은 액터 격리, 우선순위, 태스크 지역 상태로 실행돼요. 주변 코드에서 더 독립적인 구조화되지 않은 태스크, 좀 더 정확히는 *분리 태스크(detached task)*를 만들려면 Task.detached(name:priority:operation:) 정적 메서드를 호출해요. 새 태스크는 기본적으로 어떤 액터 격리도 없이 실행되고, 현재 태스크의 우선순위나 태스크 지역 상태도 상속하지 않아요. 이 두 연산 모두 상호작용할 수 있는 태스크를 반환하는데, 예를 들어 그 결과를 기다리거나 취소할 수 있어요.

let newPhoto = // ... some photo data ...
let handle = Task {
    return await add(newPhoto, toGalleryNamed: "Spring Adventures")
}
let result = await handle.value

분리 태스크를 관리하는 더 자세한 내용은 Task를 참고하세요.

격리

앞 절들에서는 동시적 작업을 나누는 접근 방식들을 다뤘어요. 그 작업은 앱의 UI 같은 공유 데이터를 바꾸는 것을 포함할 수 있죠. 코드의 서로 다른 부분이 같은 데이터를 동시에 수정할 수 있다면 데이터 레이스가 생길 위험이 있어요. Swift는 코드에서 데이터 레이스로부터 보호해 줘요. 데이터를 읽거나 수정할 때마다 Swift는 다른 코드가 그것을 동시에 수정하고 있지 않음을 보장해요. 이러한 보장을 *데이터 격리(data isolation)*라고 불러요. 데이터를 격리하는 방법은 크게 세 가지가 있어요.

  1. 불변(immutable) 데이터는 항상 격리돼요. 상수는 수정할 수 없으니, 내가 값을 읽는 동안 다른 코드가 상수를 수정할 위험이 없어요.
  2. 현재 태스크만이 참조하는 데이터는 항상 격리돼요. 지역 변수는 읽고 쓰기에 안전해요. 태스크 밖의 어떤 코드도 그 메모리에 대한 참조를 갖고 있지 않으니, 다른 코드가 그 데이터를 수정할 수 없기 때문이죠. 게다가 변수를 클로저 안에서 캡처한다면 Swift는 그 클로저가 동시에 사용되지 않음을 보장해요.
  3. 액터로 보호되는 데이터는, 그 데이터에 접근하는 코드도 그 액터로 격리되어 있으면 격리돼요. 현재 함수가 어떤 액터로 격리되어 있다면 그 액터가 보호하는 데이터를 읽고 쓰는 것이 안전해요. 같은 액터로 격리된 다른 모든 코드가 자기 차례를 기다려야 하기 때문이죠.

메인 액터

액터(actor)는 가변 데이터에 대한 접근을 보호하는 객체인데, 코드가 그 데이터에 차례로 접근하도록 강제하는 식이에요. 많은 프로그램에서 가장 중요한 액터는 *메인 액터(main actor)*예요. 앱에서 메인 액터는 UI를 보여 주는 데 쓰이는 모든 데이터를 보호해요. 메인 액터는 UI를 렌더링하고, UI 이벤트를 처리하고, UI를 조회·갱신해야 하는 당신이 작성한 코드를 차례로 실행해요.

코드에서 동시성을 쓰기 시작하기 전에는 모든 것이 메인 액터에서 실행돼요. 오래 걸리거나 자원을 많이 쓰는 코드를 찾아내면, 여전히 안전하고 올바르게 유지하면서도 그 작업을 메인 액터 밖으로 옮길 수 있어요.

Note: 메인 액터는 메인 스레드와 밀접하게 관련되어 있지만, 같은 것은 아니에요. 메인 액터는 비공개 가변 상태를 갖고 있고, 메인 스레드는 그 상태에 대한 접근을 직렬화해요. 메인 액터에서 코드를 실행하면 Swift는 그 코드를 메인 스레드에서 실행해요. 이런 연결 때문에 두 용어가 서로 바꿔 쓰이는 걸 볼 수도 있겠죠. 당신의 코드가 상호작용하는 대상은 메인 액터이고, 메인 스레드는 더 저수준의 구현 세부 사항이에요.

메인 액터에서 작업을 실행하는 방법은 여러 가지가 있어요. 함수가 항상 메인 액터에서 실행되게 하려면 @MainActor 속성으로 표시해요.

@MainActor
func show(_: Data) {
    // ... UI code to display the photo ...
}

위 코드에서 show(_:) 함수의 @MainActor 속성은 이 함수가 오직 메인 액터에서만 실행되도록 요구해요. 메인 액터에서 실행 중인 다른 코드 안에서는 show(_:)를 동기 함수로 호출할 수 있어요. 하지만 메인 액터에서 실행되지 않는 코드에서 show(_:)를 호출하려면 await를 넣고 비동기 함수로 호출해야 해요. 메인 액터로 전환하면 일시 중단 지점이 생길 수 있기 때문이죠. 예를 들면요.

func downloadAndShowPhoto(named name: String) async {
    let photo = await downloadPhoto(named: name)
    await show(photo)
}

위 코드에서 downloadPhoto(named:)show(_:) 두 함수 모두 호출할 때 멈출 수 있어요. 이 코드는 또 하나의 흔한 패턴을 보여줘요. 오래 걸리고 CPU를 많이 쓰는 작업을 백그라운드에서 수행한 뒤, 메인 액터로 전환해 UI를 갱신하는 거죠. downloadAndShowPhoto(named:) 함수가 메인 액터에 있지 않으므로 downloadPhoto(named:) 안의 작업도 메인 액터에서 실행되지 않아요. 메인 액터에서 실행되는 것은 show(_:)에서 UI를 갱신하는 작업뿐인데, 그 함수가 @MainActor 속성으로 표시되어 있기 때문이에요.

클로저가 메인 액터에서 실행되게 하려면 클로저 시작 부분, 캡처 목록 앞과 in 앞에 @MainActor를 써요.

let photo = await downloadPhoto(named: "Trees at Sunrise")
Task { @MainActor in
    show(photo)
}

위 코드는 앞선 코드 목록의 downloadAndShowPhoto(named:)와 비슷하지만, 이 예제의 코드는 UI 갱신을 기다리지 않아요. 구조체·클래스·열거형에 @MainActor를 쓰면 그 모든 메서드와 모든 프로퍼티 접근이 메인 액터에서 실행되게 할 수도 있어요.

@MainActor
struct PhotoGallery {
    var photoNames: [String]
    func drawUI() { /* ... other UI code ... */ }
}

위 코드의 PhotoGallery 구조체는 photoNames 프로퍼티의 이름들을 사용해 화면에 어떤 사진을 표시할지 정하면서 사진을 그려요. photoNames가 UI에 영향을 주므로, 그것을 바꾸는 코드는 메인 액터에서 실행되어 그 접근을 직렬화해야 해요.

프레임워크 위에서 개발하고 있다면 그 프레임워크의 프로토콜과 기반 클래스가 보통 이미 @MainActor로 표시되어 있어서, 그 경우 당신이 만든 타입에 직접 @MainActor를 쓸 일은 드물어요. 다음은 단순화한 예시예요.

@MainActor
protocol View { /* ... */ }

// Implicitly @MainActor
struct PhotoGalleryView: View { /* ... */ }

위 코드에서 SwiftUI 같은 프레임워크가 View 프로토콜을 정의해요. 프로토콜 선언에 @MainActor를 쓰면, PhotoGalleryView처럼 그 프로토콜에 적합한 타입도 암시적으로 @MainActor로 표시돼요. View가 기반 클래스이고 PhotoGalleryView가 그 하위 클래스라면 같은 동작을 볼 수 있어요. 하위 클래스도 암시적으로 @MainActor로 표시되거든요.

위 예제들에서 PhotoGallery는 구조체 전체를 메인 액터로 보호했어요. 더 세밀하게 제어하고 싶다면, 메인 스레드에서 접근·실행돼야 하는 프로퍼티·메서드에만 @MainActor를 쓸 수 있어요.

struct PhotoGallery {
    @MainActor var photoNames: [String]
    var hasCachedPhotos = false

    @MainActor func drawUI() { /* ... UI code ... */ }
    func cachePhotos() { /* ... networking code ... */ }
}

PhotoGallery 버전에서 drawUI() 메서드는 갤러리 그림들을 화면에 그리므로 메인 액터로 격리되어야 해요. photoNames 프로퍼티는 직접 UI를 만들지 않지만, drawUI()가 UI를 그릴 때 사용하는 상태를 저장하므로 이 프로퍼티도 메인 액터에서만 접근되어야 해요. 반면 hasCachedPhotos 프로퍼티의 변경은 UI와 상호작용하지 않고, cachePhotos() 메서드는 메인 액터에서 실행될 필요가 있는 어떤 상태에도 접근하지 않아요. 그래서 이 둘은 @MainActor로 표시하지 않아요.

앞선 예제들처럼 UI를 만드는 데 프레임워크를 쓰고 있다면, 그 프레임워크의 프로퍼티 래퍼가 당신의 UI 상태 프로퍼티를 이미 @MainActor로 표시하고 있을 가능성이 커요. 프로퍼티 래퍼를 정의할 때, 그 wrappedValue 프로퍼티가 @MainActor로 표시되어 있다면 그 프로퍼티 래퍼를 적용한 어떤 프로퍼티도 암시적으로 @MainActor로 표시돼요.

액터

Swift는 메인 액터를 제공해 주지만, 직접 자신만의 액터를 정의할 수도 있어요. 액터는 동시적 코드 사이에서 정보를 안전하게 공유하게 해 줘요.

액터는 클래스처럼 참조 타입(reference type)이에요. 그래서 doc:ClassesAndStructures#Classes-Are-Reference-Types에서 다룬 값 타입과 참조 타입의 비교가 클래스뿐 아니라 액터에도 적용돼요. 클래스와 달리 액터는 한 번에 하나의 태스크만 그 가변 상태에 접근할 수 있게 해 줘요. 그래서 여러 태스크의 코드가 같은 액터 인스턴스와 상호작용해도 안전하죠. 예를 들어 다음은 온도를 기록하는 액터예요.

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
    }
}

액터는 actor 키워드로 도입하고, 그 뒤에 정의를 중괄호 쌍으로 작성해요. TemperatureLogger 액터는 액터 밖의 다른 코드가 접근할 수 있는 프로퍼티들을 갖고 있고, max 프로퍼티는 액터 안의 코드만 최댓값을 갱신하도록 제한해요.

구조체·클래스와 같은 이니셜라이저 문법으로 액터의 인스턴스를 만들어요. 액터의 프로퍼티·메서드에 접근할 때는 await를 써서 가능한 일시 중단 지점을 표시해요. 예를 들면요.

let logger = TemperatureLogger(label: "Outdoors", measurement: 25)
print(await logger.max)
// Prints "25".

이 예제에서 logger.max에 접근하는 것은 가능한 일시 중단 지점이에요. 액터는 한 번에 하나의 태스크만 그 가변 상태에 접근할 수 있게 하므로, 다른 태스크의 코드가 이미 logger와 상호작용하고 있다면 이 코드는 그 프로퍼티에 접근하기를 기다리며 멈춰요.

반면 액터의 일부인 코드는 액터의 프로퍼티에 접근할 때 await를 쓰지 않아요. 예를 들어 다음은 TemperatureLogger를 새 온도로 갱신하는 메서드예요.

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

update(with:) 메서드는 이미 액터에서 실행되고 있으므로 max 같은 프로퍼티에 접근할 때 await로 표시하지 않아요. 이 메서드는 또 액터가 왜 한 번에 하나의 태스크만 그 가변 상태와 상호작용하게 하는지 보여 주는 이유 중 하나이기도 해요. 액터 상태에 대한 일부 갱신은 불변식(invariant)을 일시적으로 깨뜨리거든요. TemperatureLogger 액터는 온도 목록과 최고 온도를 추적하는데, 새 측정값을 기록할 때 최고 온도를 갱신해요. 갱신 도중, 즉 새 측정값을 추가한 뒤 max를 갱신하기 전에는 온도 로거가 일시적으로 일관성 없는 상태에 놓여요. 여러 태스크가 같은 인스턴스와 동시에 상호작용하는 것을 막으면 다음과 같은 사건 순서가 일어나는 문제를 방지할 수 있어요.

  1. 코드가 update(with:) 메서드를 호출해. 먼저 measurements 배열을 갱신해.
  2. 코드가 max를 갱신하기 전에, 다른 곳의 코드가 최댓값과 온도 배열을 읽어.
  3. 코드가 max를 바꿔 갱신을 마쳐.

이 경우 다른 곳에서 실행되는 코드는 잘못된 정보를 읽게 돼요. 그 코드의 액터 접근이 update(with:) 호출의 중간, 데이터가 일시적으로 유효하지 않은 동안에 끼어들었기 때문이죠. Swift 액터를 쓰면 이 문제를 막을 수 있어요. 액터가 한 번에 자기 상태에 대한 작업 하나만 허용하고, 코드가 중단될 수 있는 곳은 await로 일시 중단 지점이 표시된 곳뿐이기 때문이에요. update(with:)에는 일시 중단 지점이 없으므로, 다른 어떤 코드도 갱신 중간에 그 데이터에 접근할 수 없어요.

액터 밖의 코드가 그 프로퍼티들을 구조체·클래스의 프로퍼티처럼 직접 접근하려 하면 컴파일 타임 오류가 나요. 예를 들면요.

print(logger.max)  // Error

await를 쓰지 않고 logger.max에 접근하는 것은 실패해요. 액터의 프로퍼티는 그 액터의 격리된 지역 상태의 일부이기 때문이죠. 이 프로퍼티에 접근하는 코드는 액터의 일부로 실행되어야 하고, 이는 비동기 연산이라 await를 써야 해요. Swift는 액터에서 실행되는 코드만 그 액터의 지역 상태에 접근할 수 있음을 보장해요. 이 보장을 *액터 격리(actor isolation)*라고 불러요.

공유된 가변 상태에 대해 추론하기 쉽게 해 주는 Swift 동시성 모델의 다음과 같은 측면들이 함께 작동해요.

  • 가능한 일시 중단 지점 사이의 코드는 다른 동시적 코드의 간섭 없이 순차적으로 실행돼요. 다만 여러 조각의 동시적 코드가 동시에 실행될 수 있으니, 다른 코드가 그동안 실행 중일 수는 있어요.
  • 액터의 지역 상태와 상호작용하는 코드는 오직 그 액터에서만 실행돼요.
  • 액터는 한 번에 코드 한 조각만 실행해요.

이런 보장 덕분에 await를 포함하지 않으면서 액터 안에 있는 코드는, 프로그램의 다른 곳에서 일시적으로 유효하지 않은 상태를 관찰할 위험 없이 갱신을 수행할 수 있어요. 예를 들어 아래 코드는 측정된 온도를 화씨에서 섭씨로 변환해요.

extension TemperatureLogger {
    func convertFahrenheitToCelsius() {
        for i in measurements.indices {
            measurements[i] = (measurements[i] - 32) * 5 / 9
        }
    }
}

위 코드는 측정값 배열을 한 번에 하나씩 변환해요. 매핑 연산이 진행되는 동안 일부 온도는 화씨이고 다른 일부는 섭씨인 상태가 돼요. 하지만 어느 코드도 await를 포함하지 않으므로 이 메서드에는 잠재적 일시 중단 지점이 없어요. 이 메서드가 수정하는 상태는 액터에 속하고, 액터는 그 코드가 액터에서 실행되는 경우를 제외하면 다른 코드가 그 상태를 읽거나 수정하는 것을 보호해요. 즉 단위 변환이 진행되는 동안 다른 코드가 부분적으로 변환된 온도 목록을 읽을 방법이 없다는 뜻이에요.

잠재적 일시 중단 지점을 생략해서 일시적으로 유효하지 않은 상태를 보호하는 코드를 액터 안에 작성하는 것에 더해, 그 코드를 동기 메서드로 옮길 수도 있어요. 위의 convertFahrenheitToCelsius() 메서드는 동기 메서드라 잠재적 일시 중단 지점을 결코 포함하지 않음이 보장돼요. 이 함수는 데이터 모델을 일시적으로 불일치하게 만드는 코드를 캡슐화해서, 데이터 일관성이 작업 완료로 복원되기 전에는 다른 코드가 실행될 수 없음을 코드를 읽는 사람이 알아차리기 쉽게 해 줘요. 그 기간 동안 Swift가 이 코드에서 다른 부분의 코드를 실행하도록 전환하지 않는 것이 중요해요. 나중에 이 함수에 가능한 일시 중단 지점을 도입하는 동시적 코드를 추가하려 하면 버그를 만들 대신 컴파일 타임 오류가 나요.

전역 액터

메인 액터는 MainActor 타입의 전역 싱글턴 인스턴스예요. 액터는 보통 여러 인스턴스를 가질 수 있고, 각 인스턴스가 독립적인 격리를 제공해요. 그래서 액터의 격리된 데이터를 모두 그 액터의 인스턴스 프로퍼티로 선언하는 거예요. 하지만 MainActor는 싱글턴이라 이 타입의 인스턴스는 단 하나뿐이기 때문에, 타입 그 자체만으로 액터를 식별하기에 충분해요. 그래서 속성 하나만으로 메인 액터 격리를 표시할 수 있는 거죠. 이 방식은 코드를 자신에게 가장 잘 맞는 방식으로 구성할 더 큰 자유를 줘요.

자신만의 싱글턴 전역 액터를 정의하려면 @globalActor 속성을 쓰면 돼요. 자세한 내용은 doc:Attributes#globalActor에서 설명해요.

Sendable 타입

태스크와 액터는 프로그램을 안전하게 동시에 실행될 수 있는 조각들로 나누게 해 줘요. 태스크나 액터 인스턴스 안에서, 변수·프로퍼티처럼 가변 상태를 담고 있는 프로그램의 부분을 *동시성 도메인(concurrency domain)*이라고 불러요. 어떤 종류의 데이터는 동시성 도메인 사이에서 공유될 수 없어요. 그 데이터가 가변 상태를 담고 있으면서도 겹치는 접근을 보호하지 않기 때문이죠.

하나의 동시성 도메인에서 다른 도메인으로 공유될 수 있는 타입을 sendable 타입이라고 불러요. 예를 들어 액터 메서드를 호출할 때 인자로 전달되거나 태스크의 결과로 반환될 수 있죠. 이번 장 앞부분의 예제들은 sendability를 다루지 않았는데, 그 예제들이 동시성 도메인 사이를 오가는 데이터에 대해 항상 공유해도 안전한 단순한 값 타입들을 쓰기 때문이에요. 반면 어떤 타입들은 동시성 도메인을 가로질러 전달하기에 안전하지 않아요. 예를 들어 가변 프로퍼티를 담고 있으면서 그 프로퍼티에 대한 접근을 직렬화하지 않는 클래스는, 그 인스턴스를 다른 태스크들 사이로 전달하면 예측할 수 없고 잘못된 결과를 낳을 수 있어요.

타입을 sendable로 표시하려면 Sendable 프로토콜에 적합하다고 선언해요. 이 프로토콜에는 코드 요구 사항이 없지만, Swift가 강제하는 의미론적 요구 사항은 있어요. 일반적으로 타입이 sendable이 되는 방법은 세 가지예요.

  • 타입이 값 타입이고, 그 가변 상태가 다른 sendable 데이터로 이루어진 경우. 예를 들어 sendable한 저장 프로퍼티를 가진 구조체나, sendable한 연관 값을 가진 열거형이죠.
  • 타입에 가변 상태가 없고, 그 불변 상태가 다른 sendable 데이터로 이루어진 경우. 예를 들어 읽기 전용 프로퍼티만 가진 구조체·클래스가죠.
  • 타입이 자신의 가변 상태 안전성을 보장하는 코드를 가진 경우. 예를 들어 @MainActor로 표시된 클래스, 또는 특정 스레드·큐에서 프로퍼티에 대한 접근을 직렬화하는 클래스가 있죠.

의미론적 요구 사항의 자세한 목록은 Sendable 프로토콜 참조를 보세요.

어떤 타입들은 항상 sendable이에요. sendable한 프로퍼티만 가진 구조체나 sendable한 연관 값만 가진 열거형처럼요. 예를 들면요.

struct TemperatureReading: Sendable {
    var measurement: Int
}

extension TemperatureLogger {
    func addReading(from reading: TemperatureReading) {
        measurements.append(reading.measurement)
    }
}

let logger = TemperatureLogger(label: "Tea kettle", measurement: 85)
let reading = TemperatureReading(measurement: 45)
await logger.addReading(from: reading)

TemperatureReading은 sendable한 프로퍼티만 가진 구조체이고, public이나 @usableFromInline으로 표시되지 않았으므로 암시적으로 sendable이에요. 다음은 그 구조체에서 Sendable 프로토콜 적합이 암시되는 버전이에요.

struct TemperatureReading {
    var measurement: Int
}

타입이 sendable이 아님을 명시적으로 표시하려면 Sendable에 대한 unavailable 적합을 쓰면 돼요.

struct FileDescriptor {
    let rawValue: Int
}

@available(*, unavailable)
extension FileDescriptor: Sendable {}

unavailable 적합은 doc:Protocols#Implicit-Conformance-to-a-Protocol에서 다뤘듯 프로토콜에 대한 암시적 적합을 억제하는 데에도 쓸 수 있어요.

더 알아보기