Swift와 Realm으로 산타 위치 추적 앱 만들기 튜토리얼: Part 2

잘 지내셨나요? 👋

지난 포스팅을 따라 앱을 작성한 분이라면, 앱을 실행했을 때 아래와 같은 화면을 보게 될 것입니다.

The finished Santa tracking app

(지난 포스팅의 7.4 단계에서 pin 위치를 서울로 설정했기 때문에 지도 화면에는 pin 이 서울 위치에 표시될 것입니다. 다른 좌표를 지정했다면 해당 장소가 지도 화면에 나타날 것입니다. 저자는 샌프란시스코로 지정했기 때문에 예제 화면에는 샌프란시스코가 지도에 표시되었습니다. 시뮬레이터가 실행될 때 사용자의 지역에 따라 지도 시작 위치가 다를 수 있습니다. 만약 화면에 pin 이 나타나지 않는다면 여러분이 지정한 좌표가 있는 지역으로 지도 화면을 이동해 보세요)

괜찮은 앱처럼 보이지만 아직 구현할 기능이 많이 남아있습니다. 현재까지 저희가 작업한 내용은 지도 화면에 여러분이 지정한 좌표를 pin 으로 표시한 것 뿐입니다. 이제, 기능을 좀 더 추가해보도록 합시다!

이번 포스팅에서는 Realm 을 이용해 데이터를 저장하고 이들을 outlet 과 연결하는 것에 대해 학습할 것입니다. 이를 위해 데이터 모델을 추가적으로 작성할 것이며, 이들은 다음 번 포스팅에서 학습할 Realm Object Server 와의 연결에도 사용될 것입니다.

1. 모델 추가 생성하기

추가적인 앱 기능 구현을 위해 데이터 모델을 생성해야 합니다. 구현해야 할 기능들은 다음과 같습니다:

  • 산타가 현재 위치에 도착하는데 걸리는 시간
  • 산타의 현재 상황
  • 산타가 있는 곳의 날씨
  • 산타가 배달해야 하는 선물 개수

하나씩 살펴봅시다.

산타가 현재 위치에 도착하는데 걸리는 시간

이 정보를 서버에 요청할 수도 있지만, 산타가 목적지들에 언제 도착하는지 알고 있다면 디바이스에서 이 정보를 계산할 수 있을 것입니다. 다음 번 포스팅에서는 이 정보를 서버로부터 수신할 것입니다. 편리하겠지요? Santa 클래스에 아래의 코드를 추가합니다:

class Santa: Object {
    // 위에는 Current location 를 다룬 코드들 위치합니다.
    
    let route = List<Stop>()
    
    // 아래에는 ignoredProperties 코드가 있습니다.
}

route 에는 산타의 목적지와 목적지에 언제 도착하는지에 대한 정보들이 들어있습니다. Realm 문서에는 모델 리스트는 List 를 이용해 표현할 수 있다고 설명되어 있으며, 위의 코드에서는 List<Stop>() 형태로 표현하고 있습니다. 내용들은 변경사항이 발생하는 즉시 자동적으로 업데이트 될 것입니다. 컴파일러 에러를 막기 위해 Stop 이라는 새로운 파일을 생성하여 아래의 코드를 추가합니다:

class Stop: Object {
    dynamic var location: Location?
    dynamic var time: Date = Date(timeIntervalSinceReferenceDate: 0)
    
    convenience init(location: Location, time: Date) {
        self.init()
        self.location = location
        self.time = time
    }
}

앞에서 언급했듯이 stop 은 산타의 배달 목적지와 목적지에 대한 산타의 도착 시간 정보를 담고 있습니다. 산타의 일정은 매우 빡빡하지만 배달 목적지에 늦거나 일찍 도착하는 일은 없습니다. 산타가 수년간 일해오면서 축적된 데이터이므로, 우리는 이 데이터를 믿을 수 있습니다.

여기서는 지난 주에 작성한 Location 클래스를 재사용하였고, 시간을 기록하기 위해 Date 를 추가했습니다. 이것은 새로운 Swift 3 Foundation 클래스이지요.

