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

Swift 6.2의 새 기본값: 메인 액터 먼저, 동시성은 일부러

~16 min · concurrency, swift-6-2, default-isolation, nonisolated-nonsending, concurrent

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"단일 스레드로 시작해. 빠져나갈 거면 일부러 빠져나가고, 그걸 타입에 적어."

Swift 6.2에서 바뀐 것

2025년 9월에 나온 Swift 6.2는 Swift 6에 대한 제일 흔한 불만에 답했어. 진짜 병렬 처리도 없는 작은 앱이 격리 에러에 파묻힌다는 불만이었지. 그래서 서로 이어진 세 가지를 넣었고, 이걸 묶어서 approachable concurrency라고 불러.

  • 기본 액터 격리. 모듈 하나가 "격리를 따로 안 적은 선언은 전부 @MainActor"라고 선언할 수 있어. SwiftPM에선 .defaultIsolation(MainActor.self), 컴파일러 명령줄에선 -default-isolation MainActor야. 예외는 상식적이야. actor, 격리를 명시한 선언, 슈퍼클래스나 프로토콜에서 격리를 물려받는 선언은 원래 격리를 그대로 유지해.
  • nonisolated(nonsending). 6.2 전엔 nonisolated async 함수가 무조건 전역 동시 실행자로 넘어갔어. NonisolatedNonsendingByDefault 예정 기능을 켜면 그 대신 호출한 쪽의 격리에서 돌아. 메인 액터에서 부르면 메인 액터에 그대로 머물러.
  • @concurrent. 명시적인 출구야. @concurrent가 붙은 함수는 누가 불렀든 그 액터를 떠나 동시 실행자에서 돌아. 무거운 디코딩, 이미지 처리, 스캔에 이걸 눈에 보이게 붙여.

Xcode 26은 앱 프로젝트에 이걸 켜놔. SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, SWIFT_APPROACHABLE_CONCURRENCY = YES. 기존 프로젝트는 네가 바꾸기 전까지 옛 설정을 유지해.

경고 스위치가 아니라 뜻이 바뀌는 설정이야

기존 모듈에서 기본 격리를 뒤집으면 코드의 이 바뀌어. 격리 안 돼 있던 헬퍼가 메인 액터에 격리되니까, 백그라운드에서 부르는 쪽은 이제 await를 붙여야 해. nonisolated(nonsending)을 켜면 비동기 코드가 어디서 도는지가 바뀌어. 메인 액터를 떠날 줄 알았던 async 함수가 이제 거기 머물러. 그래서 그 안의 긴 동기 구간은 @concurrent를 붙이기 전까진 UI를 막아. 둘 다 호출하는 곳에선 티가 안 나. 언어 모드를 바꿀 때만큼 조심해서, 타깃마다 마이그레이션으로 다뤄.

가족은 이렇게 받아들여

가족 패키지는 이 기본값이 생기기 전에 쓰여서 격리를 다 적어뒀어. UI 타입엔 @MainActor, 공유 상태엔 actor, 프레임워크 콜백이 오는 곳엔 nonisolated@Sendable. 그 방식은 6.2에서도 여전히 맞아. UI가 많은 새 앱 타깃(Spark의 Mac 앱이나 아이폰 앱처럼)이면 메인 액터 기본 격리가 현실에 맞아. 거의 모든 코드가 UI를 건드리니까. 앱, 확장, 워치 앱이 같이 쓰는 공유 코어 라이브러리는 격리 없는 기본값과 명시적 어노테이션을 그대로 유지해. 여러 문맥에서 도는 코드라서, 슬그머니 한 스레드에 묶이면 안 되거든.

Code

Package.swift: UI 타깃에만 메인 액터 기본값·swift
// swift-tools-version: 6.2
import PackageDescription

let package = Package(
    name: "Spark",
    platforms: [.macOS(.v14), .iOS(.v17), .watchOS(.v10)],
    targets: [
        .target(name: "SparkCore"),                 // runs everywhere: explicit isolation
        .target(
            name: "SparkUI",
            dependencies: ["SparkCore"],
            swiftSettings: [
                .defaultIsolation(MainActor.self),
                .enableUpcomingFeature("NonisolatedNonsendingByDefault"),
            ]
        ),
    ],
    swiftLanguageModes: [.v6]
)
SparkUI 안: 암묵적 @MainActor, 명시적 @concurrent·swift
import SparkCore

final class ScreenModel {          // implicitly @MainActor through defaultIsolation
    var title = "Spark"
    func rename(_ newTitle: String) { title = newTitle }
}

@concurrent
func checksum(of bytes: [UInt8]) async -> Int {   // leaves the caller's actor, on purpose
    bytes.reduce(0) { ($0 &* 31) &+ Int($1) }
}

func refresh(_ model: ScreenModel, bytes: [UInt8]) async {
    let sum = await checksum(of: bytes)            // off the main actor
    model.rename("Spark \(sum)")                   // back on it
}

External links

Exercise

위 매니페스트대로 Spark를 두 타깃으로 나눠. 하나는 기본 격리가 없는 SparkCore, 다른 하나는 메인 액터 기본 격리에 nonisolated(nonsending)까지 켠 SparkUI야. SparkUI에 일부러 느린 동기 루프를 도는 async 함수를 하나 쓰고, 뷰 모델에서 불러서 메인 액터가 막히는 걸 관찰해. 다음으로 @concurrent를 붙이고 차이를 봐. SparkCore는 왜 같은 기본값을 쓰면 안 되는지 두 문장으로 써봐.
Hint
Swift 6에선 async 함수 안에서 Thread.isMainThread를 못 써. 그러니 작은 동기 헬퍼 nonisolated func runningOnMainThread() -> Bool { Thread.isMainThread }를 만들어서, @concurrent 붙이기 전후로 느린 함수 안에서 불러봐. nonsending 규칙에 메인 액터 기본값까지 켜져 있으면, 메인 액터에서 부른 평범한 async 함수는 메인 스레드에서 돌아.

Progress

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

댓글 0

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

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