iOS 응용 (SwiftUI)

[iOS] Swift6 개념 (actor, Sendable, @MainActor, @unchecked Sendable)

jake-kim 2026. 9. 23. 00:39

지난 글 복습

class Counter {
    var n = 0
    func bump() { n += 1 }
}

let c = Counter()
Task { c.bump() }
Task { c.bump() }
  • n += 1은 읽기 → 더하기 → 쓰기 3단계 → 두 스레드가 겹치면 한쪽 작업이 사라짐 (데이터 레이스)
  • 문제는 재현이 안 됨: 99번 잘 돌다가 사용자 기기에서만 터짐. Swift 5는 이걸 막아주지 않았음
  • Swift 6는 이런 코드를 컴파일 에러로 만들어버림

핵심 아이디어 = 격리 구역(isolation domain)

  • 코드는 항상 어느 구역엔가 속함: actor 인스턴스마다 구역 하나, @MainActor 구역, 또는 아무 데도 안 속한 nonisolated
  • 같은 구역 안 = 안전 (동시 접근 없음)
  • 구역 경계를 넘어갈 때만 위험 → 넘어가는 값이 안전한지 검사하는 게 Swift 6의 본질
  • 이걸 위한 도구 3가지: actor / @MainActor (코드가 어느 구역에 있는지) + Sendable (값이 구역을 넘어도 되는지)

① actor — 구역을 만든다

actor Counter {
    private var n = 0
    func bump() { n += 1 }
    func value() -> Int { n }
}

class → actor 한 글자 바꿨을 뿐인데, 이제 이 인스턴스는 자기만의 구역

구역 안의 상태(n)는 오직 구역 안 코드만 만질 수 있습니다. 바깥에서 접근하려면 줄을 서야 하고, 그래서 await이 붙음

let c = Counter()

await c.bump()          // ✅ 줄 서서 들어감
print(await c.value())  // ✅

print(c.n)              // ❌ 컴파일 에러: 구역 밖에서 상태 직접 접근

await은 여기서 "느리다"는 뜻이 아니라 "구역에 들어가려고 기다린다" 는 표시

반대로 actor 내부에서는 이미 구역 안이니 await 없이 씀

actor Counter {
    private var n = 0
    func bump() { n += 1 }
    func bumpTwice() {
        bump()   // await 없음 — 같은 구역
        bump()
    }
}

직관: actor = 상태를 감싼 방 + 자동으로 붙는 자물쇠. 한 번에 한 명만 들어감

② @MainActor — 가장 많이 쓰게 될 구역

  • UI는 반드시 메인 스레드에서 건드려야 함. 이걸 타입으로 표현한 게 @MainActor
@MainActor
final class ViewModel {
    var title = ""          // 메인 구역에 있는 상태
    func setTitle(_ s: String) { title = s }
}

이제 백그라운드에서 실수로 건드리면 컴파일 에러가 발생하며 안정성 확보

func loadInBackground(vm: ViewModel) async {
    let data = await fetch()
    vm.title = data.name           // ❌ 에러: 메인 구역 밖
    await MainActor.run {
        vm.title = data.name       // ✅ 구역 안으로 들어가서 수정
    }
}

예전에 DispatchQueue.main.async를 빼먹어서 생기던 버그가 컴파일 타임에 잡힘. Swift 6에서 가장 실질적인 이득이 이곳.

final class Service {
    @MainActor var lastError: String?     // 이 프로퍼티만 메인 구역
    func work() { ... }                    // 나머지는 자유
}

 

③ Sendable — 구역을 넘어가도 되는 값

  • 구역이 나뉘었으니, 이제 값이 구역을 넘을 때 검사가 필요
actor Database {
    func save(_ user: User) { ... }   // 바깥 → Database 구역으로 User가 넘어감
}

이때 User가 넘어가도 되는 타입인가? 그 판정 기준이 Sendable 프로토콜

 

// 안전한 경우 — 자동으로 Sendable

struct User {          // Sendable 안 써도 자동 인정
    let id: Int
    let name: String
}

struct는 값 타입이라 넘길 때 복사됨. 양쪽이 서로 다른 사본을 가지니 애초에 공유가 없음. 내부 멤버가 전부 Sendable이면 컴파일러가 알아서 Sendable로 취급.

Int, String, Bool, enum(연관값이 Sendable이면), Sendable 원소의 Array/Dictionary — 전부 해당

 

// 위험한 경우 — 거부됨

class Session {        // 참조 타입 + 가변 상태
    var token: String = ""
}

actor Database {
    func save(_ s: Session) { ... }   // ❌ Session is not Sendable
}

클래스는 참조가 복사됨. 사본이 아니라 같은 객체를 양쪽이 가리키게 되므로 컴파일러 발생

 

  • 해결 방법

방법 A — 불변으로 만든다

final class Session: Sendable {
    let token: String       // 전부 let → 쓰기가 없으니 레이스도 없음
    init(token: String) { self.token = token }
}

final + 모든 프로퍼티가 let + 그 타입들도 Sendable이면 컴파일러가 검증해 줌

 

방법 B — actor로 바꾼다

actor Session { var token = "" }     // actor는 언제나 Sendable

 

방법 C — 직접 락을 걸고 컴파일러에게 약속한다

final class Session: @unchecked Sendable {
    private let lock = NSLock()
    private var _token = ""
    var token: String {
        lock.withLock { _token }
    }
}

@unchecked는 "검사를 끄겠다, 안전은 내가 책임진다" 는 선언. 기존 코드 마이그레이션에서 유혹이 크지만, 락 하나 빠뜨리면 Swift 6를 쓰는 의미가 사라짐. A나 B방법이 좋은 것

 

클로저에서의 컴파일 에러

  • 클로저도 value type이며 아래와 같은 상황이 많이 발생함
var total = 0
Task {
    total += 1        // ❌ 클로저가 바깥 변수를 캡처해서 다른 구역으로 감
}

Task { }의 내용물은 별도 구역에서 실행되는데, total을 캡처하면 두 구역이 같은 변수를 공유하게 됨. 컴파일 에러 발생

 
  • await를 붙여서 해결이 가능함
let result = await Task { 1 + 1 }.value

 

요약

  1. actor / @MainActor 로 상태를 구역 안에 가두기 → 구역 안에서는 동시 접근이 없음
  2. 구역 경계를 넘는 값은 Sendable 검사를 받음 → 값 타입·불변 타입은 Sendable없어도 그냥 통과됨 (공유 메모리가 아니고 복사해서 쓰기 때문)
  3. 막히면 @unchecked로 덮지 말고 상태를 어느 구역에 둘지 다시 설계
┌─────────────────────┐         ┌─────────────────────┐
│  @MainActor 구역     │         │  actor Database 구역 │
│                     │         │                     │
│  vm.title           │  User   │  var rows           │
│  (UI 상태)           │ ──────> │  (DB 상태)           │
│                     │  ↑      │                     │
└─────────────────────┘  │      └─────────────────────┘
                         │
              여기서 컴파일러가 묻는다:
              "User는 Sendable인가?"
              struct → ✅ 통과
              가변 class → ❌ 에러