iOS

[iOS] CoreData를 활용하여 CRUD 구현하기

yanni13 2025. 4. 28. 23:30

 

 

오늘은 CoreData에 대해서 CoreData가 무엇인지,

CoreData의 특징과 이를 활용한 CRUD까지 다뤄볼 예정이다.

 

 

 

✏️ CoreData 란?

 

 

 

CoreData는 apple이 제공하는 iOS 자체에서 데이터를 저장할 수 있도록 하는 프레임워크이다.

 

데이터베이스가 아닌, 데이터 모델링과 영속성 관리를 위한 프레임워크로 Swift에서 데이터를 쉽게 저장할 수 있도록 해주는 도구이다.

 

CoreData는 iOS 버전 3 이상부터 사용할 수 있으며, 오래전에 나왔고 아직도 사용되고 있는 프레임워크이다.

 

 

 

위 사진은 CoreData의 Stack을 보여주는 구조이며 CoreData의 구조는 4가지로 분류할 수 있다.

최상위 컨테이너인 Persistent Container 는 model, context, Store coordinator를 캡슐화하도록 하며 앱이 시작될 때 초기화되어 전체 CoreData 스택을 관리하도록 한다.

 

하위 속성에는 Model, Context, Store Coordinator, 데이터 베이스 저장소가 포함된다.

 

  • Model : 모델의 엔티티, 속성, 관계, 구성등을 정의하며 스키마를 정의한다.
  • Context : 메모리 내 작업 공간으로 데이터를 임시로 저장하고 조작한다. CRUD 작업을 주로 수행하며 변경사항을 추적하고 저장하기 전까지는 실제 저장소에 반영되지 않는다.
  • Store Coordinator : 모델과 실제 저장소 사이의 중재자 역할을 하며 다양한 유형의 저장소와 통신을 주로 조정한다.
  • 데이터 베이스 저장소: 실제 데이터가 영구적으로 저장되는 디스크 상의 파일이다. SQLite 데이터 베이스 형태로 구현된다.

 

즉 한마디로 정리하면 앱에서 데이터를 저장하려고 (Create) 하는 경우에는 NSManagedObjectContext에서 작업되며 context에서 변경사항을 저장했을 경우 디스크에 반영되기 때문에, 해당 변경사항이 Presistent Container를 통해 실제 저장소에 반영된다.

 

결과적으로 CoreData 앱의 데이터를 효율적으로 관리하고 저장하기 위해 Persistent Container 중심으로 다양한 구성 요소들이 협력하는 구조이다.

 

 

CoreData 특징

1️⃣ 영속성

 

DB를 직접 관리하지 않고도 데이터를 쉽게 저장할 수 있다.

 

앱을 꺼도, 기기를 꺼도 데이터가 없어지지 않고 남아있는 것을 의미하는데 이게 데이터 베이스도 존재하지 않고 서버와의 통신도 불가능한 상황에서 어떻게 데이터가 오가는지를 살펴보자!

 

박스는 디스크를 의미하고 뇌는 메모리를 의미함!

 

 

앱을 첫 실행했을때 디스크에 저장된 SQLite 파일들을 NSManagedObject를 통해서 메모리에서 불러온다. 이후 데이터의 수정, 추가, 삭제 작업은 모두 메모리에서 이루어지며 메모리 안에서는 NSManagedObjectContext라는 작업 공간이 사용된다.

 

NSManagedObjectContext 안에서, 각각의 데이터는 NSManagedObject 형태로 관리되고, 우리는 객체를 통해 수정, 추가, 삭제를 진행하게 된다.

 

이렇게 메모리에서 변경된 데이터는 context.save() 호출해야 디스크로 옮겨지므로 이 코드를 작성하지 않으면 메모리에서는 데이터가 변경됐어도 디스크에는 실제로 반영되지 않았기 때문에 앱을 종료하면 사라지게 된다.

 

CoreData로 개발했을 때 실수했던 것 중 하나..

 

따라서 CoreData는 DB가 아닌 디스크에 파일 형태로 저장을 함으로써 데이터를 저장하는 게 가능하게 된 것이다.

