2024년 3월 5일 공개된 Swift 5.10 업데이트에 대한 포스팅입니다.
Swift 5.10 Released
Swift was designed to be safe by default, preventing entire categories of programming mistakes at compile time. Sources of undefined behavior in C-based languages, such as using variables before they’re initialized or a use-after-free, are defined away i
www.swift.org
다음과 같이 5가지 업데이트 사항이 있습니다.
SE-0327: On Actors and Initialization
SE-0383: Deprecate @UIApplicationMain and @NSApplicationMain
SE-0404: Allow Protocols to be Nested in Non-Generic Contexts
SE-0411: Isolated default value expressions
SE-0412: Strict concurrency for global variables
글이 길지만 천천히 읽어볼 수 있으실 겁니다.
하나씩 살펴보시죠!
SE-0327: On Actors and Initialization
4가지 이유로 제안서가 작성되었습니다.
Actor는 Swift 5.5에서 소개된 자료형이며, 이 제안서는 Actor의 Initialization과 Deinitialization 시에 생길 수 있는 Data-race를 최소화하는 것을 목표로 합니다.
SE-0327의 경우, 제안된 일부 기능들이 다른 제안으로부터 통과되어 업데이트 되기도 하였습니다.
따라서 해당 제안서의 코드는 버전마다 경고, 오류 등을 다르게 일으킬 수 있습니다.
1. Non-async 초기화는 self 키워드를 사용하는 작업에 대해 지나치게 제한적입니다.
다음의 예시 코드를 봅시다.
actor Clicker {
var count: Int
func click() { self.count += 1 }
init(bad: Void) {
self.count = 0
// no actor hop happens, because non-async init.
Task { await self.click() }
self.click() // 💥 this mutation races with the task!
print(self.count) // 💥 Can print 1 or 2!
}
}
과연 count의 값은 1일까요 2일까요?
1000번의 초기화를 통해 count의 값을 수집해보았습니다.

실제로 여러번 실행한다면, 매번 다르게 표시됩니다.
이렇게 초기화 함수 내에서 self 키워드를 사용시에 Data-race가 일어날 수 있습니다.
또한 Swift 6 버전 이후로 위와 같은 코드가 오류로 판단될 것이라는 경고가 표시되기도 합니다.

