감자주먹밥

노래방Book 앱 정리 - 데이터 저장 (CoreData) 본문

IOS/UIKit

노래방Book 앱 정리 - 데이터 저장 (CoreData)

JustHm 2024. 5. 5. 00:49
728x90

iOS 강의를 다 듣고 바로 만들어서 배포해 본 앱이 있는데,, 이번에 다시 보니 정말 배웠던거 그대로 다 넣느라고 이상한게 많아서 수정하면서 정리를 하기로 했다.

앱의 기능 중 맘에 드는 곡을 북마크 하면 따로 애창곡 리스트로 볼 수 있는 기능이 있는데, 당시에 UserDefaults를 알게되고 아! 이게 iOS에서 DB처럼 쓰는거구나! 하면서 그냥 사용했다.

그러다 옛날 블로그 포스팅으로도 있지만 CoreData 존재를 알고나서 보니 멍청한 짓을 한 거 같아서 이것부터 수정하기로 했다.

일단 둘의 차이점을 먼저 알아보자면
UserDefaults는 App Setting 정보 같은 간단한 정보를 저장하기에 적합하고
CoreData는 복잡하고 큰 데이터를 저장하기 적합하다.

CoreData를 데이터베이스 자체로 알고 있었지만, 조금 찾아보니 CoreData는 프레임워크고  영구 데이터 저장, 임시 데이터 캐시, CloudKit을 통해 데이터 동기화 등을 할 수 있는 것으로, 데이터베이스를 사용할 수 있는 기능도 있는것!

https://justhm.tistory.com/69

 

[SwiftUI] CoreData + CloudKit으로 데이터 관리하기

CoreData는 UserDefaults와 비슷하다 생각할 수 있지만, UserDefaults는 간단한 데이터 정도의 저장이 적합하고, CoreData는 UserData, 큰 데이터를 저장하기 용이하다. 여기서 CloudKit까지 같이 사용해 준다면, G

justhm.tistory.com

여기서도 정리를 해 두었지만 이번에는 CoreData 사용과 UserDefaults에 있는 데이터를 CoreData로 이전한 내용 정도만 정리 하기로
추가로 CoreData에 만들어둔 Entity들은 SwiftData로도 그대로 쓸 수 있게 마이그레이션을 지원해준다. 나중에 SwiftData도 사용하게 된다면 이 앱에서 마이그레이션 해 기능을 변경해 볼 생각이다.

Zedd님 블로그를 참고해서 공부했었다...
https://zeddios.tistory.com/987

 

Core Data (1)

안녕하세요 :) Zedd입니다. Core Data를 사용할 일이 생겼는데...제가 옜날에 해봤단 말이죠..!?!?!? 근데 다시 하려니까 생각이 하나도 안나는거에요 그때는 문서 볼 생각도 안했었는데..ㅎㅎㅎㅎ 이

zeddios.tistory.com


1. CoreData 모델 생성 및 사용

프로젝트 생성시 체크하지 않았기 때문에 아무것도 없다. 그렇기에 먼저 모델을 생성해줘야 한다.

왼쪽의 Entity를 생성하고 Attribute를 추가하면 사용할 모델을 만들 수 있다.
기본으로 지원하는 타입 말고도 custom한 enum, struct를 사용할 수 도 있다.

다 만들고 나면 Inspector에서 Class>Module의 설정을 Current Product Module로 바꿔야 한다.
이것때문에 초반에 문제가 있었는데, 기본으로 Model, Entity를 만들면 Class가 구현되어 접근을 할 수 있는데 안되길래 보니 이 설정이 필요했다.
SwiftUI에서 프로젝트 생성시에는 문제없이 됐던걸로 기억하는데 프로젝트 생성시 설정을 안한 프로젝트여서 그런 것 같다.

추가로 Inspector에 Codegen 속성도 있는데, Entity를 확장해서 쓸 때 저 속성에서 Category/Extension 속성을 선택하고 
Editor>Create NSManagedObject Subclass를 누르면 파일들이 프로젝트에 생성되는데, 거기서 확장을 하면 된다.
실제로는 Category/Extension 속성을 활용하는 경우가 더 많다고 하는데 내 프로젝트는 간단해서 그럴 필요가 없었다.

2. CoreData 사용할 클래스 생성

보통 프로젝트 생성시 선택했다면 기본적으로 만들어진 걸 활용하면 되지만, 없으니 만들었다.
기본적으로 두 가지를 먼저 해야하는데, Container와 Context를 생성해야한다.

lazy var persistentContainer: NSPersistentContainer = {
    let container = NSPersistentContainer(name: "SongModel")
    
    container.loadPersistentStores(completionHandler: { (storeDescription, error) in
        if let error = error as NSError? {
            fatalError("Unresolved error \(error), \(error.userInfo)")
        }
    })
    
    return container
}()
var context: NSManagedObjectContext {
    return self.persistentContainer.viewContext
}

PersistentContainer 뭔지 알아보려면 CoreDataStack을 알아야 한다. [[이거 추가해야함]]

먼저 데이터를 저장하는 코드

