Lecture 11: Error Handling Persistence

sun·2021년 11월 17일
0

유튜브 링크

# Error Handling

  • 에러를 던질 수 있는 함수에는 throws 키워드가 붙는다..!

에러에 대응하지 않는 3가지 방법

  • try? : 에러가 발생하면 nil 을 반환
  • try! : 에러가 발생하면 프로그램이 터짐
  • rethrow : 에러를 던지는 다른 함수 안에서 사용
    func foo() throws {
        try sthThatThrows()
	}

에러에 대응하는 방법 : do-catch

do {
    try functionThatThrows()
} catch let error {
    // handle the thrown error here!
}
  • 아래와 같이 catch 할 에러를 더 구체적으로 지정할 수도 있음


iOS에서 데이터 저장하는 법


iOS file system

유닉스 파일 시스템 공부하기

  • 이것저것 문서를 읽어보긴 했는데 솔직히 정리할 정도로 이해하진 못해서 나중에 추가로 더 공부해 봐야할 것 같다...

접근 가능한 샌드박스 디렉토리 주소 도출하기

  • 특히 메인 큐에서 파일 매니저를 사용할 때는 반드시 FileManger.default 를 사용한다 (b/c thread-safe)
  • iOS 에서는 .urls(for: , in: ) 메서드에서 반드시 .userDomainMask 옵션 사용

위에서 도출한 주소 활용하기

  • 안에 하위 디렉토리를 새로 생성하거나 들어있는 파일에 접근

파일 시스템에서 뭔가를 불러오고 저장하는 법

  • Data 타입을 사용

그 외 FileManager가 제공하는 기능들


