Tech/iOS

Swift Actor로 Race Condition 해결하기 (feat. NSLock, DispatchQueue 비교)

seu11ee 2026. 1. 21. 21:56

 

WWDC21에 소개된 Actor 라는 개념이 있습니다.

WWDC21 Actor 소개 영상의 제목과 캡션

변경 가능한 상태를 보호하기 위해 Swift Actor를 도입했다고 합니다.

변경 가능한 상태를 왜 보호해야할까?

iOS는 멀티스레드 환경이므로 변경 가능한 상태에 대해

두 스레드가 동시에 값을 쓰고자 할 경우

Race Condition(경쟁 상태)가 발생할 수 있기 때문입니다.

경쟁 상태는 하나의 값에 대해

예제를 통해 알아볼게요.

class Cart {
    var itemCount = 0
}

let cart = Cart()

// 상품 추가
DispatchQueue.global().async {
    cart.itemCount += 1
}

// 상품 삭제
DispatchQueue.global().async {
    cart.itemCount -= 1
}

Thread.sleep(forTimeInterval: 0.1)
print(cart.itemCount)  // 0? 1? -1? (예측 불가)

 

실행할 때마다 결과가 달라집니다. 왜 그럴까요?

Race Condition (경쟁 상태)

iOS 앱은 멀티스레드 환경에서 동작합니다.
위 코드에서는 두 스레드가 동시에 itemCount를 수정하려고 하는데,
이때 누가 먼저 쓰느냐에 따라 결과가 달라집니다.

시나리오 1: 추가 → 삭제
  itemCount: 0 → 1 → 0 ✅

시나리오 2: 삭제 → 추가  
  itemCount: 0 → -1 → 0 ⚠️

시나리오 3: 동시 실행
  itemCount: 예측 불가 ❌

 

이것이 mutable한 참조 타입의 상태를 보호해야 하는 이유입니다.

기존 해결 방법들

그렇다면 이 문제를 어떻게 해결할까요?

전통적으로 두 가지 방법이 있었습니다.

1. NSLock 2. DispatchQueue

NSLock

Race Condition은 두 개의 스레드가 하나의 값에 동시에 쓰려고 하는 경우, 개발자의 의도와 달리 예측할 수 없는 값이 써질 수 있는 문제였죠.

NSLock을 사용하면 Lock을 점유한 스레드만 mutable state에 접근할 수 있게 강제할 수 있습니다.

class Cart {
    private var itemCount = 0
    private let lock = NSLock()

    func addItem() {
        lock.lock()
        itemCount += 1
        lock.unlock()
    }

    func removeItem() {
        lock.lock()
        itemCount -= 1
        lock.unlock()
    }
}

let cart = Cart()

DispatchQueue.global().async {
    cart.addItem()
}

DispatchQueue.global().async {
    cart.removeItem()
}
```

**동작 방식:**
```
Thread 1: lock.lock() → 획득 성공 → itemCount += 1 → unlock
Thread 2: lock.lock() → 대기... → (Thread 1 unlock 후) 획득 → itemCount -= 1 → unlock

 

Lock을 획득하지 못한 Thread는 대기하므로, 한 번에 하나의 Thread만 접근할 수 있습니다.

NSLock 문제점

하지만 NSLock은 치명적인 단점이 있습니다.

func withdraw(_ amount: Int) -> Bool {
    lock.lock()

    guard itemCount >= amount else {
        return false  // ❌ unlock을 깜빡함!
    }

    itemCount -= amount
    lock.unlock()
    return true
}

 

unlock()을 호출하지 않으면 영원히 잠긴 상태가 되어 데드락(Deadlock)이 발생합니다.

func withdraw(_ amount: Int) -> Bool {
    lock.lock()
    defer { lock.unlock() }  // 함수 종료 시 자동 호출

    guard itemCount >= amount else {
        return false
    }

    itemCount -= amount
    return true
}

 

defer를 사용해서 unlock을 빠트리지 않게 작성할 수 있지만

여전히 unlock을 빠트리지 않게 정신을 차려야하는건 개발자의 몫이 됩니다.

DispatchQueue

작업들을 큐에 줄을 세워서 동시 접근을 막는 방법입니다.

