GitHub - EST-iOS-TEAM2/Dietto: ESTSoft Final 팀프로젝트
ESTSoft Final 팀프로젝트. Contribute to EST-iOS-TEAM2/Dietto development by creating an account on GitHub.
github.com
이전 프로젝트에서 내부 DB가 필요할 때 CoreData를 주로 사용했는데
SwiftData도 알아두면 추후에 마이그레이션 하기 좋을 거 같아 iOS 17 부터 사용할 수 있는 SwiftData를 이번 기회에 도입해보기로 결정했다.
CoreData보다 쉽게 모델을 정의하고, 비슷하게 CRUD를 할 수 있는점 덕분에 SwiftData 도입에 어려운것은 없었다!
난관에 봉착한 때는 CoreData처럼 BackgroundContext를 이용해 비동기로 CRUD 작업을 해보기 위한 도전부터 시작됐다..
SwiftData Repository 생성
SwiftData를 처음 도입할때 CoreData를 도입했을때와 비슷하게 작성하였다.
final class StorageRepositoryImpl<T: PersistentModel>: StorageRepository {
private let context: ModelContext
init(modelContainer: ModelContainer) {
self.context = ModelContext(modelContainer)
}
...
func updateData(predicate: Predicate<T>, updateBlock: @escaping (T) -> Void) throws {
let descriptor = FetchDescriptor<T>(predicate: predicate)
if let result = try context.fetch(descriptor).first {
updateBlock(result)
try context.save()
}
}
}
사용할 modelContext를 선언해두고 함수에서 CRUD를 구현하는 방식을 사용했다.
데이터 타입을 Generic으로 한 이유는 여러 모델에 대응하는 하나의 Repository만 두고싶어 사용하게 됐다.
Generic으로 Repository를 만들면 사용할때는 StorageRepositoryImpl<InterestsDTO>()와 같이 주입해주면 된다!
하지만 이렇게 했을땐 CRUD가 동기적으로 실행된다.
데이터가 적을때는 Main Thread에서 블락이 일어날 일이 없겠지만, 데이터가 많으면 블락이 생길 수 밖에 없다
그래서 이번에는 어떻게 하면 비동기로 Thread-safe하게 CRUD 할 수 있을지를 알아봤다.
먼저 CoreData는 NSPersistentContainer에서 newBackgroundContext()메서드를 통해 비동기 작업을 할 수 있었다.
하지만 SwiftData에는 ModelContainer에 위와 같은 메서드를 제공하지 않는다.
그렇다면 이런 방법은 어떨까?
Task.detached {
let object = ...
await modelContext.insert(object)
}
문제가 없을거 같았지만 아래와 같은 경고 문구가 떴다.
Non-sendable type ‘ModelContext’ in implicitly asynchronous access to main actor-isolated property ‘modelContext’ cannot cross actor boundary
ModelContext는 non-sendable타입인데 암묵적으로 비동기로 접근되었다. 이 속성은 Main Actor에 속해있어 actor 경계를 넘을 수 없다.
코드에서 사용된 modelContext는 MainActor의 리소스기 때문에 반드시 MainActor 내에서만 호출해야 안전하다는 경고다.
그래서 위 코드로는 비동기 CRUD를 구현해도 충돌은 안나지만 문제가 생길 수 있기에 더 좋은 방법을 찾아봤다.
ModelActor 매크로 사용하기
먼저 놓치고 있던 부분! 비동기 작업에 안전성을 보장해야한다.
일반 class로 생성했을때는 동시성 제어를 자동으로 해주지 않는다.
ModelContext는 그냥 사용하면 Thread-safe 하지 않기 때문에 동시 접근시 Race Condition이 발생할 수 있다.
@ModelActor는 이름에도 있듯이 SwiftData를 위한 Actor라고 생각하면 된다.
기본적으로 actor 기반으로 동작하며 ModelContext의 접근을 직렬화해준다.
이런 특징 덕분에 Thread-safe한 비동기 작업이 가능해진다.
@ModelActor
actor AnotherStorageRepositoryImpl<T: PersistentModel>: AnotherStorageRepository {
init() {
let configure = ModelConfiguration("\(T.self)") // 이름 지정
do {
let modelContainer = try ModelContainer(for: T.self, configurations: configure)
self.init(modelContainer: modelContainer)
} catch {
fatalError(error.localizedDescription)
}
}
func insertData(data: T) async throws {
modelContext.insert(data)
try modelContext.save()
}
func updateData(predicate: Predicate<T>, updateBlock: @escaping (T) -> Void) async throws {
let descriptor = FetchDescriptor<T>(predicate: predicate)
guard let result = try modelContext.fetch(descriptor).first else { throw StorageError.updateError }
updateBlock(result)
try modelContext.save()
}
func fetchData(where predicate: Predicate<T>? = nil, sort: [SortDescriptor<T>] = []) async throws -> [T] {
let descriptor = FetchDescriptor<T>(predicate: predicate, sortBy: sort)
return try modelContext.fetch(descriptor)
}
func deleteData(where predicate: Predicate<T>) async throws {
try modelContext.delete(model: T.self, where: predicate)
try modelContext.save()
}
}
ModelActor 매크로에는 ModelContext와 Executor가 존재해 따로 Context를 선언을 하지 않아도 된다.
그냥 Container만 잘 생성해서 넘겨주면 된다! (아래는 ModelActor 매크로의 본문 중 일부)
@available(swift 5.9)
@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *)
extension ModelActor {
/// The optimized, unonwned reference to the model actor's executor.
nonisolated public var unownedExecutor: UnownedSerialExecutor { get }
/// The context that serializes any code running on the model actor.
public var modelContext: ModelContext { get }
}
여기까지가 비동기 작업을 구현할 수 있는 초석이다.
그럼 이제 Container를 주입해주고 초기화하여 사용하면 의도한대로 MainThread가 아닌 다른 Thread에서 동작을 할까?
NOPE
미자막으로 하나만 해주면 완성이다.
바로 ModelActor를 초기화 할 때, Background Thread에서 초기화 해준다면 이 후 CRUD 함수를 호출해도 Background Thread에서 동작하는 걸 확인 할 수 있다.
Task.detached(priority: .background) { [weak self] in
self?.userStorageUsecase = UserStorageUsecaseImpl(storage: AnotherStorageRepositoryImpl<UserDTO>())
//Container는 Generic을 정의할 때 자동으로 초기화 후 주입되게 했습니다. (ModelActor 매크로 사용하기 부분 코드 참조)
}

