본문 바로가기
감정일기(가칭)

📦 Core Data와 FileManager로 이미지 관리 구조 설계하기

by 밤새는 탐험가89 2025. 10. 17.
728x90
SMALL

Core Data를 사용할 때 이미지 데이터를 어떻게 관리하느냐는
앱의 성능과 안정성에 직접적인 영향을 미친다.

 

특히 감정일기처럼
“사용자가 이미지를 여러 장 첨부하고, 수정·삭제할 수 있는 앱”이라면
이미지 파일은 Core Data 내부에 저장하지 말고, FileManager로 분리하는 게 훨씬 효율적이다.


🧠 1️⃣ 왜 FileManager로 관리해야 할까?

Core Data에 UIImage를 직접 넣을 수도 있지만,
이는 여러 문제를 낳는다.

문제점 설명
⚠️ 성능 저하 대용량 이미지를 Core Data 내부에 저장하면 fetch 시점마다 전체 파일이 로드됨
⚠️ 메모리 낭비 캐싱이 중복 발생하며, 메모리 점유가 커짐
⚠️ 백업 비효율 iCloud 백업 시 데이터베이스 크기가 비정상적으로 커짐
⚠️ 삭제 관리 어려움 이미지 삭제 시 엔티티만 지워지고 파일은 남아 쌓임

 

따라서 우리는 이미지 파일은 FileManager에,
경로(path)만 Core Data에 저장
하는 구조를 채택했다.


🧩 2️⃣ 기본 설계 구조

Documents/
 └── EmotionDiaryImages/
      ├── [일기ID_1]/
      │    ├── image_0.jpg
      │    └── image_1.jpg
      ├── [일기ID_2]/
      │    ├── image_0.jpg
      │    └── image_1.jpg

 

감정일기(EmotionDiaryEntity)가 고유한 UUID를 가짐

해당 UUID를 기준으로 하위 폴더를 생성

여러 장의 이미지를 “image_인덱스.jpg” 형식으로 저장

 

이렇게 하면

 

1. 감정일기별 이미지 관리 용이

2. 중복 파일 방지

3. 일기 삭제 시 폴더 단위로 정리 가능


🧱 3️⃣ DiaryImageFileManager 클래스 설계

이제 FileManager를 통해
이미지의 저장 / 불러오기 / 삭제 / 갱신(update) 을 수행하는 클래스를 만들어보자.

import UIKit

final class DiaryImageFileManager {
    
    // MARK: ✅ Singleton
    static let shared = DiaryImageFileManager()
    private init() {}
    
    // MARK: ✅ Property
    private let folderName = "EmotionDiaryImages"
    
    // MARK: ✅ Private Path Methods
    private func getDocumentsDirectory() -> URL {
        guard let doc = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
            fatalError("❌ Document 디렉토리를 찾을 수 없습니다.")
        }
        
