
💬 본 전사문은 ChatGPT 4o에 의해 번역되었습니다.
♪ 베이스 음악 재생 중 ♪
다리오 렉슨: 안녕하세요, 제 이름은 다리오 렉슨이고, 저는 애플의 Swift 팀에서 엔지니어로 일하고 있습니다. 오늘은 제 동료 더그와 함께 Swift의 액터(actor)에 대해 이야기하고, 이들이 Swift의 동시성(concurrent) 애플리케이션에서 어떻게 가변 상태를 보호하는 데 활용되는지 설명드리겠습니다.

동시성 프로그램을 작성할 때 근본적으로 어려운 문제 중 하나는 데이터 경합(data races)을 피하는 것입니다. 데이터 경합은 별도의 두 개의 스레드가 동시에 같은 데이터에 접근하고, 그 중 적어도 하나의 스레드가 데이터를 수정하려고 할 때 발생합니다. 데이터 경합은 이르기에 매우 쉽지만, 디버깅은 매우 어렵습니다. 아래는 카운터 값을 증가시키고 그 새로운 값을 반환하는 하나의 연산을 가진 간단한 카운터 클래스입니다.
두 개의 작업(task)에서 카운터를 증가시키려고 한다고 가정해봅시다. 이것은 좋지 않은 생각입니다. 실행 타이밍에 따라 1 다음에 2가 되거나, 2 다음에 1이 될 수도 있습니다. 이는 예상 가능한 결과이며, 두 경우 카운터는 모두 일관된 상태로 유지됩니다. 하지만 데이터 경합이 발생했기 때문에, 두 작업이 모두 0을 읽고 1을 쓰는 경우 1과 1이 나올 수도 있고, 두 번의 증가 연산 이후에 반환되면 2와 2가 나올 수도 있습니다.
데이터 경합은 피하기도 디버깅하기도 매우 어렵습니다. 경합을 일으키는 데이터 접근이 프로그램의 서로 다른 부분에 있을 수 있기 때문에, 이를 이해하려면 국지적인 판단이 아닌 전체적인 추론이 필요합니다. 또한, 운영 체제의 스케줄러가 매번 프로그램을 실행할 때마다 동시 작업들을 서로 다른 방식으로 섞어서 실행할 수 있으므로 데이터 경합은 비결정적입니다.
데이터 경합은 공유된 가변 상태로 인해 발생합니다. 데이터가 변경되지 않거나 여러 동시 작업 간에 공유되지 않는다면 해당 데이터에 대해 데이터 경합이 발생할 수 없습니다. 데이터 경합을 피하는 한 가지 방법은 값 타입을 사용하여 공유된 가변 상태를 제거하는 것입니다. 값 타입의 변수는 모든 변경이 지역적으로 일어나기 때문에 안전합니다. 또한, let으로 선언된 값 타입은 불변하므로 서로 다른 동시 작업에서 접근하더라도 안전합니다.
Swift는 처음부터 값 타입을 적극적으로 장려해 왔습니다. 값 타입은 프로그램의 동작을 더 쉽게 이해할 수 있게 해줄 뿐만 아니라, 이러한 특성 덕분에 동시성 프로그램에서도 안전하게 사용할 수 있습니다.

이 예제에서는 몇 개의 값을 가진 배열을 생성합니다. 그 다음, 해당 배열을 두 번째 변수에 할당합니다. 이후, 각 배열 복사본에 서로 다른 값을 추가합니다. 마지막에 두 배열을 출력해 보면, 두 복사본 모두 초기화된 값들을 포함하고 있으며, 추가된 값은 각각 해당 복사본에만 존재한다는 것을 알 수 있습니다. 딕셔너리와 같은 컬렉션 타입이나 이 예제의 배열처럼 Swift의 표준 라이브러리에 있는 대부분의 타입은 값 타입입니다.

이제 값 타입이 모든 데이터 경합 문제를 해결한다는 점을 확인했으니, 카운터를 구조체로 바꿔서 값 타입으로 만들어 봅시다. 값을 변경할 수 있도록 increment 함수를 mutating으로 표시해야 합니다. 이제 카운터를 수정하려고 하면, 카운터가 let으로 선언되어 있기 때문에 값을 변경할 수 없다는 컴파일 오류가 발생합니다.
이제 counter 상수를 변경 가능하게 만들기 위해 그냥 var로 바꾸는 것이 매우 유혹적으로 보일 수 있습니다. 하지만 그렇게 하면 카운터가 두 개의 동시 작업에서 참조되기 때문에 다시 데이터 경합 상태에 빠지게 됩니다. 다행히도 컴파일러는 이런 안전하지 않은 코드를 허용하지 않기 때문에 컴파일 자체가 되지 않습니다.