그렇다면 마지막으로 Background Thread 동작이 보장되는 이유도 알아보자.
ModelActor를 초기화 할 때는 ModelContext라는 변수를 먼저 초기화 해 준다. 여기서 이 Context가 초기화 시점에 부모 Context를 상속받아 사용하기 때문에, 비동기 컨텍스트 내에서 생성하면 비동기로 동작하는게 보장되는 ModelActor가 생성된다.
Ref
UnownedSerialExecutor Apple dev docs
Concurrency of SwiftData, by Donny Wals
Power of ModelActor in SwiftData
이렇게 자세히 공부하게 된 이유는..
이 프로젝트에서는 비동기로 초기화 후 주입하지 않아도 Background Thread에 할당되기에 너무 이상해서 다른 팀원과 함께 열심히 알아보았다.. (도움을 받은 ㅎㅎ)
결국 샘플 프로젝트를 만들어 실행해보니, Background Thread에서 초기화를 해줘야만 되는것이 맞았다.
그래서 여전히 미스테리다.. 왜 이 프로젝트는 시뮬, 실 기기 모두 백그라운드에서 잘 돌았는가..
- 다른 친구의 기기로 실행해보니 Background Thread에서 초기화 하지 않으면 Main Thread에 할당되는 정상적인 모습 확인. (내 기기가 이상한 거 같다)
- 참고로 SwiftData는 auto save가 된다고 했지만, Background Thread에서는 안된다. 그래서 꼭 context.save() 명시 해줘야함