Swift 동시성: async/await·actor·Swift 6 오류 실측
async let 과 태스크 그룹 실측, Swift 5 모드는 통과시키고 Swift 6 은 막는 데이터 경쟁, actor 재진입 함정을 실제 출력으로 확인하고 선택 기준표로 정리합니다.
시리즈: Swift 강좌 5/6 (고급). 클래스·클로저·제네릭은 쓸 수 있지만 Swift 6 마이그레이션 앞에서 막힌 분을 위한 글입니다. 다 읽으면 독립된 작업을 동시에 돌리고, 엄격한 동시성 검사 오류를 끄지 않고 읽어 내고, actor 재진입 버그를 피하고, @MainActor·actor·Mutex·값 타입 중 무엇을 쓸지 고를 수 있습니다.
iOS 앱 프로젝트에서 Swift 6 언어 모드를 켜자마자 Sendable 오류가 수십 개 쏟아져 "일단 @unchecked Sendable" 로 넘어간 적이 있다면 이 글이 그 상황을 위한 것입니다. iOS 면접에서 자주 나오는 "actor 는 스레드 안전한가?", "async/await 로 바꾸면 빨라지나?" 같은 질문에 실측 숫자로 답하고 싶은 분에게도 맞습니다. 아래 출력과 숫자는 모두 직접 돌린 결과이고, 설명만 한 부분은 그렇다고 밝힙니다.
실행 환경: Swift 6.4.2 (swiftlang-6.4.2.1.9), 타깃 arm64-apple-macosx26.0, Apple Silicon Mac (M1 Pro, 8 코어: 성능 6 + 효율 2), macOS 26. 시간은 ContinuousClock 로 잰 실제 시간, -O 릴리스 빌드, 3 회 중앙값입니다. 출력마다 언어 모드를 적습니다. 모드에 따라 결과가 바뀌기 때문입니다.
지금 쓰는 언어 모드는 무엇인가
이 강좌에서 가장 중요한 점이라 가장 먼저 확인했습니다.
| 빌드 방법 | Swift 6.4.2 의 언어 모드 | 데이터 경쟁 검사 |
|---|---|---|
swift file.swift / swiftc file.swift |
Swift 5 | 거의 없음. 경쟁해도 통과 |
swiftc -strict-concurrency=complete |
Swift 5 | 전체 검사, 경고로 표시 |
swiftc -swift-version 6 |
Swift 6 | 전체 검사, 오류 |
swift package init (tools-version 6.4) |
Swift 6 | 오류 |
| Swift 6 이전에 만든 Xcode 프로젝트 | 빌드 설정에 따라 | 대개 Swift 5 |
패키지 줄은 아래의 경쟁 파일을 새 패키지에 넣고 swift build 해서, -swift-version 6 과 같은 오류로 실패하는 것을 확인했습니다. 컴파일러 자체는 모든 줄에서 같고 모드만 다릅니다. 동료가 "내 쪽에선 컴파일되는데요" 라고 하면 어느 줄인지 물어보세요.
async/await·async let·태스크 그룹 실측
async 는 "도중에 일시 중단(서스펜드)될 수 있는 함수", await 는 "여기서 중단될 수 있다"는 표시입니다. 중단은 블로킹이 아닙니다. 기다리는 동안 스레드는 다른 일을 합니다. 여기서는 가짜 API 호출(300ms·200ms·100ms 의 Task.sleep)을 차례로 기다리는 버전, async let 으로 동시에 시작하는 버전, 100 개를 태스크 그룹으로 돌리는 버전을 비교합니다.
// sleeps.swift — swiftc -parse-as-library -swift-version 6 -O sleeps.swift
func fetch(_ name: String, ms: Int) async throws -> String {
try await Task.sleep(for: .milliseconds(ms))
return name
}
@main struct Main {
static func main() async throws {
let a = try await fetch("user", ms: 300) // 1. 차례로
let b = try await fetch("orders", ms: 200)
let c = try await fetch("ads", ms: 100)
async let a2 = fetch("user", ms: 300) // 2. 셋을 지금 시작
async let b2 = fetch("orders", ms: 200)
async let c2 = fetch("ads", ms: 100)
let all = try await [a2, b2, c2] // 여기서 기다림
let names = try await withThrowingTaskGroup(of: String.self) { group in
for i in 0..<100 { group.addTask { try await fetch("item\(i)", ms: 300) } }
var out: [String] = []
for try await n in group { out.append(n) } // 3. 끝난 순서대로 도착
return out
}
// 시간 측정 코드는 생략
}
}
$ ./sleeps # Swift 6 모드, -O, 3 회 중 1 회째
sequential : user orders ads 638 ms
async let : user orders ads 319 ms
task group : 100 results 320 ms
3 회 중앙값은 차례로 636ms, async let 319ms, 300ms × 100 개 태스크 그룹 320ms 였습니다. 차례로 기다리면 합계 600ms 에 타이머 지연이 한 번에 약 12ms 씩 붙고, 동시에 돌리면 가장 느린 호출 시간만 듭니다. 자식 태스크 수가 코드를 쓸 때 정해져 있으면 async let, 데이터에 따라 정해지면 태스크 그룹입니다. 둘 다 '구조적'이라서 자식 태스크는 자신을 만든 스코프보다 오래 살 수 없습니다.
취소는 협조적이다: 실제 결과 세 가지
task.cancel() 은 플래그만 세웁니다. 실제로 멈출지는 돌고 있는 코드에 달렸습니다.
// cancel.swift (발췌) — Swift 6 모드, -O
let sleeper = Task { try await Task.sleep(for: .seconds(5)) }
try? await Task.sleep(for: .milliseconds(100)); sleeper.cancel()
func busyWork(checking: Bool) async throws -> Int { // 약 2 초의 순수 CPU 작업
...
while ContinuousClock.now < deadline {
if checking { try Task.checkCancellation() }
for _ in 0..<10_000 { x ^= x << 13; x ^= x >> 7; x ^= x << 17 }
steps += 1
}
}
sleep 5 s, cancelled at 100 ms -> failure(Swift.CancellationError()) after 105 ms
busy loop checking=false -> success(105273) after 2000 ms
busy loop checking=true -> failure(Swift.CancellationError()) after 100 ms
leaving scope without awaiting 'slow'
slow got CancellationError()
scope with unawaited async let ended after 0 ms
Task.sleep (URLSession 등 대부분의 시스템 비동기 API 도 마찬가지)은 취소를 바로 알아챕니다. 직접 짠 루프는 모릅니다. 2 초를 꽉 채워 돌고 아무도 쓰지 않을 결과를 돌려줬습니다. 긴 루프에는 try Task.checkCancellation() 이나 if Task.isCancelled { break } 를 넣으세요. 마지막 세 줄은 의외의 규칙입니다. await 하지 않은 async let 은 스코프를 벗어날 때 자동으로 취소되고, 끝날 때까지 기다려집니다. 컴파일러도 warning: immutable value 'slow' was never used ... [#NoUsage] 로 힌트를 줍니다.
데이터 경쟁: Swift 5 모드는 통과, Swift 6 은 거부
평범한 클래스를 자식 태스크 1,000 개가 공유하고 각자 1,000 번씩 증가시킵니다. 정답은 1,000,000 입니다.
// race2.swift
final class Counter {
var value = 0
func increment() { value += 1 }
}
@main struct Main {
static func main() async {
let counter = Counter()
await withTaskGroup(of: Void.self) { group in
for _ in 0..<1_000 {
group.addTask { for _ in 0..<1_000 { counter.increment() } }
}
}
print("expected 1000000, got \(counter.value)")
}
}
$ swiftc -parse-as-library race2.swift -o r2 && ./r2 # Swift 5 모드: 진단 없음
expected 1000000, got 477945
counter 를 전역 let 으로 바꾼 버전도 Swift 5 모드로 세 번 돌리니 849,554, 802,689, 786,948. 매번 다른 틀린 값이고 경고는 하나도 없습니다. 같은 파일을 Swift 6 모드로 컴파일하면:
$ swiftc -parse-as-library -swift-version 6 race2.swift
race2.swift:13:23: error: passing closure as a 'sending' parameter risks causing data races between main actor-isolated code and concurrent execution of the closure [#RegionIsolation::SendingClosureRisksDataRace]
14 | for _ in 0..<1_000 { counter.increment() }
| `- note: closure captures 'counter' which is accessible to main actor-isolated code
전역 버전은:
$ swiftc -parse-as-library -swift-version 6 race.swift
race.swift:7:5: error: let 'counter' is not concurrency-safe because non-'Sendable' type 'Counter' may have shared mutable state [#MutableGlobalVariable]
2 | final class Counter {
| `- note: class 'Counter' does not conform to the 'Sendable' protocol
7 | let counter = Counter()
| |- note: add '@MainActor' to make let 'counter' part of global actor 'MainActor'
| `- note: disable concurrency-safety checks if accesses are protected by an external synchronization mechanism
Sendable 은 "동시성 영역을 넘어 건네도 안전하다"는 뜻입니다. Sendable 필드만 가진 struct 는 자동으로 Sendable 이고, var 를 가진 클래스는 아닙니다. 대괄호 안의 #MutableGlobalVariable 등은 Swift 6.4 가 보여 주는 진단 그룹 이름이고 각각 설명 페이지가 있습니다. Swift 5 모드에서 -strict-concurrency=complete 를 붙이면 같은 문장이 "this is an error in the Swift 6 language mode" 로 끝나는 경고로 나옵니다. 큰 프로젝트를 파일 단위로 옮길 때 쓰는 부드러운 방법입니다.
고치기: actor (그리고 Mutex)
actor Counter { // swiftc -parse-as-library -swift-version 6 -O
var value = 0
func increment() { value += 1 }
}
// 태스크 안: for _ in 0..<1_000 { await counter.increment() }
import Synchronization
final class LockedCounter: Sendable {
private let state = Mutex(0)
func increment() { state.withLock { $0 += 1 } }
var value: Int { state.withLock { $0 } }
}
actor : got 1000000 in 12 ms
mutex : got 1000000 in 29 ms
둘 다 매번 정확했습니다(Swift 6 모드, 100만 번 증가에 actor 중앙값 12ms, Mutex 29ms). actor 는 자기 상태에 대한 접근을 하나씩 차례로 처리하고, 바깥에서는 await 가 필수이며 컴파일러가 강제합니다. await 를 잊거나 바깥에서 actor 프로퍼티에 쓰면 아래 오류가 나는데, Sendable 검사와 달리 이것은 Swift 5 모드에서도 오류입니다.
iso.swift:6:7: error: actor-isolated instance method 'increment()' cannot be called from outside of the actor
iso.swift:8:7: error: actor-isolated property 'value' can not be mutated from a nonisolated context
| `- note: consider declaring an isolated method on 'Counter' to perform the mutation
Mutex 는 Synchronization 모듈에 있고 macOS 15 이상이 필요합니다. -target arm64-apple-macosx14.0 으로 컴파일하면 error: 'Mutex' is only available in macOS 15.0 or newer 로 실패했습니다. 임계 구역이 짧고 await 가 없을 때 씁니다(withLock 안에서는 await 할 수 없습니다).
actor 재진입 함정 (실제 출력)
actor 는 자기 코드를 한 번에 하나씩만 돌리지만, await 마다 다른 호출을 안으로 들여보냅니다. 이것을 재진입(reentrancy)이라 하며, "확인 → await → 변경" 형태의 코드를 망가뜨립니다.
actor Account {
var balance = 100
func withdraw(_ amount: Int) async -> Bool {
guard balance >= amount else { return false }
try? await Task.sleep(for: .milliseconds(10)) // 예: 이상 거래 확인 API
balance -= amount // 위의 확인은 이미 낡았을 수 있다
return true
}
func withdrawFixed(_ amount: Int) async -> Bool {
guard balance >= amount else { return false }
balance -= amount // 먼저 확보
let ok = await fraudCheck()
if !ok { balance += amount } // 실패하면 되돌림
return ok
}
}
$ ./reentrancy # Swift 6 모드: 경고 없이 컴파일됨
naive withdraw 30 x5 from 100: ok=5 balance=-50
fixed withdraw 30 x5 from 100: ok=3 balance=10
naive cache 5 requests for 'logo' -> downloads=5
shared Task 5 requests for 'logo' -> downloads=1
다섯 번의 출금이 모두, 누구도 balance -= amount 에 닿기 전에 guard 를 통과했습니다. Swift 6 모드도 아무 말이 없습니다. 모든 접근이 격리돼 있어 데이터 경쟁이 아니기 때문입니다. 이것은 로직 경쟁이고 컴파일러 검사로는 찾을 수 없습니다. 캐시에서 같은 버그가 나면 같은 이미지를 다섯 번 내려받습니다. 해결책은 진행 중인 Task 를 딕셔너리에 넣고 나중에 온 호출이 같은 태스크를 기다리게 하는 것입니다. 규칙은 하나: actor 안에서는 await 할 때마다 상태가 바뀌었다고 가정한다.
@MainActor 한 절 정리
@MainActor 는 메인 스레드에 묶인 전역 actor 입니다. ViewModel 처럼 UIKit/SwiftUI 가 만지는 것에 붙이면, 다른 곳에서의 호출은 모두 await 로 메인 스레드에 옮겨 가도록 컴파일러가 강제합니다. 이 강좌에서는 UI 앱을 빌드하지 않았고 @MainActor 클래스를 타입 검사만 했습니다.
mainactor.swift:7:8: error: main actor-isolated instance method 'update' cannot be called from outside of the actor
mainactor.swift:10:8: error: main actor-isolated property 'title' can not be mutated from a nonisolated context
앞의 출력에서 보았듯 @main 타입의 static func main() 도 메인 액터에서 돕니다. 경쟁 오류에 "main actor-isolated code" 가 나온 이유입니다. @MainActor 함수에서 긴 CPU 루프를 돌리면 Swift Concurrency 가 없을 때와 똑같이 UI 가 멈춥니다. actor 가 정하는 것은 '어디서 도는가'이지 '얼마나 걸리는가'가 아닙니다.
CPU 작업: 태스크 그룹은 코어를 쓰고, actor 하나는 못 쓴다
func countPrimes(_ range: Range<Int>) -> Int { ... } // 평범한 함수, 시험 나눗셈
actor Worker { func count(_ r: Range<Int>) -> Int { countPrimes(r) } }
let total = await withTaskGroup(of: Int.self) { g in
for r in chunks(8) { g.addTask { countPrimes(r) } } // 협조 스레드 풀에서 돈다
var s = 0
for await c in g { s += c }
return s
}
| 600만 미만의 소수 (412,849 개) | 3 회 중앙값 |
|---|---|
| 한 번 호출 | 675ms |
| 태스크 그룹 2 / 4 / 8 분할 | 430 / 231 / 156ms |
| 태스크 그룹 16 / 64 분할 | 148 / 140ms |
| 8 분할을 각각 actor 하나로 | 694ms |
8 분할로 4.3 배. 큰 수일수록 시험 나눗셈 횟수가 늘어나 잘게 나눌수록 부하가 고르게 퍼져 조금씩 더 빨라졌습니다(2 분할이 1.6 배에 그친 것도 같은 이유). 협조 스레드 풀은 대략 코어 수만큼의 스레드만 가지므로, 그 위에서 sleep() 이나 세마포어 대기로 스레드를 막으면 전체가 막힙니다. actor 하나를 거치게 하자 한 번 호출과 같은 속도가 됐습니다. actor 는 상태를 지키는 직렬 큐이지 작업자 풀이 아닙니다.
흔한 실수 → 실제로 일어나는 일 → 고치는 법
| 실수 | 실제로 일어난 일 | 고치는 법 |
|---|---|---|
그냥 swiftc 로 컴파일하고 "오류 없음"을 믿음 |
Swift 5 모드. 갱신이 15만~52만 번 사라짐 | -swift-version 6 또는 -strict-concurrency=complete |
@unchecked Sendable 로 오류를 끔 |
Swift 6 모드에서 경고 없이 통과, -O 3 회 989,000·971,000·993,000 |
actor, Mutex, 값 타입 |
actor 안에서 확인 → await → 변경 |
잔액 -50, 다운로드 5 번 | await 앞에서 변경, 또는 Task 공유 |
main 안에서 태스크 그룹에 g.reduce(into:) |
error: sending 'g' risks causing data races [#RegionIsolation::SendingRisksDataRace] |
for await x in g { ... } |
| 취소 확인 없는 CPU 루프 | cancel() 뒤에도 2,000ms 돌았음 |
try Task.checkCancellation() |
| CPU 작업을 actor 하나 안에서 | 156ms 일 것이 694ms | 태스크 그룹에 평범한 함수 |
@unchecked 결과가 1,000 의 배수인 이유는 -O 가 태스크마다 안쪽 루프를 value += 1000 한 번으로 합쳤기 때문입니다. 경쟁은 모양만 바뀌었지 사라지지 않았습니다. reduce 줄은 이 글을 쓰다가 실제로 밟은 것입니다. main 안에서는 reduce(into:) 에 넘긴 클로저가 메인 액터 격리가 되고, Swift 6.4 는 그것을 @concurrent 메서드로 보내는 것을 거부했습니다.
선택 기준표
| 필요한 것 | 쓸 것 | 여기서의 실측 |
|---|---|---|
| 개수가 정해진 독립 await 몇 개 | async let |
호출 3 개가 636ms → 319ms |
| 데이터로 개수가 정해지는 N 개 await | 태스크 그룹 | 300ms × 100 개가 320ms |
안에 await 가 있는 공유 가변 상태 |
actor |
정확. 재진입 주의 |
작은 임계 구역, await 없음 |
final Sendable 클래스 + Mutex |
정확. macOS 15 이상 |
| UI 상태 | @MainActor |
타입 검사만 |
| CPU 병렬 작업 | 평범한 함수를 태스크 그룹으로 | 8 코어에서 4.3 배 |
| 태스크 사이에 건네는 데이터 | struct / enum (Sendable) |
검사 불필요 |
솔직한 한계와 버전 메모
- 모두 Swift 6.4.2 에서만 돌렸습니다.
async/await와 actor 는 Swift 5.5, Swift 6 언어 모드는 Swift 6.0 에 들어왔다고 알려져 있지만 옛 툴체인은 설치하지 않았습니다. 같은 컴파일러로-swift-version 5와-swift-version 6을 비교했을 뿐입니다. - 대괄호 진단 그룹 이름은 Swift 6.4 의 표시입니다. 예전 컴파일러에는 없고, region isolation 오류 문장은 릴리스마다 바뀌어 왔습니다.
- 경쟁 결과는 본래 무작위라 여러분의 틀린 숫자는 다를 것입니다. 첫 호출 외에 워밍업은 하지 않았으니 CPU 표는 순위로 읽어 주세요.
@MainActor는 타입 검사만 했고, 실제 앱 런 루프에서의 동작은 재지 않았습니다.- actor 의 기본 실행기(executor)와 협조 스레드 풀은 구현 세부입니다. 최근 릴리스에는
nonisolated(nonsending)과@concurrent도 들어왔습니다(위reduce오류에도@concurrent가 보입니다). 이 글에서는 다루지 않았고, 어느 릴리스에 들어왔는지도 확인하지 않았습니다.
연습 문제
- 사용자 정보(300ms)와 그 사용자 ID 가 필요한 아바타(200ms)를 불러오는 코드를 써 보세요. 가장 빠르면 몇 ms 이고, 왜
async let으로도 더 줄일 수 없을까요? ImageCache예제에서 동시에 온 요청 5 개가 다운로드 1 번으로 끝나도록shared(_:)를 써 보세요.
정답
1. 약 500ms 입니다. 직접 돌려 보니 avatar42 ads 0.514229916 seconds (두 번에 514ms, 533ms)였습니다. 아바타가 사용자 결과에 의존하므로 let user = try await fetchUser(); let avatar = try await fetchAvatar(user.id) 처럼 차례로 기다릴 수밖에 없습니다. async let 은 서로 의존하지 않는 호출에만 효과가 있습니다. 독립된 세 번째 호출(광고 100ms)이 있다면 두 순차 await 앞에서 async let 으로 시작해 두면 500ms 안에 숨습니다.
2. 진행 중인 태스크를 딕셔너리에 두고, 새로 시작하기 전에 확인합니다.
actor ImageCache {
private var done: [String: String] = [:]
private var inFlight: [String: Task<String, Never>] = [:]
func shared(_ key: String) async -> String {
if let v = done[key] { return v }
if let t = inFlight[key] { return await t.value } // 진행 중인 다운로드에 합류
let t = Task { try? await Task.sleep(for: .milliseconds(50)); return "png:\(key)" }
inFlight[key] = t // 첫 await 전에 등록
let v = await t.value
done[key] = v; inFlight[key] = nil
return v
}
}
실행 결과는 shared Task 5 requests for 'logo' -> downloads=1 이었습니다. 핵심은 await 앞의 inFlight[key] = t 입니다. 중단된 사이에 들어온 호출은 이 태스크를 찾아 합류합니다.
다음 강좌에서는 동시성을 떠나 메모리를 다룹니다. ARC, leaks 로 찾는 순환 참조, Copy-on-Write, 그리고 -O 가 실제로 바꾸는 것을 같은 방식으로 잽니다.
이전 강좌: 4강: 오류 처리와 제네릭 · 다음 강좌: 6강: ARC·메모리·성능
관련: Java 강좌 5강: 가상 스레드 · Python 강좌 5강: 스레드·프로세스·asyncio 와 GIL · Dart 강좌 5강: Future·Stream·Isolate
자주 묻는 질문
swiftc 는 기본으로 Swift 6 언어 모드인가요?
단일 파일은 아닙니다. Swift 6.4.2 에서도 `swiftc file.swift` 와 `swift file.swift` 는 Swift 5 모드라서, 경쟁하는 클래스가 경고 없이 컴파일되고 갱신의 최대 절반이 사라졌습니다. `swift package init` 으로 만든 패키지는 `swift-tools-version: 6.4` 라 Swift 6 모드이고, 같은 코드가 컴파일 오류입니다. 단일 파일에는 `-swift-version 6` 을 붙이세요.
actor 를 쓰면 '확인 후 변경'이 안전한가요?
확인과 변경 사이에 await 가 없을 때만 안전합니다. 잔액 100 에서 30 을 동시에 5 번 빼자, await 를 끼운 버전은 5 번 모두 성공하고 잔액이 -50 이 됐습니다. 변경을 await 앞으로 옮기자 성공 3 번, 잔액 10 으로 맞았습니다.
actor 에 넣으면 CPU 작업이 병렬이 되나요?
아니요. actor 는 한 번에 한 작업만 돌립니다. 600만 미만의 소수 세기는 한 번 호출로 675ms, 8 분할 태스크 그룹으로 156ms, 같은 8 분할을 actor 하나로 보내면 694ms 였습니다(M1 Pro, 3 회 중앙값).