그 대신, 각 동시 작업 내부에서 카운터를 변수에 할당할 수 있습니다. 이제 이 예제를 실행하면 두 개의 동시 작업 모두 항상 1을 출력하게 됩니다. 하지만 코드가 데이터 경합으로부터 안전해졌음에도 불구하고, 이는 우리가 원하는 동작은 아닙니다. 이로써 여전히 공유된 가변 상태가 필요하다는 사실을 알 수 있습니다.

동시성 프로그램에서 공유된 가변 상태가 있을 경우, 동시 접근으로 인해 데이터 경합이 발생하지 않도록 하기 위해 어떤 형태로든 동기화가 필요합니다. 동기화를 위한 도구에는 원자적 연산(atomics)이나 락(locks)과 같은 저수준 도구부터, 직렬 디스패치 큐(serial dispatch queues)와 같은 고수준 도구까지 다양한 방식이 존재합니다. 이러한 동기화 도구들은 다양한 장점을 가지고 있지만, 모두 공통된 치명적인 약점을 가지고 있습니다. 바로 매번 정확히 조심스럽게 사용하지 않으면 데이터 경합이 발생할 수 있다는 점입니다.

이것이 바로 액터가 등장한 이유입니다. 액터는 공유된 가변 상태를 위한 동기화 메커니즘을 제공합니다. 액터는 고유한 상태를 가지며, 그 상태는 프로그램의 다른 부분으로부터 격리되어 있습니다. 해당 상태에 접근하는 유일한 방법은 액터를 통해서만 가능하며, 액터를 통해 접근할 때마다 액터의 동기화 메커니즘이 동작하여 다른 코드가 동시에 액터의 상태에 접근하지 못하도록 보장합니다.
이것은 우리가 직접 락이나 직렬 디스패치 큐를 사용할 때 얻을 수 있는 상호 배제(mutual exclusion)와 동일한 효과를 줍니다. 하지만, 액터를 사용하면 Swift가 이를 기본적으로 보장합니다. 동기화를 깜빡하는 일이 발생할 수 없으며, 만약 그렇게 시도하면 Swift는 컴파일 오류를 발생시켜 이를 막습니다.

액터는 Swift에서 새롭게 도입된 타입입니다. 액터는 Swift의 다른 타입들과 동일한 기능을 제공합니다. 프로퍼티, 메서드, 이니셜라이저, 서브스크립트 등을 가질 수 있으며, 프로토콜을 따를 수도 있고, 확장할 수도 있습니다. 클래스처럼 액터도 참조 타입입니다. 왜냐하면 액터의 목적은 공유된 가변 상태를 표현하는 것이기 때문입니다. 실제로, 액터 타입의 주요한 특징은 인스턴스 데이터를 프로그램의 나머지 부분으로부터 격리하고, 해당 데이터에 대한 동기화된 접근을 보장한다는 점입니다. 액터의 모든 특별한 동작은 이러한 핵심 개념에서 비롯됩니다.
여기서는 카운터를 액터 타입으로 정의했습니다. 여전히 카운터에 대한 value 프로퍼티와 증가된 새로운 값을 반환하는 increment 메서드가 있습니다. 차이점은 액터가 value 프로퍼티에 대한 동시 접근이 발생하지 않도록 보장해준다는 점입니다. 이 경우, increment 메서드가 호출되면, 해당 메서드는 액터 내부에서 다른 코드가 실행되지 않은 상태로 완료될 때까지 단독으로 실행됩니다. 이러한 보장은 액터의 상태에서 발생할 수 있는 데이터 경합의 가능성을 제거합니다.

