스레드 안전 영속성을 위한 Swift 액터
Swift 액터를 사용하여 스레드 안전한 데이터 영속성 계층을 구축하기 위한 패턴입니다. 인메모리 캐싱과 파일 기반 스토리지를 결합하며, 액터 모델을 활용하여 컴파일 타임에 데이터 경합을 제거합니다.
활성화 시점
- Swift 5.5+에서 데이터 영속성 계층을 구축할 때
- 공유 가변 상태에 대한 스레드 안전 접근이 필요할 때
- 수동 동기화(lock, DispatchQueue)를 없애고 싶을 때
- 로컬 저장소를 사용하는 오프라인 우선 앱을 구축할 때
핵심 패턴
액터 기반 리포지토리
액터 모델은 직렬화된 접근을 보장합니다. 데이터 경합이 없으며, 컴파일러가 강제합니다.
public actor LocalRepository<T: Codable & Identifiable> where T.ID == String {
private var cache: [String: T] = [:]
private let fileURL: URL
public init(directory: URL = .documentsDirectory, filename: String = "data.json") {
self.fileURL = directory.appendingPathComponent(filename)
// init 중 동기적 로드 (액터 격리가 아직 활성화되지 않음)
self.cache = Self.loadSynchronously(from: fileURL)
}
// MARK: - 공개 API
public func save(_ item: T) throws {
cache[item.id] = item
try persistToFile()
}
public func delete(_ id: String) throws {
cache[id] = nil
try persistToFile()
}
public func find(by id: String) -> T? {
cache[id]
}
public func loadAll() -> [T] {
Array(cache.values)
}
// MARK: - 비공개
private func persistToFile() throws {
let data = try JSONEncoder().encode(Array(cache.values))
try data.write(to: fileURL, options: .atomic)
}
private static func loadSynchronously(from url: URL) -> [String: T] {
guard let data = try? Data(contentsOf: url),
let items = try? JSONDecoder().decode([T].self, from: data) else {
return [:]
}
return Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) })
}
}
사용법
액터 격리로 인해 모든 호출은 자동으로 비동기(async)입니다:
let repository = LocalRepository<Question>()
// 읽기 — 인메모리 캐시에서 빠른 O(1) 조회
let question = await repository.find(by: "q-001")
let allQuestions = await repository.loadAll()
// 쓰기 — 캐시를 업데이트하고 파일에 원자적으로 영속화
try await repository.save(newQuestion)
try await repository.delete("q-001")
@Observable ViewModel과 함께 사용하기
@Observable
final class QuestionListViewModel {
private(set) var questions: [Question] = []
private let repository: LocalRepository<Question>
init(repository: LocalRepository<Question> = LocalRepository()) {
self.repository = repository
}
func load() async {
questions = await repository.loadAll()
}
func add(_ question: Question) async throws {
try await repository.save(question)
questions = await repository.loadAll()
}
}
주요 설계 결정 사항
| 결정 사항 | 근거 |
|---|---|
| 액터 (클래스 + lock 대신) | 컴파일러가 강제하는 스레드 안전성, 수동 동기화 불필요 |
| 인메모리 캐시 + 파일 영속성 | 캐시에서 빠른 읽기, 디스크에 안정적 쓰기 |
| init의 동기적 로딩 | 비동기 초기화의 복잡성 회피 |
| ID로 키를 지정한 딕셔너리 | 식별자로 O(1) 조회 |
Codable & Identifiable에 대한 제네릭 | 모든 모델 타입에서 재사용 가능 |
원자적 파일 쓰기 (.atomic) | 충돌 시 부분 쓰기 방지 |
모범 사례
- 액터 경계를 넘는 모든 데이터에
Sendable타입 사용 - 액터의 공개 API를 최소화 — 영속성 세부 사항이 아닌 도메인 연산만 노출
.atomic쓰기를 사용하여 충돌 시 데이터 손상을 방지init에서는 동기적 로딩 — 비동기 초기화는 로컬 파일의 경우 이점이 거의 없으면서 복잡성만 증가- 반응형 UI 업데이트를 위해
@ObservableViewModel과 결합
피해야 할 안티 패턴
- 새로운 Swift 동시성 코드에서 액터 대신
DispatchQueue또는NSLock사용 - 내부 캐시 딕셔너리를 외부 호출자에게 노출
- 검증 없이 파일 URL을 설정 가능하게 만듦
- 모든 액터 메서드 호출이
await임을 잊음 — 호출자는 async 컨텍스트를 처리해야 함 nonisolated를 사용하여 액터 격리를 우회 (목적을 무력화함)
사용 시기
- iOS/macOS 앱의 로컬 데이터 저장 (사용자 데이터, 설정, 캐시된 콘텐츠)
- 나중에 서버와 동기화하는 오프라인 우선 아키텍처
- 앱의 여러 부분이 동시에 접근하는 공유 가변 상태
- 레거시
DispatchQueue기반 스레드 안전성을 최신 Swift 동시성으로 대체