본문 바로가기

UIKIT

Core Data + FileManager + Combine + MVVM 구조

핵심 아이디어는 이미지를 디스크에 저장하고, 그 경로(상대 경로)를 Core Data에 저장하는 것입니다.

 

  • Create (새 피드를 생성할 때):
    • 이미지를 먼저 FeedStorageManager로 저장 → 저장된 상대 경로 배열FeedItem.imagePath에 설정 → Core Data에 FeedModel 엔티티로 저장
  • Fetch (피드를 불러올 때):
    • Core Data에서 FeedItem을 가져옴 → 필요한 경우 FeedStorageManager.loadImages로 실제 UIImage를 불러올 수 있음
  • Update (기존 피드 수정 시):
    • 기존에 저장된 이미지를 삭제하고, 새 이미지가 있으면 다시 저장 → Core Data 업데이트
  • Delete (피드 삭제 시):
    • 해당 피드에 연결된 이미지들을 FileManager에서 제거 → Core Data 엔티티 삭제

 

 

1. FeedStorageManager (이미지 저장소)

import Foundation
import UIKit

class FeedStorageManager {
    
    static let shared = FeedStorageManager()
    private let fileManager = FileManager.default
    
    private func getDocumentsDirectory() -> URL {
        fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
    }
    
    /// 이미지를 저장하고 경로를 반환하는 함수
    func saveImages(images: [UIImage], feedID: String) -> [String] {
        
        let feedFolder = getDocumentsDirectory().appendingPathComponent(feedID)
        
        // 폴더가 없다면 생성
        if !FileManager.default.fileExists(atPath: feedFolder.path) {
            try? FileManager.default.createDirectory(at: feedFolder,
                                                     withIntermediateDirectories: true,
                                                     attributes: nil)
        }
        
        var savedImagesPaths: [String] = []
        
        for (index, image) in images.enumerated() {
            let fileName = "image_\(index).jpg"
            let fileURL = feedFolder.appendingPathComponent(fileName)
            
            if let imageData = image.jpegData(compressionQuality: 1.0) {
                try? imageData.write(to: fileURL)
                // feedID/파일명 형태의 상대 경로 저장
                savedImagesPaths.append("\(feedID)/\(fileName)")
            }
        }
        return savedImagesPaths
    }
    
    /// 저장된 이미지를 상대경로로 불러옴
    func loadImages(from relativePaths: [String]) -> [UIImage] {
        var images: [UIImage] = []
        
        for relativePath in relativePaths {
            let fullPath = getDocumentsDirectory().appendingPathComponent(relativePath)
            if let image = UIImage(contentsOfFile: fullPath.path) {
                images.append(image)
            }
        }
        return images
    }

    /// 이미지 삭제
    func deleteImages(from relativePaths: [String]) {
        for relativePath in relativePaths {
            let fullPath = getDocumentsDirectory().appendingPathComponent(relativePath)
            do {
                try fileManager.removeItem(at: fullPath)
                print("Deleted image at: \(fullPath.path)")
            } catch {
                print("Failed to delete image at: \(fullPath.path). Error: \(error)")
            }
        }
    }
}

 

 

 

2. FeedManager (Core Data + FileManager 통합)

FeedManager에서 이미지 저장 및 삭제 로직을 통합합니다.

  • Create할 때: 새 이미지가 들어오면 디스크에 먼저 저장 → 저장된 상대 경로를 FeedItem.imagePath에 반영 → Core Data save()
  • Update할 때: 먼저 기존 이미지 삭제 → 새 이미지 저장 → Core Data save()
  • Delete할 때: DB에서 엔티티 제거 전, 해당 엔티티 이미지 경로를 찾아 삭제
import Foundation
import CoreData
import UIKit
import Combine

class FeedManager {
    
    static let shared = FeedManager()
    
    private let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
    
    // FileManager 역할 통합 (이미지 저장/삭제)
    private let storageManager = FeedStorageManager.shared
    
    // MARK: - Create
    /// images: 새로 저장할 UIImage 배열
    func createFeed(_ feed: FeedItem, images: [UIImage]) -> AnyPublisher<FeedItem, Error> {
        return Future { [weak self] promise in
            guard let self = self else { return }
            
            // 1) 우선 이미지들을 FileManager에 저장
            let savedPaths = self.storageManager.saveImages(images: images, feedID: feed.id)
            
            // 2) 저장된 이미지 경로를 feed.imagePath에 설정
            var updatedFeed = feed
            updatedFeed.imagePath = savedPaths
            
            // 3) Core Data에 저장
            let feedModel = FeedModel(context: self.context)
            feedModel.id = updatedFeed.id
            feedModel.title = updatedFeed.title
            feedModel.content = updatedFeed.contents
            feedModel.date = updatedFeed.date
            feedModel.imagePath = updatedFeed.imagePath.isEmpty ? nil : updatedFeed.imagePath.joined(separator: ",")
            
            do {
                try self.context.save()
                print("Feed + images saved successfully to CoreData.")
                promise(.success(updatedFeed)) // 성공 시, 갱신된 feed 반환
            } catch {
                print("Failed to save feed: \(error)")
                promise(.failure(error))
            }
        }
        .eraseToAnyPublisher()
    }
    