다시 데이터 경합 예제를 가져와 봅시다. 두 개의 작업이 동일한 카운터를 증가시키려고 시도하고 있습니다. 액터의 내부 동기화 메커니즘은 하나의 increment 호출이 완료되기 전까지 다른 호출이 시작되지 않도록 보장합니다. 둘 다 유효한 동시 실행이기 때문에 우리는 1과 2 또는 2와 1을 얻을 수 있습니다. 하지만 동일한 값을 두 번 얻거나 값을 건너뛰는 일은 발생하지 않습니다. 이는 액터의 내부 동기화가 액터 상태에서의 데이터 경합의 가능성을 제거했기 때문입니다.
두 개의 작업이 동시에 카운터를 증가시키려고 할 때 실제로 어떤 일이 일어나는지 생각해 봅시다. 하나가 먼저 도착하고, 다른 하나는 자신의 차례를 기다려야 합니다. 하지만 두 번째 작업이 액터에서 차례를 기다릴 수 있도록 하려면 어떻게 해야 할까요? Swift에는 이를 위한 메커니즘이 존재합니다.
액터가 외부와 상호작용할 때는 항상 비동기적으로 이루어집니다. 액터가 다른 작업을 수행하고 있다면, 코드가 일시 중단되어 현재 실행 중인 CPU가 다른 유용한 작업을 수행할 수 있도록 합니다. 액터가 다시 사용 가능해지면, 코드를 재개하여 작업이 실행될 수 있게 됩니다. 이 예제에서 await 키워드는 액터에 대한 비동기 호출이 이러한 일시 중단을 포함할 수 있음을 나타냅니다.

카운터 예제를 조금 더 확장해보겠습니다. reset 연산은 먼저 값을 0으로 초기화한 다음, 카운터를 새로운 값까지 올리기 위해 적절한 횟수만큼 increment를 호출합니다. resetSlowly 메서드는 카운터 액터 타입의 확장에 정의되어 있으므로 액터 내부에 존재합니다. 이는 이 메서드가 액터의 상태에 직접 접근할 수 있다는 것을 의미하며, 실제로 카운터 값을 0으로 초기화할 때 그렇게 합니다. 또한 increment 호출처럼 액터의 다른 메서드들을 동기적으로 호출할 수도 있습니다.
await이 필요하지 않은 이유는 이미 액터 내부에서 실행 중이라는 것을 알고 있기 때문입니다. 이것은 액터의 중요한 특징입니다. 액터에서의 동기 코드는 항상 중단되지 않고 끝까지 실행됩니다. 따라서 우리는 액터 상태에 대한 동시성의 영향을 고려할 필요없이 동기 코드를 순차적으로 이해할 수 있습니다. 동기 코드는 중단되지 않고 실행된다고 강조했지만, 액터는 종종 서로 또는 시스템의 다른 비동기 코드와 상호작용합니다. 비동기 코드와 액터에 대해 이야기하기 전에, 먼저 더 나은 예제를 보여드리겠습니다.

여기서는 이미지 다운로드 액터를 만들고 있습니다. 이 액터는 다른 서비스에서 이미지를 다운로드하는 역할을 합니다. 또한 같은 이미지를 여러 번 다운로드하지 않도록 다운로드된 이미지를 캐시에 저장합니다. 논리적 흐름은 매우 간단합니다. 캐시를 확인하고, 이미지를 다운로드한 다음, 이미지를 캐시에 기록하고 반환합니다.
코드가 액터 내부에 구현되어 있기 때문에 저수준의 데이터 경합으로부터 자유롭습니다. 어떤 수의 이미지든 동시에 다운로드할 수 있습니다. 액터의 동기화 메커니즘은 캐시 프로퍼티에 접근하는 코드를 한 번에 하나의 작업만 실행할 수 있도록 보장하기 때문에 캐시가 손상될 가능성은 없습니다.

여기서의 await 키워드는 매우 중요한 의미를 전달하고 있습니다. await이 발생한다는 것은 함수가 그 지점에서 일시 중단될 수 있다는 뜻입니다. 해당 함수는 CPU를 포기하고, 이어서 전체 프로그램 상태에 영향을 미칠 수 있는 다른 코드가 실행될 수 있습니다.


두 개의 서로 다른 작업이 같은 이미지를 동시에 가져오려고 시도한다고 상상해봅시다. 첫 번째 작업은 캐시에 항목이 없다는 것을 확인하고, 서버에서 이미지를 다운로드하기 시작합니다. 하지만 다운로드에 시간이 걸리기 때문에 해당 작업은 일시 중단됩니다.
첫 번째 작업이 이미지를 다운로드하는 동안, 동일한 URL에 새로운 이미지가 서버에 업로드될 수 있습니다. 이제 두 번째 동시 작업이 해당 URL에서 이미지를 가져오려고 시도합니다.



