TechFeedTechFeed
Programming Languages

Swift 6 동시성 완벽 가이드 — async/await부터 Actor까지

Swift 6의 Strict Concurrency, async/await, Actor, Sendable, 데이터 레이스 방지와 마이그레이션 패턴.

한 줄 요약: Swift 6는 컴파일 타임에 데이터 레이스를 완전히 차단하는 Strict Concurrency를 도입했다. async/await, Actor, Sendable 프로토콜을 제대로 이해하면 기존 코드의 컴파일 에러를 해결하고 안전한 동시성 코드를 작성할 수 있다.

이 글이 필요한 사람
  • Swift 6로 마이그레이션 시 발생하는 동시성 컴파일 에러를 해결하려는 iOS/macOS 개발자
  • async/await를 사용하지만 Actor와 Sendable의 차이가 아직 모호한 경우
  • 데이터 레이스가 발생하는 원인을 코드 레벨에서 이해하고 싶은 경우
  • MainActor와 커스텀 Actor의 올바른 사용법을 실전 패턴으로 익히려는 경우

Swift 6 Strict Concurrency — 무엇이 바뀌었나

Swift 5.x에서 async/await는 선택적이었다. Swift 6에서는 Strict Concurrency Checking이 기본값으로 활성화되어, 데이터 레이스가 발생할 수 있는 코드는 컴파일 에러로 차단된다.

핵심 변화는 세 가지다:

  1. Sendable 강제 검사: 태스크 경계를 넘어 전달되는 타입은 반드시 Sendable을 준수해야 한다.
  2. Actor Isolation 강화: Actor의 가변 상태에 대한 접근은 반드시 await로만 가능하다.
  3. @MainActor 추론 감소: Swift 5.x에서는 암묵적으로 MainActor가 추론되던 경우가 있었지만, Swift 6는 명시적 어노테이션을 요구한다.

Swift 6 마이그레이션은 Xcode에서 SWIFT_STRICT_CONCURRENCY = complete를 설정하거나 Package.swift에서 swiftLanguageVersions: [.v6]로 활성화할 수 있다.

async/await 기초와 Task 관리

async/await는 콜백과 CompletionHandler 기반 비동기 코드를 선형 흐름으로 작성할 수 있게 해준다. 기본 개념을 코드로 먼저 확인하자.

async/await 기본 패턴과 에러 처리
import Foundation // async 함수 정의 func fetchUser(id: String) async throws -> User { let url = URL(string: "https://api.example.com/users/\(id)")! let (data, response) = try await URLSession.shared.data(from: url) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw URLError(.badServerResponse) } return try JSONDecoder().decode(User.self, from: data) } // 병렬 실행 — async let func loadDashboard() async throws -> Dashboard { // 두 요청이 동시에 실행됨 async let user = fetchUser(id: "1") async let posts = fetchPosts(userId: "1") // 두 결과가 모두 완료될 때까지 대기 return Dashboard(user: try await user, posts: try await posts) } // Task 생성 func onButtonTap() { Task { do { let dashboard = try await loadDashboard() await updateUI(with: dashboard) } catch { print("Error: \(error)") } } } // Task.detached — 현재 Actor 컨텍스트와 무관하게 실행 func runInBackground() { Task.detached(priority: .background) { let result = await heavyComputation() // MainActor 필요 시 명시적 전환 await MainActor.run { self.label.text = "\(result)" } } }
Task vs Task.detached: Task { }는 현재 Actor 컨텍스트를 상속한다. UI 코드에서 Task를 만들면 자동으로 MainActor에서 실행된다. Task.detached는 컨텍스트를 상속하지 않으므로 백그라운드 작업에 적합하다.

Actor — 공유 가변 상태를 안전하게 관리하는 방법

Actor는 내부 상태에 대한 접근을 직렬화하는 참조 타입이다. 같은 Actor에 여러 태스크가 동시에 접근해도 내부 상태는 한 번에 하나의 태스크만 수정할 수 있도록 보장된다.

