본문 바로가기
한눈가계부/데이터 모델

💡 iOS 가계부 앱 개발: Core Data와 FileManager 연동하기

by 밤새는 탐험가89 2025. 8. 24.
728x90
SMALL

 안녕하세요! 가계부 앱의 데이터 모델과 로컬 파일 관리 클래스를 살펴보았는데요.

오늘은 이 둘을 하나로 묶어주는 핵심 클래스, TransactionCoreDataManager를 소개합니다.

이 클래스는 Core Data와 FileManager를 유기적으로 결합해 데이터를 안전하고 효율적으로 관리하는 '하이브리드' 데이터 관리자 역할을 합니다.


1. TransactionCoreDataManager의 역할: 싱글톤 패턴과 의존성 주입

이 클래스는 앱의 모든 데이터 작업을 총괄하는 '싱글톤'으로 설계되었습니다.

final class TransactionCoreDataManager {
    static let shared = TransactionCoreDataManager()
    private let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
    private let storageManager = TransactionFileManager.shared
    // ... (메서드 생략)
}

 

  • 싱글톤:
    • static let shared를 사용해 앱 전체에서 단 하나의 인스턴스만 사용하도록 보장합니다.
    • 이로써 데이터의 일관성을 유지하고, 메모리를 효율적으로 관리할 수 있습니다.
  • 의존성 주입:
    • storageManager 변수를 통해 FileManager 클래스를 사용합니다.
    • 이처럼 각 클래스가 자신의 역할에만 집중하도록 분리하면 코드가 훨씬 깔끔해지고 유지보수가 쉬워집니다.

2. 핵심 기능: 데이터 CRUD (생성, 읽기, 업데이트, 삭제) 🛠️

TransactionCoreDataManager는 Core Data를 사용해 거래 내역을 저장하고, FileManager를 사용해 이미지를 관리합니다.


✔️ 데이터 생성 (createTransaction)

 새로운 거래 내역을 Core Data에 저장합니다.

이미지 파일이 있다면, 데이터베이스에 저장하기 전에 FileManager를 이용해 먼저 로컬에 저장합니다.

func createTransaction(_ transaction: ExpenseModel) -> AnyPublisher<ExpenseModel, Error> {
    // ... 이미지 저장 로직
    
    let expenseEntity = ExpenseEntity(context: self.context)
    expenseEntity.id = transaction.id.uuidString
    // ... 데이터 매핑

    do {
        try self.context.save()
        // ...
    } catch {
        // ...
    }
}

 

🤔 guard vs. if let

// guard 코드:

 guard let savePath = self.storageManager.saveImage(selectedimage, transaction.id.uuidString) else {
     print("❌ 이미지 저장 실패")
     promise(.failure(NSError(domain: "TransactionImageSaveError", code: 1)))
     return
 }

 

이 코드는 saveImage의 반환값이 nil일 경우, 즉 이미지 저장에 실패하면 바로 함수를 종료하도록 합니다.

// if let 코드:

if let selectedImage = transaction.image {
    savePath = self.storageManager.saveImage(selectedImage, transaction.id.uuidString)
    
    if savePath == nil {
        print("❌ 이미지 저장 실패")
        promise(.failure(NSError(domain: "TransactionImageSaveError", code: 1)))
        return
    }
}

 

 이 코드는 selectedImage가 존재할 때만 이미지 저장 로직을 실행하고, 저장 결과가 nil일 경우 다시 한 번 실패를 처리합니다.

 

 두 코드는 본질적으로 같은 목적을 가지고 있지만, 현재 if let 구조가 더 명확합니다. ExpenseModel의 image 속성은 UIImage? (옵셔널) 타입이기 때문에, 이미지가 없을 수도 있는 상황을 먼저 if let으로 검사하는 것이 논리적으로 자연스럽습니다.


✔️ 데이터 조회 (readAllTransaction)

 저장된 모든 거래 내역을 최신순으로 불러옵니다.

