스레드-세이프 지속성을 위한 Swift 액터
액터를 사용하여 스레드-세이프한 데이터 지속성 계층을 구축하는 패턴입니다. 인메모리 캐싱과 파일 기반 저장소를 결합하여 액터 모델을 활용해 컴파일 타임에 데이터 레이스를 제거합니다.
활성화 시점
- Swift 5.5+에서 데이터 지속성 계층을 구축할 때
- 공유 가변 상태에 대한 스레드-세이프한 접근이 필요할 때
- 수동 동기화(락, 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()
}
}
주요 디자인 결정
| 결정 | 근거 |
|---|---|
| 액터(클래스 + 락 대신) | 컴파일러가 강제하는 스레드 안전성, 수동 동기화 불필요 |
| 인메모리 캐시 + 파일 지속성 | 캐시에서 빠른 읽기, 디스크에 안정적인 쓰기 |
| 동기식 init 로딩 | 비동기 초기화의 복잡성 방지 |
| ID를 키로 하는 딕셔너리 | 식별자로 O(1) 조회 |
Codable & Identifiable 제네릭 | 모든 모델 타입에 재사용 가능 |
원자적 파일 쓰기 (.atomic) | 충돌 시 부분 쓰기 방지 |
모범 사례
- 액터 경계를 넘는 모든 데이터에는
Sendable타입 사용 - 액터의 공개 API를 최소화 — 도메인 작업만 노출하고 지속성 세부 사항은 숨기기
.atomic쓰기 사용 — 앱이 쓰기 도중 충돌할 때 데이터 손상 방지init에서 동기적으로 로드 — 비동기 초기화는 로컬 파일에 비해 복잡성만 증가@ObservableViewModel과 결합하여 반응형 UI 업데이트
피해야 할 안티 패턴
- 새로운 Swift 동시성 코드에 액터 대신
DispatchQueue또는NSLock사용 - 내부 캐시 딕셔너리를 외부 호출자에게 노출
- 검증 없이 파일 URL을 설정 가능하게 함
- 모든 액터 메서드 호출이
await임을 잊음 — 호출자는 비동기 컨텍스트를 처리해야 함 - 액터 격리를 우회하기 위해
nonisolated사용 (목적을 무의미하게 만듦)
사용 시기
- iOS/macOS 앱의 로컬 데이터 저장(사용자 데이터, 설정, 캐시된 콘텐츠)
- 나중에 서버와 동기화하는 오프라인 우선 아키텍처
- 앱의 여러 부분이 동시에 접근하는 공유 가변 상태
- 기존
DispatchQueue기반 스레드 안전성을 최신 Swift 동시성으로 대체