Swift 6.2 Approachable Concurrency 완벽 가이드: @concurrent, nonisolated(nonsending), 기본 액터 격리

Swift 6.2의 Approachable Concurrency를 실전 코드와 함께 해부합니다. nonisolated(nonsending), @concurrent, 기본 MainActor 격리, Task.immediate, 우선순위 에스컬레이션까지 — 단계별 마이그레이션 전략으로 Swift 동시성의 새 패러다임을 마스터하세요.

Swift 동시성(Concurrency) 모델은 Swift 5.5에서 처음 도입된 이래 꾸준히 발전해 왔습니다. 하지만 솔직히 말해서, 많은 개발자들이 데이터 레이스 안전성(data-race safety)을 달성하기 위해 써야 하는 어노테이션과 개념의 양에 압도당했던 것도 사실이에요. Sendable, @Sendable, sending, nonisolated, 액터 격리... 단순한 네트워크 호출 하나에도 컴파일러 경고가 줄줄이 뜨는 경험, 한 번쯤 해보셨죠?

Swift 6.2는 이 문제에 정면으로 도전합니다. "Approachable Concurrency(접근하기 쉬운 동시성)"이라는 철학 아래, 동시성을 훨씬 직관적이고 단계적으로 학습할 수 있도록 언어를 재설계했거든요.

핵심 아이디어는 점진적 공개(progressive disclosure)입니다. 순차적인 코드를 작성할 때는 동시성에 대해 전혀 알 필요가 없고, async/await를 사용할 때도 최소한의 개념만 이해하면 됩니다. 의도적으로 병렬 처리를 도입할 때에만 액터와 Sendable 등을 고민하면 되는 구조죠.

이 글에서는 Swift 6.2의 Approachable Concurrency를 구성하는 핵심 기능들을 하나하나 살펴보겠습니다. nonisolated(nonsending)의 동작 원리부터 @concurrent 속성의 역할, 기본 액터 격리(Default Actor Isolation), Task 이름 지정, Task.immediate, 우선순위 에스컬레이션까지 — 실전 코드 예제와 함께 정리해 봤습니다.

왜 Approachable Concurrency가 필요했을까?

Swift 6.0과 6.1에서 strict concurrency checking을 활성화해 본 적 있으신가요? 프로젝트 전체에 쏟아지는 경고와 에러의 양에 놀랐을 겁니다. 특히 문제가 되었던 부분은 nonisolated async 함수의 동작이었어요.

Swift 6.1까지의 문제점

Swift 6.1에서 nonisolated 동기 함수는 호출자의 액터에서 실행되었습니다. 이건 직관적이고 자연스럽죠.

그런데 nonisolated async 함수는 완전히 다르게 동작했습니다. 글로벌 실행기(global executor)로 자동 전환되어 백그라운드 스레드에서 실행됐거든요. 이게 문제의 시작이었습니다.

// Swift 6.1의 문제 상황
@MainActor
class ViewModel {
    let networkClient = NetworkClient()

    func loadData() async {
        // 컴파일 에러!
        // 'sending' main actor-isolated 'self.networkClient'
        // to nonisolated instance method risks causing data races
        let photos = try? await networkClient.fetchPhotos()
    }
}

class NetworkClient {
    // 이 함수는 nonisolated async이므로 글로벌 실행기에서 실행됨
    // MainActor에서 호출하면 networkClient를 다른 격리 도메인으로
    // "보내는(sending)" 것이 되어 데이터 레이스 위험 발생
    func fetchPhotos() async throws -> [Photo] {
        let (data, _) = try await URLSession.shared.data(from: photosURL)
        return try JSONDecoder().decode([Photo].self, from: data)
    }
}

이 코드에서 fetchPhotos()는 비동기 함수이므로 글로벌 실행기에서 실행됩니다. 그런데 networkClientSendable이 아닌데 메인 액터에서 다른 격리 도메인으로 넘어가니까, 컴파일러가 데이터 레이스 가능성을 감지하고 에러를 뱉는 거예요. 함수 자체는 전혀 위험한 일을 하지 않는데도요.