NSFetchRequest를 사용해 Core Data에서 데이터를 효율적으로 가져옵니다.

func readAllTransaction() -> AnyPublisher<[ExpenseModel], Error> {
    let fetchRequest: NSFetchRequest<ExpenseEntity> = ExpenseEntity.fetchRequest()
    fetchRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
    
    do {
        let expenseEntities = try self.context.fetch(fetchRequest)
        // ... 엔티티를 모델로 변환
    } catch {
        // ...
    }
}

✔️ 데이터 업데이트 (updateTransaction)

 기존 거래 내역을 수정합니다.

수정된 데이터를 Core Data에 반영하고, 이미지 변경 여부에 따라 기존 파일을 삭제하거나 새 파일을 저장합니다.

func updateTransaction(_ updatedTransaction: ExpenseModel) -> AnyPublisher<ExpenseModel, Error> {
    let fetchRequest: NSFetchRequest<ExpenseEntity> = ExpenseEntity.fetchRequest()
    fetchRequest.predicate = NSPredicate(format: "id == %@", updatedTransaction.id.uuidString)

    do {
        guard let entity = try self.context.fetch(fetchRequest).first else {
            // ...
        }
        
        // ... 기존 속성 및 이미지 업데이트 로직
        
        try self.context.save()
        // ...
    } catch {
        // ...
    }
}

 


✔️ 데이터 삭제 (deleteTransaction)

특정 거래 내역을 삭제합니다.

가장 중요한 로직은 데이터베이스에서만 삭제하는 것이 아니라, FileManager를 이용해 연관된 이미지 파일도 함께 삭제하는 것입니다.

func deleteTransaction(id: UUID) -> AnyPublisher<Bool, Error> {
    // ... 삭제할 엔티티 찾기
    
    if let imagePath = entityToDelete.imagePath {
        self.storageManager.deleteFolder(for: imagePath)
    }
    
    self.context.delete(entityToDelete)
    
    do {
        try self.context.save()
        // ...
    } catch {
        // ...
    }
}

3. 마무리하며: 유기적인 결합의 힘 💪

TransactionCoreDataManager 클래스는 Core Data와 FileManager를 유기적으로 결합하여, 텍스트 데이터와 바이너리 데이터를 모두 안전하게 관리하는 모범적인 사례를 보여줍니다.

이러한 구조 덕분에 앱은 안정성과 효율성을 모두 갖추게 됩니다.


✅ 전체코드 

final class TransactionCoreDataManager {
    
    
    
    // MARK: - Variable
    static let shared = TransactionCoreDataManager()
    private let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
    private let storageManager = TransactionFileManager.shared
    
    
    
    // MARK: - Function
    
