천원의 개발

iOS SwiftData CRUD를 구현해보자 본문

iOS&Swift🍎/iOS

iOS SwiftData CRUD를 구현해보자

천 원 2024. 8. 22. 13:28

우선 SwiftData는 데이터 모델링 및 관리를 위한 프레임워크로 iOS 17이상 부터 사용이 가능한 데이터베이스입니다.
개발하면서 오류 로그들을 확인해 보면 CoreData를 기반으로 만들어져 발생하는 오류가 CoreData에서 발생하는 오류와 비슷한 경우가 많았습니다.

기본적으로 SwiftUI와 친화적으로 만들어져있어서 SwiftUI와 함께 사용하는게 더 간편하지만, UIKit과 함께 사용하는 자료는 적은 것 같아 UIKit을 기반으로 어떻게 사용하면 좋을지 고민한 내용을 공유하고자 해당 글을 작성합니다.

 

 

모델 생성

@Model atributte 키워드를 통해서 모델의 생성이 가능합니다. 저는 영화의 정보를 저장할 Movie 모델을 생성하고  @Attribute(.unique)를 활용하여 모델의 기본키를 지정해 주었습니다.

import SwiftData

@Model
final public class Movie {
    @Attribute(.unique) public var id: String?
    public var title: String
    public var content: String
    public var image: [Data]
    public var date: Date
    public var rate: Int
    public var heart: Int
    
    public init(
        title: String,
        content: String,
        image: [Data],
        date: Date,
        rate: Int
    ) {
        self.id = UUID().uuidString
        self.title = title
         self.content = content
        self.image = image
        self.date = date
        self.rate = rate
        self.heart = 0
    }
}

 

 

PersistentModel

우선 다양한 Model의 CURD를 제공해주기 위해서 제네릭형태로 Repository를 구현을 하였습니다. 그러기 위해서 모델들의 공통적으로 가지고 있는 프로토콜 제약을 찾아보았고 PersistentModel이 SwiftDate의 인터페이스를 제공함을 확인할 수 있었습니다.

 

 

 

 

ModelContainer

초기화 구문에서 모델의 CRUD에 필요한 ModelContainer 를 생성해 주었습니다.  

public final class SwiftDataRepository<T: PersistentModel>: SwiftDataRepositoryProtocol {
    
    public init() {
        let configure = ModelConfiguration("\(T.self)")
        do {
            container = try ModelContainer(for: T.self, configurations: configure)
        } catch {
            fatalError(error.localizedDescription)
        }
    }
    
    let container: ModelContainer
}

ModelContainer를 생성하기 위해서는 모델의 메타타입과 ModelConfiguration이 필요한데 여기서 만났던 이슈는 ModelConfiguration의 초기화 구문을 살펴보면 name을 옵셔널 형태로 가지고 있는데 

 

ModelConfiguration() 형태로 생성하게 되면 하나의 모델에서는 정상적으로 동작하지만 모델의 개수가 2개 이상이 되면 충돌이 발생하니ModelConfiguration 생성 시에는 항상 name을 지정해 주면 좋을 것 같습니다. 저는 모델의 메타타입을 사용하여 name을 지정해 주었습니다.

 

 

Insert Data 

Repository의 인터페이스는 아래와 같이 두었습니다. 해당 프로젝트에서는 clean architecture 적용하여 인터페이스는 Domain 모듈에 두어 Usecase에서 접근이 가능하도록 하였습니다.

public protocol SwiftDataRepositoryProtocol {
    
    associatedtype T: PersistentModel
    
    func insertData(data: T) async
    func fetchData() async throws -> [T]
    func deleteData(data: T) async
    func deleteAllData() async
}

 

 

비동기 처리는 async await 스타일로 진행을 하였고 초기화 시점에서 만든 container를 활용하여 insert와 save를 진행합니다.

public func insertData(data: T) {
    Task { @MainActor in
        let context = container.mainContext
        context.insert(data)

        do {
            try context.save()
        } catch {
            print("Error saving context: \(error.localizedDescription)")
        }
    }
}

 

 