# Codable

  • object(e.g. struct)file system 에 저장할 수 있게 해준다..!

  • 주의할 점은 enum 의 경우 associated data 가 있으면 별도의 처리가 필요

  • object 의 모든 변수가 Codable 해서 그 자체 또한 Codable 하다면 JSON (혹은 다른 포맷도 가능)으로 인코딩해서 해당 struct 를 나타내는 Data blob(i.e. JSON representation of my object 으로 변환할 수 있다!

    • JSON : 데이터를 나타내는 데 사용되는 표준 양식

JSON 형태의 오브젝트 저장하고 불러오기

decoding 과정에서의 error handling


# 스위프트가 자동으로 Codable 프로토콜을 적용할 수 없는 경우

  • codable 하게 해주려면 encodingdecoding 을 해줘야 함

decoding

encoding


# UserDefaults

  • Property List 만 저장할 수 있음
  • Property List 는 일종의 개념으로 String, Int, Bool, floating point, Date, Data, Array, Dictionary 의 조합을 의미
  • Codable 프로토콜을 이용해 임의의 structData 로 변환할 수 있으므로 사실상 무엇이든 저장할 수 있음

Any

UserDefaults 사용 방법


# FileManager를 사용해서 EmojiArt 저장하기 1단계 : 저장할 주소 생성

  • file manager 객체 는 파일 시스템에 접근하고, 변경하는 것을 가능하게 한다. 예를 들어 파일/디렉토리에 접근해 정보를 기져오거나 파일/디렉토리를 생성, 복사, 이동, 변경할 수 있게 해준다!
  • FileManager.default 를 사용하면 항상 동일한 shared file manager object 에 접근할 수 있다.
  • 유저가 만든 EmojiArt 오브젝트 를 저장하기 위해서는 먼저 저장할 위치를 지정해줘야 하며, url 이 내부적으로 더 효율적이라 보통 url 을 사용한다고 한다!
  • FileManager 로 접근할 수 있는 디렉토리는 한정되어 있는 데, 그 중 유저가 생성했으며, 유저에게 항상 보여지는 것들을 저장하려면 Document directory 에 저장해야 한다.
  • 따라서 FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first 와 같이 선언해 iOS 파일 시스템 내부의 Document directory 의 주소를 얻을 수 있다.
    • 유저의 홈 디렉토리(유저 관련 정보를 저장)에서 Document Directory 를 찾아 주소를 반환한다.
  • 이렇게 얻은 주소를 이용하면 하위 디렉토리를 새로 생성하고, 들어있는 파일에 접근할 수 있으므로 우리는 URL 메서드appendingPathComponent(_ pathComponent: String) 을 이용해 EmojiArt 를 저장할 디렉토리를 생성한다!
class EmojiArtDocument: ObservableObject {
    ...
    private struct Autosave {
        static let filename = "Autosaved.emojiart"
        static var url: URL? {
            let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
            return documentDirectory?.appendingPathComponent(filename)
        }
        static let coalescingInterval = 5.0
    }
    ...
}

# FileManager를 사용해서 EmojiArt 저장하기 2단계 : Encoding

  • 유닉스 파일 시스템은 모든 파일을 바이트 단위의 스트림으로 인식하므로 파일 시스템에 저장하기 위해서는 Data 타입 을 이용해야 한다.
  • 따라서 EmojiArtModelData 타입 으로 바꿔주는 encoding 과정이 필요하기 때문에 업계 표준 형식인 JSON 데이터 형식 으로 바꿔줄 것이다!
  • 킹짱 스위프트는 이 작업을 쉽게 처리할 수 있는 JSONEncoder 클래스 를 제공하는데, 인코딩하려는 객체가 Codable 프로토콜에 순응하기만 하면 JSONEncoderencode 메서드 를 이용해 JSON 데이터 로 인코딩할 수 있다!
  • 킹짱 스위프트가 더 킹짱인 점은 우리가 아는 기본적인 타입의 대부분이 이미 Codable 에 순응해 단순히 덧붙여주기만 하면 된다는 것이다...! 몇 안되는 예외가 associated data 를 가진 enum 이었으나 강의 이후 불과 몇 개월만에 킹짱빛 스위프트에서 해당 케이스까지 바로 Codable 하도록 업데이트해줬다...!
  • 따라서 아래와 같이 선언하면 인코딩 끝~
struct EmojiArtModel: Codable {
    ...
    func json() throws -> Data {
        return try JSONEncoder().encode(self)
    }
    ...
}
  • 그리고 나서 ViewModel 에서 인코딩한 데이터를 아까 구한 파일 시스템 상의 주소에 저장해주면 저장 끝이다...! 이때 주소를 구하는 과정이나 인코딩 과정에서 에러를 던질 수 있으므로 do-catch 문으로 에러도 처리해주면 진짜 끝...
    • 별거 아니지만
      let thisfunction = "\(String(describing: self)).\(#function)"
      와 같이 선언하면 에러 메시지 출력 시 어디서 좀 더 편리하게 나타낼 수 있다...!
class EmojiArtDocument: ObservableObject {
    ...
    private func save(to url: URL) {
        let thisfunction = "\(String(describing: self)).\(#function)"
        do {
            let data: Data = try emojiArt.json()  // 인코딩
            print("\(thisfunction) json = \(String(data: data, encoding: .utf8) ?? "nil")")
            try data.write(to: url)
            print("\(thisfunction) success!")
        } catch let encodingError where encodingError is EncodingError {
            print("\(thisfunction) couldn't encode EmojiArt as JSON because \(encodingError.localizedDescription)")
        } catch {
            print("\(thisfunction) error = \(error)")
        }
        ...
    }
}
  • 그래도 혹시 몰라 써보자면 Codable 에 자동으로 순응하지 않는 타입을 순응하게 하려면 encodingdecoding 과정을 다음과 같이 직접 지정해주면 된다!
    • 인코딩과 디코딩이란 결국 각각 인코더/디코더에서 컨테이너를 가져와서 오브젝트의 타입에 따라 key 를 지정해 이를 변환한 데이터를 넣고 빼는 과정이다.

      • 이때, conainer 들은 딕셔너리와 유사한데, enum 을 사용해서 해당 container 가 어떠한 key 들을 갖는 지 알려줘야 한다!
      • 이 과정에서 CodingKey 를 사용할 수 있는데, String 타입raw value 를 선언하면 인코딩/디코딩 시 key 를 커스텀 해 사용할 수도 있다!
    • 디코딩 시에는 각 key 별로 디코딩을 해보면서 실패하면(해당 케이스가 아니라면) 다음 케이스로 넘어가는 방식

왜 타입을 인자로 넘길 때는 TypeName.self 와 같은 형식을 사용할까? 궁금했는데 공식문서를 살펴봐도 그냥 그렇게 쓴다고 얘기할 뿐 따로 설명이 없다...

extension EmojiArtModel {
    enum Background: Equatable, Codable {
        case blank
        case url(URL)
        case imageData(Data)
        
        init(from decoder: Decoder) throws {
            let container = try decoder.container(keyedBy: CodingKeys.self)
            if let url = try? container.decode(URL.self, forKey: .url) {
                self = .url(url)
            } else if let imageData = try? container.decode(Data.self, forKey: .imageData) {
                self = .imageData(imageData)
            } else {
                self = .blank
            }
        }
        
        enum CodingKeys: String, CodingKey {
            case url = "theURL"
            case imageData
        }
        
        
        func encode(to encoder: Encoder) throws {
            var container = encoder.container(keyedBy: CodingKeys.self)
            switch self {
            case .url(let url): try container.encode(url, forKey: .url)
            case .imageData(let data): try container.encode(data, forKey: .imageData)
            case .blank: break
            }
        }
        ...
}

# FileManager를 사용해서 EmojiArt 저장하기 3단계 : 자동 저장! 근데 이제 타이머를 곁들인

  • save 함수 를 만들었으니 언제 저장해야 할 지도 정해야 하는데 그냥 모델에 변화가 있을 때마다 자동 저장하도록 구현해 볼 것이다...! 매우 간단하게 그냥 didset 을 활용해서 모델이 바뀔 때마다 autosave 함수 를 호출하면 된다
class EmojiArtDocument: ObservableObject
{
    @Published private(set) var emojiArt: EmojiArtModel {
        didSet {
            scheduleAutosave()
            ...
    }
    
    private struct Autosave {
        static let filename = "Autosaved.emojiart"
        static var url: URL? {
            let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
            return documentDirectory?.appendingPathComponent(filename)
        }
        static let coalescingInterval = 5.0
    }
    
    private func autosave() {
        if let url = Autosave.url {
            save(to: url)
        }
    }
    ...
}
  • 그런데 잘 보면 didset 에서 호출하고 있는 건 scheduledAutosave 함수 다...! 이모지를 여러 개 추가하는 등 변화가 여러번 있을 때 이를 매번 저장하는 것은 교수님 말씀에 의하면 too aggressive 하므로 연속적으로 일어난 변화는 코알리싱해서 한 번만 저장하는 것...!
  • 이를 위해서 Timer 를 이용할건데, didset 을 사용해서 모델에 변화가 있을 때마다, Timer 가 이미 있다면 무효화하고, 없는 경우 바로 새 타이머를 설정해서 시간이 다 되면 autosave 함수 를 호출하는 방식이다!
    • 이렇게 하면 변화가 연속적으로 발생할 경우 이전 타이머는 계속 무효화되고 새로운 타이머로 갱신되어 저장 시점이 뒤로 미뤄지게 된다!
  • 유의할 점은 scheduledAutosave 함수 를 보내면 내부에서 self 를 호출함에도 weak 처리를 하지 않았다...! 이는 우리가 해당 document 를 닫았을 때 바로 self 가 바로 프리되는 게 아니라 닫았더라도 타이머가 다 돼서 자동 저장이 된 다음에 프리되기를 원하기 때문이다...!
class EmojiArtDocument: ObservableObject
{
    ...
    private var autosaveTimer: Timer?
    
    private func scheduleAutosave() {
        autosaveTimer?.invalidate()
        autosaveTimer = Timer.scheduledTimer(withTimeInterval: Autosave.coalescingInterval, repeats: false) { _ in
            self.autosave()
        }
    }
    ...
}

# FileManager를 사용해서 EmojiArt 불러오기 1단계 : 디코딩

  • 파일 시스템에는 우리의 EmojiArtModelJSON Data 형식으로 저장되어 있으므로 1) 주소를 이용해 데이터를 불러와서 2) EmojiArtModel 타입 으로 디코딩 해주면 된다!
    • 참고로 디코딩에 실패하면 에러를 던진다
struct EmojiArtModel: Codable {
    ...
    init(url: URL) throws {
        let data = try Data(contentsOf: url)
        self = try EmojiArtModel(json: data)
    }
    
    init(json: Data) throws {
        self = try JSONDecoder().decode(EmojiArtModel.self, from: json)
    }
    ...
}
  • 그러고 나서 ViewModel 에서 init 할 때 파일 시스템에 저장된 Model 이 있는지 확인해서 있으면 불러오고 없으면 새로운 Model 객체를 생성한다!

    • 주의할 점은 우리의 Model 은 배경 이미지를 URL 혹은 Data 형태로 저장하고 있으므로 실제로 적용해주기 위해서는 Model 을 불러온 후 fetchBackgroundImageDataIfNecessary 함수 를 호출해줘야 한다...!
classs EmojiArtDocument: ObservableObject {
    init() {
        if let url = Autosave.url, let autosavedEmojiArt = try? EmojiArtModel(url: url) {
            emojiArt = autosavedEmojiArt
            fetchBackgroundImageDataIfNecessary()
        } else {
            emojiArt = EmojiArtModel()
        }
    }
}

# filter things in the console

  • 이건 정말 별 거 아닌데 print문 을 사용해서 콘솔로 결과를 체크할 때, 키워드를 입력해서 일치하는 결과만 필터링 할 수 있다...!

# Palette Store: MVVM

  • 이번 강의에서 처음으로 ViewModel 을 여러 개 적용하는 법을 실습했다...! UI 하단에 EmojiArt 에 추가할 수 있는 이모지들이 테마별로 나타나는 데 이러한 이모지 팔레트들은 저장하는 PaletteStore 클래스 와 개별 Palette 구조체 를 정의했다...! 아마도 다음 강의에서 View 와 연동하는 방법을 배울 것 같아서 자세한 설명은 생략...

  • remove 함수 위에 보면 @discardableResult 가 붙어있는데, 말그대로 리턴 값을 버릴 수 있다는 의미다. 즉, 리턴 값을 사용하지 않아도 warning 을 띄우지 않도록 하는 역할...!


struct Palette: Identifiable, Codable, Hashable {
    var name: String
    var emojis: String
    var id: Int
    
    fileprivate init(name: String, emojis: String, id: Int) {
        self.name = name
        self.emojis = emojis
        self.id = id
    }
}

class PaletteStore: ObservableObject {
    let name: String
    
    @Published var palettes = [Palette]() 
    
    init(named name: String) {
        self.name = name
        if palettes.isEmpty {
            insertPalette(named: "Vehicles", emojis: "🚙🚗🚘🚕🚖🏎🚚🛻🚛🚐🚓🚔🚑🚒🚀✈️🛫🛬🛩🚁🛸🚲🏍🛶⛵️🚤🛥🛳⛴🚢🚂🚝🚅🚆🚊🚉🚇🛺🚜")
            insertPalette(named: "Sports", emojis: "🏈⚾️🏀⚽️🎾🏐🥏🏓⛳️🥅🥌🏂⛷🎳")
            insertPalette(named: "Music", emojis: "🎼🎤🎹🪘🥁🎺🪗🪕🎻")
            insertPalette(named: "Animals", emojis: "🐥🐣🐂🐄🐎🐖🐏🐑🦙🐐🐓🐁🐀🐒🦆🦅🦉🦇🐢🐍🦎🦖🦕🐅🐆🦓🦍🦧🦣🐘🦛🦏🐪🐫🦒🦘🦬🐃🦙🐐🦌🐕🐩🦮🐈🦤🦢🦩🕊🦝🦨🦡🦫🦦🦥🐿🦔")
            insertPalette(named: "Animal Faces", emojis: "🐵🙈🙊🙉🐶🐱🐭🐹🐰🦊🐻🐼🐻‍❄️🐨🐯🦁🐮🐷🐸🐲")
            insertPalette(named: "Flora", emojis: "🌲🌴🌿☘️🍀🍁🍄🌾💐🌷🌹🥀🌺🌸🌼🌻")
            insertPalette(named: "Weather", emojis: "☀️🌤⛅️🌥☁️🌦🌧⛈🌩🌨❄️💨☔️💧💦🌊☂️🌫🌪")
            insertPalette(named: "COVID", emojis: "💉🦠😷🤧🤒")
            insertPalette(named: "Faces", emojis: "😀😃😄😁😆😅😂🤣🥲☺️😊😇🙂🙃😉😌😍🥰😘😗😙😚😋😛😝😜🤪🤨🧐🤓😎🥸🤩🥳😏😞😔😟😕🙁☹️😣😖😫😩🥺😢😭😤😠😡🤯😳🥶😥😓🤗🤔🤭🤫🤥😬🙄😯😧🥱😴🤮😷🤧🤒🤠")
        }
    }
    
    // MARK: - Intent
    
    func palette(at index: Int) -> Palette {
        let safeIndex = min(max(index, 0), palettes.count - 1)
        return palettes[safeIndex]
    }
    
    @discardableResult
    func removePalette(at index: Int) -> Int {
        if palettes.count > 1, palettes.indices.contains(index) {
            palettes.remove(at: index)
        }
        return index % palettes.count
    }
    
    func insertPalette(named name: String, emojis: String? = nil, at index: Int = 0) {
        let unique = (palettes.max(by: { $0.id < $1.id })?.id ?? 0) + 1
        let palette = Palette(name: name, emojis: emojis ?? "", id: unique)
        let safeIndex = min(max(index, 0), palettes.count)
        palettes.insert(palette, at: safeIndex)
    }
}

# UserDefaults에 palettes 저장하기

  • palettes 는 유저가 document 로 인식하지는 않지만 저장될 필요가 있는 간단한 정보이므로 UserDefaults 에 저장하기 매우 적합하다...!
  • UserDefaults 는 이름 그대로 유저의 선택에 따른 설정값들을 딕셔너리 형태로 저장해놓고 앱이 다시 실행될 때마다 저장된 값들을 불러워 디폴트 상태를 구축하게 도와주는 인터페이스다!
  • 위에서 file system 에 데이터를 저장했을 때와 유사한 과정으로 데이터를 저장한다. 그러나 인코딩을 해주는 이유가 좀 다르다
    • 우리의 palettes 배열 이 담고 있는 개별 Palette 는 구조체인데, 구조체는 UserDefaults 가 저장할 수 있는 Property List 에 포함되지 않는다...
    • DataProperty List 에 포함되므로 UserDefaultspalettes 배열 을 저장하기 위해서는 Palette 구조체Data 로 인코딩해줘야한다...!
  • 인코딩에 성공하면 set(_:forkey:) 메서드 를 이용해 앞서 선언한 userDefaultsKey 의 밸류를 JSON Data 로 설정해주면 끝!
  • 그리고 나서 palettes 가 바뀔 때마다 didSet 을 이용해 다시 저장하도록 하면 된다!
class PaletteStore: ObservableObject {
    let name: String
    
    @Published var palettes = [Palette]() {
        didSet {
            storeInUserDefaults()
        }
    }
    
    private var userDefaultsKey: String {
        "PaletteStore:" + name
    }
    
    private func storeInUserDefaults() {
        UserDefaults.standard.set(try? JSONEncoder().encode(palettes), forKey: userDefaultsKey)
    }
    ...
}

# UserDefaults에서 palettes 불러오기

  • 1) 앞서 지정한 키에서 데이터를 불러온 다음 2) palettes 타입 로 디코딩해서 palettes 변수 에 할당해주면 된다!
  • 불러오는 게 실패하는 경우 palettes 가 비어있으므로 init 에서 이를 체크해서 미리 정의해 둔palette 들을 넣어준다
