본문 바로가기
개발(Development)/iOS(아이폰)

[한글 번역_11] Start Developing iOS Apps (Swift) - Working with Table Views > Persist Data

by 카레유 2015. 10. 30.
안녕하세요 카레유입니다.

Apple에서 제공해주는 Swift iOS 앱 개발 가이드를 한글 번역해보았습니다.

영어실력도, 개발실력도, 심지어 한국어 실력도 미흡합니다.

부족한 부분이 많지만, 많은 도움 되시길 바라겠습니다.

아직 작성 중인 내용으로 시간을 갖고 개선해 나갈 생각입니다.

감사합니다.







Persist Data

이번 레슨에서는 FoodTracker앱에서 meal list를 저장하는 기능을 구현하겠습니다. 데이터의 저장은 iOS 앱 개발에서  아주 중요한 부분입니다. iOS는 다양한 데이터 저장 방식을 제공하지만, 이번 레슨에서는 NSCoding을 이용한 데이터 저장 메커니즘을 학습하도록 하겠습니다. NSCoding은 객체 및 각종 스트럭쳐(structure)들을 아카이빙할 수 있는 경량화된 솔루션을 제공하는 프로토콜입니다. NSCoding 프로토콜을 통해 아카이빙 된 객체는 Disk에 저장할 수 있으며, 추후에도 꺼내 쓸 수 있는 기능도 제공합니다.(*역자 : 데이터를 저장하는 것을 save, 꺼내쓰는 것을 load라고 합니다)

Learning Objectives

At the end of the lesson, you’ll be able to:

  • Structure를 만들 수 있다.

  • static 프로퍼티와 instance 프로퍼티의 차이를 이해할 수 있다.

  • NSCoding 프로토콜을 이용하여 데이터를 쓰고, 읽을 수 있다.

Save and Load the Meal

Meal클래스를 통해 음식을 저장하고 로드하는 기능을 구현하겠습니다. NSCoding 프로토콜을 이용하면 Meal클래스의 프로퍼티를 저장하고 로드하는 기능을 구현할 수 있습니다. 각 프로퍼티의 값에 특정 Key값을 할당하여 데이터를 저장하고, 데이터를 불러올 때도 key값을 이용하겠습니다. 

Key는 단순한 String 값이며, 알아보기 쉬운 규칙대로 정의하면 됩니다. 예를 들어 name프로퍼티 값을 저장하기 위한 key값은 "name"으로 정의하면 됩니다.

각각의 데이터에 매칭 되는 key를 효율적으로 관리하기 위해  key 값들을  따로 structure로 만들어 상수로 관리하겠습니다. 상수로 key값을 선언해두면 코드의 어디에서나 사용할 수 있으므로 매번 key값을 타이핑할 필요가 없습니다. (매번 key값을 입력하는 것은 실수가 발생할 가능성이 높습니다)

To implement a coding key structure

  1. Meal.swift 를 열어주세요

  2. // MARK: Properties"주석 밑에 아래와 같이 structure를 선언해주세요

    1. // MARK: Types
    2. struct PropertyKey {
    3. }
  3. "PropertyKey" structure 에 아래의 케이스들을 생성해주세요

    1. static let nameKey = "name"
    2. static let photoKey = "photo"
    3. static let ratingKey = "rating"

    각각의 상수들은 Meal에 있는 3가지 프로퍼티에 대응되는 key 값을 갖게 됩니다. static 키워드는 이 상수들이  structure의 인스턴스에만 적용되는 것이 아니라 structure 자체에 적용됨을 의미합니다. 즉 structure의 인스턴스를 통해 접근하지 않고, structure자체를 통해 접근할 수 있는 값이 됩니다.(PropertyKey.nameKey 와 같은 형식으로 접근 가능) 또한 이 값들은 "static 상수"이므로 절대로 변하지 않습니다.(*역자 : Java에 익숙하시다면 final static 으로 선언된 상수의 개념이라고 생각하셔도 될 것 같습니다)

완성된 structure의 모습은 아래와 같습니다.

  1. struct PropertyKey {
  2. static let nameKey = "name"
  3. static let photoKey = "photo"
  4. static let ratingKey = "rating"
  5. }

