본문 바로가기
C.W.K.
Stream
Lesson 03 of 07 · published

프레임워크 경계에서 만나는 actor, @MainActor, Sendable

~17 min · concurrency, actors, sendable, isolated-conformance, nonisolated

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"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 … }로 넘어가는 게 안전했어. 그리고 NSEventSendable이 아니라서 assumeIsolated 클로저가 이벤트를 그대로 돌려줄 수 없어. 클로저는 이벤트를 삼킬지만 Bool로 알려주고, 이벤트를 넘길지 nil을 돌려줄지는 모니터가 클로저 밖에서 정해.

3. 동기 프로토콜 요구사항 뒤에 숨은 actor

iOS 클라이언트 네트워크 계층이 actor였는데, 따르는 프로토콜의 요구사항이 AsyncThrowingStream을 돌려주는 동기 함수였어. actor 메서드는 격리돼 있고, 격리된 메서드는 동기 요구사항을 만족시킬 수 없어. "준수가 actor 격리 코드로 넘어간다"는 에러가 나. 고치는 법은 준수 메서드를 nonisolated로 만들고, actor 상태는 스트림 태스크 안에서 격리된 private 헬퍼로만 건드리는 거야. Sendable 타입의 변하지 않는 let은 격리 안 된 메서드에서도 읽을 수 있어.

Code

격리된 준수: 보장은 준수 선언에 적어·swift
import AppKit

@MainActor
final class LibraryController: NSViewController {
    var selection: [URL] = []
}

// Without "@MainActor" here, Swift 6 reports:
// conformance of 'LibraryController' to protocol 'NSServicesMenuRequestor'
// crosses into main actor-isolated code and can cause data races
extension LibraryController: @MainActor NSServicesMenuRequestor {
    func writeSelection(to pboard: NSPasteboard, types: [NSPasteboard.PasteboardType]) -> Bool {
        pboard.writeObjects(selection as [NSURL])
    }
}
동기 요구사항 뒤의 actor: 격리 안 된 준수, 격리된 헬퍼·swift
import Foundation

public protocol CaptureStreaming: Sendable {
    func stream(_ id: String) -> AsyncThrowingStream<String, Error>
}

public actor CaptureClient: CaptureStreaming {
    private var token = "t0"

    public nonisolated func stream(_ id: String) -> AsyncThrowingStream<String, Error> {
        AsyncThrowingStream { continuation in
            let task = Task {
                let token = await self.currentToken()     // actor state, through the actor
                continuation.yield("\(id) with \(token)")
                continuation.finish()
            }
            continuation.onTermination = { _ in task.cancel() }
        }
    }

    private func currentToken() -> String { token }
}

External links

Exercise

Spark에서 네가 고른 AppKit 델리게이트 프로토콜(NSServicesMenuRequestor는 진단이 나오고, NSWindowDelegate는 SDK가 이미 메인 액터로 표시해서 이제 안 나와)을 따르는 @MainActor 창 컨트롤러를 만들고 Swift 6 모드로 빌드해. 진단이 나오면 적고, 격리된 준수로 고쳐. 다음으로 동기 프로토콜 요구사항을 만족해야 하는 actor CaptureClient를 만들고, 거기서 나는 에러를 nonisolated 준수랑 격리된 헬퍼로 고쳐봐.
Hint
고칠 때마다 그 수정이 담고 있는 실제 보장을 한 문장으로 적어. 예를 들면 "AppKit은 이걸 메인에서 부른다"나 "이 메서드는 스트림만 내주고, 상태 접근은 전부 actor에서 일어난다" 같은 거. 보장을 말로 못 하면 그 어노테이션은 짐작일 뿐이야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.