본문 바로가기
개발 이야기

[iOS 앱 개발]객체의 프로퍼티 변화를 감지하는 방법 - KVO 패턴 적용 방법

by Jimmy_iOS 2023. 7. 26.

객체의 프로퍼디 변화를 감지하는 방법

UserDefault에 값을 저장하고, 저장된 값을 Label에 표시하는 앱을 만들면서 한 가지 고민이 생겼습니다.

그것은 UserDefault가 현재 ViewController에 정의된 객체가 아닌 외부에 있는 객체라는 것입니다.

그래서 UserDefault의 값을 추적하고 변화된 값을 현재 ViewController에 있는

Label에 반영하는 방법에 대해 고민하게 되었습니다.

앱 개요

우선 어떤 앱을 만들었는지 설명을 해보겠습니다.

앱의 요구사항

  1. 5가지의 감정 버튼을 누르면 값이 1씩 추가된다.
  2. 버튼을 길게 누르면 PullDownButton이 나오고 다양한 클릭 횟수와 리셋 기능을 추가해준다
  3. 추가된 값은 UserDefault에 저장해준다.
  4. 통계 탭에 UserDefault에 저장된 값을 보여준다
  5. PullDownButton을 통해 저장된 값을 수정해준다.

발생한 문제점

1~4번까지의 요구사항을 구현하는데는 무리가 없었습니다다.

 

하지만!!!

초기화 버튼을 누르면 UserDefault값을 0으로 만들고 Label에 변경된 값을 표시해줘야 하는데

UserDefault값만 변경되고 변경된 값이 Label에 반응적으로 변경이 안되는 문제가 발생했습니다…

그러면

어떤 기술을 사용해야 반응적으로 Label을 표시해줄 수 있을까?

  1. Rxswift or Combine
  2. delegate 패턴
  3. KVO 패턴
  4. didSet

총 4가지의 기술을 적용해 볼 수 있을것 같습니다다.

1~2번의 경우 양방향 데이터 전달 기술이라 적용하기 좋을것 같지만,

너무 쉽게 문제가 해결되 버리니 3번과 4번을 사용해서 비교해 보겠습니다.

3. KVO(Ket Value Observing) 패턴 적용

첫 구현은 KVO패턴을 적용해서 만들어 봤는데요

우선 KVO의 간단한 개념을 설명드리자면

KVO (Key-Value Observing)는 iOS 개발에서 사용되는 디자인 패턴으로

다른 객체의 프로퍼티 값 변경을 감지하는 기능입니다.

 

이를 통해 객체 간에 직접적인 참조 없이도 객체가 값의 변경을 감지할 수 있습니다.

옵저버는 관찰 대상 객체에 등록되며, 관찰 대상 객체의 프로퍼티 값이 변경될 때마다 알림을 받게 됩니다.

 

 

다음은 뷰컨트롤러에 있는 configPullDownButton()

PullDownButton을 눌렀을 때 UserDefault값을 초기화 해주는 코드들 입니다.

 

final class StaticsViewController: UIViewController {
    ...
        /// UserDefault값을 표시해줄 Label
        @IBOutlet var countLabelCollection: [UILabel]!

    /// 풀다운버튼 구성
    private func configPullDownButton() {
        let firstButton = UIAction(title: "모두 초기화", attributes: .destructive) { _ in
            Emotion.clearAllCount()
        }
        let secondButton = UIAction(title: "완전행복지수 초기화") { _ in
            Emotion.clearCount(at: .veryHappy)
        }
        let thirdButton = UIAction(title: "적당미소지수 초기화") { _ in
            Emotion.clearCount(at: .moderateSmile)
        }
        let fourthButton = UIAction(title: "그냥그냥지수 초기화") { _ in
            Emotion.clearCount(at: .neutral)
        }
        let fifthButton = UIAction(title: "좀속상한지수 초기화") { _ in
            Emotion.clearCount(at: .slightlyUpset)
        }
        let sixthButton = UIAction(title: "많이슬픈지수 초기화") { _ in
            Emotion.clearCount(at: .verySad)
        }
        let buttonMenu = UIMenu(children: [
            firstButton,
            secondButton,
            thirdButton,
            fourthButton,
            fifthButton,
            sixthButton
        ])
        rightBarButton.menu = buttonMenu
    }
}

아래는 UserDefault와 Emotion값을 다룬 열거형이고 열거형에 메서드를 추가해준 코드입니다.

 

