| name | swift-actor-persistence |
| description | Swift의 actor를 사용한 스레드 안전 데이터 영속화 패턴입니다. 메모리 캐시와 파일 기반 저장을 결합해 데이터 레이스를 설계 단계에서 제거합니다. |
| origin | ECC |
스레드 안전한 영속화를 위한 Swift Actors (Swift Actors for Thread-Safe Persistence)
Swift actor를 이용해 스레드 안전한 데이터 영속화 레이어를 만드는 패턴입니다. 메모리 캐시와 파일 기반 저장을 결합하고, actor 모델을 활용해 컴파일 단계에서 데이터 레이스를 없앱니다.
활성화 시점
- Swift 5.5+에서 데이터 영속화 레이어를 만들 때
- 공유 가변 상태에 대한 스레드 안전 접근이 필요할 때
- 수동 동기화(lock, DispatchQueue)를 제거하고 싶을 때
- 로컬 저장소를 쓰는 offline-first 앱을 만들 때
핵심 패턴
Actor 기반 레포지토리 (Actor-Based Repository)
actor 모델은 직렬화된 접근을 보장합니다. 데이터 레이스가 없고, 컴파일러가 이를 강제합니다.
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)
self.cache = Self.loadSynchronously(from: fileURL)
}
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)
}
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) })
}
}
사용 예시
actor 격리 때문에 모든 호출은 자동으로 async가 됩니다.
let repository = LocalRepository<Question>()
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()
}
}
핵심 설계 결정
| 결정 사항 | 근거 |
|---|
| Actor 사용 | 컴파일러가 강제하는 스레드 안전성 |
| 메모리 캐시 + 파일 저장 | 빠른 읽기 + 디스크 내구성 |
동기 init 로딩 | async init의 복잡성 회피 |
| ID 키 딕셔너리 | O(1) 조회 성능 |
Codable & Identifiable 제네릭 | 여러 모델에 대한 재사용 가능성 |
.atomic 파일 쓰기 | 앱 중단 시 부분 쓰기(partial write) 방지 |
모범 사례
- actor 경계를 넘는 데이터는
Sendable 타입을 사용합니다.
- actor의 public API는 최소화합니다.
- 데이터 손상을 막기 위해
.atomic 쓰기를 사용합니다.
- 로컬 파일이면
init에서 동기 로딩을 선호합니다.
- 반응형 UI 업데이트에는
@Observable ViewModel과 결합합니다.
피해야 할 안티패턴
- 현대적인 Swift concurrency 코드에서 actors 대신
DispatchQueue나 NSLock 사용
- 내부 캐시 딕셔너리를 외부에 노출
- 검증 없이 파일 URL 구성 가능하게 만들기
- 모든 actor 메서드 호출 시
await가 필요하다는 점을 잊는 것
nonisolated로 actor 격리를 우회하는 것
적합한 사용 사례
- iOS/macOS 앱의 로컬 데이터 저장
- 나중에 서버와 동기화하는 offline-first 아키텍처
- 앱의 여러 부분에서 동시에 접근하는 공유 가변 상태
- 기존
DispatchQueue 기반의 스레드 안전 코드를 현대적인 Swift concurrency로 교체할 때