Actor 정의와 사용 — 카운터 예시
// Actor 정의 actor Counter { private var value = 0 func increment() { value += 1 } func decrement() { value -= 1 } var current: Int { value // Actor 내부에서는 동기적 접근 가능 } } // Actor 외부에서는 await 필요 let counter = Counter() Task { await counter.increment() await counter.increment() let value = await counter.current print(value) // 2 } // 여러 태스크가 동시에 접근해도 안전 await withTaskGroup(of: Void.self) { group in for _ in 0..<1000 { group.addTask { await counter.increment() } } } // counter.current == 1002 (데이터 레이스 없음)
실전 패턴 — 네트워크 캐시 Actor
actor ImageCache { private var cache: [URL: Data] = [:] private var inProgress: [URL: Task<Data, Error>] = [:] func image(for url: URL) async throws -> Data { // 캐시 히트 if let cached = cache[url] { return cached } // 중복 요청 방지 — 진행 중인 태스크 재사용 if let existingTask = inProgress[url] { return try await existingTask.value } // 새 요청 시작 let task = Task<Data, Error> { let (data, _) = try await URLSession.shared.data(from: url) return data } inProgress[url] = task do { let data = try await task.value cache[url] = data inProgress.removeValue(forKey: url) return data } catch { inProgress.removeValue(forKey: url) throw error } } }

Sendable — 태스크 경계를 넘는 타입의 안전 규칙

Sendable은 여러 태스크 또는 Actor 간에 안전하게 공유할 수 있는 타입을 나타내는 프로토콜이다. Swift 6에서는 태스크 경계를 넘는 값은 반드시 Sendable이어야 한다.

자동으로 Sendable인 타입

  • 값 타입 (struct, enum): 모든 프로퍼티가 Sendable이면 자동 채택
  • 기본 타입: Int, String, Bool, Double
  • actor: 자체 격리로 Sendable 자동 채택

Sendable이 안 되는 패턴

  • 가변 프로퍼티가 있는 class
  • non-Sendable 타입을 저장하는 타입
  • 클로저 (명시적 @Sendable 어노테이션 없이)
Sendable 준수 패턴 — struct, class, @unchecked
// struct는 모든 프로퍼티가 Sendable이면 자동 Sendable struct UserProfile: Sendable { let id: String let name: String let score: Int } // class는 명시적 선언 + final + 불변 or 직렬화 보장 필요 final class ImmutableConfig: Sendable { let apiKey: String let baseURL: URL init(apiKey: String, baseURL: URL) { self.apiKey = apiKey self.baseURL = baseURL } } // @unchecked Sendable — 직접 동기화를 보장하는 경우 // (Lock 등을 사용하는 레거시 코드 마이그레이션 시) final class ThreadSafeCache: @unchecked Sendable { private var storage: [String: Any] = [:] private let lock = NSLock() func set(_ value: Any, for key: String) { lock.withLock { storage[key] = value } } func get(for key: String) -> Any? { lock.withLock { storage[key] } } } // @Sendable 클로저 func runAsync(_ work: @Sendable @escaping () async -> Void) { Task { await work() } }

MainActor — UI 업데이트 안전 패턴

@MainActor는 메인 스레드에서만 실행되어야 하는 코드를 표시하는 글로벌 액터다. UIKit/SwiftUI에서 UI 업데이트는 반드시 메인 스레드에서 이루어져야 하므로 이를 컴파일 타임에 보장한다.

@MainActor 활용 — ViewModel 패턴
import SwiftUI // 전체 클래스를 MainActor로 격리 @MainActor final class UserViewModel: ObservableObject { @Published var users: [User] = [] @Published var isLoading = false @Published var errorMessage: String? private let service = UserService() func loadUsers() { isLoading = true errorMessage = nil Task { do { // service.fetchAll()은 백그라운드에서 실행 let result = try await service.fetchAll() // 이미 @MainActor 컨텍스트이므로 바로 업데이트 users = result } catch { errorMessage = error.localizedDescription } isLoading = false } } } // 특정 메서드만 MainActor로 지정 class DataProcessor { // 백그라운드 작업 func process(data: [Int]) async -> [Int] { data.map { $0 * 2 } } // UI 업데이트만 MainActor @MainActor func updateLabel(_ text: String, on label: UILabel) { label.text = text } }

Swift 6 마이그레이션 — 자주 발생하는 에러와 해결

Swift 6로 마이그레이션할 때 가장 자주 보이는 컴파일 에러 케이스와 해결 방법을 정리했다.

케이스 1: "Sending value of non-Sendable type across actor boundary"

원인: Non-Sendable 타입을 태스크 또는 Actor 경계를 넘어 전달하려 할 때 발생.

해결: 타입을 struct로 변경하거나, final class + Sendable 채택, 또는 Actor 내부에서만 사용하도록 리팩토링.

케이스 2: "Call to main actor-isolated can only be made from main actor"

원인: @MainActor로 격리된 메서드를 비동기 컨텍스트 외에서 직접 호출.

해결: 호출부를 await MainActor.run { }으로 감싸거나, 호출하는 함수에도 @MainActor 추가.

케이스 3: 단계적 마이그레이션 전략

모든 파일을 한 번에 마이그레이션하기 어렵다면 파일 단위로 // swift-tools-version: 6.0을 적용하거나, SWIFT_STRICT_CONCURRENCY = targeted로 부분 적용하면서 단계적으로 전환할 수 있다.

공식 마이그레이션 가이드: Apple이 제공하는 공식 Swift 6 마이그레이션 가이드는 swift.org/migration에서 확인할 수 있다. Xcode 진단 메시지와 함께 참고하면 빠르게 해결할 수 있다.
swift-6동시성async-awaitactorsendableiOS개발

관련 포스트

Go로 마이크로서비스 구축하기2026-03-01웹 개발자를 위한 Rust 입문2026-02-28Python 3.13 새 기능 총정리2026-03-08JavaScript ES2026 새 기능 정리2026-03-10