        let folder = doc.appendingPathComponent(folderName)
        if !FileManager.default.fileExists(atPath: folder.path) {
            do {
                try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
            } catch {
                print("❌ 이미지 폴더 생성 실패:", error.localizedDescription)
            }
        }
        return folder
    }
    
    private func getDiaryFolder(for diaryID: String) -> URL {
        let baseFolder = getDocumentsDirectory()
        let diaryFolder = baseFolder.appendingPathComponent(diaryID)
        
        if !FileManager.default.fileExists(atPath: diaryFolder.path) {
            do {
                try FileManager.default.createDirectory(at: diaryFolder, withIntermediateDirectories: true)
            } catch {
                print("❌ Diary 폴더 생성 실패 [\(diaryID)]:", error.localizedDescription)
            }
        }
        return diaryFolder
    }
    
    // MARK: ✅ Save
    @discardableResult
    func saveImage(_ image: UIImage, diaryID: String, index: Int) -> String? {
        guard let data = image.jpegData(compressionQuality: 0.8) else {
            print("❌ JPEG 변환 실패")
            return nil
        }
        
        let fileName = "image_\(index).jpg"
        let fileURL = getDiaryFolder(for: diaryID).appendingPathComponent(fileName)
        
        do {
            try data.write(to: fileURL)
            print("✅ 이미지 저장 성공:", fileURL.lastPathComponent)
            return "\(diaryID)/\(fileName)"
        } catch {
            print("❌ 이미지 저장 실패:", error.localizedDescription)
            return nil
        }
    }
    
    // MARK: ✅ Load
    func loadImage(from path: String) -> UIImage? {
        let fileURL = getDocumentsDirectory().appendingPathComponent(path)
        if !FileManager.default.fileExists(atPath: fileURL.path) {
            print("⚠️ 파일 없음:", fileURL.lastPathComponent)
            return nil
        }
        return UIImage(contentsOfFile: fileURL.path)
    }
    
    // MARK: ✅ Delete (개별 이미지)
    func deleteImage(diaryID: String, index: Int) {
        let diaryFolder = getDiaryFolder(for: diaryID)
        let fileURL = diaryFolder.appendingPathComponent("image_\(index).jpg")
        
        if FileManager.default.fileExists(atPath: fileURL.path) {
            do {
                try FileManager.default.removeItem(at: fileURL)
                print("🗑️ 이미지 삭제 완료:", fileURL.lastPathComponent)
            } catch {
                print("❌ 이미지 삭제 실패:", error.localizedDescription)
            }
        } else {
            print("⚠️ 삭제할 이미지 없음:", fileURL.lastPathComponent)
        }
    }
    
    // MARK: ✅ Delete (일기 전체 폴더)
    func deleteDiaryFolder(for diaryID: String) {
        let folderURL = getDocumentsDirectory().appendingPathComponent(diaryID)
        if FileManager.default.fileExists(atPath: folderURL.path) {
            do {
                try FileManager.default.removeItem(at: folderURL)
                print("🗑️ \(diaryID) 폴더 삭제 완료")
            } catch {
                print("❌ 폴더 삭제 실패:", error.localizedDescription)
            }
        }
    }
    
    // MARK: ✅ Update (기존 이미지 교체)
    func updateImage(_ newImage: UIImage, diaryID: String, index: Int) -> String? {
        deleteImage(diaryID: diaryID, index: index)
        return saveImage(newImage, diaryID: diaryID, index: index)
    }
}

⚙️ 4️⃣ 설계 포인트 정리

FileManager 설계의 핵심은 단순히 “이미지를 저장한다”가 아니라,
폴더 구조를 어떻게 관리하고, 에러를 어떻게 처리하느냐에 있다.

 

이번 설계에서는 다음 4가지 포인트를 중점으로 잡았다.


🧭 1. 폴더 경로 관리: getDocumentsDirectory / getDiaryFolder로 분리한 이유

FileManager는 단일 경로만 반환하는 단순 함수로도 만들 수 있지만,
이번 설계에서는 명확하게 2단계로 분리했다.

함수명 역할 설명
getDocumentsDirectory() 앱 전용 루트 폴더 생성 /Documents/EmotionDiaryImages 폴더를 관리.
앱 전체 이미지의 상위 디렉토리 역할
getDiaryFolder(for:) 감정일기별 하위 폴더 생성 각 일기(diaryID)마다 고유 폴더 생성. 개별 일기 단위로 이미지 관리 가능

 

이렇게 계층 구조를 나누면 얻는 장점은 3가지다 👇

 

1️⃣ 폴더 구조의 명확성

루트 폴더(EmotionDiaryImages)와 하위 폴더(diaryID)가 역할이 분리되어,
유지보수나 디버깅 시 혼동이 없다.

 

2️⃣ 확장성 확보

나중에 “썸네일 폴더”, “백업 폴더”, “삭제 대기 폴더” 등을 추가하기 쉬워진다.

예를 들어 getThumbnailFolder() 같은 함수를 추가해도 구조적 일관성이 유지됨.

 

3️⃣ 삭제 관리 단순화

일기 전체 삭제 시 → deleteDiaryFolder()로 하위 폴더 통째로 제거

이미지 개별 삭제 시 → deleteImage()로 특정 파일만 제거

구조적으로 정리가 쉬워지고, 실수로 다른 일기의 이미지를 지울 위험이 사라진다.

 

결국 이 두 함수의 존재는 “책임 분리(Single Responsibility Principle)”를 실현한 설계다.

 

🪄 2. 에러 처리: “로깅 + 반환형”

fatalError로 앱이 죽는 구조 대신,
try-catch로 예외를 잡아 로그를 남기거나 nil을 반환하도록 처리했다.

do {
    try data.write(to: fileURL)
} catch {
    print("❌ 이미지 저장 실패:", error.localizedDescription)
}

 

→ 이렇게 하면 파일 입출력 중 오류가 발생해도 앱이 정상적으로 동작하고,
콘솔에서 문제 원인을 즉시 확인할 수 있다.

 