두 번째 작업도 캐시에 항목이 없다는 것을 확인합니다. 이는 첫 번째 다운로드가 아직 완료되지 않았기 때문입니다. 그래서 두 번째 작업도 이미지를 다운로드하기 시작하고, 완료될 때까지 일시 중단됩니다. 시간이 지난 후, 첫 번째 다운로드가 완료되고, 해당 작업이 액터에서 실행을 재개하여 캐시에 데이터를 저장하고 고양이 이미지를 반환합니다.

이제 두 번째 작업의 다운로드도 완료되어 실행을 재개합니다. 그리고 자신이 받은 슬픈 고양이 이미지를 동일한 캐시 항목에 덮어씁니다. 그래서 캐시에 이미 이미지가 저장되어 있음에도 불구하고, 동일한 URL에 대해 다른 이미지를 얻게 됩니다. 이는 다소 놀라운 결과입니다.
우리는 한 번 캐시에 이미지를 저장하면, 동일한 URL에 대해서는 항상 같은 이미지를 반환받아 사용자 인터페이스가 일관되게 유지되기를 기대합니다. 적어도 우리가 수동으로 캐시를 지우기 전까지는 말이죠. 하지만 여기서는 캐시된 이미지가 예상치 않게 변경되었습니다. 저수준의 데이터 경합은 없었지만, await을 기준으로 상태가 변하지 않을 것이라 가정(assumption)했기 때문에 잠재적인 버그가 발생한 것입니다.

여기서의 해결책은 await 이후에 상태를 다시 확인하는 것입니다. 실행이 재개되었을 때 캐시에 이미 항목이 있다면, 원래 버전을 유지하고 새로 받은 이미지는 버립니다. 하지만 더 나은 해결책은 아예 중복된 다운로드를 방지하는 것입니다. 이 영상과 함께 제공된 코드에는 그런 해결책이 포함되어 있습니다.
💾 11:59 - Check your assumptions after an await: A better solution
actor ImageDownloader { private enum CacheEntry { case inProgress(Task<Image, Error>) case ready(Image) } private var cache: [URL: CacheEntry] = [:] func image(from url: URL) async throws -> Image? { if let cached = cache[url] { switch cached { case .ready(let image): return image case .inProgress(let task): return try await task.value } } let task = Task { try await downloadImage(from: url) } cache[url] = .inProgress(task) do { let image = try await task.value cache[url] = .ready(image) return image } catch { cache[url] = nil throw error } } }

액터의 재진입성(re-entrancy)은 교착 상태(deadlock)를 방지하고 코드가 계속 진행(forward progress)되게 하는 것을 보장하지만, 각 await 지점마다 가정이 유지되는지 확인해야 합니다. 재진입을 잘 설계하려면 액터 상태의 변경을 동기 코드 내에서 수행해야 합니다. 이상적으로, 모든 상태 변경을 동기 함수 내부에서 수행하게 하여 상태 변경이 캡슐화될 수 있도록 하는 게 좋습니다. 상태 변경은 일시적으로 액터를 일관되지 않은 상태로 만들 수 있으므로, await 이전에는 반드시 상태의 일관성을 회복해야 합니다.
그리고 await은 잠재적인 일시 중단 지점이라는 점을 기억하세요. 코드가 일시 중단되는 동안에도 프로그램은 코드가 다시 재개될 때까지 계속 진행 중에 있습니다. 따라서 await 이후에는 전역 상태, 시계(clock), 타이머나 액터에 대해 가정한 모든 것들을 다시 확인해야 합니다. 그리고 이제 제 동료 도그가 액터 격리에 대해 더 자세히 설명해 드릴 겁니다. 도그?
도그 그레고르: 고마워요, 다리오. 액터 격리(isolation)는 액터 타입의 동작에서 근본적인 요소입니다. 다리오는 액터 외부로부터의 비동기 상호작용을 통해 Swift 언어 모델이 어떻게 액터 격리를 보장하는지를 설명했습니다. 이 섹션에서는 프로토콜 준수, 클로저, 클래스 등 다른 언어 기능들과 액터 격리가 어떻게 상호작용하는지를 다룰 것입니다.

다른 타입들과 마찬가지로, 액터도 프로토콜의 요구사항을 충족할 수 있다면 해당 프로토콜을 따를 수 있습니다. 예를 들어, LibraryAccount 액터를 Equatable 프로토콜에 따르게 만들어봅시다. 정적(static) 동등성 메서드는 두 개의 도서관 계정을 ID 번호를 기준으로 비교합니다.
메서드가 정적이기 때문에 self 인스턴스가 존재하지 않으며, 따라서 해당 메서드는 액터에 격리되지 않습니다. 대신, 이 메서드는 두 개의 액터 타입 매개변수를 가지며, 두 액터 중 어느 쪽에도 속하지 않습니다. 왜냐하면 구현부에서는 액터의 불변 상태만 접근하고 있기 때문입니다.

