[Swift] 동시성과 Actor

별똥별·2025년 6월 23일

🧠 Swift 동시성과 Actor 기반 안전한 코드 정리

🔍 목표

  • Data race condition이 뭐고 왜 위험한지
  • Actor격리(isolation), Sendable, @MainActor, nonisolated의 역할

1. 왜 동시성 문제가 문제가 될까? 🛑

여러 쓰레드가 동시에 변수에 접근하면 기대와 다른 결과가 나올 수 있어요.

class Counter {
  var count = 0
  func increment() { count += 1 }
}

let c = Counter()
DispatchQueue.concurrentPerform(iterations: 1000) { _ in
  c.increment()
}
print(c.count) // 기대: 1000, 실제: 600~900처럼 작아짐
  • 여러 쓰레드엣어 increment()를 동시에 호출할 때, count가 잘못된 값으로 업데이트될 수 있습니다.
  • 이는 비동기 접근 중 하나라도 쓰기가 포함되면 데이터 충돌 기능이 있는 전형적인 Race condition 예시입니다.
    Stackoverflow 설명

왜 문제가 생기나?

value += 1은 사실 load, increment, store 3단계로 구성되어 있습니다.
그래서 여러 스레드가 동시에 접근하면:
1. 스레드 A가 value를 읽음 (0)
2. 스레드 B도 읽음 (0)
3. A가 1을 더해 1을 쓰고
4. B도 1을 더해 1을 쓰면 -> 결국 value = 1
-> 즉, 증가 작업 2번이 하나로 처리되어버림.
이렇게 순서와 충돌 떄문에 값이 틀리게 나옵니다.

이를 방지하기 위해 락(lock) 또는 Swift Actor를 사용합니다.

해결방법 1. 락(Lock) 사용

class SafeCounter {
    private var value = 0
    private let lock = NSLock()

    func increment() {
        lock.lock()
        value += 1
        lock.unlock()
    }

    func getValue() -> Int {
        lock.lock()
        defer { lock.unlock() }
        return value
    }
}

해결방법 2. Swift Actor 사용

actor CounterActor {
    private var value = 0

    func increment() {
        value += 1
    }

    func getValue() -> Int {
        return value
    }
}
let actor = CounterActor()

Task {
    await withTaskGroup(of: Void.self) { group in
        for _ in 0..<1_000 {
            group.addTask { await actor.increment() }
        }
    }
    let final = await actor.getValue()
    print("🔢 final actor value: \(final)") // 1000 → 안전하게 잘 증가됨
}
  • actor는 내부 상태에 대한 동시 접근을 직렬화(serialized)하기 때문에 데이터 충돌이 없습니다.
    관련 정보

2. Lock 없이 안전하게! Swift의 Actor 🌟

actor SafeCounter {
  private var count = 0
  func increment() { count += 1 }
  func get() -> Int { count }
}

let a = SafeCounter()
Task {
  await withTaskGroup(of: Void.self) { group in
    for _ in 0..<1000 {
      group.addTask { await a.increment() }
    }
  }
  print(await a.get()) // 결과: 항상 1000
}
  • actor는 내부 상태를 직렬화하여 안전하게 변경하기때문에 race condition을 막아 줍니다.
  • 내부 상태는 한 번에 하나의 Task만 접근하는데, 이게 바로 격리(isolation)입니다.
    forums.swift.org

3. Sendable: 안전하게 데이터 전달하기 🚀

struct MyData: Sendable {
  let name: String
}
  • Sendable은 "다른 Task/스레드로 이 값을 전달해도 안전하다"는 의미입니다.
  • actor는 격리된 상태를 제공하고, 외부에 전달하는 타입은 반드시 Sendable이어야 안전하게 사용할 수 있습니다.
  • 구조체처럼 값 타입이거나, 내부 상태 불변이고 클래스라면 final + Sendable 하면 됩니다.

4. @MainActor: UI는 메인 스레드에서 🎯

@MainActor
class ViewModel: ObservableObject {
  @Published var text: String = "Hello"
}
  • @MainActor는 특별한 전역 actor입니다.
  • 이 어노테이션이 붙은 타입이나 프로퍼티는 반드시 메인 스레드에서 실행되도록 컴파일러에서 강제해 줍니다. MainActor 설명

5. init과 nonisolated 관계 🛠

@MainActor
class MyViewModel {
  let name: String

  nonisolated init(name: String = "") {
    self.name = name
  }

  func update() { /* 메인 스레드에서 실행됨 */ }
}
  • @MainActor 클래스를 기본으로 선언하면, 그 init() 도 메인 액터 isolation을 의미합니다.
  • 하지만 @StateObject처럼 동기(non-isolated) 컨텍스트에서 초기화할 떄는 “⚠️ 메인 격리 init()을 비격리 콘텍스트에서 호출 중” 라고 에러가 납니다. MainActor 오류
  • 이럴때 nonisolated init(...)을 사용하면, "이 init만 메인 격리 예외로 처리할게요!"라고 명시적으로 선언하는 방식입니다.

🧩 핵심 개념 요약

개념설명
race condition여러 스레드가 동시에 같은 데이터를 읽고 쓰며 충돌이 일어나는 상황
actorSwift에서 race condition을 막기 위한 타입. 내부 상태를 직렬 접근하도록 보장
isolationactor 내부는 한 번에 하나의 Task만 상태에 접근. 이를 통해 동시성 안전을 확보
Sendable타입이 다른 Task/쓰레드로 안전하게 전달될 수 있는지 컴파일러가 확인
@MainActorUI 업데이트처럼 반드시 메인 스레드에서 실행되어야 하는 코드에 적용되는 전역 actor 설명1 설명2
nonisolated@MainActor 타입의 특정 함수(예: init)에만 격리 예외를 허용해, 격리되지 않은(동기) 컨텍스트에서도 안전하게 호출할 수 있게 해줌

✅ 마무리

  • actor, @MainActor는 Swift 언어 차원에서 동시성 안전을 자동으로 보장해주는 혁신적인 기능입니다.
  • Sendable은 데이터를 Task 간에 안전하게 이동할 수 있게 도와주는 Type 안전성 장치이기도 해요.
  • 때로는 init과 같은 초기화 함수는 격리 예외가 필요한데, 이때 nonisolated가 바로 그 고민을 깔끔하게 해결해 줍니다.

이 요약만 잘 기억해도, Swift 동시성 모델의 핵심을 잡고 꼼꼼한 코드 구조 설계가 가능해집니다!

profile
밍밍

0개의 댓글