DispatchQueue를 사용하면 작업을 순차적으로 실행할 수 있습니다.

Serial Queue는 작업을 하나씩 순서대로 처리하므로, 동시 접근 문제를 자연스럽게 해결할 수 있습니다.

class Cart {
    private var itemCount = 0
    private let queue = DispatchQueue(label: "cart.queue")

    func addItem() {
        queue.async {
            self.itemCount += 1
        }
    }

    func removeItem() {
        queue.async {
            self.itemCount -= 1
        }
    }
}

let cart = Cart()

DispatchQueue.global().async {
    cart.addItem()
}

DispatchQueue.global().async {
    cart.removeItem()
}
```

**동작 방식:**
```
Queue: [addItem] [removeItem]
       ↓
실행: addItem 완료 → removeItem 실행

 

모든 작업이 같은 Queue에 들어가므로 순차적으로 실행되며, unlock을 깜빡할 걱정도 없습니다.

 

DispatchQueue 문제점

DispatchQueue는 NSLock보다 안전하지만, 여전히 문제가 있습니다.

class Cart {
    private var itemCount = 0
    private let queue = DispatchQueue(label: "cart.queue")

    func addItem() {
        queue.async {
            self.itemCount += 1
        }
    }

    // ❌ 실수로 queue를 사용하지 않음
    func getCount() -> Int {
        return itemCount  // Race Condition 발생 가능!
    }
}

 

개발자가 수동으로 queue를 사용해야 하므로, 실수로 빼먹으면 보호되지 않습니다.

컴파일러는 이를 감지하지 못합니다.

비교 정리

 

방법 장점 단점
NSLock • 빠름
• 명시적
• unlock 깜빡하면 데드락
• defer 써도 실수 가능
DispatchQueue • unlock 걱정 없음
• 사용 간편
• queue 사용 안 하면 보호 안 됨
• 컴파일러가 확인 못 함

 

공통 문제:

  • 둘 다 개발자가 수동으로 관리
  • 컴파일러가 실수를 잡아주지 못함

이런 한계를 해결하기 위해 Actor가 등장했습니다.

Actor의 등장

Actor는 기존 방법들의 문제를 어떻게 해결할까요?

개발자가 신경쓰지 않도록 내 코드가 문제를 일으킬 거라는 걸 누군가 알려주면 좋겠죠.

그래서 Swift 컴파일러가 이를 알려줍니다.

Actor를 사용하면 mutable state 값을 보호해줍니다. 여러 스레드가 접근하고자 할 때 내부적으로 큐를 만들어 순차적으로 대기시키죠.

lock, unlock, DispatchQueue도 필요 없습니다.

actor 키워드를 통해 참조 타입을 만들고, await와 함께 호출하면 되죠.

Actor 사용법

actor Cart {
    private var itemCount = 0

    func addItem() {
        itemCount += 1
    }

    func removeItem() {
        itemCount -= 1
    }

    func getCount() -> Int {
        return itemCount
    }
}

 

class 랑 매우 비슷하게 생겼죠.

class를 actor로 바꾸기만 하면 data race가 일어나지 않도록 mutable state를 보호할 수 있습니다 (너무 쉽다.ᐟ)

actor는 class처럼 참조타입이며, protocol 채택 가능하지만, 상속은 안됩니다

 

Actor는 class와 비슷하지만 몇 가지 차이가 있습니다:

- ✅ 참조 타입 (class처럼)
- ✅ Protocol 채택 가능
- ❌ 상속 불가능

actor Cache: Cacheable {  // ✅ Protocol OK
    // ...
}

actor SubCache: Cache {  // ❌ 상속 불가
    // Error: Actors cannot inherit from other actors
}

Actor 특징

1. 컴파일 타임 검증 ⭐️⭐️⭐️

let cart = Cart()

// ❌ 컴파일 에러!
cart.addItem()
// Error: Expression is 'async' but is not marked with 'await'

// ✅ 이렇게 해야 함
await cart.addItem()

 


await 없이는 아예 컴파일이 안 됩니다.

NSLock이나 DispatchQueue처럼 "까먹으면 어쩌지?"라는 걱정이 필요 없습니다.

Actor의 가장 큰 장점이라고 생각합니다.

 

2. Data Isolation (데이터 격리)