이런 거짓 양성(false positive) 진단이야말로 Swift 동시성 모델의 가장 큰 진입 장벽이었습니다. 개발자들은 실제로 문제가 없는 코드에 @Sendable, @unchecked Sendable, nonisolated(unsafe) 같은 어노테이션을 덕지덕지 붙여야 했고, 솔직히 이건 코드를 읽기 어렵게 만들 뿐이었죠.

nonisolated(nonsending): 비동기 함수의 게임 체인저

Swift 6.2의 가장 핵심적인 변화는 SE-0461에서 도입된 nonisolated(nonsending)입니다. 이 기능을 활성화하면, nonisolated async 함수가 호출자의 격리 컨텍스트를 상속받아 실행돼요.

동작 원리

이전에는 nonisolated async 함수가 항상 글로벌 실행기로 전환되었습니다. 하지만 NonisolatedNonsendingByDefault 기능 플래그를 활성화하면 이 함수들이 호출자의 액터에서 그대로 실행됩니다. 메인 액터에서 호출하면 메인 액터에서, 다른 액터에서 호출하면 해당 액터에서 실행되는 거죠. 정말 직관적이지 않나요?

// Swift 6.2에서는 이 코드가 문제없이 컴파일됩니다!
@MainActor
class ViewModel {
    let networkClient = NetworkClient()

    func loadData() async {
        // networkClient가 다른 격리 도메인으로 전송되지 않음
        // fetchPhotos()가 호출자(메인 액터)의 컨텍스트에서 실행되기 때문
        let photos = try? await networkClient.fetchPhotos()
    }
}

class NetworkClient {
    // nonisolated(nonsending) 기본 동작:
    // 호출자의 격리 컨텍스트를 상속받아 실행
    func fetchPhotos() async throws -> [Photo] {
        let (data, _) = try await URLSession.shared.data(from: photosURL)
        return try JSONDecoder().decode([Photo].self, from: data)
    }
}

fetchPhotos()가 메인 액터에서 호출되면 메인 액터에서 실행됩니다. URLSession.shared.data(from:)에서 await 포인트를 만나면 메인 액터가 일시적으로 해제되어 다른 작업을 처리할 수 있고, 네트워크 응답이 돌아오면 다시 메인 액터에서 디코딩이 이루어져요.

명시적으로 nonisolated(nonsending) 선언하기

기능 플래그를 활성화하지 않더라도, 개별 함수에 명시적으로 nonisolated(nonsending)을 적용할 수 있습니다. 점진적으로 도입하고 싶을 때 유용하죠.

class DataManager {
    // 명시적으로 호출자 격리 컨텍스트 상속을 선언
    nonisolated(nonsending) func processData() async throws -> ProcessedData {
        let rawData = try await fetchRawData()
        return transform(rawData)
    }
}

기능 플래그 활성화 방법

Swift Package Manager를 사용하고 계시다면, Package.swift에서 다음과 같이 활성화할 수 있습니다.

// Package.swift
let package = Package(
    name: "MyApp",
    platforms: [.iOS(.v18)],
    targets: [
        .executableTarget(
            name: "MyApp",
            swiftSettings: [
                .enableUpcomingFeature("NonisolatedNonsendingByDefault")
            ]
        )
    ]
)

Xcode 프로젝트에서는 Build Settings > "Upcoming Features" 섹션에서 Nonisolated Nonsending By DefaultYes로 설정하면 됩니다.

@concurrent: "이건 진짜 백그라운드에서 돌려주세요"

nonisolated(nonsending)이 기본이 되면, 자연스럽게 이런 의문이 들겠죠. "그러면 진짜로 백그라운드에서 실행하고 싶은 함수는 어떻게 하나요?"

바로 이 질문에 대한 답이 @concurrent 속성입니다.

@concurrent의 역할

