WWDC22: Eliminate data races using Swift Concurrency | Apple
우선 위 영상에서 예측하기 어려운 동시성(Concurrency)의 세계를 바다에 비유하여 설명한다.
- ⛵️ Task (배): 바다를 항해하는 배로, 독립적으로 자신의 할 일을 수행.
- 🏝️ Actor (섬): 바다 위 고립된 섬으로, 한 번에 하나의 배(Task)만 접근할 수 있는 안전한 데이터 보관소.
- 🍍 Value Type (파인애플): 복사해서 다른 배로 넘겨줄 수 있는 안전한 데이터 구조체.
- 🐓 Reference Type (닭): 복사본이 아닌 원본 참조를 공유하게 되어 혼란(Data Race)을 유발할 수 있는 클래스 객체.
Task와 데이터 격리 (Task Isolation)
Task는 독립적인 실행 컨텍스트(Context)를 가지며, 다음 세 가지 특징을 갖는다.
- Sequential: 시작부터 끝까지 순차적으로 코드를 수행
- Asynchronous: 비동기적이며, 언제든 일시 중단(suspend)될 수 있고, 언제 완료될지 모른다.
- Self-contained: 각 Task는 고유한 리소스를 가지며 각 Task내에서 독립적으로 사용된다.
Task에서 Task로의 데이터 전달
Task 간에 🍍 Value Type(값 타입) 데이터를 전달하면 실제로는 복사본(copy)이 전달된다.
따라서 다른 Task의 리소스에 영향을 주지 않게되어 Data Race 안전성을 보장된다.
(* 참고 : 이 때문에 Swift 에서는 가능하면 Value Type으로 구현하기를 권장한다)
반면, 🐓 Reference Type(참조 타입)을 넘기면 참조 주소만 전달되기 때문에, 여러 Task가 동시에 데이터를 수정하게 되면 공유 가변 상태(Shared mutable data) 문제가 발생하게 된다..!
즉, Data Race를 유발하기 딱 좋은 상태라는 의미이다. 이럴 때, Data Race를 방지하기 위해 등장한게 Sendable 이다.
Sendable 프로토콜: 안전한 데이터 공유
Swift의 Sendable은 컴파일 단계에서 Reference Type 데이터가 무분별하게 Task 간에 공유되지 않도록 제한한다.
Sendable 프로토콜을 채택함으로써 여러 Task(Isolation Domain) 사이에서 안전하게 주고받을 수 있는 데이터임을 명시할 수 있다.
하지만 Sendable이 되려면 아래의 케이스들 중 하나일때만 가능한다.
- Value Type (struct, enum):
내부의 모든 데이터가 Sendable이면 암시적으로 Sendable을 만족한다.
(Array도 Element가 Sendable이면 만족) - Reference Type (Class):
가변 상태를 가지므로 원칙적으로 불가능하다. 하지만 아래의 경우에는 예외적으로 만족한다.- 불변 데이터(let)만 있는 final class인 경우
- 내부적으로 Lock을 구현하여 직접 Data Race를 방지한 경우, @unchecked Sendable로 명시가 가능하다.
(@unchecked Sendable로 명시하면 컴파일 단계에서 Sendable 에러가 발생하지 않게 된다)
enum Ripeness: Sendable { ✅
case hard
case perfect
case mushy(daysPast: Int)
}
struct Pineapple: Sendable { ✅
var weight: Double
var ripeness: Ripeness
}
struct Crate: Sendable { ✅
var pineapples: [Pineapple]
}
struct Coop: Sendable { 🚫
var flock: [Chicken] // non-sendable state!!
}
class ConcurrentCache<Key: Hashable & Sendable, Value: Sendable>: @unchecked Sendable {
var lock: NSLock
var storage: [Key: Value]
/* NSLock을 이용해 개발적으로 Data Race 방지 */
}
Task 초기화에서의 Sendable 체크
Task 의 operation closure 는 @Sendable 로 선언되어있다.
struct Task<Success: Sendable, Failure: Error> {
static func detached(
priority: TaskPriority? = nil,
operation: @Sendable @escaping () async throws -> Success
) -> Task<Success, Failure>
}
일반적으로 함수 타입은 프로토콜을 채택할 수 없지만, 함수 타입에서도 @Sendable로 선언이 가능하다.
함수가 Sendable로 선언되었다는 건 함수의 value 가 다른 isolation domain(= Task)으로 전달될 수 있다는 의미이다.
따라서 Task 를 별도로 생성할 때에도 데이터를 전달하려면 Sendable 타입이어야 한다.
let lily = Chicken(name: "Lily") // should be Sendable!
Task.detached { // `@Sendable` clousure 이기 때문에
lily.feed()
}
또한 Task에서 데이터를 return하는 경우에도 Sendable 타입이어야 한다.
let petAdoption = Task { // type 'Chicken' should be Sendable
let chickens = await hatchNewFlock()
return chickens.randomElement!
}
let pet = await petAdoption.value
Actor: 안전한 상태 격리 (Actor Isolation)
분명..! Swift Concurrency 적용하다보면 Task 간에 참조 타입 데이터를 주고받고 싶은데... 이미 클래스엔아 가변 데이터가 존재하고, 내부적으로 data race를 방지하는 로직도 없는 경우가 있을 것이다. 이럴 때 🏝️ Actor를 사용한다!
Actor 자체는 참조 타입이지만, 아래 특성들을 통해 내부적으로 data race를 방지할 수 있으며, Actor는 묵시적으로 Sendable을 만족한다.
- Actor는 내부의 모든 가변 데이터와 메서드 접근을 격리한다.
= Actor 는 Reference Type이지만 concurrent 접근에 대해 모든 프로퍼티와 코드를 보장한다. - non-sendable 데이터는 Actor와 Task 간에서 주고 받을 수 없다.
Actor Reference Isolation
아래와 같은 상황에서 Actor Isolation이 어떻게 동작하는지 살펴보자.
단, 이전에 배웠듯이 Task는 actor context를 상속하고, Task.detached는 actor context를 상속하지 않는 특성이 있다.
actor Island {
var flock: [Chicken] // 🏝️
var food: [Pineapple] // 🏝️
func advanceTime() { // 🏝️
// 🏝️ Island actor의 isolated context
let totalSlices = food.indices.reduce(0) { total, nextIndex in
total + food[nextIndex].slice()
}
Task { // 🏝️ Task 는 actor context를 상속
flock.map(Chicken.produce)
}
Task.detached { // ⛵️ Task.detached는 actor context를 상속하지 않음
// ❗️ 따라서 food 에 접근할 때 await 키워드 필요
let ripePineapples = await food.filter { $0.ripeness == .perfect }
print("There are \(ripePineapples.count) ripe pineapples on the island")
}
}
}
- Island는 actor로 구현되어있기 때문에 내부의 모든 가변 데이터와 메서드 접근을 격리한다.
- advanceTime 함수가 호출되면, 그 함수 내부 코드 또한 격리된 actor context에서 실행된다.
- Task는 현재의 actor context를 상속하기 때문에 클로저 내부에서도 isolation을 유지하며, 클로저에서 actor isolated 데이터인 flock 프로퍼티에 접근할 수 있다.
- 하지만 Task.detached는 actor context를 상속하지 않기 때문에 클로저 내부에서 actor isolated 데이터인 flock 프로퍼티에 접근하려고 한다면, await 키워드를 사용해 isolated context에 접근해야만 한다.
Non-isolated
nonisolated 키워드는 Actor의 보호가 필요 없는 독립적인 함수에 명시하며, 외부에서도 await 없이 즉시 호출할 수 있다.
nonisolated 함수 내부에서 actor context에 접근할 땐, await 키워드를 사용해야 접근이 가능하다.
단, nonisolated async 코드는 항상 Cooperative Pool에서 실행된다.
이제껏 Actor에 관해 배운 내용을 정리하면, 아래와 같다.
- 각각의 🏝️ Actor 인스턴스는 완전히 독립적으로 동작한다.
- 한번에 하나의 ⛵️Task만 actor로 접근해 실행/처리가능하다.
- Actor에 진입하고 나올 때마다 Sendable 체크가 발생한다.
- Actor는 그 자체로 Sendable 하다.
@MainActor: UI를 담당하는 특별한 섬
@MainActor는 UI 작업을 위해 MainThread에서 실행되는 유일한 Actor이다.
- 뷰(View)나 뷰 컨트롤러(ViewController)와 같은 수많은 UI 작업이 이곳에서 수행되어야 한다.
- 일반 Actor처럼 한 번에 하나의 업무만 수행하므로, 너무 오래 걸리는 작업을 MainActor에서 수행하면 UI 응답이 지연(멈춤)될 수 있다.
@MainActor function/closure
특정 함수나 클로저에만 @MainActor 속성을 지정하여 해당 코드 구간만 메인 스레드에서 실행되도록 격리할 수 있다.
// 함수 에 @MainActor 지정 가능
@MainActor func updateView() {...}
// closure 에 @MainActor 지정 가능
Task { @MainActor in
// ...
view.selectedChicken = lily
}
예를 들어 non-isolated 함수에서 @MainActor 로 지정된 함수 호출하게 되면, 반드시 MainActor로 진입되므로 await 키워드가 필요하다.
// 어떤 Actor에도 격리되지 않은 외부 비동기 함수
nonisolated func computeAndUpdate() async {
computeNewValues()
await updateView() // MainActor로 진입해야 하므로 await 필수!
}
@MainActor Type
클래스(Class)나 구조체(Struct) 같은 타입에 @MainActor 속성을 지정할 수 있다. 이렇게 지정된 타입으로 생성된 인스턴스는 그 자체가 'UI를 담당하는 거대한 섬(MainActor)'에 완전히 소속되어 메인 스레드에서만 실행된다.
// 클래스 전체를 MainActor 섬에 격리 (자동으로 Sendable 만족)
@MainActor class ChickenViewModel {
var flock: [Chicken] = [] // MainActor 내에서만 접근 가능
func updateFlock() {
// UI 업데이트 로직 (항상 메인 스레드에서 안전하게 실행됨)
}
}
💡 실무 활용 팁
이 특성 덕분에 @MainActor는 앱의 UI 뷰(View), 뷰 컨트롤러(ViewController) 그리고 뷰모델(ViewModel)을 설계할 때 매우 유용할듯 하다. 예를 들어 뷰모델을 @MainActor로 지정하고 네트워크 요청 같은 무거운 비동기 작업을 구현하면, 네트워크 통신 결과를 UI에 반영할 때 별도의 스레드 전환 처리 없이도 안전하게 메인 스레드에서 UI 업데이트를 할 수 있을 것이다.
원자성(Atomicity)
원자성은 Actor는 한번에 하나의 Task 만 수행한다는 Actor의 특성이다.
Actor 내에서 실행되던 Task가 await을 만나면 다른 task 가 수행될 수 있다.
이 때, 아래 예시와 같은 상황에서 주의할 점이 있다.
// ❌ 잘못된 접근 (외부에서 수정 시도 -> 컴파일 에러 발생)
nonisolated func deposit(pineapples: [Pineapple], onto island: Island) async {
var food = await island.food // 1차 접근 (읽기)
food += pineapples // (이 틈에 해적이 들어와서 원래 섬의 파인애플을 훔쳐갈 수 있음!)
await island.food = food // 2차 접근 (쓰기) - 컴파일러가 에러로 차단함!
}
내가 두 번째 await을 실행하기 전에 island.food 값이 변경되었다면, 두 번째 await을 실행했을 때 이전 계산 값을 덮어써버리는 이슈가 발생하게 된다. 즉, 논리적 데이터 레이스(High-level Data Race)가 발생하게 된다.
Swift 컴파일러는 이러한 위험을 원천 차단하기 위해, 비격리 컨텍스트(non-isolated)에서는 Actor의 격리된 프로퍼티를 외부에서 마음대로 수정하지 못하도록 컴파일 에러를 발생시킨다.
actor-isolated 프로퍼티를 수정하려면 아래처럼 isolated context (즉, actor 내에서) 수정이 이루어져야한다.
// ⭕️ 올바른 접근 (Actor 내부의 동기 함수로 분리)
extension Island {
func deposit(pineapples: [Pineapple]) { // isolated 컨텍스트 (중단점 없음)
var food = self.food
food += pineapples
self.food = food
}
}
실행 순서 (Ordering)
종종 정해진 순서대로 이벤트를 처리해야하는 경우가 있다.
- 유저 인터랙션을 발생 순서대로 처리하거나
- 서버 response를 받은 순서대로 처리하고 싶은 경우
하지만 Actor는 우선순위 역전을 방지하기 위해 높은 우선순위 작업을 먼저 처리하므로 "Serial Dispatch Queue(FIFO 보장)"처럼 선입선출(FIFO)을 보장하지는 않는다.
그렇다면 Swift Concurrency에서 각각의 이벤트가 발생한 순서대로 처리되면 좋겠을 땐 어떻게 처리할 수 있을까?
Swift 는 이 기능을 지원하기 위해 다양한 tool 을 제공하고 있다.
- Task 이용하기 (Task 는 언제나 sequential 하게 동작한다)
- AsyncStream (for await event in stream) 사용하기
이벤트가 순서대로 처리되길 원한다면 Actor보다는 Task를 이용하거나 AsyncStream을 활용하는 것이 적합하다.
Data Race 완전 방지를 위한 점진적 검사
Swift는 작업 중단을 막기 위해 Data Race 안전성 검사를 점진적으로 적용하도록 권장한다.
Swift 5.7에서는 아래 옵션들 중 하나를 선택해 Sendability 를 얼마나 강하게 검토할건지를 직접 지정할 수 있다.
- Minimal:
Swift 5.5, 5.6과 동일하며 명시적인 시도에 대해서만 진단한다. (오류가 아닌 경고 표기) - Targeted:
async/await, actor 등을 이미 채택한 코드를 중심으로 검사한다. 아직 업데이트되지 않은 외부 프레임워크에서 발생하는 경고는 @preconcurrency import를 사용하여 임시로 무시할 수도 있다. (하지만 경고를 무시하기 위한 임시방편이다)
하지만 추후 framework 가 Sendable 을 지원하게 되면 @preconcurrency 를 제거해주어야 한다. 제거하지 않으면 다시 경고가 발생된다. - Completed:
Swift 6을 가정한 완전한 검사 모드로, 프로그램 전반의 잠재적인 Data Race를 모두 잡아낸다.
💡 Apple의 권장 사항: 한 번에 하나씩 모듈의 검사 단계를 높여가며 적용하고, 준비되지 않은 외부 모듈은 @preconcurrency로 대처하며 점진적으로 마이그레이션하기
유익했다면 댓글/공감 남겨주세요~~ 작성자에게 큰 힘이 됩니다 ☺️
'Swift' 카테고리의 다른 글
| Swift Concurrency #마무리 - 여러 번 보면서 이해한 Swift Concurrency (0) | 2026.03.02 |
|---|---|
| Swift Concurrency #7 - AsyncSequence와 AsyncStream (0) | 2026.03.02 |
| Swift Concurrency #5 - Concurrency와 Thread (0) | 2026.02.24 |
| Swift Concurrency #4 - Actor로 Mutable State 보호하기 (0) | 2026.02.24 |
| Swift Concurrency #3 - Task cancellation과 hierarchy 심화 (1) | 2026.01.14 |