func addFavoriteSong(song: Song) -> Bool {
    let entity = NSEntityDescription.entity(forEntityName: "FavoriteSong", in: self.context)
    if let entity {
        let managedObject = NSManagedObject(entity: entity, insertInto: self.context)
        managedObject.setValue(song.id, forKey: "id")
        managedObject.setValue(song.brand.name, forKey: "brand")
        managedObject.setValue(song.title, forKey: "title")
        managedObject.setValue(song.no, forKey: "number")
        managedObject.setValue(song.singer, forKey: "singer")
        managedObject.setValue(song.composer, forKey: "composer")
        managedObject.setValue(song.lyricist, forKey: "lyricist")
        
        do {
            try self.context.save()
            return true
        } catch {
            //ERROR
            return false
        }
    }
    else {
        //ERROR
        return false
    }
}

NSEntityDescription.entity 메서드를 이용해 먼저 정의했던 Entity를 가져온다.
그 후 Entity의 Attribute에 맞게 데이터를 넣어주면 된다. CoreData의 Entity 데이터 형식은 NSManagedObject이니 다음과 같이 생성해주고 각 Attribute의 값을 주입하면 된다.

CodeGen에서 클래스 확장을 선택해 간편하게 저장할 데이터를 넣을 수 있다는 거

그리고 꼭! 해야할 작업이 save()다.
데이터 저장, 삭제시 save()를 하지 않으면 반영되지 않으니 절대 잊지 말기.

추가, 수정, 삭제 등 전체 코드로 나중에 보기로 하고 먼저 좀 헤맸던 부분을 정리해보자면
노래를 저장할 때 브랜드이름+곡번호 로 저장을 하고 검색도 그 ID를 사용하려 했는데 CoreData에서는 어떻게 검색을 하는지 의문이었다.
그냥 특정키, 값을 파라미터로 하는 검색 메서드가 있는 줄 알았지만, Entity 요청에 처음 본 NSPredicate 클래스를 사용해 검색을 할 수 있었다.

func fetchByBrand(brand: String) -> [FavoriteSong] {
    let context = persistentContainer.viewContext
    let request: NSFetchRequest<FavoriteSong> = FavoriteSong.fetchRequest()
    request.predicate = NSPredicate(format: "brand == %@", brand)
    do {
        let song = try context.fetch(request)
        return song
    } catch {
        return []
    }
}

NSFetchRequest는 Persistent store에서 데이터를 검색하는 데 사용되는 검색 기준에 대한 설명으로 Document에 나와있는데
검색을 하고싶은 Entity를 타겟으로 요청을 생성하는 것이다. 
가만보면 URLSessionDataTask였나 그 친구도 그렇고 request를 한다면 그것에 대한 정의를 하고 요청을 하는게 정석인거 같다.

NSPredicate는 메모리 내에 어떤 값을 가져올 때 Filter에 대한 조건을 정의하는 것으로 주로 NSArray, CoreData, 정규식에서 사용된다고 한다.

사용하는 검색용 문법 정도로 이해가 된다..
Predicate문법이 정말 생소했는데 아래 블로그를 보며 금방 감을 잡았다.
복잡하게 검색을 할 것도 아니였기 때문에 코드를 보면 brand Attribute 중 해당 브랜드와 같은 브랜드의 데이터를 필터링 하는 것을 간단하게 만들 수 있었다.
https://onelife2live.tistory.com/35 

 

[iOS] NSPredicate 문법 정리

NSArray를 필터링 할 때, CoreData를 사용할 때 Predicate 문법을 사용하여 필터링 하곤 합니다. 이 때 주로 사용하는 문법들을 정리해보겠습니다. let request: NSFetchRequest = Entity.fetchRequest() let predicate = NSPr

onelife2live.tistory.com

Singleton으로 만든 Persistence Manager 전체코드

더보기
import CoreData

class PersistenceManager {
    static var shared: PersistenceManager = PersistenceManager()
    lazy var persistentContainer: NSPersistentContainer = {
        let container = NSPersistentContainer(name: "SongModel")
        
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
        
        return container
    }()
    var context: NSManagedObjectContext {
        return self.persistentContainer.viewContext
    }
    
    func fetchData() -> [FavoriteSong] {
        do {
            let fetchRequest = FavoriteSong.fetchRequest()
            let data = try self.context.fetch(fetchRequest)
            return data
        } catch {
            //ERROR
            print("\(error.localizedDescription)")
            return []
        }
    }
    @discardableResult
    func addFavoriteSong(song: Song) -> Bool {
        let entity = NSEntityDescription.entity(forEntityName: "FavoriteSong", in: self.context)
        if let entity {
            let managedObject = NSManagedObject(entity: entity, insertInto: self.context)
            managedObject.setValue(song.id, forKey: "id")
            managedObject.setValue(song.brand.name, forKey: "brand")
            managedObject.setValue(song.title, forKey: "title")
            managedObject.setValue(song.no, forKey: "number")
            managedObject.setValue(song.singer, forKey: "singer")
            managedObject.setValue(song.composer, forKey: "composer")
            managedObject.setValue(song.lyricist, forKey: "lyricist")
            
            do {
                try self.context.save()
                return true
            } catch {
                //ERROR
                return false
            }
        }
        else {
            //ERROR
            return false
        }
    }
    