@concurrent는 Swift 6.2에서 새롭게 도입된 선언 속성으로, 해당 함수가 호출자의 격리 컨텍스트에서 벗어나 글로벌 실행기에서 실행되어야 함을 명시적으로 선언합니다. 이전에 모든 nonisolated async 함수가 기본적으로 하던 동작을, 이제는 의도적으로 선택하는 겁니다.

@MainActor
class ImageProcessor {
    // 이 함수는 CPU 집약적이므로 메인 액터를 블로킹하면 안 됨
    // @concurrent로 명시적으로 백그라운드 실행을 선언
    @concurrent
    nonisolated func processImage(_ data: Data) async throws -> UIImage {
        // 무거운 이미지 처리 작업
        let ciImage = CIImage(data: data)!
        let filter = CIFilter(name: "CIGaussianBlur")!
        filter.setValue(ciImage, forKey: kCIInputImageKey)
        filter.setValue(10.0, forKey: kCIInputRadiusKey)

        let context = CIContext()
        let outputImage = filter.outputImage!
        let cgImage = context.createCGImage(outputImage, from: outputImage.extent)!

        return UIImage(cgImage: cgImage)
    }

    func applyFilter(to imageData: Data) async {
        // processImage는 @concurrent이므로 글로벌 실행기에서 실행
        // 메인 액터는 블로킹되지 않음
        let processed = try? await processImage(imageData)
        // 결과는 다시 메인 액터에서 처리
        self.displayImage = processed
    }
}

@concurrent를 사용해야 하는 경우

@concurrent는 다음과 같은 상황에서 사용하면 됩니다.

  • CPU 집약적 작업: JSON 디코딩, 이미지 처리, 복잡한 알고리즘 실행 등 호출자의 액터(특히 메인 액터)를 오래 점유하면 안 되는 작업
  • 독립적 연산: 호출자의 상태에 접근할 필요 없이, 입력 데이터를 받아 결과를 반환하는 순수한 연산
  • 병렬 처리가 필요한 경우: 여러 작업을 동시에 실행해서 처리량을 높여야 하는 상황
class DataParser {
    // JSON 디코딩은 CPU 집약적 → @concurrent 적합
    @concurrent
    nonisolated func decode(_ data: Data, as type: T.Type) async throws -> T {
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        return try decoder.decode(type, from: data)
    }

    // 단순 URL 구성은 가벼운 작업 → @concurrent 불필요
    func buildURL(for endpoint: String) async -> URL? {
        var components = URLComponents()
        components.scheme = "https"
        components.host = "api.example.com"
        components.path = "/v2/\(endpoint)"
        return components.url
    }
}

@concurrent 사용 시 주의사항

한 가지 중요한 점 — @concurrent를 남용하지 마세요. 대부분의 앱에서는 실제로 성능 문제가 있을 때만 @concurrent를 도입해야 합니다. 단순한 네트워크 호출은 이미 await 포인트에서 액터를 양보하기 때문에, 굳이 @concurrent를 붙일 필요가 없어요.

그리고 @concurrentnonisolated인 함수에만 적용할 수 있습니다. 이미 특정 액터에 격리된 함수에는 사용할 수 없죠.

// 올바른 사용
@concurrent
nonisolated func heavyComputation() async -> Result { ... }

// 컴파일 에러! @MainActor와 @concurrent는 함께 사용 불가
@concurrent
@MainActor func updateUI() async { ... }

기본 액터 격리: 더 이상 @MainActor를 붙이고 다니지 않아도 됩니다

SE-0466에서 도입된 기본 액터 격리는 Approachable Concurrency의 또 다른 핵심 축입니다. 이 기능을 사용하면 프로젝트의 모든 코드가 기본적으로 @MainActor에 격리되어, 별도의 어노테이션 없이도 메인 스레드에서 실행돼요.

왜 MainActor를 기본값으로?

iOS, macOS 앱 개발에서 대부분의 코드는 UI와 관련되어 있고, UI 업데이트는 반드시 메인 스레드에서 이루어져야 합니다. 실제로 많은 프로젝트에서 거의 모든 타입에 @MainActor를 일일이 붙이고 있었잖아요. (저도 그랬습니다.) 기본 격리를 MainActor로 설정하면 이런 반복적인 어노테이션을 완전히 생략할 수 있습니다.