예제를 좀 더 확장해보면서, LibraryAccount가 Hashable 프로토콜을 따르도록 만들어보겠습니다. 이를 위해서는 hash(into:) 메서드를 구현해야 하는데, 이 작업은 다음과 같이 할 수 있습니다. 하지만 여기서 문제가 발생합니다. Swift 컴파일러는 이 Hashable 프로토콜의 준수를 허용하지 않는다고 경고합니다. 즉, 우리가 이 방식으로 Hashable을 적용하려고 하면 컴파일 오류가 발생하게 됩니다.
무슨 일이 일어난 걸까요? 이렇게 Hashable 프로토콜을 따르게 되면, 이 함수가 액터 외부에서 호출될 수 있다는 의미가 됩니다. 그러나 hash(into:)는 비동기가 아니기 때문에 액터 격리를 유지할 방법이 없습니다. 이를 해결하기 위해서는 이 메서드를 nonisolated로 만들어야 합니다.

비격리(non-isolated)는 이 메서드가 문법적으로는 액터에 정의되어 있더라도, 실제로는 액터 외부에 있는 것으로 취급한다는 의미입니다. 이는 Hashable 프로토콜의 동기적 요구사항을 만족시킬 수 있게 해줍니다. nonisolated 메서드는 액터 외부에 있는 것으로 간주되기 때문에 액터의 변경 가능한 상태를 참조할 수 없습니다.

이 메서드는 변경 불가능한 ID 번호를 참조하고 있기 때문에 문제가 없습니다. 만약 대출 중인 책들의 배열과 같이 다른 값을 기반으로 해싱을 시도한다면 오류가 발생할 것입니다. 이는 외부에서 가변 상태에 접근하게 되면 데이터 경합이 발생할 수 있기 때문입니다. 프로토콜 준수에 대한 설명은 여기까지입니다.

클로저에 대해 이야기해봅시다. 클로저는 하나의 함수 내에서 정의된 작은 함수들로, 이후 다른 함수에 전달되어 나중에 호출될 수 있습니다. 함수와 마찬가지로, 클로저도 액터에 격리되거나 nonisolated가 될 수 있습니다.
이 예제에서는 우리가 대출 중인 각 책에서 일부를 읽고, 읽은 총 페이지 수를 반환하려고 합니다. reduce 호출에는 읽기를 수행하는 클로저가 있습니다. readSome 호출에 await이 없다는 것을 주목하세요. 이는 해당 클로저가 액터에 격리된 함수인 read 내부에서 생성되었기 때문이며, 클로저 자체도 액터에 격리되어 있기 때문입니다.

여기서는 Detached Task를 생성합니다. Detached Task는 클로저를 액터가 수행 중인 다른 작업들과 동시에 실행합니다. 따라서 이 클로저는 액터에 속할 수 없으며, 그렇지 않으면 데이터 경합이 발생할 수 있습니다. 그래서 해당 클로저는 액터에 격리되지 않습니다. 이 클로저가 read 메서드를 호출하고자 할 때는 await를 사용하여 비동기적으로 호출해야 합니다.
우리는 액터 격리에 대해 조금 이야기했는데, 이는 그 코드가 액터 내부에서 실행되는지 외부에서 실행되는지를 의미합니다. 이제는 액터 격리와 데이터에 대해 이야기해봅시다.

우리는 LibraryAccount 액터 예제에 Book 타입이 실제로 무엇인지에 대해서는 일부러 언급하지 않았습니다. 저는 그것이 구조체와 같은 값 타입이라고 가정하고 있었습니다. 이는 좋은 선택인데, LibraryAccount 액터 인스턴스의 모든 상태가 자체적으로 포함되어 있다는 것을 의미하기 때문입니다. 우리가 selectRandomBook 메서드를 호출해서 읽을 책을 무작위로 선택하면, 읽을 수 있는 책의 복사본을 얻게 됩니다. 우리가 이 책의 복사본에 대해 변경을 하더라도 액터에 영향을 미치지 않으며, 그 반대도 마찬가지입니다.