Read Data

아래는 모든 데이터를 가져오지만 FetchDescriptor 생성 시 predicate에 가져오려는 데이터의 조건을 추가해 준다면 원하는 데이터를 소팅해서 가져올 수 있습니다.

@MainActor
public func fetchData() async throws -> [T] {
    let descriptor = FetchDescriptor<T>(predicate: nil)

    let context = container.mainContext
    let data = try context.fetch(descriptor)
    return data
}

 

 

Predicate 예시 코드

let moviePredicate = #Predicate<Movie> { 
    $0.rate > 4 && 
    $0.title.contains("birthday") && 
    $0.date > today
}

fetchData 시 매개변수로 받아서 사용해도 좋을 것 같습니다.

 

 

Delete Data

public func deleteData(data: T) async {
    let context = await container.mainContext
    context.delete(data)

    do {
        try context.save()
    } catch {
        print("Error saving context: \(error.localizedDescription)")
    }
}

 

특정 데이터를 삭제하는 함수이고 해당 함수와 fetch함수를 활용하여 전체 삭제 함수를 구현하였습니다.

public func deleteAllData() async {
    do {
        let data = try await fetchData()
        for item in data {
            await deleteData(data: item)
        }
    } catch {
        print("Error fetching or deleting all data: \(error.localizedDescription)")
    }
}

 

 

 

Repository 사용해보기

Usecase에서 repository의 인스턴스를 생성해서 사용하는 sample code 입니다.  repository의 타입을

SwiftDataRepositoryProtocol 인터페이스에 두고 repository의 associatedtype 조건을 Movie로 설정하여 Movie 정보를 DB에서 fetch하는 함수를 구현해 주었습니다.

public protocol FetchMovieUsecaseProtocol {
    func execute() async -> [Movie]
}

public final class FetchMovieUsecase<Repository: SwiftDataRepositoryProtocol>: FetchMovieUsecaseProtocol where Repository.T == Movie {
    
    private let repository: Repository
    
    public init(repository: Repository) {
        self.repository = repository
    }
    
    public func execute() async -> [Movie] {
        do {
            let data = try await repository.fetchData()
            return data
        } catch {
            fatalError(error.localizedDescription)
        }
    }
}

 

 

여기까지 간단히 정리한 SwiftData의 CRUD 였습니다.

 

참고:

https://developer.apple.com/documentation/swiftdata

https://developer.apple.com/videos/play/wwdc2023/10196

https://developer.apple.com/videos/play/wwdc2023/10187

 

 

전체 코드:

public final class SwiftDataRepository<T: PersistentModel>: SwiftDataRepositoryProtocol {
    
    public init() {
        let configure = ModelConfiguration("\(T.self)")
        do {
            print("configure Init")
            container = try ModelContainer(for: T.self, configurations: configure)
        } catch {
            fatalError(error.localizedDescription)
        }
    }
    
    let container: ModelContainer
    
    
    public func insertData(data: T) {
        Task { @MainActor in
            let context = container.mainContext
            context.insert(data)
            
            do {
                try context.save()
            } catch {
                print("Error saving context: \(error.localizedDescription)")
            }
        }
    }
    
    @MainActor
    public func fetchData() async throws -> [T] {
        let descriptor = FetchDescriptor<T>(predicate: nil)
        
        let context = container.mainContext
        let data = try context.fetch(descriptor)
        return data
    }
    
    public func deleteData(data: T) async {
        let context = await container.mainContext
        context.delete(data)
        
        do {
            try context.save()
        } catch {
            print("Error saving context: \(error.localizedDescription)")
        }
    }
    
    public func deleteAllData() async {
        do {
            let data = try await fetchData()
            for item in data {
                await deleteData(data: item)
            }
        } catch {
            print("Error fetching or deleting all data: \(error.localizedDescription)")
        }
    }
}