class PaletteStore: ObservableObject {
    ...
    
    private func restoreFromUserDefaults() {
        if let jsonData = UserDefaults.standard.data(forKey: userDefaultsKey),
           let decodedPalettes = try? JSONDecoder().decode(Array<Palette>.self, from: jsonData) {
            palettes = decodedPalettes
        }
    }
    
    init(named name: String) {
        self.name = name
        restoreFromUserDefaults()
        if palettes.isEmpty {
            // default Palettes to insert
        }
    }
    ...
}

☀️ 느낀점

  • File System 을 언제 쓰면 좋을 지에 대해, 그리고 유닉스 파일 시스템 에 대해 더 공부를 해 보면 좋을 것 같다....
  • 다른 튜토리얼들에서 여러 개의 ViewModel 들을 쓰는 것을 보면서 늘 ViewModel 을 분리하는 기준이라던가, 여러 개를 사용하는 법이 궁금했는데 이번 주차에 궁금증이 해소될 것 같아 기대가 된다!
  • UserDefaults 는 사실 이전에도 몇 번 써본 적 있었는데 부끄럽지만 그냥 시키니까 저장하고 불러올 때 인코딩/디코딩 했었다...그런데 Property List 에 해당하지 않는 오브젝트들을 저장하기 위함임을 배울 수 있었다..!
  • 그 외에도 콘솔을 활용하는 다양한 방법이 정말 유익했는데 내가 이걸 기억할 수 있겠지...?
profile
☀️

0개의 댓글

관련 채용 정보