    // MARK: - Read
    func fetchFeeds() -> AnyPublisher<[FeedItem], Error> {
        return Future { [weak self] promise in
            guard let self = self else { return }
            
            let request: NSFetchRequest<FeedModel> = FeedModel.fetchRequest()
            request.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
            
            do {
                let results = try self.context.fetch(request)
                let feeds = results.map { feedModel -> FeedItem in
                    let imagePaths = feedModel.imagePath?.components(separatedBy: ",") ?? []
                    return FeedItem(
                        id: feedModel.id ?? UUID().uuidString,
                        title: feedModel.title ?? "",
                        contents: feedModel.content ?? "",
                        date: feedModel.date ?? Date(),
                        imagePath: imagePaths
                    )
                }
                promise(.success(feeds))
                print("Fetched \(feeds.count) feeds from CoreData.")
            } catch {
                promise(.failure(error))
                print("Failed to fetch feeds: \(error)")
            }
        }
        .eraseToAnyPublisher()
    }
    
    // MARK: - Update
    /// images: 새로 업데이트할 UIImage 배열
    func updateFeed(_ feed: FeedItem, images: [UIImage]) -> AnyPublisher<FeedItem, Error> {
        return Future { [weak self] promise in
            guard let self = self else { return }
            
            let request: NSFetchRequest<FeedModel> = FeedModel.fetchRequest()
            request.predicate = NSPredicate(format: "id == %@", feed.id as CVarArg)
            
            do {
                let results = try self.context.fetch(request)
                if let feedToUpdate = results.first {
                    
                    // 1) 기존 이미지 삭제
                    if let oldPaths = feedToUpdate.imagePath?.components(separatedBy: ",") {
                        self.storageManager.deleteImages(from: oldPaths)
                    }
                    
                    // 2) 새 이미지 저장
                    let newPaths = self.storageManager.saveImages(images: images, feedID: feed.id)
                    
                    // 3) feedToUpdate에 새로운 정보 업데이트
                    feedToUpdate.title = feed.title
                    feedToUpdate.content = feed.contents
                    feedToUpdate.date = feed.date
                    feedToUpdate.imagePath = newPaths.isEmpty ? nil : newPaths.joined(separator: ",")
                    
                    try self.context.save()
                    
                    // 4) combine을 위해, 갱신된 feed 반환
                    var updatedFeed = feed
                    updatedFeed.imagePath = newPaths
                    print("Feed updated successfully.")
                    promise(.success(updatedFeed))
                } else {
                    throw NSError(domain: "", code: 404, userInfo: [NSLocalizedDescriptionKey: "Feed not found."])
                }
            } catch {
                promise(.failure(error))
                print("Failed to update feed: \(error)")
            }
        }
        .eraseToAnyPublisher()
    }
    
    // MARK: - Delete
    func deleteFeed(by id: String) -> AnyPublisher<Void, Error> {
        return Future { [weak self] promise in
            guard let self = self else { return }
            
            let request: NSFetchRequest<FeedModel> = FeedModel.fetchRequest()
            request.predicate = NSPredicate(format: "id == %@", id)
            
            do {
                let results = try self.context.fetch(request)
                if let feedToDelete = results.first {
                    
                    // 1) Core Data에 저장된 이미지 경로를 모두 삭제
                    if let pathString = feedToDelete.imagePath {
                        let paths = pathString.components(separatedBy: ",")
                        self.storageManager.deleteImages(from: paths)
                    }
                    
                    // 2) 피드를 Core Data에서 삭제
                    self.context.delete(feedToDelete)
                    try self.context.save()
                    promise(.success(()))
                    print("Feed + images deleted successfully.")
                } else {
                    throw NSError(domain: "", code: 404, userInfo: [NSLocalizedDescriptionKey: "Feed not found."])
                }
            } catch {
                promise(.failure(error))
                print("Failed to delete feed: \(error)")
            }
        }
        .eraseToAnyPublisher()
    }
}

 

 

3. FeedItemViewModel 

import Foundation
import Combine
import UIKit

class FeedItemViewModel: ObservableObject {
    