Meal 클래스 및 프로퍼티를 인코딩하고 디코딩하기 위해서는 NSCoding 프로토콜을 따라야하며, NSCoding 프로토콜을 따르기 위해서 NSObject의 서브클래스여야 합니다.(상속을 받으면 됩니다) NSObject는 runtime system에서의 기본 인터페이스를 정의하고 있는 기본 클래스입니다.

To subclass NSObject and conform to NSCoding

  1. Meal.swift 파일에서 class 선언부를 찾으세요

    1. class Meal {
  2. Meal 다음에 콜론( : ) 을 붙이고, "NSObject"라고 입력해주세요. NSObject를 상속받게 됩니다.

    1. class Meal: NSObject {
  3. NSObject 옆에 콤마(,)를 붙이고, "NSCoding"이라고 입력해주세요. NSCoding 프로토콜을 채택(adopt)하게 됩니다.

    1. class Meal: NSObject, NSCoding {

NSCoding 프로토콜에는 두개의 메서드가 선언 되어있으며, NSCoding을 채택한 클래스는 반드시 이 두 메서드를 구현해야만 합니다. 이를 통해 클래스의 인스턴스가 인코딩되고 디코딩될 수 있게 됩니다.(*역자 : 프로토콜을 채택(adopt)하고, 필수 메서드를 구현하는 작업을 프로토콜을 따른다고(conform) 합니다.)

  1. func encodeWithCoder(aCoder: NSCoder)
  2. init(coder aDecoder: NSCoder)

데이터를 저장하고 로드하기 위해서는 위의 두 메서드를 모두 구현해야합니다. encodeWithCoder(_:)메서드는 클래스의 정보를 아카이브 하며, 이니셜라이져는 클래스가 생성될 때 데이터를 언아카이브합니다.

To implement the encodeWithCoder NSCoding method

  1. Meal.swift 파일의 마지막 괄호( } ) 바로 앞에 다음의 주석을 추가해주세요

    1. // MARK: NSCoding

    이렇게 주석을 붙임으로써 이 코드가 NSCoding 프로토콜에 의거한 데이터 저장 관련 섹션이라는 것을 쉽게 파악할 수 있게됩니다.

  2. 주석 밑에 아래의 메서드를 추가하세요

    1. func encodeWithCoder(aCoder: NSCoder) {
    2. }
  3. 메서드 구현부에 아래 코드를 작성해주세요

    1. aCoder.encodeObject(name, forKey: PropertyKey.nameKey)
    2. aCoder.encodeObject(photo, forKey: PropertyKey.photoKey)
    3. aCoder.encodeInteger(rating, forKey: PropertyKey.ratingKey)

    encodeObject(_:forKey:) 메서드는 어떠한 타입의 객체도 인코딩할 수 있으며, encodeInteger(_:forKey) 메서드는 Integer 만 인코딩할 수 있습니다. 위의 코드는 Meal클래스의 각 프로퍼티의 값들을 인코딩하고, 해당 key값으로 저장하고 있습니다.

완성된 encodeWithCoder(_:) 메서드의 모습은 아래와 같습니다.

  1. func encodeWithCoder(aCoder: NSCoder) {
  2. aCoder.encodeObject(name, forKey: PropertyKey.nameKey)
  3. aCoder.encodeObject(photo, forKey: PropertyKey.photoKey)
  4. aCoder.encodeInteger(rating, forKey: PropertyKey.ratingKey)
  5. }

encoding 메서드를 작성했으니, 이제 인코딩된 데이터를 디코딩하기 위한 initializer를 구현하겠습니다.

To implement the initializer to load the meal

  1. encodeWithCoder(_:)메서드 밑에 아래와 같이 initializer 를 선언해주세요

    1. required convenience init?(coder aDecoder: NSCoder) {
    2. }

    required 키워드는 이 initializer가 정의된 클래스를 상속하는 경우, 반드시 이 initializer를 구현해야만 한다는 것을 의미합니다.

    convenience 키워드는 이 이니셜라이저가 convenience initializer임을 지정합니다. convenience initializer는 클래스의 슈퍼클래스의 이니셜라이저를 호출하고 전체 프로퍼티에 대한 초기화 작업을 수행하는 designated initializer(primary initializer)과는 별개로 추가적인 작업을 하기 위해 사용되는 부수적인 이니셜라이저입니다. 따라서 convenience initializer 는 designated initializer 중 하나를 반드시 호출해야만 합니다. 여기서는 저장된 데이터를 로드하는데만 사용되기 때문에 convenience 키워드를 사용하였습니다.

    물음표(?)는 객체 생성에 실패하여 nil값을 반환할 수도 있는 failable initializer 임을 의미합니다.

  2. 다음 라인에 아래의 코드를 추가하세요

    1. let name = aDecoder.decodeObjectForKey(PropertyKey.nameKey) as! String

    decodeObjectForKey(_:)메서드는 해당 키값으로 저장된 객체를 언아카이브합니다. decodeObjectForKey(_:)메서드는 AnyObject 타입으로 디코딩한 객체를 반환하기 떄문에 String타입으로 다운캐스팅하여 name상수에 할당하고 있습니다. 만약 해당 객체가 nil이거나 String으로 형변화지 되지 않는 경우 명백한 오류임을 의미하며, 런타임환경에서도 충돌이 발생하게 됩니다. 따라서 강제형변환연선자인 as!(forced type cast operator)를 사용하여 형변환을 하였습니다.(*역자 : 즉, 사용자가 음식 이름을 입력하지 않는 경우는 없기 때문에, 값이 반드시 존재하며 형변환도 가능하다고 확신할 수 있습니다. 그렇지 않으면 명백한 오류이므로 프로그램 자체에 문제가 있는 것입니다.)

  3. 다음 라인에 아래 코드를 추가하세요

    1. // Because photo is an optional property of Meal, use conditional cast.
    2. let photo = aDecoder.decodeObjectForKey(PropertyKey.photoKey) as? UIImage

    여기서도 decodeObjectForKey(_:)메서드가 반환하는 값을 UIImage로 다운캐스팅하여 photo 상수에 할당하고 있습니다. 하지만 이번에는 optional type cast operator(as?)를 사용하고 있습니다. photo 프로퍼티가 optional 타입이기 때문에 nil이 될 수도 있기 때문입니다. 즉, 사용자가 사진을 추가하지 않는 경우에 대비하기 위해서 optional cast operator를 사용하였습니다.

  4. 다음 라인에 아래 코드를 추가해주세요

    1. let rating = aDecoder.decodeIntegerForKey(PropertyKey.ratingKey)

    decodeIntegerForKey(_:)메서드는 Integer 를 언아카이브합니다. 이 메서드는 디코딩 된 값을 Int타입으로 반환하기 때문에 따로 다운 캐스팅 할 필요가 없습니다.

  5. 구현부 맨 마지막에 아래의 코드를 추가하세요

    1. // Must call designated initilizer.
    2. self.init(name: name, photo: photo, rating: rating)

    이 이니셜라이저는 convenience initializer이기 때문에 클래스 자체의 designated initializer 중 하나를 반드시 호출해주어야 합니다. initializer의 인자(argument)는 저장된 데이터를 아카이빙하면서 생성한 상수들의 값을 넣어주면 됩니다. (이를 통해서 키캆으로 저장된 프로퍼티 값들을 언아카이브하여 객체를 초기화할 수 있게 됩니다.)

완성된 init?(coder:) initializer 모습은 아래와 같습니다.

  1. required convenience init?(coder aDecoder: NSCoder) {
  2. let name = aDecoder.decodeObjectForKey(PropertyKey.nameKey) as! String
  3. // Because photo is an optional property of Meal, use conditional cast.
  4. let photo = aDecoder.decodeObjectForKey(PropertyKey.photoKey) as? UIImage
  5. let rating = aDecoder.decodeIntegerForKey(PropertyKey.ratingKey)
  6. // Must call designated initializer.
  7. self.init(name: name, photo: photo, rating: rating)
  8. }

Meal 클래서의 정의되어 있는 또다른 initializer인 init?(name:photo:rating:)은 designated initializer이기 때문에 superclass의 initializer를 호출해주어야만 합니다.(Meal클래스는 NSObject글래스를 상속받고 있습니다. 슈퍼클래스의 이니셜라이져인 super.init()를 호출해야 NSObject로부터 상속받는 각종 프로퍼티에 대한 초기화 작업이 수행됩니다)

즉, designated initializer는 슈퍼클래스의 init메서드를 호출하여 하며, convenience initializer는 designated initializer를 호출해야 합니다.

To update the initializer implementation to call its superclass initializer

  1. 현재 designated initializer는 아래와 같습니다.

    1. init?(name: String, photo: UIImage?, rating: Int) {
    2. // Initialize stored properties.
    3. self.name = name
    4. self.photo = photo
    5. self.rating = rating
    6. // Initialization should fail if there is no name or if the rating is negative.
    7. if name.isEmpty || rating < 0 {
    8. return nil
    9. }
    10. }
  2. self.rating = rating 라인 아래에 superclass의 initializer를 호출하는 코드를 추가해주세요

    1. super.init()

init?(name:photo:rating) initializer의 완성된 모습은 아래와 같습니다.

  1. init?(name: String, photo: UIImage?, rating: Int) {
  2. // Initialize stored properties.
  3. self.name = name
  4. self.photo = photo
  5. self.rating = rating
  6. super.init()
  7. // Initialization should fail if there is no name or if the rating is negative.
  8. if name.isEmpty || rating < 0 {
  9. return nil
  10. }
  11. }

다음으로 데이터가 저장되고 로드되어질 파일 시스템상의 저장경로가 필요합니다. 어디에 저장할지 알아야, 어디서 불러올지도 알 수 있습니다. 이 경로(path)를 class의 global 상수로 선언하여 어디서나 사용할 수 있게 구현하겠습니다.

To create a file path to data

  • Meal.swift파일의 "// MARK: Properties" 주석 섹션 바로 아래에 다음의 코드를 작성해주세요

    1. // MARK: Archiving Paths
    2. static let DocumentsDirectory = NSFileManager().URLsForDirectory(.DocumentDirectory, inDomains: .UserDomainMask).first!
    3. static let ArchiveURL = DocumentsDirectory.URLByAppendingPathComponent("meals")

    static 키워드를 사용하여 상수를 선언했기 때문에 클래스의 인스턴스(instance)가 아니라 class 자체에 적용되어 사용할 수 있게 됩니다. 즉, Meal 클래스 밖에서 이 경로를 사용할 때 , static 상수로 선언되었기 때문에 "Meal.ArchivePath"형태로 접근할 수 있습니다(따로 Meal클래스의 인스턴스를 생성하고, 인스턴스.ArchivePath 형식으로 사용할 필요가 없습니다)

Checkpoint: Command-B를 눌러 빌드해보세요. 아무런 이슈가 없어야만 합니다.

Save and Load the Meal List

지금까지의 작업을 통해 개별 meal을 저장하고 로드할 수 있게 되었습니다. 이제 개별 meal을 추가/편집/삭제할 때마다 meal list에도 해당 내용을 저장하고 로드하는 기능을 구현해야합니다.

To implement the method to save the meal list

  1. MealTableViewController.swift 파일을 열어주세요

  2. MealTableViewController.swift 파일의 맨마지막 괄호 ( } ) 바로 앞에 아래의 주석을 추가해주세요

    1. // MARK: NSCoding

    이 주석을 통해 이 부분이 데이터 저장과 관련된 부분이라는 것을 쉽게 파악할 수 있습니다.

  3. 주석 바로 아래에 다음의 메서드를 선언해주세요

    1. func saveMeals() {
    2. }
  4. saveMeals() 메서드에 다음의 코드를 작성해주세요

    1. let isSuccessfulSave = NSKeyedArchiver.archiveRootObject(meals, toFile: Meal.ArchiveURL.path!)

    이 메서드는 meals 배열을 지정한 경로로 아카이브하고 성공한 경우 true 를 리턴합니다. meal list를 저장할 경로는 Meal 클래스에서 정의한 ArchivePath상수 값으로 지정하고 있습니다.(static으로 선언된 전역상수이므로 어느 클래스에서나 Meal.ArchiveURL 로 접근할 수 있습니다)

    데이터가 제대로 저장되었는지를 쉽게 테스트해보기 위해서는 print 구문을 이용해서 console에 메시지를 출력해보면 됩니다. meals 배열이 제대로 저장되지 않은 경우, console창에 실패 메시지를 출력하도록 하면됩니다.

  5. Below the previous line, add the following if statement: - 다음 라인에 if 구문을 작성하세요

    1. if !isSuccessfulSave {
    2. print("Failed to save meals...")
    3. }

    저장 작업이 실패한다면 console 창에 위에 작성한 메시지가 출력됩니다.

완성된 saveMeals()메서드의 모습은 아래와 같습니다.

  1. func saveMeals() {
  2. let isSuccessfulSave = NSKeyedArchiver.archiveRootObject(meals, toFile: Meal.ArchiveURL.path!)
  3. if !isSuccessfulSave {
  4. print("Failed to save meals...")
  5. }
  6. }

Now, implement a method to load saved meals. - 이제 저장된 meal 들을 불러오는 메서드를 구현해봅시다.

To implement the method to load the meal list

  1. MealTableViewController.swift파일의 마지막 괄호( } ) 앞에 다음 메서드를 선언하세요

    1. func loadMeals() -> [Meal]? {
    2. }

    이 메서드는 optional타입으로 Meal객체의 배열을 반환합니다. 즉, Meal객체들로 구성된 배열을 반환하거나, 아니면 아무것도 반환하지 않게됩니다(nil).

  2. loadMeals()메서드 구현부에 아래 코드를 추가하세요

    1. return NSKeyedUnarchiver.unarchiveObjectWithFile(Meal.ArchivePath) as? [Meal]

    이 코드는 Meal.ArchivePath 경로에 저장된 객체를 언아카이브하여 반환하며, 여기서는 Meal객체의 배열 형태로 다운캐스팅하여 반환하고 있습니다. 해당 경로에 저장된 값이 존재하지 않는 경우, 다운캐스팅에 실패하고 nil을 반환해야하기 때문에 as? 연산자를 사용했습니다.