그리고 지난 주에 설명을 빼먹은 convenience initializer 를 추가했습니다. Realm 문서에 따르면, Realm object 에 대한 커스텀 initializers 는 반드시 convenience 를 추가해야 하며, 맨 먼저 self.init() 를 호출해야 합니다. 이렇게 해야 하는 이유는 Swift 의 제한된 introspection 때문입니다.

이런 개발 뉴스를 더 만나보세요

도착 시간을 계산하는 알고리즘은 나중에 구현하도록 하겠습니다.

산타의 현재 상황

지도 화면 바로 아래에서는 산타가 현재 무슨 일을 하고 있는지에 대한 정보를 제공하고 있습니다. 하늘을 날아다니고 있을 수도 있고, 선물을 배달 중이거나 부인과 이야기를 하고 있을지도 모르겠습니다. 여러분은 어떻게 생각할지 모르겠지만, 저에게는 이것이 마치 Swift enum 처럼 들리는 군요. 안타깝게도 Realm 에서는 이것을 직접 표현하지는 못합니다. 대신, 이들의 raw values 를 이용해 표현할 수 있습니다.

Activity 파일을 생성하여 아래의 코드를 추가합시다:

enum Activity: Int {
    case unknown = 0
    case flying
    case deliveringPresents
    case tendingToReindeer
    case eatingCookies
    case callingMrsClaus
}

위의 코드를 살펴보면, enum 이 Realm 에서 사용할 수 있는 Int 타입으로 표기된 것을 확인할 수 있습니다. String 을 사용할 수도 있지만 Int 의 크기가 좀 더 작기 때문에 Int 를 선택했습니다. 첫 번재 case 에 0 을 할당하여 이어지는 case 가 1 씩 자동 증가할 수 있도록 했습니다. Int 를 사용하면, 다른 플랫폼에서도 이 모델을 쉽게 사용할 수 있는 장점도 있습니다.

view controller 같은 곳에서 이런 활동에 대한 설명을 덧붙이거나, 해석하기 위한 객체를 만들 수도 있지만, extension 을 이용해 이 과정을 간단히 처리할 수 있습니다.

extension Activity: CustomStringConvertible {
    var description: String {
        switch self {
        case .unknown:
            return "❔ We're not sure what Santa's up to right now…"
        case .callingMrsClaus:
            return "📞 Santa is talking to Mrs. Claus on the phone!"
        case .deliveringPresents:
            return "🎁 Santa is delivering presents right now!"
        case .eatingCookies:
            return "🍪 Santa is having a snack of milk and cookies."
        case .flying:
            return "🚀 Santa is flying to the next house."
        case .tendingToReindeer:
            return "𐂂 Santa is taking care of his reindeer."
        }
    }
}

실제 앱에서는 지역화된 문자열을 사용해야 하지만, 이 포스팅에서는 영어만 사용했습니다. 😓 여러분은 지역화 문자열을 추가해도 괜찮고 이들 문장을 수정해서 사용해도 좋습니다!

이제 이들을 Santa 에 추가합시다.

class Santa: Object {
    // 위쪽에는 Current location 와 route 코드가 있습니다.
    
    private dynamic var _activity: Int = 0
    var activity: Activity {
        get {
            return Activity(rawValue: _activity)!
        }
        set {
            _activity = newValue.rawValue
        }
    }
    
    // 아래쪽에는 ignoredProperties 코드가 있습니다.
}

public API 를 살펴보겠습니다. 우선, Activity 타입의 activity 속성을 정의했습니다. Activity 는 enum 이기 때문에 Realm 에서 그대로 저장할 수 없습니다. 이를 해결하기 위해 Activity 에서 Int 만 추출하여 Realm 에 저장했습니다. public enum 을 지원하기 위해 private 변수 _activity 를 추가함으로써 enum 속성이 포함된 Realm object 를 구현했습니다.

지난 포스팅에서처럼, read-write 속성으로 구현했습니다. 때문에 이것을 저장하지 말라고 Realm 에 알려줘야 합니다. ignoredProperties 에 activity 를 추가합니다:

class Santa: Object {
	// Properties are all up here
	
	// We defined this function last week, so use that
    override static func ignoredProperties() -> [String] {
        // Just add "activity" to this array
        return ["currentLocation", "activity"]
    }
}

산타가 있는 곳의 날씨

이 정보는 Realm Object Server 의 동작 방식을 배우는 과정에서 사용할 예정입니다. 다음 번 포스팅에서 좀 더 자세히 살펴보도록 하겠습니다.

산타가 배달해야 하는 선물 개수

아마 가장 간단한 속성이 아닐까 생각됩니다. 이것은 그냥 Int 로 표현할 수 있습니다!

class Santa: Object {
    // Complicated properties up here
    
    dynamic var presentsRemaining: Int = 0
    
    // New ignoredProperties down here
}

dynamic 이 사용되었습니다. 사용하지 않는 경우도 있지만, 사용하는 것이 훨씬 간편합니다.

테스트 데이터 수정

속성이 추가되었기 때문에 테스트 데이터를 업데이트해야 합니다:

extension Santa {
    static func test() -> Santa {
        let santa = Santa()
        santa.currentLocation = Location(latitude: 37.7749, longitude: -122.4194)
        santa.activity = .deliveringPresents
        santa.presentsRemaining = 42
        return santa
    }
}

테스트 데이터 설정은 자유롭게 할 수 있습니다. 나중에 UI 업데이트가 제대로 되었는지만 확인해 주세요.

앱을 실행해서 모델이 제대로 컴파일 되는지 확인해보세요. 데이터 모델을 수정했기 때문에 Realm 은 마이그레이션이 필요하다는 에러 메시지를 출력할 것입니다. 간단한 해결책은 시뮬레이터에서 앱을 삭제하고 앱을 재실행하는 것입니다. 디바이스에서도 마찬가지로 앱을 삭제 후 재설치하면 됩니다. 아직 UI 업데이트와 관련된 코드를 작성하지 않았기 때문에 UI 상의 변화는 느끼지 못할 것입니다. 하지만, 앱이 성공적으로 실행되었다는 것은 Realm 이 새로운 데이터 모델을 잘 인식했다는 것을 의미합니다.

2. Realm 데이터와 UI 연동하기