// Package.swift에서 기본 격리 설정
.executableTarget(
    name: "MyApp",
    swiftSettings: [
        .defaultIsolation(MainActor.self),
        .enableUpcomingFeature("NonisolatedNonsendingByDefault")
    ]
)

기본 격리 활성화 후의 변화

기본 격리가 활성화되면, 모든 클래스, 구조체, 열거형의 메서드가 암시적으로 @MainActor로 격리됩니다. 명시적 어노테이션이 필요 없어지는 거죠.

// defaultIsolation(MainActor.self) 활성화 상태

// 이 클래스는 자동으로 @MainActor에 격리됨
class UserViewModel: ObservableObject {
    @Published var userName: String = ""
    @Published var isLoading: Bool = false

    // 이 함수도 자동으로 @MainActor
    func loadUser() async {
        isLoading = true
        let user = try? await fetchUser()
        userName = user?.name ?? "Unknown"
        isLoading = false
    }
}

// SwiftUI 뷰도 자연스럽게 작동
struct UserView: View {
    @StateObject var viewModel = UserViewModel()

    var body: some View {
        VStack {
            if viewModel.isLoading {
                ProgressView()
            } else {
                Text(viewModel.userName)
            }
        }
        .task { await viewModel.loadUser() }
    }
}

격리에서 벗어나기: nonisolated

기본 격리에서 특정 함수나 타입을 제외하고 싶다면 nonisolated 키워드를 사용하면 됩니다.

// 기본 격리가 MainActor이지만, 이 구조체는 격리되지 않음
nonisolated struct MathHelper {
    static func fibonacci(_ n: Int) -> Int {
        guard n > 1 else { return n }
        return fibonacci(n - 1) + fibonacci(n - 2)
    }
}

class SomeViewModel {
    // 특정 메서드만 격리에서 제외
    nonisolated func pureCalculation(_ values: [Double]) -> Double {
        values.reduce(0, +) / Double(values.count)
    }
}

Xcode 26에서의 기본 설정

참고로 Xcode 26에서 새로 생성하는 프로젝트는 기본적으로 Default Actor Isolation이 MainActor로 설정됩니다. Apple이 이 방향을 공식적으로 권장하고 있다는 명확한 신호죠. 다만 NonisolatedNonsendingByDefault는 기본적으로 비활성화 상태이므로 별도로 켜줘야 합니다.

5가지 Upcoming Feature 플래그 한눈에 보기

Approachable Concurrency는 단일 기능이 아닙니다. 5가지 Upcoming Feature 플래그의 조합으로 이루어져 있어요. 각각 어떤 역할을 하는지 살펴보겠습니다.

1. DisableOutwardActorInference (SE-0401)

프로퍼티 래퍼가 가진 글로벌 액터 격리가 해당 프로퍼티 래퍼를 사용하는 타입 전체로 자동 전파되는 것을 방지합니다. 예를 들어, @StateObject 프로퍼티 래퍼가 @MainActor이라고 해서 이를 사용하는 뷰 전체가 자동으로 @MainActor가 되지 않도록 하는 거죠.

// 이 플래그가 없으면 @StateObject의 @MainActor가
// 전체 뷰에 암시적으로 적용될 수 있었음
// 플래그 활성화 후에는 명시적 선언이 필요
@MainActor
struct ContentView: View {
    @StateObject var viewModel = ContentViewModel()
    // ...
}

2. GlobalActorIsolatedTypesUsability (SE-0434)

글로벌 액터로 격리된 타입을 더 쉽게 사용할 수 있게 해줍니다. Sendable 프로퍼티에 대한 접근이 간소화되고, 격리된 클로저에서 비Sendable 값의 캡처가 안전한 경우 허용됩니다.

@MainActor
class UserSettings {
    let maxRetryCount = 3  // Sendable이고 let이므로
    var theme: Theme = .light
}