또한 saveImage()는 경로를 String?으로 반환하여
호출부에서 필요할 경우 저장된 경로를 Core Data에 바로 넣을 수 있게 했다.

 

🧩 3. 삭제 / 갱신 구조 분리: 폴더 삭제 vs 개별 삭제

이미지를 다루는 기능은 “삭제”라고 해서 전부 같은 것이 아니다.
삭제 대상의 단위에 따라 정확히 구분해야 한다.

메서드  설명
deleteDiaryFolder(for:) 감정일기 삭제 시 사용. 해당 ID의 폴더 전체를 제거
deleteImage(diaryID:index:) 기존 일기 내 특정 인덱스의 이미지만 삭제
updateImage(_:,diaryID:index:) 개별 이미지 교체 기능. 내부적으로 delete + save 수행
 

이렇게 역할을 나누면,

 

전체 삭제 시에는 폴더 기반 접근

개별 삭제/수정 시에는 파일 단위 접근


이 가능해진다.

 

즉, ViewModel이나 Controller가 목적에 맞는 메서드를 명확히 선택할 수 있어
로직의 의도가 훨씬 명확해진다.

 

🧱 4. @discardableResult로 유연성 확보

Swift에서는 반환값이 있는 함수를 호출하고 결과를 사용하지 않으면
컴파일러가 경고를 띄운다.

 

하지만 saveImage()처럼
“경로가 필요할 때도 있고, 아닐 때도 있는 함수”의 경우에는
그 경고가 오히려 불필요하다.

 

이럴 땐 @discardableResult를 붙여서,
결과를 사용하지 않아도 안전하게 호출할 수 있도록 했다.

@discardableResult
func saveImage(...) -> String? { ... }

 

“필요하면 반환값을 쓰고, 필요 없으면 신경 쓰지 않아도 된다”
는 유연한 설계를 의도한 것이다.

 

🗂 5. 파일명 및 폴더명 규칙의 일관성

파일명과 폴더명을 규칙적으로 구성하면
디버깅, 백업, 마이그레이션 시 관리가 훨씬 편해진다.

항목 규칙 예시
폴더명 감정일기 UUID /Documents/EmotionDiaryImages/2F7A-AAD.../
파일명 인덱스 기반 image_0.jpg, image_1.jpg, ...
경로 저장 상대경로(String) "2F7A-AAD/image_0.jpg"

 

이 구조를 사용하면 FileManager는 단순히
getDocumentsDirectory() + path로 전체 경로를 쉽게 재구성할 수 있다.

 

🧩 6. 디렉토리 자동 생성 로직

각 폴더 생성 시 createDirectory를 사용하되,
중복 생성이 일어나도 오류가 발생하지 않도록
withIntermediateDirectories: true 옵션을 활성화했다.

try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)

 

이 덕분에

 

최초 실행 시 폴더가 없을 경우 자동 생성

중간 폴더가 비어 있거나 삭제된 경우에도 안전하게 재생성

 

즉, “폴더가 없어서 저장 실패” 같은 오류를
사전에 완전히 차단할 수 있다.

 

🧩 7. 함수 단위의 책임 명확화

모든 메서드가 하나의 명확한 역할만 수행하도록 설계했다.

메서드 역할
getDocumentsDirectory() 앱 전체 이미지 루트 경로 반환
getDiaryFolder(for:) 특정 감정일기 전용 폴더 생성 및 반환
saveImage() 이미지 저장 후 경로 반환
loadImage() 이미지 파일 로드
deleteImage() 특정 인덱스 이미지 삭제
deleteDiaryFolder() 일기 전체 폴더 삭제
updateImage() 기존 이미지 교체 (delete + save)

💬 정리

“폴더 구조와 함수 책임을 명확히 나눈 FileManager는
Core Data 기반 앱의 수명을 연장시킨다.”

 

이 설계는 단순히 이미지를 저장하는 수준을 넘어서,

 

1. 폴더 기반의 계층 구조

2. 예외 안전성

3. 함수 단위 책임 분리

4. 삭제 및 갱신의 명확한 흐름


을 모두 고려한 구조다.

 

이제 이 기반 위에,Core Data의 EmotionDiaryEntity와 연동하면
앱의 이미지 관리 로직이 훨씬 안정적이고 유지보수하기 쉬워진다.

728x90
LIST