안녕하세요! 가계부 앱의 데이터 모델과 로컬 파일 관리 클래스를 살펴보았는데요.
오늘은 이 둘을 하나로 묶어주는 핵심 클래스, 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()
}
}'한눈가계부 > 데이터 모델' 카테고리의 다른 글
| 안녕! '신(God) 객체' 👋 더 똑똑한 ViewModel 아키텍처로 가자 (3) | 2025.08.27 |
|---|---|
| 💡 ViewModel 설계: 통합형 vs. 분리형, 당신의 선택은? 🤔 (2) | 2025.08.25 |
| 💡 iOS 개발 필수 개념: Future와 AnyPublisher 완벽 정리 (0) | 2025.08.24 |
| 📝 iOS 가계부 앱 개발: FileManager로 로컬 데이터 관리하기 (0) | 2025.08.24 |
| 📱 iOS 가계부 앱 개발기: 데이터 모델 설계 파헤치기 ✨ (1) | 2025.08.24 |