하지만, Book을 클래스 타입으로 바꾸게 되면 상황이 조금 달라집니다. 이제 LibraryAccount 액터는 Book 클래스의 인스턴스를 참조하게 됩니다. 이것 자체는 문제가 되지 않습니다. 그러나 책을 무작위로 선택하는 메서드를 호출하게 되면 어떤 일이 일어날까요? 이제 우리는 가변 상태에 대한 참조를 액터 외부로 공유하게 됩니다. 이는 데이터 경합이 발생할 수 있는 잠재적인 위험을 만들어냅니다.
이제 책의 제목을 업데이트하게 되면, 그 수정은 액터 내부에서 접근 가능한 상태에서 일어나게 됩니다. 하지만 visit 메서드는 액터에 속하지 않기 때문에 해당 수정은 데이터 경합으로 이어질 수 있습니다. 값 타입과 액터는 동시에 사용해도 안전하지만, 클래스는 여전히 문제를 일으킬 수 있습니다. 동시에 사용해도 안전한 타입을 우리는 Sendable이라고 부릅니다.

Sendable 타입이란 서로 다른 액터 간에 값을 공유할 수 있는 타입을 말합니다. 어떤 값을 한 곳에서 다른 곳으로 복사했을 때, 두 곳이 자신들의 복사본을 안전하게 수정할 수 있고, 서로 간섭하지 않는다면, 그 타입은 Sendable이 될 수 있습니다. 값 타입은 각 복사본이 독립적이기 때문에 Sendable입니다. 이는 다리오가 앞서 설명한 부분입니다. 액터 타입은 가변 상태에 대한 접근을 동기화하기 때문에 Sendable입니다.
클래스도 Sendable이 될 수 있지만, 이는 신중하게 구현된 경우에만 가능합니다. 예를 들어, 어떤 클래스와 그 모든 하위 클래스가 오직 불변 데이터만을 가지고 있다면, 그 클래스는 Sendable이 될 수 있습니다. 또는 클래스가 내부적으로 락과 같은 동기화 메커니즘을 사용하여 안전한 동시 접근을 보장한다면, 역시 Sendable이 될 수 있습니다. 그러나 대부분의 클래스는 이러한 조건을 만족하지 않기 때문에 Sendable이 될 수 없습니다.
함수는 반드시 Sendable인 것은 아니기 때문에, 액터 간에 안전하게 전달할 수 있는 새로운 유형의 함수 타입이 생겼습니다. 이에 대해서는 곧 다시 다루겠습니다.

모든 동시성 코드는 Sendable 타입을 기반으로 통신해야 합니다. Sendable 타입은 코드가 데이터 경합로부터 보호되도록 해줍니다. 이 속성은 Swift가 정적으로 검사하게 되며, Sendable이 아닌 타입이 액터 경계를 넘어서 전달하면 오류라는 의미입니다.

어떻게 하면 타입이 Sendable인지 알 수 있을까요? Sendable은 하나의 프로토콜이며, 다른 프로토콜들과 마찬가지로 여러분의 타입이 Sendable을 따르도록 명시하면 됩니다. 그러면 Swift는 해당 타입이 Sendable로서 타당한지 확인하게 됩니다. 예를 들어, Book 구조체는 그 안에 저장된 모든 프로퍼티들이 Sendable 타입일 경우에만 Sendable이 될 수 있습니다. Author가 실제로 클래스라고 가정해봅시다. 그렇다면 Author 자체는 물론이고 Author들의 배열도 Sendable이 아닙니다. 이 경우 Swift는 Book이 Sendable이 될 수 없다는 컴파일 오류를 발생시킬 것입니다.
제네릭 타입의 경우, Sendable 여부는 그 제네릭 인자들에 따라 달라질 수 있습니다. 적정한 때에 조건부 준수(conditional conformance)를 사용하면 Sendable을 전파할 수 있습니다. 예를 들어, Pair 타입은 제네릭 인자 두 개가 모두 Sendable일 때에만 Sendable이 됩니다. 같은 방식으로, Sendable 타입들의 배열이 Sendable로 간주되는 것도 이러한 접근에 기반합니다.
값을 동시에 공유해도 안전한 타입에는 Sendable 준수를 도입하는 것을 권장합니다. 이러한 타입들을 액터 내부에서 사용하세요. 그러면 Swift가 액터 간 Sendable 준수를 강제하기 시작할 때, 여러분의 코드는 스레드에 안전한 코드가 되어 있을 것입니다.