여기서 디스크란 그냥 코어데이터에서 사용하는 로컬 DB라고 이해하면 편할 거 같다.

 

2️⃣ 개별 변경사항이나 여러 변경사항을 redo 및 undo 할 수 있다.

redo, undo

 

개별 변경사항이나 여러 변경사항을 되돌리거나 수정 취소할 수 있다. 

 

예를 들어 이름 수정 -> 나이 수정 -> 주소 수정 총 세가지의 수정을 했을 때 각각 따로 변경사항을 취소할 수 있고, 한 덩어리로 묶어서 수정 전체를 undo 할 수도 있다.

 

따라서 복잡한 상태관리나 취소하기 버튼 같은 걸 구현할 때 훨씬 수월하다는 장점이 있다.

 

3️⃣ 백그라운드 데이터 작업

JSON과 같은 데이터 작업은 백그라운드에서 실행함으로써 결과를 캐시 하거나 저장하여 서버의 전체 시간을 줄일 수 있도록 한다.

그 후 메인스레드에서는 UI과 관련된 작업만 수행할 수 있도록 지원하고, 데이터 같은 영역은 백그라운드에서 작업한 후 완료했을 때 메인에 반영할 수 있도록 한다.

 

이를 통해 사용자 UI가 버벅거리거나 하지 않을 수 있도록 하는 장점이 있다.

 

4️⃣ View 동기화

 

CoreData는 테이블 및 컬렉션 뷰에 대한 데이터 소스를 제공하여 뷰와 데이터를 동기화된 상태로 유지하도록 한다.

데이터가 변경되면 자동으로 뷰와 데이터 상태를 동기화하도록 하여 데이터가 삽입/수정/삭제 되어 테이블의 변경사항이 발생했을 때 swiftUI 뷰가 자동으로 다시 그려지게 된다.

 

-> 근데 이건 뷰의 초기화 시점이랑 잘 맞물리지 않으면 오히려 단점이 될 수도 있다고 생각한다.

 

5️⃣ 버전 관리와 마이그레이션

 

예를 들어 앱을 배포하고 업데이트 하면서 새로운 기능을 도입하려고 하는 경우, 새로운 기능에 대한 모델이 추가적으로 필요할 수 있다.

 

이 때 사용자가 업데이트를 했을 경우 데이터 구조가 맞지 않아서 앱이 충돌 나면서 실행되지 않을 수가 있는데 CoreData는 이런 문제를 해결하기 위해 모델 버전을 지원하고, 마이그레이션 방법을 지원하여 간단한 옵션을 (NSMigratePersistentStoresAutomaticallyOption, NSInferMappingModelAutomaticallyOption)을 적용했을 경우 자동으로 필드 추가, 삭제 정도는 알아서 마이그레이션 시켜준다.

 

옵션 적용 예시

 

 

따라서 앱을 업데이트 할 때 기존 유저 데이터가 지워지거나 하지 않을 수 있다.

 

 

 

💻 CoreData를 활용한 CRUD 구현하기

🔥 도전항목 추가, 조회, 수정, 삭제

1. 도전항목 추가 : 필수 입력값 (title, startDate, endDate, category) / 옵셔널 (memo)
2. 도전항목 조회
3. 도전항목 수정 
4. 도전항목 삭제

 

1. 데이터 구조 설정하기

 

먼저 도전항목에 나타나는 필수 값들을 설정해 준다.

필수 입력값인 title, startDate, endDate, category와 고유 식별자를 나타내는 id까지 입력해 주고, 옵셔널 값인 memo도 추가로 속성으로 부여해 줬다.  

 

그리고 각각 타입을 설정하면 데이터 구조의 설정은 끝나게 된다!

 