이 문제는 init에 async 키워드를 추가하여, initializer 자체를 격리하는 방식으로 해결할 수 있습니다.
2. Actor의 Deinitializer는 Data-race를 발생시킬 수 있습니다.
다음 문제는 Actor 가 해제될 때 발생하는 Data-race 문제와 레퍼런스 카운트의 문제(random crashes)입니다.
Data-race 문제에 대한 재현 로직은 1번과 비슷합니다.
deinit 내에서 데이터 격리가 되지 않는 문제입니다.
더욱 중요한 두번째 문제의 예시 코드를 보시죠
class NonSendableAhmed {
var state: Int = 0
}
@MainActor
class Maria {
let friend: NonSendableAhmed
init() {
self.friend = NonSendableAhmed()
}
init(sharingFriendOf otherMaria: Maria) {
// While the friend is non-Sendable, this initializer and
// and the otherMaria are isolated to the MainActor. That is,
// they share the same executor. So, it's OK for the non-Sendable value
// to cross between otherMaria and self.
self.friend = otherMaria.friend
}
deinit {
friend.state += 1 // 💥 the deinit is not isolated to the MainActor,
// so this mutation can happen concurrently with other
// accesses to the same underlying instance of
// NonSendableAhmed.
print("Deinit")
}
}
func example() async {
let m1 = await Maria()
let m2 = await Maria(sharingFriendOf: m1)
doSomething(m1, m2)
}
문제의 핵심은 레퍼런스 카운트가 0이 되어 deinit이 호출되지만, deinit이 호출된 이후 self instance가 deinit 로직을 탈출하여 다시 레퍼런스 카운트가 증가한다는 것입니다.
이는 Actor뿐만 아니라 Class에서도 발생할 수 있는 문제이며, 레퍼런스 카운트가 두번 0이 되어 무작위 충돌(random crashes)를 발생시키는 레퍼런스 타입의 일반적인 문제입니다.
3. 초기화 과정에서 stored property의 default value에 대해 전역 Actor의 격리성이 항상 준수될 수 없습니다.
Class, Struct, Enum은 stored property 각각에 독립적으로 global-actor isolation이 적용됩니다.
stored property에 default value를 지정할 때, 각각의 기본값들은 해당되는 타입의 non-delegating 초기화에 의해 계산됩니다.
하지만, 이러한 과정에 global-actor에 의해 실행되는 것처럼 취급되어, 아래와 같은 불가능한 제약 조건을 가진 Initializer가 만들어 질 수 있습니다.
@MainActor func getStatus() -> Int { /* ... */ }
@PIDActor func genPID() -> ProcessID { /* ... */ }
class Process {
@MainActor var status: Int = getStatus()
@PIDActor var pid: ProcessID = genPID()
init() {} // Problem: what is the isolation of this init?
}
코드를 실제로 구현하는 것은 불가능한데, status와 pid가 2개의 다른 global-actor에 의해 격리되기 때문에 non-async init에 대해 단일 Actor 격리(single actor isolation)를 지정할 수 없기 때문입니다.
4. Actor가 상속을 지원하지 않더라도, Actor의 Initializer Delegation(초기화 위임)은 Class처럼 convenience 키워드를 사용해야 합니다.
Actor에 대해 Initializer Delegation를 위한 convenience 키워드의 필요성을 명시하지 않았기에, 해당 키워드를 사용하도록 제안합니다.
이 제안은 Initializer 내부에 self.init의 호출을 통해 Delegation을 구분할 수 있도록 하였습니다.
SE-0383: Deprecate @UIApplicationMain and @NSApplicationMain
0383-deprecate-uiapplicationmain-and-nsapplicationmain
@UIApplicationMain와 @NSApplicationMain가 Deprecate 됩니다.
@main 속성이 등장하면서 두 속성의 사용이 오히려 불필요한 선택을 만들었다고 합니다.
이제 @main을 사용하여 Entrypoint를 표시합니다.
이제 Swift 6 이전에서는 경고를 표시하며 Swift 6 이후로는 오류로 판단하게 됩니다.
SE-0404: Allow Protocols to be Nested in Non-Generic Contexts
Protocol이 제네릭이 아닌 struct, class, enum, actor, function에 대해서 중첩을 허용하자는 내용입니다.

원래는 사진처럼 class 내부에 protocol 선언은 불가능했습니다.
다음은 제안서에 작성된 예시입니다.
class TableView {
protocol Delegate: AnyObject {
func tableView(_: TableView, didSelectRowAtIndex: Int)
}
}
class DelegateConformer: TableView.Delegate {
func tableView(_: TableView, didSelectRowAtIndex: Int) {
// ...
}
}
TableView class 내부에 Delegate라는 Protocol이 선언된 것을 볼 수 있습니다.
당연히 이 Delegate는 TableView에 대한 내용이겠죠?
혹은 함수 내부에서도 가능합니다.
func doSomething() {
protocol Abstraction {
associatedtype ResultType
func requirement() -> ResultType
}
struct SomeConformance: Abstraction {
func requirement() -> Int { ... }
}
struct AnotherConformance: Abstraction {
func requirement() -> String { ... }
}
func impl<T: Abstraction>(_ input: T) -> T.ResultType {
// ...
}
let _: Int = impl(SomeConformance())
let _: String = impl(AnotherConformance())
}
제안서에 따르면 몇몇 코드베이스에서 중첩 타입이 포함된 대규모 closures들을 사용하며, Procotol 추상화를 할 수 있는 이점이 있다고 합니다.
단, 첫줄에서 언급했듯 제네릭이 있는 경우 중첩이 불가능합니다.
다음은 불가능한 중첩의 예시입니다.
class TableView<Element> {
protocol Delegate { // Error: protocol 'Delegate' cannot be nested within a generic context.
func didSelect(_: Element)
}
}
func genericFunc<T>(_: T) {
protocol Abstraction { // Error: protocol 'Abstraction' cannot be nested within a generic context.
}
}
class TableView<Element> {
func doSomething() {
protocol MyProtocol { // Error: protocol 'MyProtocol' cannot be nested within a generic context.
}
}
}
실제로 Swift 5.10 업데이트 이후로는