함수 자체도 Sendable이 될 수 있으며, 이는 함수 값을 액터 간에 안전하게 전달할 수 있다는 의미입니다. 이는 특히 클로저가 수행할 수 있는 작업을 제한함으로써 데이터 경합을 방지하는 데 도움을 줍니다.
예를 들어, Sendable 클로저는 가변 지역 변수를 캡처할 수 없습니다. 이는 해당 지역 변수에 대한 데이터 경합이 발생할 수 있기 때문입니다. 클로저가 캡처하는 모든 것은 Sendable이어야 하며, 이를 통해 클로저가 Sendable하지 않는 타입을 액터 경계 너머로 이동시키지 못하도록 보장합니다.
그리고 마지막으로, 동기적인 Sendable 클로저는 액터 내부에 격리시킬 수 없습니다. 왜냐하면 이런 클로저는 액터 외부에서 실행될 수 있기 때문에, 그 안에서 액터의 내부 상태에 접근하도록 만들면 격리가 제대로 이루어지지 않기 때문입니다. 사실 이번 설명 전반에서 우리는 Sendable 클로저라는 개념에 의존해 왔습니다. Detached Task를 생성하는 연산은 Sendable 함수만을 받으며, 이는 함수 타입에 @Sendable로 명시됩니다.

이번 설명의 초반부에 나왔던 카운터 예제를 기억하시나요? 우리는 값 타입으로 된 카운터를 만들려고 했습니다. 그리고 나서, 두 개의 서로 다른 클로저에서 동시에 그 값을 수정하려고 했죠.
이는 가변 지역 변수에 대한 데이터 경합을 유발할 수 있습니다. 하지만 Detached Task의 클로저는 Sendable이기 때문에 Swift는 여기서 오류를 발생시킵니다. Sendable 함수 타입은 동시 실행이 발생할 수 있는 지점을 나타내기 위해 사용되며, 따라서 데이터 경합을 방지할 수 있습니다.

앞서 봤던 또 다른 예제를 살펴봅시다. Detached Task의 클로저는 Sendable이기 때문에 해당 클로저는 액터에 격리되어 있지 않습니다. 따라서 이 클로저와의 상호작용은 비동기적으로 이루어져야 합니다. Sendable 타입과 클로저는 변경 가능한 상태가 액터 간에 공유되지 않고 동시에 수정되지 않도록 검사함으로써 액터 격리를 유지하는 데 도움을 줍니다.
우리는 지금까지 주로 액터 타입에 대해 이야기해왔고, 그것이 프로토콜, 클로저와 Sendable 타입들과 어떻게 상호작용하는지 살펴보았습니다. 이제 논의할 또 하나의 액터가 남아 있는데, 바로 특별한 액터인 메인 액터(main actor) 입니다.

앱을 개발할 때는 메인 스레드에 대해 고려해야 합니다. 메인 스레드는 사용자 인터페이스 렌더링이 이루어지는 핵심 공간이며, 사용자 상호작용 이벤트도 이곳에서 처리됩니다. UI와 관련된 작업은 일반적으로 메인 스레드에서 수행되어야 합니다.
하지만 모든 작업을 메인 스레드에서 처리하고 싶지는 않을 것입니다. 예를 들어 느린 입출력 작업이나 서버와의 블로킹 인터랙션 같은 무거운 작업을 메인 스레드에서 수행하면, UI가 멈추고 응답하지 않게 됩니다. 따라서 UI와 상호작용하는 작업은 메인 스레드에서 처리하되, 계산 비용이 크거나 대기 시간이 긴 작업은 가능한 한 빨리 메인 스레드에서 벗어나서 처리하는 것이 중요합니다.

그래서 가능한 경우에는 메인 스레드 밖에서 작업을 수행하고, 반드시 메인 스레드에서 실행되어야 하는 특정 작업이 있을 때만 DispatchQueue.main.async를 사용해 메인 스레드에서 실행되도록 합니다. 이 메커니즘의 세부사항은 잠시 뒤로 하고 코드의 구조를 살펴보면 어딘가 익숙한 느낌을 받을 수 있습니다.
실제로, 메인 스레드와 상호작용하는 방식은 액터와 상호작용하는 방식과 매우 유사합니다. 이미 메인 스레드에서 실행 중이라는 것을 알고 있다면, UI 상태에 안전하게 접근하고 수정할 수 있습니다. 그러나 메인 스레드가 아닌 곳에서 실행 중이라면 메인 스레드와 비동기적으로 상호작용해야 합니다.