    /// 새로운 거래 내역을 Core Data와 로컬 파일 시스템에 저장합니다. 이미지가 포함된 경우, 먼저 이미지를 파일로 저장하고 그 경로를 Core Data에 함께 기록합니다. 이 함수는 Combine의 Future를 사용해 비동기적으로 결과를 전달합니다.
    /// - Parameter
    ///   - transaction: 저장할 거래 내역 데이터가 담긴 ExpenseModel 객체.
    /// - Returns:
    ///   - 성공 시, 저장된 ExpenseModel 객체를 담은 Publisher를 반환합니다.
    ///   - 실패 시, 저장 과정에서 발생한 에러를 담은 Publisher를 반환합니다.
    func createTransaction(_ transaction: ExpenseModel) -> AnyPublisher<ExpenseModel, Error> {
        return Future { [weak self] promise in
            guard let self = self
            else { return }
            
            var savePath: String? = nil
            if let selectedImage = transaction.image {
                savePath = self.storageManager.saveImage(selectedImage, transaction.id.uuidString)
                
                if savePath == nil {
                    #if DEBUG
                    print("❌ 이미지 저장 실패")
                    #endif
                    promise(.failure(NSError(domain: "TransactionImageSaveError", code: 1)))
                    return
                }
            }
            
            #if DEBUG
            // ✅ 디버깅 로그 시작
            print("🧾 CoreDataManager: 저장할 Expense 정보 확인")
            print("   - ID: \(transaction.id)")
            print("   - 금액: \(transaction.amount)")
            print("   - 날짜: \(transaction.date)")
            print("   - 카테고리: \(transaction.category)")
            print("   - 타입: \(transaction.transaction.rawValue)")
            print("   - 메모: \(transaction.memo ?? "없음")")
            print("   - 이미지 경로: \(savePath ?? "이미지 없음")")
            #endif
            
            let expenseEntity = ExpenseEntity(context: self.context)
            expenseEntity.id = transaction.id.uuidString
            expenseEntity.amount = Int64(transaction.amount)
            expenseEntity.date = transaction.date
            expenseEntity.category = transaction.category
            expenseEntity.transaction = transaction.transaction.rawValue
            expenseEntity.memo = transaction.memo 
            expenseEntity.imagePath = savePath ?? ""
            
            // ✅ 반복 관련 속성 추가
            expenseEntity.isRepeated = transaction.isRepeated ?? false
            expenseEntity.repeatCycle = transaction.repeatCycle?.rawValue
            
            do {
                try self.context.save()
                if let model = expenseEntity.toModel() {
                    #if DEBUG
                    print("✅ Core Data 저장 + 변환 성공!")
                    #endif
                    promise(.success(model))
                } else {
                    #if DEBUG
                    print("❌ Entity → Model 변환 실패")
                    #endif
                    promise(.failure(NSError(domain: "ModelConversionError", code: 2)))
                }
            } catch {
                #if DEBUG
                print("❌ Core Data 저장 실패: \(error.localizedDescription)")
                #endif
                promise(.failure(error))
            }
        }
        .eraseToAnyPublisher()
    }
    
    
    /// Core Data에 저장된 모든 거래 내역을 불러옵니다. 데이터는 날짜(`date`)를 기준으로 최신순으로 정렬되며, Core Data 엔티티를 ExpenseModel 배열로 변환하여 반환합니다. 이 함수는 Combine의 Future를 사용해 비동기적으로 결과를 전달합니다.
    /// - Returns:
    ///    - 성공 시, 모든 거래 내역이 담긴 ExpenseModel 배열을 Publisher로 반환합니다.
    ///    - 실패 시, Core Data 패치(fetch) 과정에서 발생한 에러를 Publisher로 반환합니다.
    func readAllTransaction() -> AnyPublisher<[ExpenseModel], Error> {
        return Future { [weak self] promise in
            guard let self = self else {
                #if DEBUG
                print("❌ TransactionManager: self가 nil이므로 종료")
                #endif
                return
            }
            
            let fetchRequest: NSFetchRequest<ExpenseEntity> = ExpenseEntity.fetchRequest()
            fetchRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
            
            do {
                let expenseEntities = try self.context.fetch(fetchRequest)
                #if DEBUG
                print("✅ TransactionManager: Read 성공, 총 \(expenseEntities.count)개의 데이터 읽기 성공!")
                #endif
                
                let transactions: [ExpenseModel] = expenseEntities.compactMap { $0.toModel() }
                promise(.success(transactions))
                #if DEBUG
                print("✅ TransactionManager: 최종 읽어온 데이터 \(transactions.count)개")
                #endif
                
            } catch {
                #if DEBUG
                print("❌ TransactionManager: Core Data 불러오기 실패 \(error.localizedDescription)")
                #endif
                promise(.failure(error))
            }
        }
        .eraseToAnyPublisher()
    }
    
    
    /// 특정 ID에 해당하는 거래 내역을 Core Data에서 조회합니다.
    /// - Parameter
    ///     - id: 조회할 거래 내역의 고유 ID (UUID).
    /// - Returns:
    ///     - 성공 시, 찾은 거래 내역을 담은 ExpenseModel 객체(존재하지 않으면 nil)를 Publisher로 반환합니다.
    ///     - 실패 시, Core Data 페치(fetch) 과정에서 발생한 에러를 Publisher로 반환합니다.
    func readTransactionByID(by id: UUID) -> AnyPublisher<ExpenseModel?, Error> {
        return Future { [weak self] promise in
            guard let self = self else {
                #if DEBUG
                print("❌ TransactionManager: self가 nil이므로 종료")
                #endif
                return
            }
            
            let fetchRequest: NSFetchRequest<ExpenseEntity> = ExpenseEntity.fetchRequest()
            fetchRequest.predicate = NSPredicate(format: "id == %@", id.uuidString)
            fetchRequest.fetchLimit = 1
            
            do {
                let result = try self.context.fetch(fetchRequest)
                let model = result.first?.toModel()
                if model == nil {
                    #if DEBUG
                    print("⚠️ 해당 ID로 찾은 데이터가 없어요")
                    #endif
                } else {
                    #if DEBUG
                    print("✅ TransactionManager: \(id) 읽기 성공")
                    #endif
                }
                promise(.success(model))
            } catch {
                #if DEBUG
                print("❌ TransactionManager: ID로 불러오기 실패 \(error.localizedDescription)")
                #endif
                promise(.failure(error))
            }
        }
        .eraseToAnyPublisher()
    }
    
    
    /// 기존의 거래 내역을 Core Data에서 찾아 업데이트합니다. 업데이트할 항목의 ID를 사용하여 해당 엔티티를 찾고, 새로운 데이터로 속성을 갱신합니다. 이미지 변경 로직을 포함하여 기존 이미지를 삭제하거나 새로운 이미지를 저장하는 작업을 처리합니다.
    