// 다른 격리 도메인에서도 Sendable 상수에 접근 가능
@concurrent
nonisolated func getMaxRetries(_ settings: UserSettings) -> Int {
    return settings.maxRetryCount  // SE-0434 덕분에 가능
}

3. InferIsolatedConformances (SE-0470)

글로벌 액터로 격리된 타입이 자신의 액터 컨텍스트 내에서 프로토콜을 준수할 수 있게 합니다.

// @MainActor 클래스가 Equatable을 격리된 컨텍스트에서 준수
@MainActor
class Document: Equatable {
    var title: String
    var content: String

    // == 연산자도 @MainActor 컨텍스트에서 실행
    static func == (lhs: Document, rhs: Document) -> Bool {
        lhs.title == rhs.title && lhs.content == rhs.content
    }
}

4. InferSendableFromCaptures (SE-0418)

메서드와 키 패스 리터럴에서 @Sendable을 자동으로 추론합니다. 수동으로 Sendable 어노테이션을 붙이는 보일러플레이트를 줄여주는 고마운 기능이에요.

struct User {
    var name: String
    var age: Int
}

// SE-0418 덕분에 키 패스가 자동으로 Sendable로 추론
let users = await withTaskGroup(of: String.self) { group in
    for user in userList {
        group.addTask {
            return user.name  // 키 패스 리터럴의 Sendable 자동 추론
        }
    }
    return await group.reduce(into: []) { $0.append($1) }
}

5. NonisolatedNonsendingByDefault (SE-0461)

앞서 자세히 다룬 내용이죠. nonisolated async 함수가 기본적으로 호출자의 격리 컨텍스트에서 실행되도록 변경합니다. Approachable Concurrency에서 가장 임팩트가 큰 변화입니다.

전체 활성화 방법

// Package.swift에서 모든 Approachable Concurrency 플래그 활성화
.target(
    name: "MyApp",
    swiftSettings: [
        .enableUpcomingFeature("DisableOutwardActorInference"),
        .enableUpcomingFeature("GlobalActorIsolatedTypesUsability"),
        .enableUpcomingFeature("InferIsolatedConformances"),
        .enableUpcomingFeature("InferSendableFromCaptures"),
        .enableUpcomingFeature("NonisolatedNonsendingByDefault")
    ]
)

Task 이름 지정으로 디버깅이 한결 쉬워집니다 (SE-0469)

Swift 6.2는 동시성 디버깅을 크게 개선하는 Task 이름 지정 기능을 도입했습니다. 복잡한 동시성 코드에서 어떤 Task가 문제를 일으키는지 추적하는 건 정말 골치 아픈 일이었는데요, 이제 각 Task에 사람이 읽을 수 있는 이름을 부여할 수 있습니다.

// 비구조적 Task에 이름 지정
Task(name: "사용자 프로필 로드") {
    let profile = try await fetchUserProfile()
    await updateUI(with: profile)
}

// Detached Task에도 이름 지정 가능
Task.detached(name: "이미지 캐시 정리") {
    await ImageCache.shared.cleanup()
}

// TaskGroup에서 자식 Task에 이름 지정
let results = await withTaskGroup(of: ArticleContent.self) { group in
    for (index, articleId) in articleIds.enumerated() {
        group.addTask(name: "기사 #\(index) 로드") {
            return await fetchArticle(id: articleId)
        }
    }

    var contents: [ArticleContent] = []
    for await content in group {
        contents.append(content)
    }
    return contents
}

// 현재 실행 중인 Task의 이름 조회
func processItem() async {
    if let taskName = Task.name {
        print("현재 Task: \(taskName)")
    }
}

Task 이름은 LLDB 디버거에서도 표시되기 때문에, 비동기 코드를 디버깅할 때 어떤 Task에서 문제가 발생했는지 빠르게 파악할 수 있어요. 개인적으로 이 기능만으로도 Swift 6.2 업데이트 가치가 있다고 생각합니다.

Task.immediate: 기다릴 필요 없이 바로 실행 (SE-0472)

