Swift 6 Concurrency - Sendable 살펴보기

oto·2025년 11월 4일
0

🎯 1. 왜 Sendable이 필요한가?

Data Race 문제

  // ❌ Swift 5 - Data Race 가능
  class Counter {
      var count = 0
  }

  let counter = Counter()

  Task {
      counter.count += 1  // Thread A
  }

  Task {
      counter.count += 1  // Thread B
  }

  // 결과: count가 1일 수도, 2일 수도 있음 (Data Race!)

Swift 6의 해결책: Sendable 프로토콜로 컴파일 타임에 검증


🔑 2. Sendable이란?

protocol Sendable {}

의미: "이 타입은 다른 스레드로 안전하게 전달할 수 있습니다"

Sendable이 자동으로 적용되는 타입들

  // Value Types (자동 Sendable)
  struct Point {  // ✅ 자동으로 Sendable
      let x: Int
      let y: Int
  }

  enum Status {   // ✅ 자동으로 Sendable
      case success
      case failure
  }

  // Immutable Reference Types
  final class Config {  // ✅ 모든 프로퍼티가 let이면 자동 Sendable
      let apiKey: String
      init(apiKey: String) {
          self.apiKey = apiKey
      }
  }

Sendable이 자동으로 안 되는 경우

  // Mutable Reference Type
  class Counter {  // ❌ var 프로퍼티가 있으면 Sendable 아님
      var count = 0
  }

  // Reference를 포함한 Struct
  struct ViewModel {  // ❌ class 타입 포함
      let counter: Counter  // Counter가 Sendable 아님
  }

🔒 3. @unchecked Sendable

언제 사용?

"컴파일러는 모르지만, 내가 thread-safe를 보장할 수 있을 때"

  // ❌ 컴파일러 에러
  class DataStorage: Sendable {
      // Error: Stored property 'authKey' of 'Sendable'-conforming class
      // 'DataStorage' is mutable
      @KeychainStored(key: "auth_key")
      var authKey: String?
  }

  // ✅ @unchecked로 수동 보장
  class DataStorage: @unchecked Sendable {
      @KeychainStored(key: "auth_key")
      var authKey: String?

      // PropertyWrapper 내부에서 Keychain API가 동기화 처리함
      // 따라서 thread-safe를 수동으로 보장
  }

실제 예시: PropertyWrapper로 thread-safety 보장

  @propertyWrapper
  struct KeychainStored {
      var wrappedValue: String? {
          get {
              // Keychain API (Security.framework)
              // 내부적으로 lock을 사용하여 동기화
              return try? keychain.load(forKey: key)
          }
          set {
              // 이것도 내부적으로 동기화됨
              try? keychain.save(newValue, forKey: key)
          }
      }
  }

왜 안전한가?
1. Keychain API (SecItemAdd, SecItemCopyMatching)는 Apple이 thread-safe 보장
2. UserDefaults도 Apple이 thread-safe 보장
3. 따라서 PropertyWrapper를 통한 접근은 안전


🚫 4. nonisolated(unsafe)

Actor Isolation 개념부터

  @MainActor
  class ViewModel {
      var state = State()  // MainActor에 격리됨

      func update() {
          state.isLoading = true  // ✅ 항상 Main Thread
      }
  }

nonisolated란?

"이 프로퍼티/메서드는 Actor에 격리되지 않는다"

  @MainActor
  class ViewModel {
      let id: String  // Immutable이므로 어디서든 접근 가능

      nonisolated var identifier: String {
          return id  // ✅ Actor 격리 없이 접근 가능
      }
  }

nonisolated(unsafe)란?

"격리 안 하는데, 안전성도 내가 책임진다"

  @propertyWrapper
  struct KeychainStored: Sendable {
      private let key: String

      // ❌ 컴파일 에러
      private let keychain: KeychainHelper
      // Error: KeychainHelper는 Sendable이 아님

      // ✅ nonisolated(unsafe)로 해결
      private nonisolated(unsafe) let keychain: KeychainHelper
      // "KeychainHelper는 내부적으로 thread-safe니까 괜찮아"
  }

실제 동작

  // KeychainHelper는 Sendable이 아니지만
  fileprivate final class KeychainHelper {
      func save(_ value: String, forKey key: String) throws {
          // Security framework 내부에서 동기화 처리
          let status = SecItemAdd(query as CFDictionary, nil)
          // Apple이 보장: 여러 스레드에서 호출해도 안전
      }
  }

  // 따라서 nonisolated(unsafe)로 사용해도 안전
  @propertyWrapper
  struct KeychainStored: Sendable {
      private nonisolated(unsafe) let keychain: KeychainHelper
  }

🔄 5. Sendable vs @unchecked Sendable vs nonisolated(unsafe) 비교

개념의미언제 사용예시
Sendable컴파일러가 자동 검증Value type, immutable classstruct Point: Sendable
@unchecked Sendable수동으로 thread-safe 보장컴파일러는 모르지만 내부적으로 안전class DataStorage: @unchecked Sendable
nonisolated(unsafe)Actor 격리 해제 + 안전성 수동 보장Non-Sendable 타입을 Sendable 안에서 사용private nonisolated(unsafe) let keychain

📖 6. 실전 예시로 이해하기