    // 1) 현재 작성 중인 단일 피드
    @Published var userFeed: FeedItem = FeedItem(id: "")
    
    // 2) 이미 저장된 피드 목록
    @Published var feeds: [FeedItem] = []
    
    // 3) 에러 메시지 등을 표시하기 위한 프로퍼티
    @Published var errorMessage: String?
    
    private var cancellables = Set<AnyCancellable>()
    
    // CoreData, Combine 등을 다루는 매니저 (기존 코드를 재사용)
    private let feedManager = FeedManager.shared
    
    
    // MARK: - Create
    /// images: 새로 작성된 피드에 연결될 이미지 리스트
    func createFeed(_ feed: FeedItem, images: [UIImage]) {
        feedManager.createFeed(feed, images: images)
            .sink(receiveCompletion: { completion in
                switch completion {
                case .finished:
                    break
                case .failure(let error):
                    self.errorMessage = error.localizedDescription
                }
            }, receiveValue: { [weak self] savedFeed in
                self?.feeds.append(savedFeed)
                print("Successfully saved feed: \(savedFeed)")
                // 작성 후 userFeed 초기화
                self?.userFeed = FeedItem(id: "")
            })
            .store(in: &cancellables)
    }
    
    // MARK: - Read
    func fetchFeeds() {
        feedManager.fetchFeeds()
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { completion in
                switch completion {
                case .finished: break
                case .failure(let error):
                    self.errorMessage = error.localizedDescription
                }
            }, receiveValue: { [weak self] feeds in
                self?.feeds = feeds
                print("Fetched feeds: \(feeds)")
            })
            .store(in: &cancellables)
    }
    
    // MARK: - Update
    func updateFeed(_ feed: FeedItem, images: [UIImage]) {
        feedManager.updateFeed(feed, images: images)
            .sink(receiveCompletion: { completion in
                switch completion {
                case .finished: break
                case .failure(let error):
                    self.errorMessage = error.localizedDescription
                }
            }, receiveValue: { [weak self] updatedFeed in
                if let index = self?.feeds.firstIndex(where: { $0.id == updatedFeed.id }) {
                    self?.feeds[index] = updatedFeed
                }
                print("Successfully updated feed: \(updatedFeed)")
            })
            .store(in: &cancellables)
    }
    
    // MARK: - Delete
    func deleteFeed(by id: String) {
        feedManager.deleteFeed(by: id)
            .sink(receiveCompletion: { completion in
                switch completion {
                case .finished: break
                case .failure(let error):
                    self.errorMessage = error.localizedDescription
                }
            }, receiveValue: { [weak self] in
                self?.feeds.removeAll { $0.id == id }
                print("Successfully deleted feed with ID: \(id)")
            })
            .store(in: &cancellables)
    }
}

 

 

4. FeedViewController

import UIKit
import PhotosUI
import Combine

class FeedViewController: UIViewController {
    
    // MARK: - Variable
    private let tableSection: [String] = ["이미지", "제목", "내용"]
    private var selectedImages: [UIImage] = []
    
    private let viewModel = FeedItemViewModel()
    private var cancellables = Set<AnyCancellable>()
    
    
    // MARK: - UI Components
    private let feedTableView: UITableView = {
        let tableView = UITableView(frame: .zero, style: .insetGrouped)
        tableView.separatorStyle = .none
        tableView.showsVerticalScrollIndicator = false
        tableView.alwaysBounceVertical = false
        tableView.isScrollEnabled = true
        return tableView
    }()
    
    private let registerFeedButton: UIButton = {
        let button = UIButton(type: .custom)
        button.setTitle("작성 완료", for: .normal)
        button.titleLabel?.font = .systemFont(ofSize: 16, weight: .bold)
        button.setTitleColor(.black, for: .normal)
        button.backgroundColor = .systemYellow
        button.layer.cornerRadius = 5
        button.layer.masksToBounds = true
        return button
    }()
    
    // MARK: - Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        configureConstraints()
        setupTableViewDelegate()
        setupBindings()
        
