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 하나는 못 쓴다

소요 시간 막대그래프. 일반 호출 한 번은 675ms. 태스크 그룹으로 2 분할 430ms, 4 분할 231ms, 8 분할 156ms, 64 분할 140ms. 8 분할을 actor 하나로 보내면 694ms 로 한 번 호출과 같은 속도였다.
600만 미만의 소수를 세는 작업(-O, Swift 6 언어 모드, 8 코어 M1 Pro 에서 3 회 중앙값). 태스크 그룹은 청크를 여러 코어에 나누지만, 같은 청크를 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 메서드로 보내는 것을 거부했습니다.

선택 기준표

판단 그림. 첫 질문은 '그 상태를 UI 코드가 만지는가'. 그렇다면 @MainActor 를 붙인다. 아니면 '각 태스크에 복사할 수 있는 값 타입으로 만들 수 있는가'를 묻고, 가능하면 struct(따로 하지 않아도 Sendable). 불가능하면 '임계 구역 안에 await 가 있는가'를 묻는다. 있으면 actor 를 쓰고 await 마다 상태를 다시 확인한다. 없으면 final 이고 Sendable 인 클래스에 Mutex 로 충분하다.
Swift 6 에서 공유 상태와 병렬 작업에 어떤 도구를 쓸지. 데이터가 무엇이고 누가 만지는지부터 정한다.
필요한 것 쓸 것 여기서의 실측
개수가 정해진 독립 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 가 보입니다). 이 글에서는 다루지 않았고, 어느 릴리스에 들어왔는지도 확인하지 않았습니다.

연습 문제

  1. 사용자 정보(300ms)와 그 사용자 ID 가 필요한 아바타(200ms)를 불러오는 코드를 써 보세요. 가장 빠르면 몇 ms 이고, 왜 async let 으로도 더 줄일 수 없을까요?
  2. 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 회 중앙값).

  • #Swift
  • #동시성
  • #async/await
  • #actor
  • #Sendable
  • #Swift 6
  • #고급

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