CREATE

    /// 데이터 저장
    func saveChallenge(context: NSManagedObjectContext, mode: ChallengeEditorMode) {
        switch mode {
        case .add:
            let newChallenge = Challenge(context: context)
            newChallenge.id = UUID()
            newChallenge.title = self.title
            newChallenge.memo = self.memo
            newChallenge.startDate = self.startDate
            newChallenge.endDate = self.endDate
            newChallenge.category = self.category?.rawValue ?? ""
            
        case .edit:
            guard let existingChallenge = self.existingChallenge else {
                print("❌ existingChallenge가 nil입니다.")
                return
            }
            existingChallenge.title = self.title
            existingChallenge.memo = self.memo
            existingChallenge.startDate = self.startDate
            existingChallenge.endDate = self.endDate
            existingChallenge.category = self.category?.rawValue ?? ""
            
            updateTrigger = true
            
        }
        
        do {
            try context.save()
            print("✅[AddChallengeViewModel] 저장 성공")
            
            // 저장 후 바로 데이터 확인
            let fetchRequest: NSFetchRequest<Challenge> = Challenge.fetchRequest()
            let challenges = try context.fetch(fetchRequest)
            print("저장된 Challenge 수: \(challenges.count)")
            
            // 방금 저장한 데이터 확인
            if let lastChallenge = challenges.last {
                print("마지막으로 저장된 Challenge:")
                print("제목: \(lastChallenge.title ?? "없음")")
                print("메모: \(lastChallenge.memo ?? "없음")")
            }
            
        } catch {
            print("❌[AddChallengeViewModel] 저장 실패: \(error)")
        }
    }

 

수정하기인지, 추가하기 인지에 따른 저장 로직이 달라서 두 개로 분리했지만 결과적으로 보면 같은 로직을 실행하고 있다.

 

NSManageObjectContext라는 공간에, Challenge라는 엔티티를 추가하거나 수정하여 context.save()를 실행한 후 디스크에 영구적으로 저장하고 있는 모습이다.

 

context.save()를 실행하게 되면 이 시점까지 context에 쌓여 있던 모든 변경사항(추가/수정)을 실제 Persistent Store에 커밋하며 이 코드를 호출하기 전까지는 메모리 상에만 존재하며 실제 디스크에는 저장되지 않고 있는 상태를 의미한다.

 

 

 

 

READ

    /// 데이터 조회
    func fetchAllChallenges(context: NSManagedObjectContext) -> [ListItemModel] {
        let request: NSFetchRequest<Challenge> = Challenge.fetchRequest()
        request.relationshipKeyPathsForPrefetching = ["records"]
        
        
        do {
            let challenges = try context.fetch(request)
            
            // Challenge 데이터를 ListItemModel로 변환
            self.items = challenges.compactMap { $0.toListItemModel() }
                     
            return items
        } catch {
            print("CoreData fetch error: \(error)")
            return []
        }
        
    }

 

fetchAllChallenges에서는 Challenge 엔터티의 모든 데이터를 CoreData로부터 조회하여 UI에 나타낼 수 있도록 ListItemModel로 변환해서 뷰에 넘겨주는 역할을 한다.

 

Challenge라는 엔티티와 관계로 엮여있는 records라는 관계까지 불러옴으로써 성능을 최적화하고 있다.

 

UPDATE

    /// 데이터 수정 - 진행중인 데이터를 완료 데이터로 넘김
    func updateCompletion(for item: ListItemModel, context: NSManagedObjectContext) {
        let request: NSFetchRequest<Challenge> = Challenge.fetchRequest()
        request.predicate = NSPredicate(format: "id == %@", item.id as CVarArg)
        
        do {
            let results = try context.fetch(request)
            if let challenge = results.first {
                challenge.isCompleted = item.isCompleted
                try context.save()
                print("✅ isCompleted 상태가 CoreData에 반영되었습니다.")
            }
        } catch {
            print("❌ CoreData 업데이트 실패: \(error)")
        }
    }

 

위 update 함수는 Challenge 엔티티에서 특정 id 가 item.id와 일치하는 데이터만 가져오도록 조건을 검으로써 성능을 최적화 하고 있다.

 

이 또한 NSManagedObjectContext에서 데이터를 조회하고, 완료상태 (=isCompleted)를 수정하여 context.save()하여 디스크에 업데이트하는 방식이다.

 

 