이것이 바로 액터가 작동하는 방식과 동일합니다. 메인 액터는 메인 스레드를 나타내는 액터로 일반 액터와는 두 가지 중요한 점에서 다릅니다. 첫째, 메인 액터는 메인 디스패치 큐를 통해 모든 동기화를 수행합니다. 즉, 런타임 관점에서 보면 메인 액터는 DispatchQueue.main과 상호 교환적으로 사용할 수 있습니다. 둘째, 메인 스레드에서 실행되어야 하는 코드와 데이터는 앱 전반에 널리 퍼져 있습니다. 이는 SwiftUI, AppKit, UIKit 같은 시스템 프레임워크뿐만 아니라, 여러분의 뷰, 뷰 컨트롤러, 그리고 UI와 맞닿은 데이터 모델의 일부에까지 걸쳐 있습니다.
Swift의 동시성 기능을 사용하면 어떤 선언에 @MainActor 속성을 붙여 반드시 메인 액터에서 실행되어야 한다는 것을 명시할 수 있습니다. 여기에서는 checkedOut 연산에 그렇게 지정했기 때문에, 이 코드는 항상 메인 액터, 즉 메인 스레드에서 실행됩니다.
메인 액터 외부에서 이 메서드를 호출하려면 await를 사용해야 합니다. 이는 호출이 메인 스레드에서 비동기적으로 실행되도록 하기 위함입니다. 이렇게 메인 스레드에서 실행되어야 하는 코드를 @MainActor로 표시하면, 언제 DispatchQueue.main을 써야 하는지 더 이상 추측할 필요가 없습니다. Swift가 해당 코드가 반드시 메인 스레드에서 실행되도록 보장해줍니다.

타입 전체를 메인 액터에 올릴 수도 있습니다. 이렇게 하면 해당 타입의 모든 멤버와 하위 클래스들도 메인 액터에 속하게 됩니다. 이는 UI와 상호작용해야 하는 코드 영역, 즉 대부분의 코드가 메인 스레드에서 실행되어야 할 때 유용합니다. 개별 메서드는 nonisolated 키워드를 통해 예외를 둘 수 있으며, 이는 일반 액터에서 사용하는 규칙과 동일합니다.
UI 관련 타입과 작업에는 메인 액터를 사용하고, 그 외의 프로그램 상태를 관리하기 위해 자체적인 액터를 도입함으로써, 앱의 구조를 동시성에 안전하고 올바르게 사용할 수 있도록 설계할 수 있습니다.

이번 세션에서는 액터가 격리를 통해 자신의 변경 가능한 상태를 동시 접근으로부터 보호하는 방법에 대해 이야기했습니다. 액터 외부에서 비동기 접근을 요구함으로써 실행을 직렬화하고, 안전한 동시성 코드를 작성할 수 있도록 합니다.
액터를 구현할 때나 모든 비동기 코드에서 항상 재진입 가능성(reentrancy)을 염두에 두세요. 코드 내의 await은 실행을 중단시킬 수 있으며, 그 사이에 상태가 바뀌어 기존의 가정이 무효화될 수 있습니다. 값 타입과 액터는 데이터 경합을 제거하는 데 함께 작동합니다. 자체적인 동기화를 제공하지 않는 클래스나 공유 가능한 변경 상태를 가지는 Sendable하지 않는 타입들에 대해서는 주의가 필요합니다.
마지막으로, UI와 상호작용하는 코드에는 메인 액터를 사용하여 반드시 메인 스레드에서 실행되어야 하는 코드는 항상 메인 스레드에서 실행되도록 보장하세요.
자신의 애플리케이션에서 액터를 어떻게 활용할 수 있는지 더 배우고 싶다면, Swift 동시성에 맞게 앱을 업데이트하는 세션을 확인해보세요. 그리고 Swift의 동시성 모델 구현, 특히 액터를 포함한 내부 구조에 대해 더 알고 싶다면, “Behind the scenes” 세션도 참고해보세요.
액터는 Swift 동시성 모델의 핵심 요소입니다. 액터는 async/await 및 구조화된 동시성(structured concurrency) 과 함께 작동하여, 올바르고 효율적인 동시성 프로그램을 더 쉽게 구축할 수 있게 해줍니다. 여러분이 액터를 활용해 어떤 것들을 만들어낼지 기대됩니다.