핵심 아이디어는 이미지를 디스크에 저장하고, 그 경로(상대 경로)를 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
}
}
'UIKIT' 카테고리의 다른 글
Combine 연산자 (0) | 2025.01.16 |
---|---|
페이스북 로그인 기능 구현 (0) | 2025.01.14 |
Firebase Firestore를 사용하여 팔로워(follower)와 팔로잉(following) 관계를 처리하는 기능 (0) | 2025.01.10 |
ViewController 내에 init으로 viewModel 할당하는 이유 (0) | 2025.01.10 |
유저를 검색하기 위한 함수 + Combine (0) | 2025.01.10 |