기존의 Task { }는 새로운 Task를 생성하고 실행 큐에 넣습니다. 현재 실행 중인 동기 코드가 모두 완료된 후에야 Task의 본문이 실행되기 시작하죠.

하지만 때로는 Task를 생성하자마자 즉시 실행을 시작하고 싶을 때가 있습니다. Task.immediate가 바로 이 요구를 충족해요. Task를 생성하면 첫 번째 중단 지점(suspension point)까지 즉시 동기적으로 실행됩니다.

@MainActor
func setupView() {
    print("1: 설정 시작")

    // 일반 Task: 큐에 추가되므로 나중에 실행
    Task {
        print("3: 일반 Task 실행")
    }

    // Immediate Task: 즉시 실행 시작
    Task.immediate {
        print("2: Immediate Task 실행")
        // 여기서 await를 만나면 일시 중단
        await someAsyncWork()
        print("4: Immediate Task 재개")
    }

    print("마지막: 동기 코드 완료")
}

// 출력 순서:
// 1: 설정 시작
// 2: Immediate Task 실행
// 마지막: 동기 코드 완료
// 3: 일반 Task 실행
// 4: Immediate Task 재개

Task.immediate는 특히 데이터가 이미 캐시에 있는 경우처럼, 비동기 API를 제공하지만 실제로는 중단 없이 완료되는 상황에서 유용합니다. 불필요한 큐잉 지연 없이 바로 결과를 쓸 수 있으니까요.

TaskGroup에서의 사용

await withTaskGroup(of: CachedItem.self) { group in
    for key in cacheKeys {
        // 캐시 히트 시 즉시 실행으로 응답성 향상
        group.addImmediateTask {
            if let cached = cache[key] {
                return cached  // 중단 없이 즉시 반환
            }
            return await fetchFromNetwork(key)
        }
    }
    // ...
}

Task 우선순위 에스컬레이션 (SE-0462)

Swift 6.2는 Task의 우선순위가 런타임에 변경될 때 이를 감지하고 대응할 수 있는 새로운 API도 제공합니다. 우선순위 역전(priority inversion) 문제를 더 잘 처리할 수 있게 해주는 기능이에요.

func processLargeDataset(_ data: [DataItem]) async -> ProcessedResult {
    return await withTaskPriorityEscalationHandler {
        // 기본 우선순위로 데이터 처리
        var results: [ProcessedItem] = []
        for item in data {
            let processed = await processItem(item)
            results.append(processed)
        }
        return ProcessedResult(items: results)
    } onPriorityEscalated: { newPriority in
        // 우선순위가 높아지면 처리 전략 변경 가능
        print("우선순위가 \(newPriority)로 에스컬레이션됨")
        // 예: 배치 크기를 늘리거나, 캐시를 우선 사용하는 등
    }
}

// 수동으로 Task 우선순위 에스컬레이션
let backgroundTask = Task(priority: .background) {
    await processLargeDataset(data)
}

// 나중에 사용자 요청으로 인해 우선순위를 높여야 할 때
backgroundTask.escalatePriority(to: .userInitiated)

격리된 동기 deinit (SE-0371)

Swift 6.2는 디이니셜라이저에서 액터 격리 상태의 데이터에 안전하게 접근할 수 있도록 isolated deinit을 지원합니다. 이전에는 deinit에서 액터 격리 프로퍼티에 접근하면 동시성 경고가 발생했는데, 이제 디이니셜라이저가 해당 액터에서 실행되도록 보장할 수 있어요.

@MainActor
class ResourceManager {
    var activeConnections: [Connection] = []
    var observers: [NSObjectProtocol] = []

    // isolated deinit: MainActor에서 실행됨이 보장됨
    isolated deinit {
        // 안전하게 @MainActor 프로퍼티에 접근
        for connection in activeConnections {
            connection.close()
        }
        for observer in observers {
            NotificationCenter.default.removeObserver(observer)
        }
        activeConnections.removeAll()
    }
}

실전 마이그레이션 전략: 서두르지 마세요

