Swift4의 키밸류 옵저빙 구현 방식

Swift4 에서 키밸류 옵저빙에서 몇 가지 변화가 있었는데, 옵저버 중심의 콜백이 아니라, 타깃 중심의 핸들러 기반 API가 추가되었다. 그리고 몇 가지 편의 클래스들이 추가되었다. 먼저 observe(_:options:changeHandler:) 메소드가 추가되었다. 이 메소드는 addObserver:... 를 대신해서 옵저버 객체가 아닌 변경이 발생했을 때 호출될 핸들러를 지정하는 것이다. 전체 시그니처는 대략 다음과 같다. (아직까지 NSObjectNSKeyValueObserving 문서가 업데이트되지 않아서 해당 API에 대한 페이지가 존재하지는 않는다.)

func observe<Value>(_ keyPath: KeyPath<Self, Value> 
                   options: NSKeyValueObseringOptions=[] 
                   handler: @escaping (Self, NSKeyValueObservedChange) -> Void)
-> NSKeyValueObservation

일단 NSKeyValueObservedChangeNSKeyValueObservation의 두 개의 새로운 타입이 추가되었다.

NSKeyValueObservedChange

이전의 옵저버 메소드는 변경사항의 디테일을 Dictionary<NSKeyValueChangeKey, Any>? 타입으로 주었다. 이 타입을 메소드 내에서 사용하는데에는 치명적인 문제가 있었다.

  1. 일단 사전 자체가 옵셔널 타입인데다가, 사전은 키로 값을 찾으면 다시 옵셔널 값을 내놓는다. 따라서 이중 옵셔널의 문제가 발생한다.
  2. enum 값들은 NSDictionary로 들어가면서 죄다 NSNumber로 변환되고 다시 이것이 브릿징되면서 Int 값이 되어버린다. 따라서 NSKeyValueChange(rawValue:)를 사용해서 이것을 다시 .setting 과 비교하거나 하는 삽질을 해야 한다.

그래서 코드가 엄청나게 지저분해져야 했었다. NSKeyValueObservedChange는 변경사항의 세부 사항을 프로퍼티로 하는 타입으로 만들었다. 그래서 다음과 같이 깔끔하게 정리된다.

  • kind : NSKeyValueChange
  • newValue? : 이전 값
  • oldValue? : 이후 값
  • isPrior : 변경 전에 발생하는 통지인지 여부
  • indexes? : to-many 변경에서 변경이 발생한 위치

NSKeyValueObservation

옵저빙 메소드는 NSKeyValueObservation 타입의 토큰을 리턴한다. 만약 이 토큰이 릴리즈되면 옵저빙이 중단되는 식으로 동작한다. 따라서 옵저버든 어떤 컨텍스트에서 이 값을 참조할 이름을 마련해두었다가 옵저빙을 끝내려는 시점에 nil로 변경하는 식으로 참조를 제거할 수 있다.

다음 예제는 흔한 to-many 프로퍼티에 대한 옵저빙을 수행하는 코드이다.  코드가 매우 간단해졌음을 확인할 수 있다.

import Foundation

class Foo: NSObject {
  @objc var moo: [String] = []
  
  @objc(insertObject:inMooAtIndex:)
  func insert(mo: String at index:Int) { moo.insert(mo, at:index) }

  @objc(removeObjectFromMooAtIndex:)
  func remove(at index: Int) { moo.remove(at: index) }
}

let f = Foo()
f.moo = ["one", "two", "three"]

var token: NSKeyValueObservation? = f.observe(\Foo.moo){ _, change in 
  switch change {
  case .setting: print("moo가 교체되었다")
  case .insertion: print("\(change.indexes!.startIndex)에 삽입")
  case .removal: print("\(change.indexes!.startIndex)에서 제거")
  case .replacement: print("\(change.indexes!.startIndex)에서 교체")
  default:
     break
  }
}

