Starbucks Caramel Frappuccino
본문 바로가기
  • 그래 그렇게 조금씩
SwiftUI/SwiftUI(Intermediate)

21. FileManager 파일매니저

by Toughie 2023. 6. 15.

⭐️FileManager 파일매니저⭐️

iOS 앱을 만들면서 데이터를 저장할 수 있는 방법은 여러가지가 있다.(상황에 따라 적절히 선택해야함)

이번에는 아이폰 내부 폴더에 직접 파일을 저장할 수 있는 파일매니저에 대해 알아보자.

 

먼저 데이터 여러가지 데이터 저장 방법을 비교, 정리해보자.

 

1. UserDefaults

- 간단한 설정 값, 사용자 기본 설정 등과 같은 작은 크기의 데이터를 저장하기 위해 사용

- 주로 Key-Value 형식으로 데이터 저장, 간단한 데이터 유형(Int, String, Bool 등) 저장

- 데이터를 메모리에 로드하기 때문에 작은 크기의 데이터에 적합함.

2. CoreData

- 복잡한 데이터 모델과 대량의 데이터를 관리에 유용, 상호 관련성이 있는 데이터 관계를 유지하고 추적하는데 유리함

- 객체 그래프(데이터 모델 객체 간의 상호작용과 관계를 나타내는 개념) 관리, 관계형 DB 등의 기능 제공

- 객체지향적 데이터 모델링 & 관계형 데이터베이스의 장점을 혼합해서 사용 가능 (Entity, Attribute)

- 데이터 로드, 저장, 쿼리 및 관계 처리에서 편리함

3.File Manager

- 파일 시스템에 직접 파일을 읽고 쓰는 데 사용되는 클래스

- 다양한 파일 형식(텍스트, 바이너리 파일, 이미지, 오디오, 비디오 등) 저장 및 로드, 이동 삭제 가능

- 대량의 파일이나 특정 파일 형식(코어데이터 활용이 번거로운 경우 등)을 저장할 때 유용함.

 

UserDefaults와 CoreData는 메모리 내에 데이터를 로드해서 사용하지만

FileManager는 파일 시스템(ex.아이폰 내부 폴더)에서 직접 파일을 읽고 씀.

 

이미지 등을 아래와 같이 AssetCatalog에 넣어두고 사용하기도 하지만, 앱 자체의 용량이 그만큼 커진다는 문제가 있다.

따라서 꼭 필요한 앱아이콘 같은 파일만 여기에 두고, 따로 필요한 파일들은 서버에서 받아오는 식으로 처리하는 경우가 많다.

만약 서버에서 받아온 이미지,다른 파일이 중요하고 계속 사용해야해서 아이폰 내부 폴더에 직접 저장해둬야 할 경우에는?

이 때 파일매니저를 사용할 수 있다.

 

여기서는 먼저 에셋을 통해 이미지를 로드하고, 이 이미지를 파일매니저를 통해 저장하고, 삭제하고, 파일시스템 내부에

이 앱을 위한 폴더를 따로 만들고 삭제하는 코드를 살펴볼 것이다.

 

FileManagerClass

여러 뷰에서 파일매니저를 활용하는 경우가 있을 수 있기 때문에, 싱글톤으로 구현해 본다.

//  Created by Toughie on 2023/06/14.
//

import SwiftUI

final class LocalFileManager {
//싱글톤
    static let shared = LocalFileManager()
    //해당 앱이 사용할 폴더명
    let folderName = "MyApp_Images"
    //초기화 시 폴더를 만들어 준다.
    private init() {
        createFolderIfNeeded()
    }
    
    ...

기존 파일시스템 내부의 폴더에다 파일을 저장해도 되지만, 이 앱만을 위한 폴더를 만들면 좀 더 관리가 편리할 것이다.

폴더 만들기

    func createFolderIfNeeded() {
        guard
            let path = FileManager
                .default
                .urls(for: .cachesDirectory, in: .userDomainMask)
                .first?
                .appendingPathComponent(folderName)
                .path else {
            return
        }
        
        if !FileManager.default.fileExists(atPath: path) {
            do {
                try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil)
                print("SUCCESS CREATING FOLDER")
            } catch let error {
                print("ERROR CREATING FOLDER. \(error.localizedDescription)")
            }
        }
    }

guard문부터 살펴보자.

- FileManager.default를 통해 기본 파일 관리자 객체에 접근한다.

- .urls 메서드를 호출해서 캐시 디렉토리의 URL을 가져온다.

 

여기서 디렉토리의 종류가 굉장히 많다! 파일의 특징에 따라 적절한 디렉토리에 저장해야 한다.

아래의 파일시스템 데이터 저장 가이드라인을 보고 결정하자.

1. 유저가 생성하거나 앱에 의해서 재생성 될 수 없는 문서나 데이터는 document 디렉토리에 저장하자.

