← 홈으로 돌아가기
Concurrency · Actor & Task Q&A

Actor 프로토콜 · Task Isolation · nonisolated async

"Task { }는 기본적으로 글로벌 스레드인가?"에서 시작해 nonisolated async의 실행 위치(SE-0338), Actor 프로토콜의 정의(serial executor), AnyObject가 왜 필요한지까지 문답 그대로 정리한 문서다.

Confirmed Swift 5.7+ / SE-0338 자주 나옴
📖 함께 읽은 자료

카카오페이 — 계정 토큰 + Swift Concurrency
https://tech.kakaopay.com/post/account-token-swift-concurrency/

1. Task { }는 기본적으로 글로벌 스레드인가?

아니다. Task { }(unstructured task)는 만들어진 곳의 actor 격리(isolation)를 상속한다. 글로벌 풀로 가는 건 "격리가 없는 곳에서 만들었을 때"뿐이다.

어디서 Task { }를 만들었나본문이 도는 곳
@MainActor 컨텍스트 (ViewController, ViewModel 등)메인 스레드 (MainActor)
어떤 actor 내부actor
nonisolated / 평범한 함수글로벌 cooperative 스레드 풀 (백그라운드)
@MainActor class ViewModel { func load() { Task { updateUI() } // @MainActor 상속 → 메인 스레드 Task.detached { ... } // ❌ 상속 안 함 → 무조건 글로벌 풀 } }
Task { }Task.detached { }
actor 격리상속함상속 안 함 (항상 글로벌 풀)
priority상속함상속 안 함
용어 정정

여기서 "글로벌 스레드"는 GCD의 DispatchQueue.global()이 아니라 Swift Concurrency의 global concurrent executor = cooperative thread pool(대략 CPU 코어 수)이다. await에서 스레드를 블로킹하지 않고 양보한다.

2. nonisolated async는 호출자를 따라가지 않는다 (SE-0338)

가장 헷갈리는 함정. @MainActor인 ViewController의 Task { } 안에서 호출해도, nonisolated async 함수는 호출자의 actor를 상속하지 않고 글로벌 executor로 점프한다.

func globalAsyncFunc() async { print(Thread.isMainThread) } // false class SomeClass { // 평범한 class → nonisolated func asyncFunc() async { print(Thread.isMainThread) // false (글로벌 풀) await MainActor.run { print(Thread.isMainThread) } // true (명시 hop) } static func staticAsyncFunc() async { print(Thread.isMainThread) } // false @MainActor func mainActorAsyncFunc() async { print(Thread.isMainThread) } // true } class ViewController: UIViewController { // UIViewController = @MainActor func asyncFunc() async { print(Thread.isMainThread) } // true (@MainActor 상속) override func viewDidLoad() { super.viewDidLoad() Task { // MainActor 상속 → 메인에서 시작 await asyncFunc() // @MainActor → true await SomeClass().asyncFunc() // nonisolated → false (글로벌로 hop) await SomeClass.staticAsyncFunc() // nonisolated → false await SomeClass().mainActorAsyncFunc() // @MainActor → true (메인 복귀) await globalAsyncFunc() // nonisolated → false } } }
호출격리Thread.isMainThread
ViewController.asyncFunc()@MainActor 상속true
SomeClass().asyncFunc()nonisolatedfalse (내부 MainActor.run → true)
SomeClass.staticAsyncFunc()nonisolated staticfalse
SomeClass().mainActorAsyncFunc()@MainActortrue
globalAsyncFunc()전역 nonisolatedfalse
위 예제의 예측값은 전부 맞다.

단 두 전제 위에서: ① UIViewController@MainActor(최신 SDK 충족), ② Swift 5.7+ (SE-0338 적용). "메인에서 만든 Task니까 안은 전부 메인"은 틀림 — nonisolated async를 await하는 순간 글로벌 풀로 hop하고, 리턴하면 다시 MainActor로 복귀한다.

주의

Thread.isMainThread는 학습용 관찰엔 좋지만 동시성의 공식 보장 API가 아니다. "MainActor = 메인 스레드"는 보장되나, 글로벌 executor 스레드를 Thread로 들여다보는 건 구현 의존이다. 실무 판단은 actor 격리로 한다.

3. Actor 프로토콜의 정의

"모든 actor type이 conform하는 공통 프로토콜이고, serial executor에서 동작해 data race를 막는다" — 맞다. 정확한 정의:

public protocol Actor: AnyObject, Sendable { nonisolated var unownedExecutor: UnownedSerialExecutor { get } }

"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 구현). 특정 큐/스레드에 묶고 싶을 때 사용.

4. AnyObject는 프로토콜인가? 왜 필요한가?

네, 프로토콜이다 — "클래스(참조 타입) 전용"을 뜻하는 특수한 컴파일러 내장 프로토콜. 모든 class·actor 인스턴스가 자동 conform하고, struct/enum은 conform 못 한다. 옛 class 키워드 제약의 현대식 이름이다.

var anything: AnyObject = SomeClass() // (1) 타입으로 — "아무 객체나" protocol MyDelegate: AnyObject { } // (2) 제약으로 — class만 채택 (weak 가능)

protocol Actor: AnyObject에서 왜 필요한가

이유설명
공유 상태 보호가 목적actor의 존재 이유 = 여러 task가 공유하는 가변 상태를 직렬화로 보호. 값 타입은 전달 시 복사되어 공유가 안 됨 → actor 개념 성립 불가 → 반드시 참조 타입
안정적 identity여러 곳에서 "같은 actor 인스턴스"를 가리켜야 함 → 참조 타입이라야 동일성 유지
weak/unowned 가능actor 참조의 retain cycle을 끊으려면 필요 → class-bound라야 가능
한 줄

AnyObject = "참조 타입만"이라는 제약. actor는 공유 가변 상태 보호가 목적이라 반드시 참조 타입이어야 해서 AnyObject로 강제한다. AnyObject + Sendable = "공유되는 참조 타입인데 동시 접근해도 안전한 것" = actor의 정의.

5. "global cooperative thread pool" 용어가 맞나?

맞다 — 올바른 서술적 표현이다. 다만 공식 용어로 나누면 두 가지다:

nonisolated async / Task.detached → global concurrent executor (default) // "어디 올릴지" 결정 → cooperative thread pool (코어 수만큼) // 실제 도는 곳
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.

복습 체크리스트 ✅

관련 문서 (학습 경로)