let arr = f.mutableArrayValue(forKey: #keyPath(Foo.moo))
arr.add("four")
arr.removeObject(at: 1)
arr.replaceObject(at: 2, with: "hello")

token = nil

참고

 

KVC 집합 접근자/변경자 메소드 작성방법

키밸류 코딩의 집합 접근자/변경자 메소드

to-many 관계의 프로퍼티에 대한 조정은 키밸류 코딩에서 배열 프록시를 통해서 이루어진다고 했다. 이 때 개별 원소를 추가/삭제/교체하는 작업의 효율을 높이고, 각 동작에 대해서도 KVO 지원을 가능하게 하기 위해서 배열 프록시와 연계하여 동작할 수 있는 집합 메소드를 추가로 정의하는 것이 강력하게 권장된다. 이들 메소드들은 기본적으로 NSMutableArray의 기본적인 액세스 메소드들에 키 이름이 혼합된 형태로, 일정한 규칙에 의해 이름 지어진다. 단, 키 이름이 메소드 이름에 들어가기 때문에 메소드명이 고정되지 않았고, 따라서 NSKeyValueCoding 레퍼런스 상에서는 소개되지 않는다.

집합 접근자 및 변경자들은 기본적으로 NSMuatbleArray의 몇몇 메소드들의 이름에 키 이름을 추가하여 변형한 것이다. 집합메소드에서 키 이름은 <key>로 쓰여진다. 키 이름이 대문자로  시작해야하는 경우는 <Key>로 쓰였으니 잘 구분해서 사용해야 한다.

구분 Array 메소드 KVC 집합 메소드 비고
접근자 -count -countOf<Key> 필수
-objectAtIndex: -objectIn<Key>AtIndex: 둘 중 하나 필수
-objectsAtIndexes: -<key>AtIndexes:
-getObjects:range: -getObjectsIn<Key>:range: 선택적
변경자 -insertObject:atIndex: -insertObject:in<Key>AtIndex: 배열 변경이 필요한 경우에는 필수
-removeObjectAtIndex: -removeObjectFrom<Key>AtIndex:
-replaceObjectAtIndex:withObject: -replaceObjectIn<Key>AtIndex:withObject: 선택적

 

Swift 버전을 생각하기

Swift에서는 몇가지 고려해야 하는 부분이 있다.  (Swift4, Xcode9 버전 대응)

  1. KVC는 Objective-C 런타임의 기능이므로 이름 패턴 검색 정책 자체는 달라지는 점이 없다.
  2. 집합 메소드 프로퍼티는 모두 런타임에 이름을 통해서 액세스될 수 있어야 하므로 @objc를 통해서 런타임에 노출되어야 한다.
  3. insert... 를 제외하고는 메소드 이름을 지을 때,  ...AtIndex(_:)...(atIndex:)나 차이가 없다. 예를 들어 Swift 메소드명, objectInEmployeesAtIndex(_ index:)objectInEmployees(atIndex:) 모두 Objective-C에서는 -objectInEmployeesAtIndex:로 해석된다.
  4. 하지만 insertObject(_:...)insert(object:...)로 바꿔썼을 때 제대로 찾지 못한다.[^1] 즉, 파라미터 개수가 2개 이상인 경우는 명시적으로 밖으로 빼서 첫번째 파라미터를 숨겨야 한다. 이것은 Swift2 버전의 기본 메소드 이름 규칙인데, Objective-C 런타임에 적용된 이름 규칙이 오래전 버전인 것 같다.
  5. 이름 해석 규칙이 애매할 수도 있고, Xcode 버전이 올라감에 따라 다시 다른 규칙이 적용될지 모른다. 따라서 가장 좋은 방법은 @objc() 변경자에서 Objective-C 런타임상의 이름을 같이 써주는게 좋다. (그러면 Swift 이름은 뭐 어떻게 짓든 상관없다.)
  6. 그외 타입규칙
    1. 인덱스를 가리키는 NSUIntegerInt로 번역된다.
    2. Element의 타입은 Any, AnyObject가 아닌 소속 클래스 타입을 그대로 쓰면 된다. 다만 어떤 접근자이든 옵셔널타입을 쓰지는 않는다. 옵셔널 타입으로 파라미터나 리턴타입을 정의하면 올바른 메소드 이름이 아닌 것으로 간주되니 주의할 것.

다음 예에서 Foo 클래스의 words프로퍼티에 대한 집합 접근자/변경자는 다음과 같이 작성할 수 있다.

/// 프로퍼티 선언

/// Objective-C
@property (strong, nonatmoic) NSMutableArray<NSString *>* words;

/// Swift
/// setter에 의한 교체를 추적하려면 @objc dynamic var... 로 선언해야 하지만
/// 배열 프록시를 통해서 제어하는 경우, 굳이 해당 프로퍼티가 노출되지 않아도 된다.
var words:[String] = []


/// 집합 액세스 메소드
/// 꼭 필요하지는 않다.
/// Swift의 경우 @objc 를 붙이지 않았다면 이들을 구현해서
/// 배열 프록시로 동작하게 할 수 있다.
/// Objective-C
- (NSUInteger)countOfWords { 
    return [_words count]; 
}
- (NSString*)objectInWordsAtIndex:(NSUInteger)index 
{
    return [_words objectAtIndex:index];
}

/// Swift
@objc func countOfWords() -> Int {
  return words.count
}

@objc func objectInWord(atIndex index: Int) -> String
{
  return words[index]
}

/// 변경자
/// Objective-C
- (void)insertObject:(NSString*)object inWordsAtIndex:(NSUInteger)index
{
    [_words insertObject:object atIndex:index];
}

- (void)removeObjectFromWordsAtIndex:(NSUInteger)index
{
   [_words removeAtIndex:index];
}

/// swift
@objc(insertObject:inWordsAtIndex:)
func insert(words: String, at index:Int) {
  words.insert(words, at:index)
}

@objc(removeFromWordsAtIndex:)
func remove(at index:Int) {
  words.remove(at:index)
}

참고자료

코코아 바인딩이 동작하는 방식

코코아 바인딩은 뷰와 데이터 모델을 양방향으로 “묶어서(binding)” 한쪽에서의 변경이 다른쪽으로 자동으로 반영되게끔 하는 것이다. 예를 들어 슬라이더를 드래그하여 값을 변경한 것을 뷰 컨트롤러의 특정한 실수값 프로퍼티에 반영하도록 하거나, 혹은 클래스외부로부터 전달받은 메시지에 의해서 프로퍼티 값이 변경된 경우, 자동으로 이 값이 뷰에 반영되도록 하는 것이다.

이러한 기능은 코코아 바인딩을 쓰지 않고도 얼마든지 구현할 수 있지만, 코코아 바인딩을 사용하게 되면 많은 양의 “접합 코드”들을 작성하는 것을 생략할 수 있다. 예를 들어 다음과 같은 부분에 대한 코드를 일일이 작성해야 한다.

  • 데이터의 변경을 업데이트해 줄 뷰를 참조하기 위한 IBOutlet 선언 및 연결
  • 데이터의 변경에 맞추어 실제 뷰 속성을 변경하기 위한 setter 메소드 작성
  • 뷰상의 변경이 발생할 때 데이터를 변경해주기 위한 IBAction 메소드 선언 및 작성 (그리고 연결)

코코아 바인딩은 이러한 코드 없이 뷰와 데이터 모델이 동기화될 수 있도록 한다. 이 기술은 몇 가지 Objective-C 및 코코아의 기본 프레임 기술에 기반하고 있다. 고정된 액세스 메소드가 아닌 키 이름으로 객체의 프로퍼티를 접근하는 키-밸류 코딩(KVC), 다른 객체의 특정 키패스의 값이 변경되는 것을 알아차리기 위한 키-밸류 옵저빙(KVO) 그리고 한 객체의 특정 값이 다른 객체의 프로퍼티에 묶이도록 하는 키밸류 바인딩(KVB)이 그것이다.

또한 컨트롤의 변경과 값 업데이트 시점을 맞추기 위해서는 NSEditorNSEditorRegistration 프로토콜이 사용되기도 한다.

바인딩의 양상

앞서 바인딩은 간단하게 특정한 UI 컨트롤과 모델 데이터를 묶는 것이라 이야기했다. 실질적으로 바인딩은 두 객체 사이의 프로퍼티가 연결되어 양방향으로 동기화되는 것을 의미한다. 슬라이더 컨트롤과 다른 어떤 객체의 number라는 프로퍼티에 대해서 생각해보자.

[NSSlider] - value ------> Bind to: ModelObject ---> @"self.number"
           - minValue
           - maxVale
           - enabled
           ...

실제로는 NSSlider의 많은 프로퍼티 중에 value가 (이 value 속성은 objectValue, intValue, doubleValue, stringValue… 의 통칭이라 하겠다.) 다른 객체인 ModelObject 타입의 객체의 number라는 프로퍼티와 바인딩이 된다고 볼 수 있다.

ModelObject 클래스에서 number 프로퍼티를 키밸류 코딩 호환의 방식1으로 구현했다면 이 프로퍼티는 실제로 접근자 메소드를 사용하지 않아도 동적으로 키패스를 통해서 액세스가 가능해진다.  따라서 키패스를 알려주어 슬라이더가 데이터 값을 변경할 수 있게 하고, 또 동시에 키-밸류 옵저빙을 통해서 해당 키패스의 변경을 감지하여 그 값으로 자신의 상태를 업데이트할 수 있게 하는 것이 바인딩 작용의 가장 기본적인 연결이다.

이러한 작용들은 바인딩이 만들어진 후에 내부적으로 처리된다. 바인딩은 주로 인터페이스 빌더에서 설정된다. 물론 추천하는 방법은 아니지만, 바인딩의 생성은 코드상으로 처리할 수도 있다. AppKit에는 바인딩을 만들기 위한 함수가 정의되어 있다.

- (void)bind:(NSBindingName)binding
        toObject:(id)observable
        withKeyPath:(NSString*)keyPath
        options:(NSDictionary<NSBindingOption, id>*)options

포매터

포매터는 특정한 타입의 데이터를 다시, 특정한 형식의 문자열로 변환하여 표현할 때 사용하는 클래스들이다. Cocoa에는 여러 타입의 포매터 클래스가 정의되어 있고, 원한다면 필요한 포매터를 직접 디자인하여 사용할수도 있다. UI상으로 어떤 값을 표현할 때, 슬라이더와 같이 그 값을 그래픽으로 표현하는 컨트롤도 있지만, 레이블이나 텍스트 필드와 같이 문자열값을 사용하는 경우도 있다. 하지만 모델 키패스에 정의된 프로퍼티의 타입은 문자열 타입이 아닌 경우도 있기 때문에 NSNumberFormatterNSDateFormatter와 같은 포매터를 사용해서 해당 값을 적절한 형식의 문자열로 변환한다. 포매터는 바인딩 체인 내에 포함되는 것은 아니나, 코코아 바인딩을 사용하는 앱에서 중요한 위치를 차지한다. 그것은 포매터가 값 –> 문자열의 단방향 변환이 아닌 양방향 변환을 지원하기 때문이다.

텍스트 필드를 NSNumber 타입의 프로퍼티와 바인딩하는 경우를 생각해보자. 프로퍼티가 변경되었을 때, 해당 값을 표현하는 컨트롤러입장에서는 NSNumber 값이 변경되면 그 값을 setObjectValue:로 받아서 셀이 렌더링할 수 있게끔한다.

반대로 이 텍스트 필드에 값을 입력하여 숫자값을 변경하려한다면, 텍스트 필드의 문자열 값을 정수나 실수 타입의 값으로 해석해야 하는 과정이 필요하다. 포매터들은 이렇게 컨트롤의 값을 문자열로 표현하거나, 반대로 문자열로 만들어진 값을 특정 타입의 값으로 변환할 수 있다. 포매터는 코코아바인딩에서는 필수적인 요소는 아니지만 액션메소드에서 텍스트 정보를 다른 형식으로 변환해야 하는 경우를 제거해줄 수 있다.

[NSTextField] - value --> bind to: MyDataModel --> keyPath: @"number"
      |
 NSTextFieldCell  :: 값이 텍스트로 입력되면, 포매터를 통해 숫자로 변환하여 바인딩에 태운다.
      |
 NSNumberFormatter

값 트랜스포머

값 변환기(?)라고 번역하면 조금 이상한 것이, 포매터 역시 변환기의 일종이기 때문이다. 트랜스포머와 포매터가 다른 점은 포매터는 컨트롤의 셀에 표시될 값과 실제 데이터값 사이의 변환을 담당한다. (예를 들어 NSDate 값을 “12월 12일”인 문자열로 변환하거나 그 역의 변환을 수행) 대신 트랜스포머는 모델에서 사용하는 값과 뷰에서 사용하는 값 사이를 변환한다. 예를 들어 실제 모델에서는 도(dgree)단위의 각도를 사용하지만, 뷰에서는 라디안을 써야 한다거나, 모델 데이터는 섭씨 온도이나, 뷰에서는 화씨로 표현할 때 사용할 수 있다. (이 말은 섭씨-화씨 트랜스포머를 구현했다면, 이 두 온도의 변환기를 코코아 바인딩으로 손쉽게 만들 수 있다는 이야기이다.)

트랜스포머는 바인딩되는 두 개의 객체 사이에서 미들맨의 역할을 하면서 두 사이를 오가는 값을 변환한다. 대신, 트랜스포머를 사용하는 경우라도 특정 프로퍼티를 트랜스포머에 연결하는 것은 아니며, 해당 바인딩이 사용해야하는 트랜스포머 이름을 지정하는 식으로 등록한다.

참고로, 트랜스포머를 등록하기 위해서는 NSValueTransformer 클래스에 이름과 그에 해당하는 트랜스포머 인스턴스를 등록해야 한다. 그런데 인터페이스 빌더에서 지정한 정보들이 해석되는 Nib 로딩 시점에 바인딩이 구성될 것이므로, 이 지점에서는 사용하려는 트랜스포머가 등록되어 있어야 한다. 따라서 만약 커스텀 트랜스포머를 사용하려는 경우에는 앱 론칭 프로세스의 극 초반 과정인 앱 델리게이트의 +initialize 메소드에서 시작해야 한다.

오브젝트 컨트롤러

오브젝트 컨트롤러는 NSObjectController 혹은 NSArrayController의 인스턴스로, UI컨트롤과 뷰 사이에서 바인딩을 중개하는 역할을 한다. 단순한 양방향 바인딩에서 오브젝트 컨트롤러는 필요없기는 하지만, 그 자식 클래스인 NSArrayController는 몇 가지 사용해야 할 장점이 있다.

화면에 테이블 뷰가 하나 있다고 생각해보자. 모든 셀에 대해서 일일이 바인딩을 만들기는 어려울 뿐더러, 테이블 뷰의 콘텐츠는 동적으로 액세스되므로 N:N으로 바인딩할 수 있는 옵션이 필요할 것이다. NSArrayController는 코드를 사용하지 않고 바인딩만으로 테이블 뷰를 구성할 수 있게 할 수 있다. 뿐만 아니라 배열 그 자체가 그대로 바인딩 되는 경우, 새로운 요소가 추가되거나, 기존 요소가 삭제 혹은 이동하는 UI 상의 변경이 일어나게 되면 코코아 바인딩은 새로운 배열을 생성해서 기존 배열을 교체하는 식으로 동작하게 된다. 이  때 NSArrayController를 사용하면 개별 원소 단위에서 콘텐츠를 컨트롤하기 때문에 성능 측면에서도 유리하다.

다음은 배열 컨트롤러를 사용해서 테이블 뷰의 콘텐츠를 테이블뷰 델리게이트/데이터소스 메소드 구현 없이 코코아 바인딩만으로 populating 하는 예를 보여준다. (뷰 기반 테이블 뷰)

[TableView > Column] -- :content --> "arrangedObjects/name" -- NSArrayController -- :contentArray
         |                                                                         |
  TableViewCell -- "object.name" <---┐                                         "peopleArray"
         |                           |                                             |
    NSTextField  - :value -------> --┘                                          ModelObjects
  1. 테이블 뷰에는 content라는 바인딩이 있다. 이를 NSArrayController“arrangedObjects” 키에 바인딩한다.
  2. 배열 컨트롤러로 연결되는 바인딩은 모두 “arrangedObjects”에 연결된다. 여기서 개별 원소의 키에 접근하고 싶다면 배열 컨트롤러 자체에 키를 추가해두자.
  3. 배열 컨트롤러의 contentArray 바인딩은 ModelObject 객체의 “peopleArray” 키에 바인딩한다. (이 프로퍼티는 배열이며, 내부에 Person 클래스의 객체들을 가지고 있다고 가정한다.)
  4. 모든 테이블 뷰 내의 셀은 objectValue를 가지는데, 이 값은 개별 Person 인스턴스가 될 것이다. (테이블과 내부적으로 바인딩된다.)
  5. 테이블 뷰 내에 텍스트필드가 있고, 여기에 사람의 이름을 표시하려면, 해당 텍스트 필드의 value 바인딩이 테이블 뷰 셀의 “object.name” 키에 바인딩된다.

참고로, 셀 기반 테이블 뷰에서는 테이블 뷰 칼럼을 각각 배열 컨트롤러와 바인딩하며, 이 때 해당 칼럼이 표시할 키를 모델 키로 사용하면 된다.

그외에 오브젝트 컨트롤러들은 코어 데이터와도 쉽게 연계할 수 있으며, Undo/Redo 구현도 공짜로 얻을 수 있는 장점이 있다.(물론 코어데이터와 연계했을 때 한정)

정리

  1. 코코아바인딩은 키밸류 바인딩이라고도 하며, KVO, KVC에 밀접하게 관련되어 UI의 상태와 모델 객체의 프로퍼티 값이 양방향으로 자동 동기화되는 것을 말한다.
  2. 모델 객체의 프로퍼티가 KVO 호환방식으로 업데이트되면, UI는 이를 통지 받고 키밸류 코딩으로 해당 값을 읽어, setObjectValue:를 사용하여 자신의 값을 업데이트한다.
  3. 반대로 UI에서 변경사항이 발생하면 setVaue:forKeyPath:를 통해서 바인딩된 대상의 값을 업데이트한다.
  4. 모델과 UI 사이의 값에 변환이 필요하면 NSValueTransformer를 커스텀하여 사용한다.
  5. 코어데이터를 사용하거나, many-to-many 바인딩이 필요한 경우, 컨트롤러 클래스를 활용할 수 있다. 컨트롤러 클래스는 도입하는 경우, 추가적으로 필요한 코드를 제거하거나, 성능을 높일 수 있다.

참고자료

 


  1. 접근자를 number, setNumber: 와 같이 했거나, @property로 선언함. Swift의 경우 클래스가 @obj로 선언된 NSObject의 서브클래스이며, dynamic으로 선언된 프로퍼티여야 한다. 

[cocoa] 키-밸류 옵져빙 간단 예제

키-밸류 옵저빙은 (예전에 한 번 글을 쓴 적이 있는데… 자꾸 까먹음) 어떤 객체의 변경이 가해질 때 이를 다른 객체가 감지하도록 하는 매커니즘이다.

  1. 특정 객체가 KVO를 따른다면, 해당 객체에 옵저버를 붙일 수 있다. 이 때 옵저버는 변경 통지를 받을 객체가 된다.
  2. 옵저버를 붙일 때는 옵저버, 변경을 감지하고자하는 키패스, 옵션 등이 필요하다.
  3. 옵저버 쪽에서는 observeValueForKeyPath:ofObject:change:context: 메소드를 작성하면 된다.

만약 자신의 어떤 프로퍼티 값이 변경되는 것을 알아채고자 한다면

[self addObserver:self
       forKeyPath:@"self.aNumber.intValue"
          options:NSKeyValueObservingOptionNew |
                  NSKeyValueObservingOptionOld
          context:null];

이라고 하고, 이를 감지하는 메소드는 아래와 같은 식

-(void)observeValueForKeyPath:(NSString *)keypath 
                     ofObject:(NSObject *)object 
                       change:(NSDictionary *)change 
                      context:(void *)context
{
    if ([keypath isEqualToString:@"self.number.intValue"]) {
        NSLog(@"변경감지됨");
    }
}

예제 파일은 다음 링크 참고

https://www.box.com/s/0lcmd3rxl9klfgipltbi

키밸류 옵저빙이란

키밸류 옵저빙

키밸류코딩(KVC)에 이어서 키밸류 옵저빙에 대해 이야기해보자. 키밸류 코딩에 관한 포스팅에서 키밸류 코딩은 키밸류 옵저빙의 근간이 되는, 어떤 객체의 프로퍼티를 키 이름으로 런타임에 동적으로 탐색하여 액세스할 수 있게하는 기술이라고 하였다. 키밸류 옵저빙 역시 프로퍼티 액세스와 관련한 Objective-C 런타임이 제공하는 동적 기능의 일종으로, 특정한 키에 대한 객체의 프로퍼티 값이 변경될 때, 해당 변경에 대한 알림이 다른 객체로 통지되는 것을 말한다.

예를 들어 foo 라는 객체 인스턴스에 a 라는 프로퍼티가 있고, bar 라는 객체가 이 프로퍼티에 대한 옵저버로 등록이 되어 있다면, foo의 내부 혹은 외부에서 a라는 값을 업데이트하게 될 때, foo 내부의 코드에서 bar라는 객체에 명시적으로 어떤 메시지를 보내지 않더라도, bar 객체는 미리 정해진 어떤 메시지를 받게 되어 그 변경에 따른 어떤 작업을 처리할 수 있게 된다는 것이다.

한편으로 생각하면 매우 간단하고 단순한 기술처럼 보인다.

  1. KVC 호환으로 구현된 A라는 객체가 있고,
  2. B라는 객체는 A라는 객체의 특정한 키에 대해서 자신을 옵저버로 등록한다.
  3. KVO호환 방식으로 객체 A에 해당 키에 대한 값 변경이 발생한다.
  4. 객체 A는 자신을 지켜보는 옵저버들에게 값 변경에 대한 통지를 보낸다.
  5. 옵저빙하고 있는 객체의 키 값 변경에 대한 통지를 B가 받게 된다.

여기에는 별다른 마술이 없는 것처럼 보인다. A라는 객체에 대해서 명시적으로 객체 B가 “내가 이런이런 키에 대한 옵저버가 되겠다”고 말했고, 객체 A는 그에 대해서 변경이 발생하면 자신에게 옵저버가 되겠다고 말한 객체들에 대해서 정해진 메시지를 보낸다. 그러면 먼저 이 간단해 보이는 기술을 어떻게 쓸 수 있는지 살펴보자.

옵저버로 등록하기

옵저버를 등록하는 것은 타깃 객체에 -addObserver:forKeyPath:options:context: 메시지를 보내어 등록한다. 등록을 마치면 타깃 객체에서 해당 메시지에 명시한 키패스의 값에 변화가 발생하면 이 내용이 옵저버에게 통지된다.

옵션

키패스에 변경이 발생하여 이를 옵저버에게 알려줄 때, 타깃 객체는 옵저버에게 변경 사항의 세부 정보가 담긴 사전(dictionary) 객체를 전달한다. 이 때 이 사전 객체가 어떤 값을 담고 있을 것인가에 대해서는 옵저버로 등록할 때 전달한 옵션값에 의해 결정된다. 옵션값은 NSKeyValueObservingOptions라는 열거값에 정의된 상수들로 다음과 같은 것들이 있다.

  • NSKeyValueObservingOptionNew – 변경사전에 새 값에 대한 값을 포함한다.
  • NSKeyValueObservingOptionOld – 변경사전에 기존 값을 포함시킨다.
  • NSKeyValueObservingOptionInitial – 등록이 처리되는 시점에 옵저버에게 메시지를 보낸다.
  • NSKeyValueObservingOptionPrior – 변경 전/후에 각각 메시지가 보내지도록 한다.

컨텍스트

컨텍스트 정보는 통지를 구분하기 위한 방법으로 사용되는데, 통상 NULL을 넘겨준다. 그러면 변경 통지 메시지에서 NULL이 넘어오게 되며 이 때 변경 통지의 구분은 타깃의 키패스에 의존하게 된다. 이 구현도 나쁘지는 않지만, 우리가 알지 못하는 수퍼 클래스에 의해서 옵저빙되는 키패스를 놓칠 우려가 있기 때문에 이를 사용한다.

보통은 정적 포인터 변수(자기 자신을 가리키는)를 하나 만들어서 사용한다.

변경 통지 받기

타깃 객체는 특정한 프로퍼티가 변경되면 그 키패스에 대한 옵저버들에게 변경 통지를 알린다. 변경 통지를 보내는 방법은 옵저버에게 -observeValueForKeyPath:ofObject:change:context: 를 호출하는 것이다. 따라서 KVO 통지를 받고 싶은 클래스에서는 이 메소드를 오버라이드 해야 한다. 이 때 이 메소드의 각 파라미터는 다음과 같다.

  • keyPath: 옵저빙하는 키패스
  • ofObject: 통지를 보낸 객체
  • change: NSKeyValueChangeKey에 정의된 변경 사항에 대한 키들을 사용하여 변경에 대한 세부 내용을 담은 사전
  • context: 옵저버가 등록시 사용한 컨텍스트

특히 change는 어떤 형태의 변경이 일어났는지를 알려주는 정보가 담긴 사전이다. 여기에는 기본적으로 몇가지 키가 존재한다.

  1. NSKeyValueChangeNewKey – 변경 후의 값
  2. NSKeyValueChangeOldKey – 변경 전의 값
  3. NSKeyValueChangeNotificationPriorKey
  4. NSKeyValueChangeKindKey – 변경의 종류
  5. NSKeyValueChangeIndexesKey – to-many 관계에 대한 변경인 경우, 변경이 발생한 위치의 인덱스

이 중에서 NSKeyValueChangeKindKey는 어떤 종류의 변경인지를 알려준다. 일반적으로 저장 프로퍼티의 값이 다른 값으로 교체되는 것을 생각할 수 있지만, 객체의 프로퍼티는 단순한 변수라는 개념 이상의 to-one / to-many relationship이기도 하기 때문이다. 이 키가 사전에 존재한다면, 사전의 그 값은 다음 중 하나가 될 것이다. (NSUInteger기반으로 enum 값들로 만들어져 있다.)

  • NSKeyValueChangeSetting – 프로퍼티 값이 다른 값으로 set 되었다.
  • NSKeyValueChangeInsertion – to-many 관계에서 아이템이 추가되었다.
  • NSKeyValueChangeRemoval – to-many 관계에서 아이템이 제거되었다.
  • NSKeyValueChangeReplacement – to-many 관계에서 아이템이 변경되었다.

만약 커스텀 클래스의 배열에서 N 번째 원소의 특정한 프로퍼티 값이 변경되는 것은 KVO에 호환일까? 그렇지 않다. 그것은 원소 객체 그 자체의 내부 상태 변경이므로 해당 관계에 대한 KVO의 관심사항이 아니다. to-many 관계(배열이나 Set인 프로퍼티)의 변경은 다음 중 하나의 경우만 호환된다.

  1. 배열 자체가 다른 배열로 교체되었다.
  2. 배열에 원소가 추가되었다.
  3. 배열에서 원소가 삭제되었다.
  4. N번째 원소가 다른 객체로 교체되었다.

이미 추가되어 있는 원소의 다른 프로퍼티 변경에 대한 추적은 기본 KVO로 호환되지 않는다. 이는 타깃 객체 자체가 다시 각 배열 원소에 대한 옵저버가 되어야 한다. (이와 관련된 내용은 집합 메소드와 관련하여 별도의 포스팅에서 다시 한 번 다뤄보도록 하겠다.)

예제

간단한 예를 들어보자. 이전에 KVC 관련한 소개글에서 사용했던 Foo 클래스를 떠올려보자.

@interface Foo: NSObject
@property (copy, nonatomic) NSString* moo;
@end

@implementation Foo
@end

옵저버

다음은 옵저버가 될 클래스이다. 통지를 받는 부분만 아래와 같이 작성해본다.

@interface Bar: NSObject
@end

/// 이 일련의 예제는 모두 같은 파일 1개에서 
/// 사용된다고 가정한다.
/// 따라서 static 변수를 다음과 같이 초기화한다.
static void* FooBarObservingContext = &FooBarObservingContext;
@implementation Bar
- (void)observeValueForKeyPath:(NSString *)keyPath 
        ofObject:(id)object 
        change:(NSDictionary<NSKeyValueChangeKey,id> *)change 
        context:(void *)context
{
   if(context == FooBarObservingContext ) {
       /// 변경전 값과 변경 후 값을 로그에 찍어본다.
       NSLog(@"the foo's bar has changed from:%@ to:%@",
             [change objectForKey:NSKeyValueChangeOldKey],
             [change objectForKey:NSKeyValueChangeNewKey]
       );
   } else {
     /// 정해진 컨텍스트외에는 수퍼클래스로 돌린다.
     [super observeValueForKeyPath:keyPath
            ofObject:object
            change:change
            context:context];
   }
}
@end

KVC 호환으로 작성된 객체는 특별한 경우를 제외하고는 자동으로 KVO 호환이 된다. 여기서 특별한 경우라는 것은, 인스턴스 변수를 직접 액세스하는 것이다. 따라서 setValue:forKey: 를 통한 변경은 물론이고,  setter 접근자를 사용해서 키 값을 업데이트 하는 경우에도 통지가 간다. 그러니까 분명 Objective-C 런타임은 실제로 “어떤 작업을 setter 실행의 앞 뒤에 하고 있는 것”처럼 보인다. 이는 암시적으로 KVC/KVO에서 호출되는 접근자/변경자 메소드들은 실제로 코드상에 명시된 함수가 아니라 그 앞뒤로 어떤 일을 하는 실제로는 decorated된 다른 함수를 호출하고 있는 것이라 볼 수 있다. 즉 KVC/KVO에 의해서 접근자 메소드는 런타임에 실제로는 다른 함수로 바꿔치기 되어 호출되는 것이다.1

특정 키에 옵저버 설치하기

실제로 객체 인스턴스를 만들어서 옵저버를 추가해서 변경을 확인해보자. main 함수를 아래와 같이 추가로 작성하고 컴파일해보도록 하자. (지금까지 작성된 Foo, Bar 클래스 및 아래의 main 함수는 모두 main.m 이라는 하나의 파일에 작성된다. 전체 코드를 담은 Gist를 다음 페이지에 게재해두겠다.) 참고로, 옵저버의 사용이 끝나는 지점에서는 반드시 옵저버를 제거해주어야 한다.

int main(int argc, const char ** argv){
  @autoreleasepool {
    Foo* foo = [[Foo alloc] init];
    Bar* bar = [[Bar alloc] init];
    foo.moo = "one";
 
    [foo addObserver:bar forKeyPath:@"moo"
         options:(NSKeyValueObservingOptionOld|NSKeyValueObservingOptionNew)
         context:theContext];

    foo.moo = "two"

    [foo removeObserver:bar forKeyPath:@"bar"];
  }
  return 0;
}
    

위 코드에서는 옵저버의 추가와 제거를 객체 외부의 컨텍스트에서 수행했지만, 실제로 많은 경우에는 옵저버를 생성하는 시점에 옵저빙 타깃에 대한 옵저버로 추가하면서 해제될 때 옵저버를 제거하는 처리를 한다.

의존하는 키

객체의 프로퍼티 중에서는 스토리지 변수를 가지지 않는 읽기 전용의 프로퍼티들이 있다. 보통 이러한 프로퍼티는 2개 이상의 인스턴스 변수나 다른 프로퍼티로부터 계산된 값을 반영하는 것이다. 소금물에 대한 정보를 나타내는 클래스를 다음과 같이 디자인한다고 생각해보자.

@interface Solution: NSObject
@property (readwrite, nonatomic) float salt;
@property (readwrite, nonatomic) float water;
@property (readonly, nonatomic) float weight;
@property (readonly, nonatomic) float concentration;
@end

이 중에서 소금물의 무게와 농도는 소금의 무게, 물의 무게로부터 계산가능한 값이므로 readonly로 선언했다. (혹은 프로퍼티로 선언하지 않고 메소드로 선언해도 무방할 듯 하지만…) 이들 계산 프로퍼티(computed property)들의 구현은 다음과 같이 간단하다.

@implementation Solution

- (float)weight
{  return _salt + _water; }

- (float)concentration
{
  if(self.weight > 0) {
    return _salt / self.weight;
  }
  return 0;
}
@end

그런데 이러한 계산 프로퍼티들에 대해서도 KVO 적용이 가능할까? 앞선 예에서는 setter에 의해서 값이 변경될 때 KVO 통지가 발생하는 것을 보았는데, 이들 계산 프로퍼티는 setter라는 것이 없다. 계산 프로퍼티는 특성 상 다른 프로퍼티 키에 의존하게 된다. 예를 들어 weight 프로퍼티는 saltwater의 합이므로 두 프로퍼티에 의존하고 있다고 할 수 있다. 즉 두 키 중 하나의 값이 바뀌면 그에 따라서 weight의 값도 바뀌는 것이다.  concentration 역시 weight, salt 키에 의존하고 있다. 물론 농도 키를 옵저빙할 필요가 있을 때 salt, water의 키를 옵저빙하는 방법도 있겠지만, 여기서 만든 Solution 클래스를 내가 아닌 다른 누군가가 사용하려 한다면 그 원리를 모를 수도 있는 것이다. 계산 프로퍼티를 옵저빙할 수 있게 하려면 해당 키패스가 어떤 키에 의존하고 있음을 런타임에게 알려주어야 한다.

NSObject의 클래스 메소드인 +keyPathsForValuesAffectingValueForKey: 는 해당 클래스 내에서 특정 프로퍼티 키에 영향을 줄 수 있는 키패스의 집합을 리턴하는데, 이 메소드를 오버라이드해서 계산 프로퍼티가 의존하는 키들을 명시해줄 수 있다. 이 메소드를 구현해두면 salt나 water 값을 변경했을 때, concentration 키가 변경되었다는 통지를 보낼 수 있는 것이다.

+ (NSSet<NSString*> *)keyPathsForValuesAffectingValueForKeyPath:(NSString *)keyPath
{
  // 특정한 키패스에 대해서 이미 부모클래스에서 설정한 의존성이 있을 수 있으므로
  // 부모 클래스의 의존성을 먼저 구한 후, 추가적으로 의존성을 더해준다.
  NSSet<String*>* defaults = [super keyPathsForValuesAffectingValueForKeyPath:keyPath];
  if([keyPath isEqualToString:@"concentration"]) {
    defaults = [defaults setByAddingObjectsFromArray:@[@"water", @"salt"]];
  }
  return defaults;
}

참고로 정의된 키패스에 대해서 +keyPathsForValueAffecting<KeyPath>와 같은 식으로 개별 키 이름을 통한 메소드를 사용해서 제어할 수도 있다.

자동 통지 VS 수동 통지

기본적으로 특정한 프로퍼티가 KVC 호환 방식으로 정의되어 있다면, 프로퍼티에 대한 변경 통지는 KVO 호환 방식의 변경(setter를 이용하거나, setValue:forKey:를 사용)을 적용했을 때 자동으로 옵저버들에게 발송된다. 사실 이 방식은 수동으로 컨트롤할 수 있다. 통지를 보내는 방식을 수동으로 관리하는 경우는 1)빈번하게 변경되는 값에 대해서 꼭 필요한 상황에 한해서만 통지를 보내고 싶을 때, 2) 일련의 값들의 변경 통지를 그룹으로 묶어서 한 번에 처리하고 싶을 때 등의 상황에서 사용한다.

