"Swift 6에서 붙이는 어노테이션 하나하나가 옛 빌드가 조용히 가정하던 사실이야."
세 단어
- actor는 변하는 상태를 가지고 있고, 거기 접근하는 걸 한 줄로 세워. 밖에서 들어올 땐
await를 붙이고, 안에선 메서드가 하나씩 돌아.@MainActor는 네가 이미 아는 전역 액터야. "메인 스레드"를 타입으로 만든 거지. Sendable은 다른 격리 영역으로 넘겨도 안전한 값에 붙는 표시야. 필드가 전부Sendable인 구조체랑 enum은public만 아니면Sendable로 추론돼. actor는 언제나 그렇고. 변하지 않는Sendable상태만 가진final클래스도Sendable이 될 수 있지만, 준수를 직접 선언해야만 돼.@unchecked Sendable은 컴파일러 대신 네가 하는 약속이야. 락으로 상태를 지키는 작은 타입이면 정당하고, 그 밖엔 거짓말이야.
네 코드 안에선 이 셋이 깔끔하게 맞물려. 문제는 Swift 동시성이 생기기 전에 설계된 프레임워크와 만나는 경계야. 가족 앱에서 계속 되풀이되는 패턴이 세 가지 있어.
1. 옛 프로토콜을 따르는 메인 액터 클래스
파일 브라우저의 @MainActor 뷰 컨트롤러가 NSServicesMenuRequestor를 채택하자 Swift 6가 거부했어. 준수가 메인 액터 격리 코드로 넘어가서 데이터 경합을 일으킬 수 있다는 이유였어. AppKit이 어디서 부르는지 프로토콜엔 안 적혀 있으니까, 컴파일러는 메인 스레드라고 가정할 수 없어. 넌 가정할 수 있지. AppKit은 늘 메인 스레드에서 부르니까. 그걸 준수 자체에 적어. extension LibraryController: @MainActor NSServicesMenuRequestor. 이게 격리된 준수야. @preconcurrency는 그 질문을 런타임 검사로 바꾸는 대안이고.
2. 늘 메인으로 오는 격리 안 된 델리게이트
터미널 라이브러리의 델리게이트 프로토콜은 격리 없이 선언돼 있었는데, 라이브러리는 콜백을 전부 메인 큐로 보냈어. Mac 터미널 앱은 준수 메서드를 nonisolated로 선언하고 본문마다 MainActor.assumeIsolated { … }로 감쌌어. 배달 약속을 코드에 적은 거고, 런타임이 그 약속을 검사해. 격리된 준수는 컴파일러가 검사하고, 런타임엔 동적 캐스트로 찾을 때만 검사해(자기 actor 밖에선 그 캐스트가 실패해). 그 점이 달라. 같은 앱에서 얻은 주의점도 하나 있어. 아주 자주 불리는 마우스 이동 이벤트 모니터 안에서 assumeIsolated를 불렀더니 Swift 6.3 런타임에서 크래시가 났어. 거기선 그냥 Task { @MainActor in … }로 넘어가는 게 안전했어. 그리고 NSEvent는 Sendable이 아니라서 assumeIsolated 클로저가 이벤트를 그대로 돌려줄 수 없어. 클로저는 이벤트를 삼킬지만 Bool로 알려주고, 이벤트를 넘길지 nil을 돌려줄지는 모니터가 클로저 밖에서 정해.
3. 동기 프로토콜 요구사항 뒤에 숨은 actor
iOS 클라이언트 네트워크 계층이 actor였는데, 따르는 프로토콜의 요구사항이 AsyncThrowingStream을 돌려주는 동기 함수였어. actor 메서드는 격리돼 있고, 격리된 메서드는 동기 요구사항을 만족시킬 수 없어. "준수가 actor 격리 코드로 넘어간다"는 에러가 나. 고치는 법은 준수 메서드를 nonisolated로 만들고, actor 상태는 스트림 태스크 안에서 격리된 private 헬퍼로만 건드리는 거야. Sendable 타입의 변하지 않는 let은 격리 안 된 메서드에서도 읽을 수 있어.