기존 프로젝트를 Swift 6.2의 Approachable Concurrency로 마이그레이션하는 건 신중하게 진행해야 합니다. 한 번에 모든 플래그를 활성화하겠다는 생각은 접어두세요. 단계적으로 접근하는 게 훨씬 안전합니다.

단계 1: 현재 상태 파악

먼저 프로젝트에서 strict concurrency checking을 활성화하고, 현재 얼마나 많은 경고와 에러가 발생하는지 파악해 보세요.

// Build Settings에서
// Strict Concurrency Checking: Complete
// 또는 Package.swift에서
.target(
    name: "MyApp",
    swiftSettings: [
        .swiftLanguageMode(.v6)
    ]
)

단계 2: 점진적 플래그 활성화

한 번에 하나씩 플래그를 활성화하면서, 각 단계에서 발생하는 문제를 해결합니다. 제가 추천하는 순서는 이렇습니다.

  1. DisableOutwardActorInference를 먼저 — 영향 범위가 가장 작음
  2. InferSendableFromCaptures — 보일러플레이트 감소, 기존 동작 유지
  3. GlobalActorIsolatedTypesUsability — 격리된 타입 사용 개선
  4. InferIsolatedConformances — 프로토콜 준수 개선
  5. NonisolatedNonsendingByDefault — 가장 큰 동작 변화이므로 마지막에

단계 3: @concurrent 마이그레이션

NonisolatedNonsendingByDefault를 활성화하기 전에, 백그라운드에서 실행되어야 하는 nonisolated async 함수를 먼저 식별하고 @concurrent를 붙여야 합니다. 이 단계를 건너뛰면 성능 문제가 생길 수 있어요.

// 마이그레이션 전: 암시적으로 글로벌 실행기에서 실행
class NetworkService {
    func downloadFile(from url: URL) async throws -> Data {
        let (data, _) = try await URLSession.shared.data(from: url)
        return data
    }

    func parseCSV(_ content: String) async -> [[String]] {
        // CPU 집약적 파싱 작업
        content.components(separatedBy: "\n")
            .map { $0.components(separatedBy: ",") }
    }
}

// 마이그레이션 후
class NetworkService {
    // 네트워크 호출은 await에서 양보하므로 @concurrent 불필요
    func downloadFile(from url: URL) async throws -> Data {
        let (data, _) = try await URLSession.shared.data(from: url)
        return data
    }

    // CSV 파싱은 CPU 집약적이므로 @concurrent 필요
    @concurrent
    nonisolated func parseCSV(_ content: String) async -> [[String]] {
        content.components(separatedBy: "\n")
            .map { $0.components(separatedBy: ",") }
    }
}

단계 4: 기본 격리 설정 (선택사항)

프로젝트의 성격에 따라 defaultIsolation(MainActor.self)을 활성화할지 결정합니다. UI 중심의 앱이라면 강력히 권장해요.

Approachable Concurrency와 SwiftUI의 시너지

Approachable Concurrency의 혜택이 가장 크게 느껴지는 영역은 단연 SwiftUI입니다. SwiftUI 뷰와 뷰 모델은 본질적으로 메인 스레드에서 작동해야 하니까요.

// Swift 6.2 + defaultIsolation(MainActor.self) + NonisolatedNonsendingByDefault

// @MainActor 어노테이션 불필요 — 기본 격리
class ArticleListViewModel: ObservableObject {
    @Published var articles: [Article] = []
    @Published var isLoading = false
    @Published var errorMessage: String?

    private let repository = ArticleRepository()

    func loadArticles() async {
        isLoading = true
        errorMessage = nil

        do {
            // repository.fetchAll()은 호출자(메인 액터)에서 실행
            // Sendable 관련 에러 없음!
            articles = try await repository.fetchAll()
        } catch {
            errorMessage = error.localizedDescription
        }

        isLoading = false
    }

    func refreshArticle(_ id: UUID) async {
        guard let index = articles.firstIndex(where: { $0.id == id }) else { return }

        if let updated = try? await repository.fetch(id: id) {
            articles[index] = updated
        }
    }
}