정상적으로 컴파일되는 것을 확인할 수 있습니다.
해당 업데이트 사항은 기존 코드를 리팩토링할 때 유용하게 사용할 수 있을거 같습니다!
SE-0411: Isolated default value expressions
이 사항은 기본값(default value) 표현에 대한 Actor 격리 규칙을 통합하고, Data-race를 제거하며, 기본값에 대한 격리를 안전하게 허용하여 표현성을 향상시키는 내용입니다.
제안서의 예시 코드를 확인해봅시다.
@MainActor func requiresMainActor() -> Int { ... }
@AnotherActor func requiresAnotherActor() -> Int { ... }
class C {
@MainActor var x1 = requiresMainActor()
@AnotherActor var x2 = requiresAnotherActor()
nonisolated init() {} // okay???
}
이와 같은 경우에 x1과 x2의 기본값을 반환하는 두 *Actor() 함수가 각각의 글로벌 Actor에서 다른 코드와 동시에 실행될 수 있기에 격리되어있지 않게 됩니다.
따라서 이 업데이트는 예시와 같은 경우엔 error를 표시하도록 하고, 아래 코드 처럼 각각 await를 사용하여 동시성을 보장하게 합니다.
...
nonisolated init() async {
self.x1 = await requiresMainActor()
self.x2 = await requiresAnotherActor()
}
...
이처럼 함수 인자에 포함되는 기본값, class/struct/actor 등의 초기화 속성 기본값 등에서 동시성 문제를 발생시키거나, 격리 규칙에 어긋나는 사항들을 수정하도록하는 업데이트입니다.
SE-0412: Strict concurrency for global variables
0412-strict-concurrency-for-global-variables
Data-race가 생기지 않는 전역 변수 사용에 대한 내용입니다.
모든 전역 변수가 전역 actor 혹은 불변의 Sendable 타입(immutable of Sendable type)으로 분리되어야 한다는 제안입니다.
최상의 전역 변수는 이미 @MainActor에 의해 격리됩니다.
하지만 제안서의 내용처럼
var value = 1
func f() {
value = 2 // warning: reference to var 'value' is not concurrency-safe because it involves shared mutable state
}
이와 같은 상황에서 value 변수의 동시성을 완벽히 보장할 수 없습니다.
단, 개발자가 이러한 격리를 원하지 않는 경우에는
nonisolated(unsafe) var global: String
선언 시 특정 키워드를 통해 동시성 여부에 대한 검사를 진행하지 않습니다.
아래는 제안서에 포함된 예시코드입니다.
func f() async {
nonisolated(unsafe) var value = 1
let task = Task {
value = 2
return value
}
print(await task.value)
}
단, nonisolated(unsafe)의 문구가 함수로 해석될 수 있기 때문에 변수 선언 부 앞에 있는 nonisolated(unsafe)의 경우 예약어 처리를 하여 해결한다고 합니다.
전체적으로 동시성에 대한 부분들이 업데이트 되었습니다.
그만큼 동시성 프로그래밍이 자주 활용되고, 중요하기에 그렇다고 생각이 되는데요!
동시성에 대한 RxSwift, Combine에
※ 잘못된 표현 및 설명(해석)이 있다면 댓글로 알려주시기 바랍니다!
'iOS > Swift' 카테고리의 다른 글
[Swift] Method Dispatch (1) | 2024.01.15 |
---|---|
[Swift] Protocol Composition (0) | 2024.01.09 |
[Swift] HOF(Higher Order Function) (2) (0) | 2022.08.03 |
[Swift] HOF(Higher Order Function) (1) (0) | 2022.08.03 |
[Swift] await, async (0) | 2022.06.30 |