loadMeals()메서드의 완성된 전체 모습은 아래와 같습니다.

  1. func loadMeals() -> [Meal]? {
  2. return NSKeyedUnarchiver.unarchiveObjectWithFile(Meal.ArchiveURL.path!) as? [Meal]
  3. }

사용자가 meal을 추가/삭제/편집할 때마다 meals배열을 저장하고, 로드하기 위해서는 위의 코드를 필요한 곳에서 적절히 사용해야 합니다.

To save the meal list when a user adds, removes, or edits a meal

  1. MealTableViewController.swift파일에서 unwindToMealList(_:) 액션 메서드를 찾아주세요

    1. @IBAction func unwindToMealList(sender: UIStoryboardSegue) {
    2. if let sourceViewController = sender.sourceViewController as? MealViewController, meal = sourceViewController.meal {
    3. if let selectedIndexPath = tableView.indexPathForSelectedRow {
    4. // Update an existing meal.
    5. meals[selectedIndexPath.row] = meal
    6. tableView.reloadRowsAtIndexPaths([selectedIndexPath], withRowAnimation: .None)
    7. }
    8. else {
    9. // Add a new meal.
    10. let newIndexPath = NSIndexPath(forRow: meals.count, inSection: 0)
    11. meals.append(meal)
    12. tableView.insertRowsAtIndexPaths([newIndexPath], withRowAnimation: .Bottom)
    13. }
    14. }
    15. }
  2. else절 바로 밑에 다음의 코드를 추가해주세요

    1. // Save the meals.
    2. saveMeals()

    이 코드는 새로운 meal이 추가되거나 기존의 meal이 수정될 때마다 변경된 meals 배열을 저장합니다. 따라서 if-esle절 밖에 작성되어야 합니다.

  3. MealTableViewController.swift 파일에서 tableView(_:commitEditingStyle:forRowAtIndexPath:)메서드를 찾아주세요

    1. // Override to support editing the table view.
    2. override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {
    3. if editingStyle == .Delete {
    4. // Delete the row from the data source
    5. meals.removeAtIndex(indexPath.row)
    6. tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Fade)
    7. } else if editingStyle == .Insert {
    8. // Create a new instance of the appropriate class, insert it into the array, and add a new row to the table view
    9. }
    10. }
  4. meals.removeAtIndex(indexPath.row)라인 바로 밑에 아래의 코드를 추가하세요

    1. saveMeals()

    이 코드는 meal이 삭제될때 마다 변경된 meals 배열을 저장합니다.