특정 키 패스에 대한 자동 통지 여부는 NSObject의 +automaticallyNotifiesObersversForKey:에 의해서 개별적으로 설정할 수 있다. (역시 개별 키에 대해서 +automaticallyNotifiesObersversFor<Key>의 형태로 구현해도 된다. 예를 들어 위의 예에서 water에 대한 자동 통지 여부는 다음과 같이 꺼 버릴 수 있다.

+ (BOOL)automaticallyNotifiesObserversForWater
{ return NO; }

이렇게하면 setWater: 를 통한 값 변경은 자동으로 통지되지 않으며, 이에 의존하고 있는 concentration 역시 자동으로 업데이트되지 않는다. 수동으로 통지를 보내려면 -willChangeValueForKey:, -didChangeValueForKey: 를 해당 값을 변경하는 앞/뒤로 붙여주어야 한다. 소금물에서 물을 빼는 경우는 없고, 물을 더하는 경우는 있을 수 있으므로 다음과 같이 addWater: 라는 메소드를 추가하고 구현해보자.

- (void)addWater:(float)w
{
  [self willChangeValueForKey:@"water"];
  _water += w;
  [self didChangeValueForKey:@"water"];
}

이 경우 자동으로 변경통지를 보내지 않는 water이지만, addWater:를 호출하여 변경한 경우에는 변경 통지를 받을 수 있게 한다. 이러한 수동 통지는 흔히 여러 키에 대한 변경의 통지를 하나로 묶을 때 사용된다.  이 때는 마치 괄호를 이중으로 쓰듯이 willChange... ~ didChange...를 짝으로 묶어서 감싸면 된다.

- (void)setBalance:(double)theBalance
{
  if(theBalance != _balance) {
    [self willChangeValueForKey:@"balance"];
    [self willChangeValueForKey:@"itemChanged"];
    _balance = theBalance;
    _itemChanged += 1;
    [self didChangeValueForKey:@"itemChanged"];
    [self didChangeValueForKey:@"balance"];
  }
}

 

to-many 관계의 각 원소를 추적하기

특정한 키에 대한 의존성을 설정할 수 있다 하더라도, 그것이 만능은 아니다. 예를 들어서 to-many 관계는 의존 키패스를 지원하지 않는다. 예를 들어서 Department라는 부서를 표현하는 클래스가 있고, 여기에 해당 부서 직원들의 전체 급여합을 계산하는 totalSalary라는 키가 있다. 각 직원은 Employee라는 클래스로 표현되고, 여기에는 개개인의 급여를 나타내는 salary라는 키가 있다고 하자.

totalSalary는 해당 부서 임직원의 salary 키의 합계이므로 “employees.@sum.salary” 라는 키패스로 표현할 수 있고, 이 키는 “employees.salary”에 의존한다고 할 수 있다. 하지만 employees가 to-many 관계인 이유로 이 의존성은 올바르게 동작하지 않는다. 대신에 totalSalary는 저장 프로퍼티로서 다음과 같이 구현되어야 한다. (이 코드는 애플 공식 문서의 내용을 일부 수정한 것이다.) 특히 직원을 추가/제거하는 시점이 중요한데, 직원을 추가하면 새로 추가된 직원의 “salary” 키에 대해 감시해야 하고, 직원이 제거될 때에는 옵저버를 해제해야 한다. 그리고 직원의 수 역시 전체 급여액에 영향을 미치므로 추가/제거 시점에 변경해야 한다.

/// setter에서 다른 값이 들어왔을 때만 통지를 보내도록 한다.
- (void)setTotalSalary:(NSNumber *)newTotalSalary
{
  if(self.totalSalary != newTotalSalary) {
    [self willChangeValueForKey:@"totalSalary"];
    _totalSalary = [newTotalSalary copy];
    [self didChangeValueForKey:@"totalSalary"];
  }
}

/// 총 급여값을 업데이트하는 메소드
- (void)updateTotalSalary
{
  [self setTotalSalary:[self valueForKeyPath:@"employees.@sum.salary"]];
}

/// 소속 직원의 급여 변경에 대한 KVO 통지를 받았을 때 처리
/// 총급여를 재계산한다. (그리고 변경됐으면 자동으로 통지...)
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object 
        change:(NSDictionary<NSKeyValueChangeKey, id> *)change
        context:(void*)context
{
  if (context == totalSalaryContext) {
    [self updateTotalSalary];
  }
  else {
    [super observeValueForKeyPaht:keyPath ofObject:object change:change context:context];
  }
}

/// 소속 직원들의 급여 변경에 대해서는 직원을 추가할 때 옵저버로 등록해야 한다.
/// 이 변경은 KVC 집합 변경자를 작성하여 적용한다.
/// 코드 상으로는 배열 프록시를 사용해서 직원을 추가/제거해야 하며,
/// 가장 간단하게는 NSArrayController를 사용하는 것이 좋다.
- (void)insertObject:(Employee*)employee inEmployeesAtIndex:(NSUInteger)index
{
  [employee addObserver:self forKeyPath:@"salary" 
            options:NSKeyValueObservingOptionNew 
            context:totalSalaryContext];
  [_employees addObject:employee atIndex:index];
  [self updateTotalSalary];
}

/// 소속 직원이 빠져나갈 때에는 옵저버를 해제해주어야 한다.
- (void)removeObjectFromEmployeesAtIndex:(NSUInteger)index
{
  Employee* employeeToLeave = [_employee objectAtIndex:index];
  [employeeToLeave removeObserver:self forKeyPath:@"salary"];
  [_employee removeObjectAtIndex:index];
  [self updateTotalSalary];
}

만약 코어데이터를 사용하고 있다면, 삽입이나 삭제에 있어서 직접적인 개입이 어렵다. 이 경우에는 코어데이터 컨텍스트의 변경이 발생했을 때 노티피케이션 센터를 통해서 통지를 받아, 각 값을 업데이트 하도록 처리하는 방법을 생각해볼 수 있다.

정리

이상으로 키밸류 옵저빙이 무엇이며 어떤식으로 동작하는지, 또 어떻게 적용하고 제어할 수 있는지에 대해 살펴보았다. 대부분의 경우 NSObject가 필요한 처리를 다 해주고 있으므로 계산 프로퍼티에 대한 의존키 설정 등으로 충분히 처리할 수 있지만, 마지막에서 살펴본바와 같이 to-many 관계에 있어서는 세세한 추적과 처리가 필요하다. 따라서 KVO를 잘 이해하고 활용하기 위해서는 KVO와 관련된 메소드 뿐만 아니라 집합 접근자/변경자와 같은 KVC 관련된 내용에 대해서 많은 연습과 연구가 필요하다.

참고자료