"data race를 막는다"의 정확한 범위는?
actor가 막는 건 그 actor가 보호하는 isolated 상태에 대한 저수준 data race다. 경계 밖 접근은 컴파일러가 await(cross-actor)를 강제해 잡는다. 프로그램의 모든 race를 자동으로 막는 건 아니다.
"Task { }는 기본적으로 글로벌 스레드인가?"에서 시작해 nonisolated async의 실행 위치(SE-0338), Actor 프로토콜의 정의(serial executor), AnyObject가 왜 필요한지까지 문답 그대로 정리한 문서다.
카카오페이 — 계정 토큰 + Swift Concurrency
https://tech.kakaopay.com/post/account-token-swift-concurrency/
Task { }는 기본적으로 글로벌 스레드인가?아니다. Task { }(unstructured task)는 만들어진 곳의 actor 격리(isolation)를 상속한다. 글로벌 풀로 가는 건 "격리가 없는 곳에서 만들었을 때"뿐이다.
어디서 Task { }를 만들었나 | 본문이 도는 곳 |
|---|---|
@MainActor 컨텍스트 (ViewController, ViewModel 등) | 메인 스레드 (MainActor) |
어떤 actor 내부 | 그 actor 위 |
| nonisolated / 평범한 함수 | 글로벌 cooperative 스레드 풀 (백그라운드) |
Task { } | Task.detached { } | |
|---|---|---|
| actor 격리 | 상속함 | 상속 안 함 (항상 글로벌 풀) |
| priority | 상속함 | 상속 안 함 |
여기서 "글로벌 스레드"는 GCD의 DispatchQueue.global()이 아니라 Swift Concurrency의 global concurrent executor = cooperative thread pool(대략 CPU 코어 수)이다. await에서 스레드를 블로킹하지 않고 양보한다.
가장 헷갈리는 함정. @MainActor인 ViewController의 Task { } 안에서 호출해도, nonisolated async 함수는 호출자의 actor를 상속하지 않고 글로벌 executor로 점프한다.
| 호출 | 격리 | Thread.isMainThread |
|---|---|---|
ViewController.asyncFunc() | @MainActor 상속 | true |
SomeClass().asyncFunc() | nonisolated | false (내부 MainActor.run → true) |
SomeClass.staticAsyncFunc() | nonisolated static | false |
SomeClass().mainActorAsyncFunc() | @MainActor | true |
globalAsyncFunc() | 전역 nonisolated | false |
단 두 전제 위에서: ① UIViewController가 @MainActor(최신 SDK 충족), ② Swift 5.7+ (SE-0338 적용). "메인에서 만든 Task니까 안은 전부 메인"은 틀림 — nonisolated async를 await하는 순간 글로벌 풀로 hop하고, 리턴하면 다시 MainActor로 복귀한다.
Thread.isMainThread는 학습용 관찰엔 좋지만 동시성의 공식 보장 API가 아니다. "MainActor = 메인 스레드"는 보장되나, 글로벌 executor 스레드를 Thread로 들여다보는 건 구현 의존이다. 실무 판단은 actor 격리로 한다.
Actor 프로토콜의 정의"모든 actor type이 conform하는 공통 프로토콜이고, serial executor에서 동작해 data race를 막는다" — 맞다. 정확한 정의:
AnyObject 상속 → actor는 참조 타입Sendable 상속 → actor는 자동 Sendable (도메인 경계 넘어도 안전)unownedExecutor(SerialExecutor) → 각 actor는 자기 serial executor로 isolated 상태 접근을 직렬화 → data race 차단"data race를 막는다"의 정확한 범위는?
actor가 막는 건 그 actor가 보호하는 isolated 상태에 대한 저수준 data race다. 경계 밖 접근은 컴파일러가 await(cross-actor)를 강제해 잡는다. 프로그램의 모든 race를 자동으로 막는 건 아니다.
serial이면 완전 순차인가? (reentrancy)
serial executor는 한 번에 한 job만 실행한다. 하지만 await 지점에서 다른 task가 끼어들 수 있다(actor reentrancy). low-level data race는 없어도, await 전후로 상태가 바뀌어 있을 수 있다는 점은 별도로 주의한다.
@MainActor도 actor인가?
그렇다. @MainActor는 전역 actor(global actor)로, executor가 메인 스레드(main dispatch queue)일 뿐 같은 메커니즘이다.
executor를 바꿀 수 있나?
기본 serial executor 대신 custom executor를 지정할 수 있다(SE-0392, unownedExecutor 구현). 특정 큐/스레드에 묶고 싶을 때 사용.
AnyObject는 프로토콜인가? 왜 필요한가?네, 프로토콜이다 — "클래스(참조 타입) 전용"을 뜻하는 특수한 컴파일러 내장 프로토콜. 모든 class·actor 인스턴스가 자동 conform하고, struct/enum은 conform 못 한다. 옛 class 키워드 제약의 현대식 이름이다.
protocol Actor: AnyObject에서 왜 필요한가| 이유 | 설명 |
|---|---|
| 공유 상태 보호가 목적 | actor의 존재 이유 = 여러 task가 공유하는 가변 상태를 직렬화로 보호. 값 타입은 전달 시 복사되어 공유가 안 됨 → actor 개념 성립 불가 → 반드시 참조 타입 |
| 안정적 identity | 여러 곳에서 "같은 actor 인스턴스"를 가리켜야 함 → 참조 타입이라야 동일성 유지 |
weak/unowned 가능 | actor 참조의 retain cycle을 끊으려면 필요 → class-bound라야 가능 |
AnyObject = "참조 타입만"이라는 제약. actor는 공유 가변 상태 보호가 목적이라 반드시 참조 타입이어야 해서 AnyObject로 강제한다. AnyObject + Sendable = "공유되는 참조 타입인데 동시 접근해도 안전한 것" = actor의 정의.
맞다 — 올바른 서술적 표현이다. 다만 공식 용어로 나누면 두 가지다:
await에서 스스로 양보 → thread explosion 방지| cooperative thread pool (Swift Concurrency) | GCD DispatchQueue.global() | |
|---|---|---|
| 스레드 수 | 코어 수로 제한 | 필요 시 증가(thread explosion 가능) |
| 블로킹 | await로 양보(non-blocking) | 블로킹 가능 |
"Task는 글로벌 스레드"의 "글로벌"은 GCD global queue가 아니라 이 cooperative thread pool을 가리킨다. 엄밀히: 풀 = cooperative thread pool, 그 풀을 쓰는 executor = global(default) concurrent executor.
Task { }는 무조건 백그라운드? → 아니다. 만든 곳의 actor 격리를 상속Task.detached (격리·priority 상속 안 함)Actor 프로토콜 정의? → AnyObject, Sendable + unownedExecutor(SerialExecutor)AnyObject는? → "참조 타입만" 제약 프로토콜. actor는 공유 상태 보호 목적이라 필수