완성된 unwindToMealList(_:)메서드는 아래와 같습니다.

  1. @IBAction func unwindToMealList(sender: UIStoryboardSegue) {
  2. if let sourceViewController = sender.sourceViewController as? MealViewController, meal = sourceViewController.meal {
  3. if let selectedIndexPath = tableView.indexPathForSelectedRow {
  4. // Update an existing meal.
  5. meals[selectedIndexPath.row] = meal
  6. tableView.reloadRowsAtIndexPaths([selectedIndexPath], withRowAnimation: .None)
  7. }
  8. else {
  9. // Add a new meal.
  10. let newIndexPath = NSIndexPath(forRow: meals.count, inSection: 0)
  11. meals.append(meal)
  12. tableView.insertRowsAtIndexPaths([newIndexPath], withRowAnimation: .Bottom)
  13. }
  14. // Save the meals.
  15. saveMeals()
  16. }
  17. }

완성된 tableView(_:commitEditingStyle:forRowAtIndexPath:)메서드의 모습은 아래와 같습니다.

  1. // Override to support editing the table view.
  2. override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {
  3. if editingStyle == .Delete {
  4. // Delete the row from the data source
  5. meals.removeAtIndex(indexPath.row)
  6. saveMeals()
  7. tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Fade)
  8. } else if editingStyle == .Insert {
  9. // Create a new instance of the appropriate class, insert it into the array, and add a new row to the table view
  10. }
  11. }