/// 이모션 UserDefault 값 다루기
enum Emotion: Int, CaseIterable {
    case veryHappy = 0
    case moderateSmile
    case neutral
    case slightlyUpset
    case verySad
/// UserDefault 값 초기화
    /// - Parameter emotion: 초기화 할 Emotion
    static func clearCount(at emotion: Self) -> Int{
        switch emotion {
        case .veryHappy:
            EmotionUserDefaults.VeryHappyIndex.count = 0
        case .moderateSmile:
            EmotionUserDefaults.ModerateSmileIndex.count = 0
        case .neutral:
            EmotionUserDefaults.NeutralIndex.count = 0
        case .slightlyUpset:
            EmotionUserDefaults.SlightlyUpsetIndex.count = 0
        case .verySad:
            EmotionUserDefaults.verySadIndex.count = 0
        }
    }

    /// UserDefaults 모든 값 초기화
    static func clearAllCount() {
        EmotionUserDefaults.VeryHappyIndex.count = 0
        EmotionUserDefaults.ModerateSmileIndex.count = 0
        EmotionUserDefaults.NeutralIndex.count = 0
        EmotionUserDefaults.SlightlyUpsetIndex.count = 0
        EmotionUserDefaults.verySadIndex.count = 0
    }
}
@propertyWrapper
/// 유저디폴트 프로퍼티 래퍼
struct UserDefault<T> {
    private let key: String
    private let defaultValue: T

    init(key: String, defaultValue: T) {
        self.key = key
        self.defaultValue = defaultValue
    }

    var wrappedValue: T {
        get {
            return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
        }
        set {
            UserDefaults.standard.set(newValue, forKey: key)
        }
    }
}

/// Emotion UserDefaults
struct EmotionUserDefaults {
    struct VeryHappyIndex {
        @UserDefault(key: keyEnum.veryHappyIndex.rawValue, defaultValue: 0)
        static var count: Int
    }
    struct ModerateSmileIndex {
        @UserDefault(key: keyEnum.moderateSmileIndex.rawValue, defaultValue: 0)
        static var count: Int
    }
    struct NeutralIndex {
        @UserDefault(key: keyEnum.neutralIndex.rawValue, defaultValue: 0)
        static var count: Int
    }
    struct SlightlyUpsetIndex {
        @UserDefault(key: keyEnum.slightlyUpsetIndex.rawValue, defaultValue: 0)
        static var count: Int
    }
    struct verySadIndex {
        @UserDefault(key: keyEnum.verySadIndex.rawValue, defaultValue: 0)
        static var count: Int
    }

    enum keyEnum: String, CaseIterable {
        case veryHappyIndex
        case moderateSmileIndex
        case neutralIndex
        case slightlyUpsetIndex
        case verySadIndex
    }
}

 

위 코드에서 configPullDownButton()를 호출하면 UserDefault값이 바뀌고

UserDefault값에 Observer를 추가해 줘야 합니다.

 

/// UserDefault에 addObserver
private func addObserver() {
    let defaults = UserDefaults.standard
    let keys = EmotionUserDefaults.keyEnum.allCases
    //  "키값"은 유저디폴트 값의 키
    keys.forEach { key in
        defaults.addObserver(self, forKeyPath: key.rawValue, options: .new, context: nil)
    }
}

/// Observe Value
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    // UserDefaults
    let keys = EmotionUserDefaults.keyEnum.allCases
    guard let keyPath else { return }
    switch keyPath {
    case keys[0].rawValue:
        // UserDefaults값 저장
        let value = UserDefaults.standard.string(forKey: keyPath)
        // 레이블에 값 표시
        self.countLabelCollection[0].text = "\(value!)점"
    case keys[1].rawValue:
        let value = UserDefaults.standard.string(forKey: keyPath)
        self.countLabelCollection[1].text = "\(value!)점"
    case keys[2].rawValue:
        let value = UserDefaults.standard.string(forKey: keyPath)
        self.countLabelCollection[2].text = "\(value!)점"
    case keys[3].rawValue:
        let value = UserDefaults.standard.string(forKey: keyPath)
        self.countLabelCollection[3].text = "\(value!)점"
    case keys[4].rawValue:
        let value = UserDefaults.standard.string(forKey: keyPath)
        self.countLabelCollection[4].text = "\(value!)점"
    default:
        break
    }
}

// MARK: - Deinit

deinit {
    let defaults = UserDefaults.standard
    let keys = EmotionUserDefaults.keyEnum.allCases
    //  "키값"은 유저디폴트 값의 키
    keys.forEach { key in
        defaults.removeObserver(self, forKeyPath: key.rawValue)
    }
}

 

addObserver() 메서드에서는 추적해야 할 값이 정해지고,

observeValue() 메서드에서는 해당 값이 변경될 때 알림을 받습니다.

이때 KeyPath에 추적한 값을 입력하면 Label이 자동으로 업데이트됩니다.

 

KVO의 장단점

장점으로,

KVO를 적용하면 TabBarController에서 뷰컨트롤러를 이동했다가 다시 원래 뷰컨트롤러로 돌아올 때,

ViewWillAppear()를 구현하지 않아도 자동으로 Label에 바뀐 값이 적용됩니다.

 