2. 다시 다운로드 될 수 있거나 재생성될 수 있는 데이터는 캐시 디렉토리에 저장하자.

3. 임시적으로 사용되는 데이터는 tmp 디렉토리에 저장하자.(이 파일들은 아이클라우드에 백업되지 않음) 작업이 끝나면 지워주기.

4. 만약 오프라인 상태에서도 계속 사용되어야 하는 경우에는 iCloud에 백업되지 않도록 저장해야함.

(ex - .urls(for: .applicationSupportDirectory, in [.userDomainMask, .localDomainMask])

 

해당 예시에서는 2번에 해당하기 때문에 캐시 디렉토리에 저장하는 것이다.

.urls(for: .cachesDirectory, in: .userDomainMask) 여기에서 in: .userDomainMask는 

사용자의 홈 디렉토리를 나타낸다.

 

.urls(for:in:)

URL을 프린트 해보면 

[file:///Users/Toughie/Library/Developer/CoreSimulator/Devices/3FEF9ADD-0430-42E2-9F47-CED23745E0C1/data/Containers/Data/Application/D97E5A63-FC0F-468B-B287-3CAD6CA31F55/Library/Caches/]

이와 같은 형태를 보인다. 따라서 first 속성을 사용해서 URL 배열의 첫번째 URL을 선택한다. (Optional)

 

.appendingPathComponent(_:)

선택한 URL에 folderName("MyApp_Images")를 추가해서 폴더의 최종 경로를 만든다.

 

.path

경로의 문자열을 가져온다. (String 타입)

 

자 이렇게 path를 만들었고..

        if !FileManager.default.fileExists(atPath: path) {
            do {
                try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil)
                print("SUCCESS CREATING FOLDER")
            } catch let error {
                print("ERROR CREATING FOLDER. \(error.localizedDescription)")
            }
        }

.fileExists(atPath:)

해당 경로에 파일이나 폴더가 존재하는지 확인한다. (여기서는 없을 경우 만들기 때문에 ! 연산자를 붙여줌)

.createDirectory(atPath:withIntermediateDirectories:attributes)

폴더를 생성하는 메서드. 

atPath - 폴더를 생성할 경로 전달

withIntermediateDirectories - true로 설정하면 중간 폴더 생성

attributes - 폴더의 속성을 나타냄. nil로 설정하면 기본 속성 사용


폴더 지우기

    func deleteFolder() {
        guard
            let path = FileManager
                .default
                .urls(for: .cachesDirectory, in: .userDomainMask)
                .first?
                .appendingPathComponent(folderName)
                .path else {
            return
        }
        do {
            try FileManager.default.removeItem(atPath: path)
            print("폴더 삭제 성공!")
        } catch let error {
            print("폴더 삭제 실패: \(error.localizedDescription)")
        }
    }

해당 폴더의 경로를 확인하고,

.removeItem(atPath:)를 통해 폴더를 삭제한다.


* 코드 흐름 이해를 위한 뷰모델 코드 일부

class FileManagerViewModel: ObservableObject {
    let manager = LocalFileManager.shared
    //에셋에 저장된 이미지 이름
    let imageNamge: String = "devJeans"
    
    @Published var infoMessage: String = ""
    @Published var image: UIImage?
    
    init() {
        getImageFromAssets()
    }
    
    //초기화시 에셋에서 이미지를 로드함.
    func getImageFromAssets() {
        image = UIImage(named: imageNamge)
    }

 다시 파일매니저 코드로~


이미지 저장

    func saveImage(image: UIImage, name: String) -> String {
        //        image.jpegData(compressionQuality: 1.0)
        guard
            let data = image.pngData(),
            let path = getPathForImage(name: name)
        else {
            return "이미지 데이터 에러"
        }

        do {
            try data.write(to: path)
            print(path)
            return "저장 성공"
        } catch let error {
            return ("저장 실패: \(error.localizedDescription)")
        }
    }

*에셋에서 로드한 이미지를 파일매니저를 통해 폴더에 저장하기 위한 코드이다.

반환타입을 이렇게 String으로 하는 경우는 잘 없지만, 화면에 표시하기 위함임. 뷰모델의 infoMessage에 할당하기 위함!( 안 중요함!)

보통은 알럿을 띄우거나 뭐 로그에 프린트하거나..ㅎㅎ

 

파라미터로 이미지가 있는데, 이미지에서 data를 뽑아낼 수 있다.

만약 jpeg 데이터라면 compressionQuality를 통해 압축률을 정할 수 있다. 하지만 여기서는 png 형식을 사용했음을 참고.


PNG vs JPEG

PNG(Portable Network Graphics)형식은 이미지를 압축하지 않고 손실 없이 저장하는 방식.

PNG는 이미지의 투명도를 보존하면서 고화질 이미지를 유지할 수 있는 장점이 있지만 파일 크기가 크다는 단점이 있음.

 

JPEG(Joint Photographic Experts Group) 형식은 이미지를 손실 압축해서 저장하는 방식

여기서 image.jpegeData(compressionQquality: 1.0)처럼 압축 품질을 지정할 수 있는데 1에 가까울수록
압축이 적게 적용되어 고화질 이미지를 유지할 수 있음.


이제 이미지 데이터를 추출했고, 우리가 원하는 경로에 저장해야 한다.

따라서 이미지가 저장될 경로를 반환하는 함수를 살펴보자.

    func getPathForImage(name: String) -> URL? {
        guard
            let path = FileManager
                .default
                .urls(for: .cachesDirectory, in: .userDomainMask)
                .first?
                //폴더
                .appendingPathComponent(folderName)
                .appendingPathComponent("\(name).png") else {
            print("이미지 패스 에러")
            return nil
        }
        return path
    }

이미지의 이름을 받아서 경로를 반환하는 함수이다.

이제 이미지데이터, 경로가 준비되었다. 저장해보자 !

        do {
            try data.write(to: path)
            print(path)
            return "저장 성공"
        } catch let error {
            return ("저장 실패: \(error.localizedDescription)")
        }

data.write(to:)

지정된 경로에 데이터를 쓰는 작업을 수행하는 메서드


이미지 로드(fetch)

저장된 이미지를 불러오는 방법을 살펴보자.

    func getImage(name: String) -> UIImage? {
        
        guard
            let path = getPathForImage(name: name)?.path,
            FileManager.default.fileExists(atPath: path) else {
            print("Error getting path")
            return nil
        }
        return UIImage(contentsOfFile: path)
    }

이미지 이름을 받아서 UIImage를 반환하는 메서드이다.

getPathForImage 메서드를 통해 path를 만든다. 여기서 .path를 통해 String으로 경로를 받아온다.(URL 타입 대신 String으로)

해당 path에 파일이 존재하는지 확인하고 없다면 nil을,

파일이 존재한다면 UIImage(contentsOfFile:)을 통해서 UIImage를 반환한다. 


이미지 삭제

저장된 이미지를 지워보자. 똑같이 경로를 확인하고, 경로에 파일이 존재한다면 삭제하면 될 것이다.

    func deleteImage(name: String) -> String {
        guard
            let path = getPathForImage(name: name)?.path,
            FileManager.default.fileExists(atPath: path) else {
            return "Error getting path"
        }
        do {
            try FileManager.default.removeItem(atPath: path)
            return "Successfully Deleted"
        } catch let error {
            return "Error deleting image \(error.localizedDescription)"
        }
    }

어떤 경로? 해당 경로에 파일 있어? 있으면 

.removeItem(atPath:)를 통해서 삭제!

 

이렇게 파일시스템에서 폴더를 만들고, 지우고

폴더의 경로에 파일을 저장하고, 삭제하고, 로드하는 코드를 만들었다.

이렇게 완성된 파일매니저 클래스를 뷰모델에서 활용해보자.


ViewModel

final class FileManagerViewModel: ObservableObject {
    let manager = LocalFileManager.shared
    let imageName: String = "devJeans"
    
    @Published var infoMessage: String = ""
    @Published var image: UIImage?
    
    init() {
        getImageFromAssets()
    }
    //에셋에서 이미지 로드
    func getImageFromAssets() {
        image = UIImage(named: imageName)
    }
    //파일 매니저에서 이미지 로드
    func getImageFromFileManager() {
        image = manager.getImage(name: imageName)
    }
    // 파일 매니저에 이미지 저장 (옵셔널 바인딩 후), String 받아와서 infoMessage에 할당
    func saveImage() {
        guard let image = self.image else { return }
        infoMessage = manager.saveImage(image: image, name: imageName)
    }
    //파일 매니저에서 이미지 삭제 (String 받아와서 infoMessage에 할당)
    func deleteImage() {
        infoMessage = manager.deleteImage(name: imageName)
    }
}

View

뷰에서는 각 버튼에서 뷰모델의 saveImage(), deleteImage(), deleteFolder()를 액션으로 호출하면 된다. :)

1. 에셋의 이미지를 먼저 로드함.

2. 해당 이미지를 파일매니저를 통해 저장함.

3. 파일매니저를 통해 저장된 이미지를 삭제함.

4. 따라서 해당 이미지가 삭제돼서 경로가 없기에 '패스가 없습니다' 출력

5. 폴더 삭제(처음에 생성 됨)


포스팅이 꽤 길었다.

그래도 이제 파일매니저를 활용해야 하는 경우, 금방 할 수 있을 것 같다.

결국 중요한건 'path', 그리고 파일의 형태

어떤 파일을 어떤 경로에 저장하고, 삭제하고, 불러오겠다. 이게 거의 전부다 ㅋㅋ