    /// - Parameter
    ///     - updatedTransaction: 변경된 데이터가 포함된 ExpenseModel 객체.
    /// - Returns:
    ///     - 성공 시, 업데이트된 ExpenseModel 객체를 담은 Publisher를 반환합니다.
    ///     - 실패 시, 항목을 찾지 못했거나 업데이트 과정에서 발생한 에러를 Publisher로 반환합니다.
    func updateTransaction(_ updatedTransaction: ExpenseModel) -> AnyPublisher<ExpenseModel, Error> {
        return Future { [weak self] promise in
            guard let self = self else {
                #if DEBUG
                print("❌ TransactionManager: self가 nil")
                #endif
                return
            }

            let fetchRequest: NSFetchRequest<ExpenseEntity> = ExpenseEntity.fetchRequest()
            fetchRequest.predicate = NSPredicate(format: "id == %@", updatedTransaction.id.uuidString)
            fetchRequest.fetchLimit = 1

            do {
                guard let entity = try self.context.fetch(fetchRequest).first else {
                    #if DEBUG
                    print("❌ 업데이트할 항목 없음")
                    #endif
                    promise(.failure(NSError(domain: "TransactionUpdateError", code: 404)))
                    return
                }

                entity.transaction = updatedTransaction.transaction.rawValue
                entity.amount = Int64(updatedTransaction.amount)
                entity.date = updatedTransaction.date
                entity.category = updatedTransaction.category
                entity.memo = updatedTransaction.memo
                
                // ✅ 반복여부 관련 기능
                entity.isRepeated = updatedTransaction.isRepeated ?? false
                entity.repeatCycle = updatedTransaction.repeatCycle?.rawValue

                
                if let newImage = updatedTransaction.image {
                    // 기존 이미지 제거
                    if let oldPath = entity.imagePath, !oldPath.isEmpty {
                        let deleted = self.storageManager.deleteFolder(for: oldPath)
                        if !deleted {
                            #if DEBUG
                            print("⚠️ 기존 이미지 삭제 실패: \(oldPath)")
                            #endif
                            // 삭제 실패 무시할지 여부는 정책에 따라
                        }
                    }

                    // 새 이미지 저장
                    if let newPath = self.storageManager.saveImage(newImage, updatedTransaction.id.uuidString) {
                        entity.imagePath = newPath
                    } else {
                        #if DEBUG
                        print("❌ 새 이미지 저장 실패")
                        #endif
                        promise(.failure(NSError(domain: "ImageSaveFailed", code: 500)))
                        return
                    }

                } else {
                    // 새 이미지 없음 → 기존 이미지도 삭제 대상일 수 있음
                    if let oldPath = entity.imagePath, !oldPath.isEmpty {
                        let deleted = self.storageManager.deleteFolder(for: oldPath)
                        if deleted {
                            entity.imagePath = nil  // 경로도 지워야 함
                            #if DEBUG
                            print("🗑️ 기존 이미지 삭제 완료")
                            #endif
                        } else {
                            #if DEBUG
                            print("⚠️ 기존 이미지 삭제 실패 (삭제만 시도했음)")
                            #endif
                            // 필요시 실패 처리 가능
                        }
                    } else {
                        #if DEBUG
                        print("ℹ️ 삭제할 기존 이미지 없음")
                        #endif
                    }
                }

                
                try self.context.save()
                #if DEBUG
                print("✅ 업데이트 성공!")
                #endif
                promise(.success(updatedTransaction))

            } catch {
                #if DEBUG
                print("❌ Core Data 업데이트 실패: \(error.localizedDescription)")
                #endif
                promise(.failure(error))
            }
        }
        .eraseToAnyPublisher()
    }

    
    