하지만 KVONSObject를 상속받은 객체에서만 사용할 수 있습니다.

다행히 UserDefaultNSObject를 상속받습니다.

 

 

이번 프로젝트에서는 UserDefault를 적용하는 것이 적절했습니다.

하지만 하나의 단점으로, 위 코드처럼 5개의 값을 감지하게 되면 그만큼 많은 조건문이 필요합니다.

 

4. didSet을 사용하면 어떨까?

KVO에 대해서 블로깅을 해보면, KVO를 사용하는 것보다는

didSet을 사용하는 것이 더 좋다는 글을 많이 발견할 수 있었습니다.

 

생각해보니 버튼을 탭했을 때 didSet에서 UserDefault

Label의 값을 모두 설정해주면 되는 것 아니었나라는 허탈함이 들었습니다.

 

그래서 코드를 다시 수정해보았는데요…

 

typealias로 코드의 양을 줄여주고,

프로퍼티에 UserDefault 값을 기본값으로 설정하여

값이 설정될 때마다 UserDefault 값과 Label의 텍스트를 수정하였습니다.

 

typealias ED = EmotionUserDefaults

private var veryHappy : Int = ED.VeryHappyIndex.count  {
    didSet {
        ED.VeryHappyIndex.count = veryHappy
        self.countLabelCollection[0].text = "\(veryHappy)점"
    }
}
private var moderateSmile : Int = ED.ModerateSmileIndex.count  {
    didSet {
        ED.ModerateSmileIndex.count = moderateSmile
        self.countLabelCollection[1].text = "\(moderateSmile)점"
    }
}
private var neutral : Int = ED.NeutralIndex.count  {
    didSet {
        ED.NeutralIndex.count = neutral
        self.countLabelCollection[2].text = "\(neutral)점"
    }
}
private var slightlyUpset : Int = ED.SlightlyUpsetIndex.count  {
    didSet {
        ED.SlightlyUpsetIndex.count = slightlyUpset
        self.countLabelCollection[3].text = "\(slightlyUpset)점"
    }
}
private var verySad : Int = ED.verySadIndex.count  {
    didSet {
        ED.verySadIndex.count = verySad
        self.countLabelCollection[4].text = "\(verySad)점"
    }
}

private func configPullDownButtonDidSet() {
    let firstButton = UIAction(title: "모두 초기화", attributes: .destructive) { [weak self] _ in
        self?.veryHappy = 0
        self?.moderateSmile = 0
        self?.neutral = 0
        self?.slightlyUpset = 0
        self?.verySad = 0
    }
    let secondButton = UIAction(title: "완전행복지수 초기화") { [weak self] _ in
        self?.veryHappy = 0
    }
    let thirdButton = UIAction(title: "적당미소지수 초기화") { [weak self] _ in
        self?.moderateSmile = 0
    }
    let fourthButton = UIAction(title: "그냥그냥지수 초기화") { [weak self] _ in
        self?.neutral = 0
    }
    let fifthButton = UIAction(title: "좀속상한지수 초기화") { [weak self] _ in
        self?.slightlyUpset = 0
    }
    let sixthButton = UIAction(title: "많이슬픈지수 초기화") { [weak self] _ in
        self?.verySad = 0
    }
    let buttonMenu = UIMenu(children: [
        firstButton,
        secondButton,
        thirdButton,
        fourthButton,
        fifthButton,
        sixthButton
    ])
    rightBarButton.menu = buttonMenu
}

 

이렇게 코드를 수정하고 나니 발생하는 문제가 있었습니다.

 

앞에서 스포가 약간 있었지만

TabBarController에서 뷰컨트롤러를 이동했다가 다시 원래 뷰컨트롤러로 돌아올 때,

바뀐 UserDefault값이 Label에 적용되지 않는것인데요

 

configPullDownButtonDidSet() 메서드가 ViewDidLoad()에 구현되어있는 것이 문제입니다.

PullDownButton을 생성해줄 때 didSet을 넣어주지만 ViewWillApear()에 또 한번 적용을 해줘야 합니다.

 

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    veryHappy = ED.VeryHappyIndex.count
    moderateSmile = ED.ModerateSmileIndex.count
    neutral = ED.NeutralIndex.count
    slightlyUpset = ED.SlightlyUpsetIndex.count
    verySad = ED.verySadIndex.count
}

이제 정상적으로 작동합니다만

문제는 코드의 양이 너무 많아졌다는 것입니다.

이런 경우에는 오히려 KVO 패턴이 더 효율적인 것 같습니다.

 

아래 링크는 데이터 전달 패턴에 관해 잘 정리된 포스팅이 있어서 공유합니다.

 

Delegation, Notification, 그리고 KVO

 

Delegation, Notification, 그리고 KVO

언제, 어디서 써야할까?

medium.com