DELETE

    /// 데이터 삭제
    func deleteChallenge(for item: ListItemModel, context: NSManagedObjectContext, completion: @escaping (Bool) -> Void) {
        let request: NSFetchRequest<Challenge> = Challenge.fetchRequest()
        request.predicate = NSPredicate(format: "id == %@", item.id as CVarArg)
        
        do {
            let results = try context.fetch(request)
            if let challenge = results.first {
                context.delete(challenge)
                try context.save()
                completion(true)
                print("🗑️ Challenge가 삭제되었습니다.")
            } else {
                completion(false)
                print("⚠️ 해당 ID에 해당하는 Challenge를 찾을 수 없습니다.")
            }
        } catch {
            completion(false)
            print("❌ CoreData 삭제 실패: \(error)")
        }
    }

 

delete 함수는 언뜻 보면 update와 동일한 기능을 수행하는 거 같아 보이지만, 실제로는 NSManagedObjectContext에서 item.id와 같은 id 값을 찾은 다음 context에서 지워버리도록 한다. 

 

그 후 context.save()를 통해 디스크에 영구적으로 삭제시켜 버리는 방식으로 동작한다.

 

 

 

CoreData 관점에서 설명을 했지만 이 글을 보고도 잘 모르겠다면 엔티티와 속성을 추가하는 자세한 방법은 공식문서를 참고하면 좋을 거 같다!

 

🤔 SwiftData vs CoreData

 

SwiftData는 iOS 17에서 새로 나온 프레임워크이지만 내부 동작을 보면 CoreData 기반이다. CoreData보다 훨씬 쉽지만, CoreData의 기능을 수행한다고 보면 된다.

 

 

SwiftData를 사용하는 이유는 Swift 문법에 더 친화적으로 만들어졌기 때문에 @Model을 달아주면 바로 모델이 되는 등 사용하기가 너무 쉬운 반면 iOS 17 버전 이상부터 사용할 수 있기 때문에 이미 배포를 했거나 만들어진 앱에 적용하기는 사실상 어렵긴 하다.

(최소 타깃을 iOS 17 이상으로 잡아야 하니까 그 이하 버전은 내 앱을 사용할 수 없게 됨)

 

또한 CoreData는 오프라인에서도 사용할 수 있으므로 네트워크 연결 없이도 동작하는 앱에 최적화되어 있다. 뿐만 아니라,  클라우드킷과 연동이 가능하여 앱을 삭제한 다시 설치하더라도 클라우드에 저장된 데이터를 복원할 수 있다는 것이 큰 장점으로 다가왔다. (SwiftData는 아직 연동 안됨)

 

 

장단점이 확실해서 어떤 걸 써야 하나 싶을 텐데!!!!

 

 



iOS 18 이후부터는 SwiftData와 CoreData를 함께 사용할 수 있다.

CoreData가 디스크에 저장하는 항목들이. xcdatamodelId로 저장되는데 SwiftData 또한 이 파일을 읽을 수 있기 때문에 새로운 파일을 만들어야 던가 할 필요가 없고 기존 모델을 마이그레이션 할 필요도 없어지게 된다!

 

CoreData에서는 NSManagedObjectContext를 사용하고, SwiftData에서는 ModelContext를 사용하는데 둘 다 상호작용 할 수 있다고 한다. 즉 기존 코드는 CoreData로 유지하고 새로운 화면부터는 SwiftData를 적용할 수 있다.

(둘 사이의 객체 타입이 다르기 때문에 직접적인 변환은 불가하지만 연결해서 사용할 수는 있음)

 

따라서 CoreData로 구현한 앱 규모가 조금 큰데 SwiftData를 도입하고 싶을 때 혹은 공부하고 싶을 때 사용해 보면 유용할 것이다.

 

 


참고

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

https://developer.apple.com/documentation/coredata/adopting-swiftdata-for-a-core-data-app

https://green1229.tistory.com/428

https://zeddios.tistory.com/987