[Swift6] 왜 싱글톤을 사용하면 RaceCondition 에러가 잡힐까?
Swift 6 모드를 켜보시면, 기존 코드에서 Singleton으로 처리된 부분이 에러로 잡히는 것을 본 적이 있으실 겁니다. 싱글톤 패턴은 전역적으로 공유되는 인스턴스를 제공하기 때문에, 간편하게 접근할 수 있는 장점이 있습니다. 그러나 동시성 환경에서의 잠재적인 문제를 안고 있기도 합니다. Swift 6에서는 컴파일 시점에서 엄격한 동시성 검사를 도입하여 이러한 문제를 미리 방지하고자 합니다. 이번 글에서는 Swift 6의 새로운 동시성 검사와 이를 통해 싱글톤 패턴을 안전하게 사용하는 방법에 대해 알아보겠습니다.
일반적으로 싱글톤은 다음과 같은 코드로 구현됩니다
class Singleton {
static let shared = Singleton()
}
- 이 코드에서는 shared라는 전역 인스턴스를 사용해 어디서나 접근할 수 있도록 하고 있습니다.
- 그러나 Swift 6의 엄격한 동시성 모델에서는 이러한 공유되는 가변 상태에 대한 동시 접근이 문제가 될 수 있습니다
- 여러 스레드가 동시에 shared에 접근할 때, 데이터 레이스가 발생할 가능성이 있기 때문입니다
그렇다면 왜? Swift6는 컴파일타임에 가져와서 오류로 처리할까요? 지금껏 잘썻는데 말이죠
Swift 6으로 앱을 마이그레이션하기 - WWDC24 - 비디오 - Apple Developer
기존 샘플 앱의 업데이트를 따라 Swift 6 마이그레이션 과정을 직접 경험해 보세요. 증분 마이그레이션 방법을 모듈별로 설명하고, 컴파일러로 데이터 레이스 위험이 있는 코드를 식별하는 방법
developer.apple.com
Swift6 이전에서는 동시성 문제를 런타임에서만 발견할 수 있었습니다. 만약 동시성문제가 발견되면 추적하고 수정하는 것이 매우 어려웠습니다. 특히, 데이터 레이스와 같은 문제는 특정 조건에서만 발생하기 때문에 재현이 쉽지 않고, 그로 인해 디버깅 과정이 길어지고 복잡해질 수 있습니다.
그렇다면 왜 재현하기가 어려울까요?
- 바로 비결정성이기 때문입니다. 데이터 레이스는 프로그램 실행 시 여러 스레드가 비동기적으로 실행되는 상황에서 발생합니다. 실행 순서와 타이밍은 시스템 상태, CPU 스케줄링, 또는 입력 조건 등에 따라 달라질 수 있습니다. 따라서 동일한 코드라도 실행할 때마다 다른 결과를 초래할 수 도 있고, 특정조건에서만 발생할 수 도 있기 때문입니다.
그래서 Swift 6에서는 컴파일러가 공유 가변 상태에 대한 동시 접근을 감지하고, 컴파일 시점에서 경고나 오류로 처리함으로써 개발자가 이러한 잠재적 문제를 사전에 인지하고 수정할 수 있도록 합니다
Swift 6에서 싱글톤 구현 방법
- 가변상태에 따라 구현방법의 차이가 있습니다.
불변 상태를 가진 경우
싱글톤 클래스가 완전히 불변 상태를 가진다면 Sendable만으로도 문제가 없습니다
final class Singleton: Sendable {
static let shared = Singleton()
let configuration: String
private init() {
configuration = "Default Config"
}
}
가변 상태를 가진 경우
Sendable을 사용하는 싱글톤 클래스가 가변 상태(mutable state)를 포함하면, 컴파일러는 이를 동시성 안전하지 않은 상태로 간주하고 오류를 발생시킵니다. 예를 들어 아래와 같이 말이죠
final class Singleton: Sendable {
static let shared = Singleton()
var data: [String: Any] = [:] // 가변 상태
private init() {}
}
'Singleton' does not conform to 'Sendable' because 'data' is not safe for concurrent use.
해결방법으로는 위의 코드를 불변상태로 만들거나, 모든 접근을 특정 액터 컨텍스트로 제한해야합니다.
불변상태를 만드는것은 처음 불변상태로 예시를 보여드렸으니, 스킾하겠습니다~
전역 액터(@MainActor 또는 custom Actor) 사용
@MainActor
final class Singleton: Sendable {
static let shared = Singleton()
var data: [String: Any] = [:] // 가변 상태, 하지만 MainActor로 격리됨
private init() {}
func updateData(key: String, value: Any) {
data[key] = value
}
func fetchData(key: String) -> Any? {
data[key]
}
}
@MainActor를 통해 모든 data 접근이 메인 스레드에서 직렬화됩니다.
Actor를 사용하는 예시를 알아보겠습니다.
final class Singleton: @unchecked Sendable {
static let shared = Singleton()
private let queue = DispatchQueue(label: "com.singleton.queue")
private var data: [String: Any] = [:]
private init() {}
func updateData(key: String, value: Any) {
queue.sync {
data[key] = value
}
}
func fetchData(key: String) -> Any? {
queue.sync {
data[key]
}
}
}
@unchecked Sendable을 사용한 이유:
- DispatchQueue는 Sendable이 아니므로 컴파일러가 Sendable 준수를 허용하지 않습니다.
- 그러나 개발자가 명시적으로 동기화를 관리하므로 동시성 안전을 보장할 수 있습니다.
Swift Concurrency로 더 안전하게 사용하기
actor는 상태를 동시성 환경에서 안전하게 보호하는 기본 단위입니다. Actor 내부에서 선언된 프로퍼티와 메서드는 직렬화되어 처리되므로 데이터 레이스를 방지합니다
actor Singleton {
static let shared = Singleton()
private var data: [String: Any] = [:]
private init() {}
func updateData(key: String, value: Any) {
data[key] = value
}
func fetchData(key: String) -> Any? {
data[key]
}
}
Task {
await Singleton.shared.updateData(key: "username", value: "SwiftDeveloper")
let username = await Singleton.shared.fetchData(key: "username")
print(username ?? "No data")
}
- actor는 Swift Concurrency에서 기본적으로 Sendable을 준수하므로 @unchecked Sendable이 필요하지 않습니다.
- 모든 접근이 Actor 내부에서 직렬화되어 데이터 레이스가 완전히 방지됩니다.
정리
- 가변 상태를 관리해야 한다면 actor를 사용하는 것이 가장 안전하고 간결한 방법입니다.
- UI와 관련된 작업에서는 @MainActor를 사용하여 메인 스레드에서 직렬화하여 처리하면 될 것 같습니다.
- 더 복잡한 동작이 필요하면 actor를 여러 개 정의하여 역할별로 분리하는것이 좋을 것 같습니다.