actor Cart {
    private var itemCount = 0

    func addItem() {
        itemCount += 1  // ✅ self의 프로퍼티는 자유롭게 접근
    }

    func transferItem(to other: Cart) {
        other.itemCount += 1  // ❌ 컴파일 에러!
        // Error: Actor-isolated property 'itemCount' can only be referenced on 'self'
    }
}


Actor 내부의 프로퍼티는 self에서만 직접 접근 가능합니다.

다른 Actor 인스턴스의 프로퍼티에 접근하려면 반드시 메서드를 통해 접근하고 await를 사용해야 합니다:

func transferItem(to other: Cart) async {
    let count = await other.getCount()  // ✅ 메서드를 통해 접근
    // ...
}

 

3. 한 번에 하나씩 실행

let cart = Cart()

// 100개의 작업을 동시에 실행해도
for _ in 0..<100 {
    Task {
        await cart.addItem()
    }
}

// Actor 내부에서는 한 번에 하나씩만 실행됨


Actor는 내부적으로 Mailbox(우체통)를 가지고 있어서, 들어온 요청들을 순차적으로 처리합니다.

Mailbox: [요청1] [요청2] [요청3] ...
         ↓
실행: 요청1 완료 → 요청2 실행 → 요청3 실행


DispatchQueue처럼 순차 실행되지만, 컴파일러가 강제하므로 실수할 여지가 없습니다.

비교 정리

항목 NSLock DispatchQueue Actor
보호 방식 수동 lock/unlock 수동 queue 사용 자동 격리
컴파일 검증
실수 가능성 높음 (unlock 깜빡) 중간 (queue 깜빡) 낮음 (강제됨)
코드 복잡도 높음 중간 낮음

Actor 사용 시 주의사항

순서 보장이 필요한 경우

Actor는 우선순위 기반으로 작업을 처리하므로, 들어온 순서대로 실행된다는 보장이 없습니다.

DispatchQueue와의 차이:

- DispatchQueue: 엄격한 선입선출 (FIFO)

- Actor: 우선순위 고려 (중요한 작업 먼저 처리)

actor Logger {
    func log(_ message: String) {
        print(message)
    }
}

let logger = Logger()

// ❌ 1, 2, 3 순서 보장 안 됨
Task { await logger.log("1") }
Task { await logger.log("2") }
Task { await logger.log("3") }

 

순서가 중요하다면 같은 Task 안에서 순차적으로 호출하세요:

// ✅ 1 → 2 → 3 순서 보장
Task {
    await logger.log("1")
    await logger.log("2")
    await logger.log("3")
}

동기 함수에서는 사용 불가

Actor 메서드는 비동기이므로, 동기 함수에서는 호출할 수 없습니다.

actor Counter {
    var value = 0

    func increment() {
        value += 1
    }
}

func syncFunction() {
    let counter = Counter()
    await counter.increment()  // ❌ 컴파일 에러!
    // Error: 'await' in a function that does not support concurrency
}

해결 방법:

// 1. 함수를 async로 변경
func asyncFunction() async {
    let counter = Counter()
    await counter.increment()  // ✅
}

// 2. Task 사용
func syncFunction() {
    let counter = Counter()
    Task {
        await counter.increment()  // ✅
    }
}

정리

Actor는 멀티스레드 환경에서 mutable state를 안전하게 보호하는 강력한 도구입니다.

장점:

  • 컴파일러가 안전성을 강제
  • 실수로 보호를 빼먹을 수 없음
  • 코드가 간결하고 명확함

주의사항:

  • 순서가 중요하면 같은 Task에서 순차 호출
  • 동기 함수에서는 직접 호출 불가

기존의 NSLock이나 DispatchQueue보다 안전하고 사용하기 쉬운 Actor를 활용해보세요!


참고

 

Protect mutable state with Swift actors - WWDC21 - Videos - Apple Developer

Data races occur when two separate threads concurrently access the same mutable state. They are trivial to construct, but are notoriously...

developer.apple.com

 

 

swift-evolution/proposals/0306-actors.md at main · swiftlang/swift-evolution

This maintains proposals for changes and user-visible enhancements to the Swift Programming Language. - swiftlang/swift-evolution

github.com