    func getCurrentObject(songID id: String) -> [FavoriteSong] {
        let context = persistentContainer.viewContext
        let request: NSFetchRequest<FavoriteSong> = FavoriteSong.fetchRequest()
        request.predicate = NSPredicate(format: "id == %@", id)
        do {
            let song = try context.fetch(request)
            return song
        } catch {
            return []
        }
    }
    func fetchByBrand(brand: String) -> [FavoriteSong] {
        let context = persistentContainer.viewContext
        let request: NSFetchRequest<FavoriteSong> = FavoriteSong.fetchRequest()
        request.predicate = NSPredicate(format: "brand == %@", brand)
        do {
            let song = try context.fetch(request)
            return song
        } catch {
            return []
        }
    }
    
    @discardableResult
    func deleteSong(id: String) -> Bool {
        let data = getCurrentObject(songID: id)
        guard !data.isEmpty else { return false }
        
        let object = data[0] as NSManagedObject
        
        self.context.delete(object)
        do {
            try self.context.save()
            return true
        } catch {
            //ERROR
            return false
        }
    }
    
    // 애창곡으로 되어 있는지만 확인하기 위한 용도
    func isExist(songID id: String) -> Bool {
        let request: NSFetchRequest<FavoriteSong> = FavoriteSong.fetchRequest()
        request.predicate = NSPredicate(format: "id == %@", id)
        do {
            let data = try self.context.fetch(request)
            if data.isEmpty { return false }
            return true
        } catch {
            print(error.localizedDescription)
            return false
        }
    }
}

3.  데이터 이전 작업

위에 만들어 놓은 PersistenceManager를 통해 기존에 데이터를 저장, 변경, 불러오는 코드는 다 대체를 했다.
이제 마지막으로 남은것이 데이터를 이전하는 것인데, 맘 같아서는 그냥 버리고 새로 데이터 쌓게 하지 뭐.. 싶었지만
버그 많은 별거 없는 앱도 다운수가 1.5K가 넘어가는 걸 보고 이 중 한 명정도는 저장해 둔 데이터를 요긴하게 쓰겠지 싶어 데이터 이전 작업을 하고 UserDefaults는 구현 코드 정도만 남겨두기로 했다.

func migrationToCoreData() {
    //데이터 이전 작업 UserDefaults -> CoreData
    var songs: [Song] = []
    if let data = UserDefaults.standard.data(forKey: UserDefaultsManager.Key.tjFavorite.rawValue) {
        songs.append(contentsOf: (try? PropertyListDecoder().decode([Song].self, from: data)) ?? [])
    }
    if let data = UserDefaults.standard.data(forKey: UserDefaultsManager.Key.kyFavorite.rawValue) {
        songs.append(contentsOf: (try? PropertyListDecoder().decode([Song].self, from: data)) ?? [])
    }
    
    for song in songs {
        if !PersistenceManager.shared.isExist(songID: song.id) {
            let result = PersistenceManager.shared.addFavoriteSong(song: song)
            if !result {
                print("Failed Migration: \(song.id), \(song.title)")
            }
        }
    }
}

SceneDelegate에서 윈도우 생성 후 바로 동작하도록 함수로 만들어 호출했다.
UserDefaults에 브랜드별로 데이터가 따로 저장되어 있었는데 이를 모두 가져와서 CoreData에 저장하는 방식으로 했다.
UserDefaults를 DB로 쓴 덕에 이런 경험도 해보고 나름 뿌듯?했다.

지금 코드를 보니 이전작업이 끝나도 끊임없이 UserDefaults에 있는 데이터를 가져와 같은 작업을 반복하게 될텐데,,,
이 코드는 나중에 완벽히 이전이 됐는지 불러온 데이터와 CoreData에 저장된 데이터를 교차 검증 하는 코드를 넣고
UserDefaults에 이전을 완료했는지를 확인하는 값을 저장해서 앱을 실행할 때 마다 데이터 이전 작업을 하지 않게 만들어야겠다.

이 작업 외에도 API 변경으로 Response를 받지 못하는 문제, 최신곡 날짜 선택 UI 등 추가한 것이 몇가지 있어 몇 가지 더 정리하고 나면 API 변경시 대처 외에는 더 이상 작업할 일이 없을 거 같다.. 
계속 유지할 수 있는 앱을 만들고 싶었지만,,, 정말 간단하고 더 추가할 수 있는게 없는 컨텐츠여서 아쉽다.

지금까지 버그 있는데도 다운받아주신 분들 죄송합니다.. 이제 완벽하게 잘 동작하니 가볍게 써주시면 감사하겠습니다!

https://apps.apple.com/kr/app/%EB%85%B8%EB%9E%98%EB%B0%A9book/id1672848960

 

‎노래방Book

‎[주요 기능] - 최신곡 매일 확인 가능 - 인기차트 - 노래 저장기능 (애창곡) - 노래 이름 및 가수 검색 기능 - 유튜브에 올라와 있는 MR검색 [검색 가능한 노래방] - TJ, 금영

apps.apple.com

 

728x90
Comments