meals 배열을 저장하는 작업이 모두 완료되었습니다. 이제 meals 배열을 로드하는 작업을 하겠습니다. 음식 목록을 노출하는 모든 화면에서 저장된 meals 배열을 로드하여 사용해야합니다. 화면이 노출될 때마다 해야할 작업은 언제나처럼 viewDidLoad()메서드 상에서 구현하면 됩니다. meals 배열을 이용해 음식 목록을 노출하는 화면은 mealTableViewController이므로, mealTableViewController의 viewDidLoad()메서드에 저장된 meals배열을 로드하는 작업을 하면 됩니다.

To load the meal list at the appropriate time

  1. MealTableViewController.swift 파일에서 viewDidLoad() 메서드를 찾으세요

    1. override func viewDidLoad() {
    2. super.viewDidLoad()
    3. // Use the edit button item provided by the table view controller.
    4. navigationItem.leftBarButtonItem = editButtonItem()
    5. // Load the sample data.
    6. loadSampleMeals()
    7. }
  2. navigationItem.leftBarButtonItem = editButtonItem() 바로 아래에 다음의 if 구문을 추가하세요

    1. // Load any saved meals, otherwise load sample data.
    2. if let savedMeals = loadMeals() {
    3. meals += savedMeals
    4. }

    loadMeals() 메서드가 Meal객체로 구성된 배열을 정상적으로 반환한다면 지역상수 saveMeals에 저장되며, 조건부는 true가 되어 if 구문이 실행될 것입니다. 만약 loadMeals()메서드가 nil을 반환한다면 불러올 수 있는 meal들이 없다는 것이고, 조건부는 false가 되어 if문은 수행되지 않을 것입니다. 즉, 이 코드는 저장된 meals배열이 있는 경우, 불러와서 기존 meals배열에 추가합니다.

  3. if절 다음에 else절을 추가하고 loadSampleMeals() 메서드를 else절 안으로 옮겨주세요

    1. else {
    2. // Load the sample data.
    3. loadSampleMeals()
    4. }

    이 코드는 불러올 meals배열이 없는 경우, 기존에 만들어둔 샘플 Meal객체로 구성된 meals배열을 생성합니다.

완성된 viewDidLoad() 메서드의 모습은 아래와 같습니다.

  1. override func viewDidLoad() {
  2. super.viewDidLoad()
  3. // Use the edit button item provided by the table view controller.
  4. navigationItem.leftBarButtonItem = editButtonItem()
  5. // Load any saved meals, otherwise load sample data.
  6. if let savedMeals = loadMeals() {
  7. meals += savedMeals
  8. } else {
  9. // Load the sample data.
  10. loadSampleMeals()
  11. }
  12. }

Checkpoint: 앱을 실행해보세요. meal을 몇개 추가하거나 삭제해보세요. 앱을 완전히 종료시키고 다시 실행해도 이전에 작업한 내용이 그대로 유지됩니다.


댓글