        registerFeedButton.addTarget(self, action: #selector(registerFeed), for: .touchUpInside)
        
        navigationItem.title = "오늘 리뷰 쓰기"
        
        let backButton = UIBarButtonItem(image: UIImage(systemName: "xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 16)), style: .done, target: self, action: #selector(didTapBack))
        
        navigationItem.leftBarButtonItem = backButton
        navigationController?.navigationBar.tintColor = .link
        
        view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapToDismiss)))
        
    }
    
    
    // MARK: - Functions
    // 테이블 뷰 대리자 선언 함수
    private func setupTableViewDelegate() {
        
        feedTableView.delegate = self
        feedTableView.dataSource = self
        
        feedTableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        feedTableView.register(ImageSelectedCell.self, forCellReuseIdentifier: ImageSelectedCell.reuseIdentifier)
        feedTableView.register(TitleInputCell.self, forCellReuseIdentifier: TitleInputCell.reuseIdentifier)
        feedTableView.register(ContentInputCell.self, forCellReuseIdentifier: ContentInputCell.reuseIdentifier)
    }
    
    private func setupBindings() {
        
        // feeds 배열이 바뀌면 테이블 뷰 리로드
        viewModel.$feeds
            .receive(on: RunLoop.main)
            .sink { [weak self] _ in
                self?.feedTableView.reloadData()
            }
            .store(in: &cancellables)
        
        // 에러 메세지 처리를 U로 표출, print() 문으로 확인
        viewModel.$errorMessage
            .compactMap { $0 }
            .receive(on: RunLoop.main)
            .sink { errerMessage in
                print("Error: \(errerMessage)")
                
                // 추후 alert 표시
            }
            .store(in: &cancellables)
    }
    
    
    // MARK: - Actions
    @objc private func registerFeed() {
        
        
        // userFeed에 title, contents는 이미 설정된 상태
        // selectedImages에 UIImage 배열이 들어있다고 가정
        // ID 생성
        viewModel.userFeed.id = UUID().uuidString
        
        // ViewModel에 생성 요청
        // userFeed는 현재 작성 중인 FeedItem, selectedImages는 이미 선택된 UIImage 목록
        viewModel.createFeed(viewModel.userFeed, images: selectedImages)
        
        dismiss(animated: true)
    }
    
   ...
}

// MARK: - Extension: TableView
extension FeedViewController: UITableViewDelegate, UITableViewDataSource {
    
    func numberOfSections(in tableView: UITableView) -> Int {
        return tableSection.count
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 1
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        
        let section = indexPath.section
        
        switch section {
        case 0:
            guard let cell = tableView.dequeueReusableCell(withIdentifier: ImageSelectedCell.reuseIdentifier, for: indexPath) as? ImageSelectedCell else { return UITableViewCell() }
            cell.delegate = self
            
            cell.selectionStyle = .none
            return cell
            
        case 1:
            guard let cell = tableView.dequeueReusableCell(withIdentifier: TitleInputCell.reuseIdentifier, for: indexPath) as? TitleInputCell else { return UITableViewCell()}
            cell.calledTitleTextField().delegate = self
            return cell
            
        case 2:
            guard let cell = tableView.dequeueReusableCell(withIdentifier: ContentInputCell.reuseIdentifier, for: indexPath) as? ContentInputCell else { return UITableViewCell() }
            
            cell.calledTextView().delegate = self
            return cell
            
        default:
            return UITableViewCell()
        }
    }
    ...
}

// MARK: - Extension: ImageSelectedDelegate
extension FeedViewController: ImageSelectedDelegate {
    func didTappedImageSelectedButton(in cell: ImageSelectedCell) {
        var configuration = PHPickerConfiguration()
        configuration.filter = .images
        configuration.selectionLimit = 10
        configuration.selection = .ordered
        
        let picker = PHPickerViewController(configuration: configuration)
        picker.delegate = self
        present(picker, animated: true)
    }
    
    func imageAddCell(_ cell: ImageSelectedCell, didSelectImages images: [UIImage]) {
        cell.updateImages(images)
    }
}


// MARK: - Extension: PHPickerViewControllerDelegate
extension FeedViewController: PHPickerViewControllerDelegate {
    func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
        picker.dismiss(animated: true)
        
        let group = DispatchGroup()
        selectedImages.removeAll()
        for item in results {
            group.enter()
            item.itemProvider.loadObject(ofClass: UIImage.self) { (object, error) in
                if let image = object as? UIImage {
                    self.selectedImages.append(image)
                }
                group.leave()
            }
        }
        
        group.notify(queue: .main) {
            if let cell = self.feedTableView.cellForRow(at: IndexPath(row: 0, section: 0)) as? ImageSelectedCell {
                self.imageAddCell(cell, didSelectImages: self.selectedImages)
                
            }
        }
    }
}

// MARK: - Extension: UITextFieldDelegate, UITextViewDelegate
extension FeedViewController: UITextFieldDelegate, UITextViewDelegate {
    // 제목 입력 완료 시
    func textFieldDidEndEditing(_ textField: UITextField) {
        viewModel.userFeed.title = textField.text ?? ""
    }
    
    // 내용 변경 시
    func textViewDidChange(_ textView: UITextView) {
        viewModel.userFeed.contents = textView.text
    }
}