class ArticleRepository {
    private let apiClient = APIClient()

    func fetchAll() async throws -> [Article] {
        let data = try await apiClient.get("/articles")
        // 대용량 JSON 디코딩은 백그라운드에서
        return try await decodeArticles(data)
    }

    // 디코딩만 @concurrent로 분리
    @concurrent
    nonisolated func decodeArticles(_ data: Data) async throws -> [Article] {
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        return try decoder.decode([Article].self, from: data)
    }

    func fetch(id: UUID) async throws -> Article {
        let data = try await apiClient.get("/articles/\(id)")
        return try await decodeSingleArticle(data)
    }

    @concurrent
    nonisolated func decodeSingleArticle(_ data: Data) async throws -> Article {
        return try JSONDecoder().decode(Article.self, from: data)
    }
}

struct ArticleListView: View {
    @StateObject var viewModel = ArticleListViewModel()

    var body: some View {
        NavigationStack {
            Group {
                if viewModel.isLoading {
                    ProgressView("로딩 중...")
                } else if let error = viewModel.errorMessage {
                    ContentUnavailableView(
                        "오류 발생",
                        systemImage: "exclamationmark.triangle",
                        description: Text(error)
                    )
                } else {
                    List(viewModel.articles) { article in
                        ArticleRow(article: article)
                    }
                    .refreshable {
                        await viewModel.loadArticles()
                    }
                }
            }
            .navigationTitle("기사 목록")
            .task { await viewModel.loadArticles() }
        }
    }
}

위 코드에서 주목할 점은 @MainActor, @Sendable, sending 같은 어노테이션이 전혀 없다는 것입니다. 코드가 훨씬 깔끔하고 읽기 쉽죠. 동시성에 대한 고민 없이도 안전한 코드를 작성할 수 있게 됐습니다. 진짜로 병렬 처리가 필요한 JSON 디코딩 부분만 @concurrent로 분리했을 뿐이에요.

LLDB 비동기 디버깅 개선

Swift 6.2는 코드 변경만 있는 게 아닙니다. LLDB 디버거의 비동기 스테핑(async stepping)도 크게 개선되었어요. async 함수 내에서 step in, step over, step out이 이제 예상대로 동작합니다.

이전에는 await 포인트를 넘을 때 디버거가 엉뚱한 곳으로 점프하는 경우가 꽤 있었는데, 이제 비동기 코드도 동기 코드처럼 자연스럽게 단계별 디버깅이 가능해졌습니다. Task 컨텍스트를 LLDB에서 직접 확인할 수 있어서, 현재 코드가 어떤 Task에서 실행되고 있는지도 바로 파악할 수 있고요.

마무리

Swift 6.2의 Approachable Concurrency는 단순한 기능 추가가 아닙니다. 동시성 프로그래밍에 대한 Swift의 철학적 전환이라고 봐야 해요. "모든 것을 최대한 안전하게"에서 "필요한 만큼만 점진적으로"로의 변화죠.

핵심을 정리하면 이렇습니다.

  • nonisolated(nonsending): 비동기 함수가 기본적으로 호출자의 격리 컨텍스트에서 실행되어, 불필요한 Sendable 요구사항 제거
  • @concurrent: 명시적으로 병렬 처리가 필요한 함수를 선언하는 새로운 속성
  • 기본 액터 격리: MainActor를 기본값으로 설정해 UI 앱의 보일러플레이트를 대폭 감소
  • Task 이름 지정 & Task.immediate: 디버깅과 응답성을 개선하는 실용적 기능
  • 우선순위 에스컬레이션 & isolated deinit: 세밀한 동시성 제어를 위한 고급 도구

지금 당장 모든 플래그를 활성화할 필요는 없습니다. 하지만 점진적으로 마이그레이션을 시작하는 것은 강력히 권장드려요. Swift의 다음 스텝은 Approachable Concurrency 위에 세워질 테니까요.

저자 소개 Editorial Team

Our team of expert writers and editors.