예시 1: DataStorage

  // 왜 @unchecked Sendable?
  final class DataStorage: @unchecked Sendable {
      @KeychainStored(key: "auth_key")
      var authKey: String?  // ← mutable이지만 PropertyWrapper가 동기화

      @UserDefault(key: "auto_login", defaultValue: false)
      var isAutoLogin: Bool  // ← mutable이지만 UserDefaults가 동기화
  }

  // 컴파일러 입장:
  // "var 프로퍼티가 있는데 Sendable? 위험해!"
  // → @unchecked 사용: "PropertyWrapper가 알아서 해줘"

예시 2: KeychainStored PropertyWrapper

  @propertyWrapper
  struct KeychainStored: Sendable {  // ← PropertyWrapper 자체는 Sendable
      private let key: String  // ✅ Immutable, Sendable

      // KeychainHelper는 Sendable 아님 (class이고 내부 state 있음)
      // 하지만 Security framework가 동기화 처리
      private nonisolated(unsafe) let keychain: KeychainHelper

      var wrappedValue: String? {
          get {
              // 여러 스레드에서 동시 호출 가능
              // Security framework가 내부에서 lock 처리
              try? keychain.load(forKey: key)
          }
          set {
              // 이것도 동기화됨
              try? keychain.save(newValue, forKey: key)
          }
      }
  }

예시 3: UserDefault PropertyWrapper

  @propertyWrapper
  struct UserDefault<T: Sendable>: Sendable {
      // ↑ PropertyWrapper는 Sendable
      // ↑ T도 Sendable이어야 함 (String, Int, Bool 등)

      private let key: String
      private let defaultValue: T  // ✅ T: Sendable이므로 OK

      // UserDefaults는 Sendable 아님 (class이고 내부 dictionary 있음)
      // 하지만 Apple이 thread-safe 보장
      private nonisolated(unsafe) let userDefaults: UserDefaults

      var wrappedValue: T {
          get {
              // UserDefaults는 내부적으로 동기화됨
              userDefaults.object(forKey: key) as? T ?? defaultValue
          }
          set {
              // 이것도 동기화됨
              userDefaults.set(newValue, forKey: key)
          }
      }
  }

⚠️ 7. 주의사항: 언제 @unchecked / nonisolated(unsafe) 사용하면 안 되나?

❌ 잘못된 사용 예시

  // ❌ BAD - 실제로 thread-safe 아님
  class UnsafeCounter: @unchecked Sendable {
      var count = 0  // ← 전혀 동기화 안 됨!

      func increment() {
          count += 1  // ← Data Race 발생!
      }
  }

  // ❌ BAD - 내부적으로 mutable state
  @propertyWrapper
  struct UnsafeWrapper: Sendable {
      private nonisolated(unsafe) var cache: [String: Any] = [:]
      // ↑ Dictionary는 thread-safe 아님!

      var wrappedValue: Any {
          get { cache["key"] ?? "" }
          set { cache["key"] = newValue }  // ← Data Race!
      }
  }

✅ 올바른 사용 예시

  // ✅ GOOD - Apple framework가 동기화 보장
  class DataStorage: @unchecked Sendable {
      @KeychainStored(key: "auth_key")  // ← Security framework
      var authKey: String?

      @UserDefault(key: "auto_login", defaultValue: false)  // ← UserDefaults
      var isAutoLogin: Bool
  }

  // ✅ GOOD - Lock으로 명시적 동기화
  class ThreadSafeCounter: @unchecked Sendable {
      private let lock = NSLock()
      private var _count = 0

      var count: Int {
          get {
              lock.lock()
              defer { lock.unlock() }
              return _count
          }
          set {
              lock.lock()
              defer { lock.unlock() }
              _count = newValue
          }
      }
  }

🎓 8. 핵심 요약

Sendable 체크리스트

  // 1. Value Type → 자동 Sendable
  struct Point { let x, y: Int }  // ✅

  // 2. Immutable Class → 자동 Sendable
  final class Config { let key: String }  // ✅

  // 3. Mutable Class + Apple framework → @unchecked Sendable
  class Storage: @unchecked Sendable {
      @KeychainStored var key: String?  // ✅ Keychain이 동기화
  }

  // 4. Non-Sendable을 Sendable 안에서 → nonisolated(unsafe)
  struct Wrapper: Sendable {
      nonisolated(unsafe) let helper: NonSendableHelper  // ✅
  }

기억할 것

  1. Sendable: "다른 스레드로 안전하게 보낼 수 있다"

  2. @unchecked Sendable: "내가 thread-safe를 보장한다" (책임은 개발자)

  3. nonisolated(unsafe): "Actor 격리 안 하고, 내가 안전성 책임진다"

  4. 언제 사용해도 안전: Apple framework (Keychain, UserDefaults, CoreData 등)

  5. 절대 사용 금지: 직접 만든 mutable state without lock


    🔬 9. 실제 검증 예시

  // DataStorage 사용 시나리오
  Task {
      // Thread 1
      DataStorage.shared.authKey = "key1"
      print(DataStorage.shared.authKey)  // "key1"
  }

  Task {
      // Thread 2 (동시 실행)
      DataStorage.shared.authKey = "key2"
      print(DataStorage.shared.authKey)  // "key2"
  }

  // 결과: Data Race 없음!
  // 이유: Keychain API가 내부적으로 동기화
  // → 항상 "key1" 또는 "key2" 중 하나만 읽힘
  // → 절대 중간 상태나 크래시 없음

profile
iOS Developer

0개의 댓글