지난 번 포스팅에서는 앱을 실행하면 산타의 위치를 기반으로 지도 상에 pin 을 보여주었습니다. 이번 포스팅에서는 두 가지 기능을 추가하려 합니다. 하나는 추가적인 outlet 연결이며, 또 다른 하나는 데이터 변경에 대응하는 UI 를 구성하는 것입니다.

  1. 우선 outlet 을 추가적으로 연결해보도록 하겠습니다. SantaTrackerViewController 에서 Santa 를 인자로 받는 update 함수를 정의하고 UI 를 업데이트하는 코드를 추가합니다:

    class SantaTrackerViewController: UIViewController {
        // Properties
        // viewDidLoad
           
        private func update(with santa: Santa) {
            mapManager.update(with: santa)
            let activity = santa.activity.description
            let presentsRemaining = "\(santa.presentsRemaining)"
            DispatchQueue.main.async {
                self.activityLabel.text = activity
                self.presentsRemainingLabel.text = presentsRemaining
            }
        }
    }
    

    먼저 map 을 업데이트 할 수 있도록 지난 포스팅에서 작성한 map manager 로 이 메시지를 포워딩합니다. 그런 다음 activity 와 남은 선물 개수에 대한 정보를 label 에 전달합니다. 지난 포스팅에서도 언급했지만 update(with:) 는 메인 쓰레드가 아닌 쓰레드에서 호출될 경우 문제가 발생할 수 있습니다. 지난 포스팅에서 사용했던 방법과 동일하게, 메인 쓰레드에서 dispatch 하여 이 문제를 해결했습니다.

    서버로부터 산타의 경로를 수신하여 도착 시간을 처리하는 것은 다음 번 포스팅에서 다룰 예정이며, 그 다음 포스팅에서 Realm Object Server 에 대해 좀 더 자세히 학습한 다음 날씨 정보를 다루도록 하겠습니다.

  2. 이제 데이터가 변경되었을 때 적절히 대응할 수 있게 만들어 보겠습니다. Realm 에서는 데이터 변동사항에 반응할 수 있는 리액티브 패턴을 사용할 수 있습니다. Realm 에서는 이것을 구현하는 몇 가지 방법이 있으며, 이 포스팅에서는 key-value observing, 또는 KVO 방식을 사용하겠습니다. 하나의 객체에 대해 동작하는 것은 현재까지는 이 방식 하나만 있기 때문입니다. (collection notification 에 기반한 새로운 API 는 2017 년 상반기에 도입될 예정입니다). KVO API 는 상당히 번거롭기 때문에 간단한 wrapper 함수를 하나 추가했습니다:

    class Santa: Object {
        // All of the existing code
    
        // We'll need to save these, or notifications won't be sent
        private var observerTokens = [NSObject: NotificationToken]()
    
        // This sets up observations to each of Santa's properties, plus properties of those
        func addObserver(_ observer: NSObject) {
            // Add a typical KVO observer to all the properties
            // One of these needs to generate the initial call, could be any of them
            addObserver(observer, forKeyPath: #keyPath(Santa._currentLocation), options: .initial, context: nil)
            // Want to make sure we're observing the location's properties in case someone changes one manually
            addObserver(observer, forKeyPath: #keyPath(Santa._currentLocation.latitude), options: [], context: nil)
            addObserver(observer, forKeyPath: #keyPath(Santa._currentLocation.longitude), options: [], context: nil)
               
            addObserver(observer, forKeyPath: #keyPath(Santa._activity), options: [], context: nil)
            addObserver(observer, forKeyPath: #keyPath(Santa.presentsRemaining), options: [], context: nil)
               
            observerTokens[observer] = route.addNotificationBlock {
                // self owns this route, so it will always outlive this closure
                [unowned self, weak observer] changes in
                switch changes {
                case .initial:
                    // Fake a KVO call, just to keep things simple
                    observer?.observeValue(forKeyPath: "route", of: self, change: nil, context: nil)
                case .update:
                    observer?.observeValue(forKeyPath: "route", of: self, change: nil, context: nil)
                case .error:
                    fatalError("Couldn't update Santa's info")
                }
            }
        }
    
        func removeObserver(_ observer: NSObject) {
            observerTokens[observer]?.stop()
            observerTokens.removeValue(forKey: observer)
            removeObserver(observer, forKeyPath: #keyPath(Santa._currentLocation))
            removeObserver(observer, forKeyPath: #keyPath(Santa._currentLocation.latitude))
            removeObserver(observer, forKeyPath: #keyPath(Santa._currentLocation.longitude))
            removeObserver(observer, forKeyPath: #keyPath(Santa._activity))
            removeObserver(observer, forKeyPath: #keyPath(Santa.presentsRemaining))
        }
    }
    

    이 wrapper 함수는 간편한 사용을 위해 몇 가지 작업들을 수행합니다. 우선, 일반적으로 사용하는 KVO 함수에서 잘 사용하지 않는 인자들은 모두 제거했습니다. 그리고 모든 속성들에 대해 observer 를 등록하였고, 이때 KVO 를 지원하는 private 속성들을 사용했습니다. 또한, 잘못된 타이핑을 막을 수 있도록 도와주는 #keyPath 키워드를 사용하였습니다. 마지막으로 Realm collection notifications 리다이렉팅을 통해 route observation 을 KVO notification 에 추가했습니다.

  3. wrapper 를 이용해 데이터 변경시 UI 가 반응할 수 있게 만들겠습니다. SantaTrackerViewControllerviewDidLoad() 마지막 부분에 아래 코드를 추가합니다:

    override func viewDidLoad() {
        // We already had all this code
        super.viewDidLoad()
    
        // Set up the map manager
        mapManager = MapManager(mapView: mapView)
    
        // Find the Santa data in Realm
        let realm = try! Realm()
        let santas = realm.objects(Santa.self)
    
        // Set up the test Santa if he's not already there
        if santas.isEmpty {
            try? realm.write {
                realm.add(Santa.test())
            }
        }
           
        // Be responsible in unwrapping!
        if let santa = santas.first {
            // There used to be a call to mapManager in here, but not any more!
            santa.addObserver(self)
        }
    }
    

    여기서 변경한 것은 map manager 에서 더이상 update(with:) 를 호출하지 않게 만든 것입니다. 이제 이 역할은 reactive change handler 가 담당할 것입니다.

    마지막으로 observer 를 삭제하는 작업이 꼭 필요합니다. (우리 예제에서는 listening 을 수행하는 곳이 없지만 여러분의 앱에서는 그것이 필요한 곳이 있을 거라고 생각합니다. observer 를 삭제하는 코드는 다음과 같은 형태로 작성할 수 있습니다.)

    deinit {
        let realm = try! Realm()
        let santas = realm.objects(Santa.self)
        if let santa = santas.first {
            santa.removeObserver(self)
        }
    }
    

    observation 수행이 완료된 후 이것을 반드시 제거해줘야 한다는 사실을 기억하세요. 그런데 change handler 는 어디에 있는건가요? 아직 작성하지 않았습니다. viewDidLoad() 아래에 KVO listener 역할을 담당하는 새로운 함수를 추가합니다:

    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        if let santa = object as? Santa {
            update(with: santa)
        } else {
            super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
        }
    }
    

    KVO 인자들을 걱정할 필요없이 KVO 를 매우 쉽게 이용할 수 있게 되었습니다. 위의 예제 코드에서는 Santa 에서 발생한 업데이트를 처리하는 작업을 추가했습니다. Santa 에서 변경 사항이 발생하면 update(with:) 를 호출하여 UI 를 업데이트합니다. Santa 가 아닌 경우에는, 애플 문서에 따라 notification 을 super 까지 전달합니다.

    좋습니다. 이제 앱을 실행해보세요! 테스트 데이터를 이용해 UI 가 업데이트 된 것을 확인하실 수 있을 것입니다. 업데이트 화면을 만나셨다면 축하드립니다! 데이터 모델이 잘 처리되었고 UI 는 데이터 변경 사항에 따라 반응하고 있습니다. 다음 단계를 수행할 준비가 된 것 같네요. 다음 번 포스팅에서는 Realm Object Server 와 연동하는 방법을 배워보겠습니다. 기쁜 소식은 코드에서는 그다지 변경할 것이 많지 않다는 것입니다. 서버로부터 발생한 변경 사항 역시 로컬에서의 변경 사항과 동일한 notification 을 발생시키므로 이미 구축해 놓은 reactive 구조를 이용하면 서버로부터 발생한 데이터를 처리하는 데 특별한 방법이 필요하지 않습니다.

    어쨌든 이번에는 이것으로 충분한 것 같습니다. 다음 번 포스팅을 기대해 주세요! 🎁


혹시 이전 포스팅을 놓치셨다면 이 곳에서 보실 수 있습니다. 세 번째 포스팅의 번역이 진행되고 있습니다. 궁금하신 분은 영어 원문 을 확인해 주세요!

번역: 문상준 iOS 개발자. Reactive, Functional programming 과 Swift 관련 글쓰기에 관심이 많습니다. 현재는 Vandad Nahavandipoor 가 진행하는 crowdsource swift book 프로젝트 에서 열심히 활동하고 있습니다. 관심있는 분들의 참여는 언제나 환영입니다.

컨텐츠에 대하여

이 컨텐츠는 저자의 허가 하에 이곳에서 공유합니다.


Michael Helmbrecht

Michael은 주간에는 디자이너, 야간에는 iOS 개발자로 활동합니다. 현재 Realm에서 최고의 디자인을 만들기 위해 디자인과 iOS 개발, 두 분야에서 공헌하고 있습니다. 또한 세계에서 가장 큰 Swift 모임인 SLUG의 저명한 주최자이기도 합니다.

4 design patterns for a RESTless mobile integration »

close