    /// 특정 ID에 해당하는 거래 내역을 Core Data와 로컬 파일 시스템에서 모두 삭제합니다. 데이터베이스에서 항목을 삭제하기 전에, 연관된 이미지 파일이 있으면 `storageManager`를 통해 먼저 제거합니다. 이 과정은 데이터베이스와 파일 시스템 간의 데이터 불일치를 방지합니다.
    /// - Parameter
    ///     - id: 삭제할 거래 내역의 고유 ID (UUID).
    /// - Returns:
    ///     - 성공 시, 삭제 완료를 나타내는 Bool 값(true)을 Publisher로 반환합니다.
    ///     - 실패 시, 항목을 찾지 못했거나 삭제 과정에서 발생한 에러를 Publisher로 반환합니다.
    func deleteTransaction(id: UUID) -> AnyPublisher<Bool, Error> {
        return Future { [weak self] promise in
            guard let self = self else {
                #if DEBUG
                print("❌ deleteTransaction: self가 nil입니다.")
                #endif
                return
            }
            
            let fetchRequest: NSFetchRequest<ExpenseEntity> = ExpenseEntity.fetchRequest()
            fetchRequest.predicate = NSPredicate(format: "id == %@", id.uuidString)
            
            do {
                let result = try self.context.fetch(fetchRequest)
                guard let entityToDelete = result.first else {
                    #if DEBUG
                    print("❌ deleteTransaction: 해당 ID에 해당하는 항목을 찾을 수 없음")
                    #endif
                    promise(.failure(NSError(domain: "TransactionNotFound", code: 404)))
                    return
                }
                
                // 이미지 경로가 있는 경우 파일도 삭제
                if let imagePath = entityToDelete.imagePath {
                    let imageDeleted = self.storageManager.deleteFolder(for: imagePath)
                    if imageDeleted {
                        #if DEBUG
                        print("🗑️ 이미지 삭제 성공")
                        #endif
                    } else {
                        #if DEBUG
                        print("⚠️ 이미지 삭제 실패 (파일이 없을 수도 있음)")
                        #endif
                    }
                }
                
                self.context.delete(entityToDelete)
                
                try self.context.save()
                #if DEBUG
                print("✅ deleteTransaction: Core Data 삭제 성공")
                #endif
                promise(.success(true))
            } catch {
                #if DEBUG
                print("❌ deleteTransaction: 삭제 중 오류 발생 - \(error.localizedDescription)")
                #endif
                promise(.failure(error))
            }
        }
        .eraseToAnyPublisher()
    }
    
}
728x90
LIST