Swift4의 키패스 표현

키패스는 어떤 객체의 프로퍼티 혹은 프로퍼티의 프로퍼티 체인에 대해서 그 이름을 통해서 값을 찾아나가는 표현을 말한다. Objective-C에서 키패스는 키패스 경로를 나타내는 문자열을 사용해서 특정 객체의 속성을 액세스하기 때문에 컴파일 타임이 아닌 런타임에 액세스해야 할 프로퍼티를 결정하는 동적 기능으로 키밸류코딩과 키밸류 옵저빙에 사용된다. Swift2까지는 Swift 내에 키패스에 대한 기능이 별도로 마련되지 않았고, NSObject의 value(forKey:)setValue(_:forKey:)를 사용하면서 문자열을 그대로 사용했다.

문자열을 통해서 키패스를 사용하는 것은 편리할 수는 있으나, 컴파일 타임에서 오타에 의해 존재하지 않는 키패스를 참조하는 것을 체크할 방법이 없어서 디버깅이 곤란한 부분이 있었다. 이 부분을 개선하기 위해 Swift3에서 #keyPath() 문법 표현이 추가되었는데, 이 문법은 코딩 시점에는 컴파일러의 도움을 받아 올바른 키패스를 확인할 수 있고, #keyPath() 표현을 통해 해당 키패스값을 문자열로 안전하게 변환할 수 있었다.

하지만 키패스를 문자열로 치환하는 이와 같은 방법은 Swift의 디자인 관점에서는 몇 가지 한계를 갖는다. 키패스 자체는 프로퍼티를 찾아가는 경로만을 정의하므로 타입 정보를 잃고 그 결과가 Any가 되어버린다든지, 파싱이 느리고 NSObject 기반의 클래스에서만 사용할 수 있었다. Swift4에서는 이러한 단점을 보완하고 클래스외의 모든 Swift 타입에서 키패스를 통해서 프로퍼티를 참조할 수 있는 범용적인 키패스 문법(과 키패스를 위한 코어 타입)이 추가되었다.

Swift4의 키패스 문법

Swift4의 키패스 문법은 단순히 백슬래시(\)로 시작하는 키패스 값을 말한다. Objective-C와 달리 self 대신에 타입 이름을 쓰거나, 타입이 분명한 경우, 타입 이름을 생략하고 바로 . 으로 시작하는 키패스를 사용할 수 있다. 다음은 Swift Evolution의 새로운 키패스 제안서에 실린 예제이다.

class Person {
  var name: String
  var friends: [Person] = []
  var bestFriend: Person? = nil
  init(name: String) {
    self.name = name
  }
}

var han = Person(name: "Han Solo")
var luke = Person(name: "Luke Skywalker")
luke.friends.append(han)

// 키패스 객체를 생성한다. 
let firstFriendsNameKeyPath = \Person.friends[0].name
// 생성한 키패스를 사용해서 프로퍼티를 액세스한다.
let firstFriend = luke[keyPath: firstFriendsNameKeyPath] // "Han Solo"

// 항상 . 으로 시작해야 한다. 이는 배열의 요소 참조시에도 마찬가지이다.
luke.friends[keyPath: \.[0].name]
luke.friends[keyPath: \[Person].[0].name] 

// 옵셔널 프로퍼티는 ?를 붙여서 액세스해야 한다.
let bestFriendsNameKeyPath = \Person.bestFriend?.name
let bestFriendsName = luke[Keypath: bestFriendsNameKeyPath] // nil

키 패스 타입

Swift4의 키패스는 KeyPath라는 타입에 의해서 관리된다. 이 타입은 하위 속성을 참조하기 위해서 다른 키패스를 이어 붙이는 것이 가능하고, 또한 루트 타입과 키패스가 가리키는 속성의 타입을 그 인자로 가질 수 있다. 이 말은 위 예제에서와 같은 표현으로 키패스를 생성하는 경우, luke의 타입인 Personname 속성의 타입인 String 에 대한 정보가 키패스 내부에 내제된다는 것이다. 따라서 위 예제에서 bestFriend 변수의 타입은 String이 라는 점을 컴파일러는 알 수 있다.

키밸류 코딩의 검색 패턴과 그 동작방식

키밸류 코딩은 런타임에 문자열로 된 프로퍼티 이름을 키로 하여 임의의 객체로부터 고정된 접근자가 아닌 임의의 키를 통해서 특정한 프로퍼티 값을 액세스하는 기술이다. 이는 코드에 고정되지 않은 특정 프로퍼티를 접근할 수 있게 하여 유연성을 극대화하는 동시에 “코드를 작성을 줄여주는” 코코아의 여러가지 하위 프레임워크 기술들을 사용하는 근간이 된다. 키밸류 코딩 호환 클래스를 작성하는 기본적인 방법은 이전 포스팅에서 간단히 살펴보았다. 여기서는 실제로 키밸류 코딩에서의 접근자는 어떤식으로 동작하는지를 살펴보고, 집합 형식의 프로퍼티가 KVO 호환이 되기 위해서 필요한 집합 접근자/변경자에 대해서 살펴보겠다.

기본 접근자의 검색 패턴

키밸류 코딩의 가장 기본적인 아이디어는 미리 코딩된, 즉 하드코딩된 접근자 메소드가 아닌 “문자열”로 된 이름을 통해서 특정한 데이터를 객체로부터 얻는 동적인 프로퍼티 getter에 관한 것이다. 실제로 이를 구현하는 메소드는 valueForKey:(=value(forKey:))이며, 이는 NSObject에서 기본 구현이 만들어져있기 때문에 공짜로 사용할 수 있다. 특정한 이름 키와 함께 이 메소드가 호출되었을 때, 어떤식으로 작동하는지는 키밸류 코딩 프로그래밍 가이드에 다음과 같이 설명되어 있다.

  1. get<Key>, <key>, is<Key>, _<key> 의 패턴으로 된 접근자 메소드를 찾는다. (명시된 순서) 접근자 메소드를 찾는다면 해당 접근자가 호출된다.
  2. 이 때 접근자의 리턴타입이 객체인 경우에는 그대로 리턴된다. 숫자인 경우에는 NSNumber로 래핑되며, 구조체인 경우에는 NSValue로 래핑된다.
  3. 1에서 키의 이름에 매칭되는 접근자 패턴을 찾을 수 없다면, 해당 키에 대응하는 값이 일반값이 아닌 배열형식이라고 가정한다. 기본적으로 배열은 몇 개의 원소가 있으며, 몇 번째 원소는 무엇인가만 알면 액세스할 수 있다. 따라서  countOf<key>를 찾고 objectIn<Key>AtIndex:<key>AtIndexes: 도 찾는다. countOf<key>와 후자 중 한개를 찾으면 NSArray 프록시가 생성되어 리턴된다.
  4. 2에서 찾을 수 없다면 해당 이름의 키의 값이 집합형식일 것으로 가정한다. 집합 타입이 가져야 하는 최소한의 요건인 세 가지 메소드가 존재하는지를 검사하게된다.  countOf<Key>, enumeratorOf<Key> 그리고 memberOf<Key>를 찾는다. 이 세 가지를 모두 찾으면 NSSet 프록시가 생성되어 리턴된다.
  5. 모두 실패했다면, 해당 클래스에게 accessInstanceVariablesDirectly를 호출해보고, YES를 리턴받는다면 (놀랍게도) _<key>, _is<Key>, <key>, is<Key> 의 이름인 인스턴스 변수를 찾는다.
  6. 여기서도 마찬가지로 찾은 인스턴스 변수가 객체 포인터라면 그대로 반환된다. 그렇지 않다면 NSNumberNSValue로 변환되어 리턴된다.
  7. 모든 탐색에 실패하면 valueForUndefinedKey: 가 호출된다. 이 메소드의 기본 구현은 예외를 일으키는 것이지만, 다른 객체에게 위임하고자한다면 이 메소드를 오버라이드 할 수 있다. 예외가 발생하면 우리는 해당 클래스가 그 키에 대해 키밸류 코딩 호환이 아니라는 메시지를 볼 수 있게 된다.

 

실제 키밸류 코딩에서 동적으로 프로퍼티에 접근하는 방식은 생각했던 것 보다 매우 복잡하다. 이것은 프로퍼티를 보는 관점 때문이다. 프로퍼티는 기본적으로 스토리지 변수와 이 스토리지 변수를 액세스하는 접근자 메소드들의 세트를 하나의 개념으로 묶어서 보는 것이다. 따라서 보통 프로퍼티를 객체 내부에 저장되는 특정한 값이라고 생각한다. (클래스 인스턴스의 멤버 변수같은 개념) 하지만 Objective-C의 프로퍼티는 개념상 두 가지로 구분하는데 (기술적으로는 객체 포인터를 저장하는 것이니 차이는 없다.) 하나는 우리가 알고 있던 프로퍼티와 같이 객체가 내부에 가지고 있는 어떤 값을 액세스하는 것이다. 그리고 다른 하나는 해당 객체와 어떤 관계를 맺고 있는 다른 값들을 말한다.

to-many 관계

예를 들어 Person 이라는 클래스를 하나 만든다고 가정해보자. 이 클래스에는 firstName 이나 lastName과 같은 프로퍼티가 있을 수 있다. 그리고 이 프로퍼티는 객체 내부에 문자열을 저장하는 변수가 있고, 이 변수를 통해서 값을 액세스하는 개념이라 생각할 수도 있지만 Person 객체가 프로퍼티 하나 당 NSString 객체와 one-to-one의 관계(relationship)를 맺고 있는 것으로 이해할 수 있다. 만약 Person 클래스에 friends라는 프로퍼티가 있고, 이 프로퍼티는 다른  Person 객체들의 배열이라고 해보자. 이 때는 Person 하나가 다른 여러 개의 Person 객체와 one-to-many 관계를 맺고 있는 것으로 이해할 수 있다. 즉, 단순히 배열 타입의 프로퍼티를 가지고 있는 것이 아니라 여러 개의 다른 객체와 관계를 의미하는 프로퍼티가 있다고 보는 것이다.

왜 이렇게 one-to-one, one-to-many 의 관계를 구분하여 사용하려는 것일까? 그것은 여러 가지 이유가 있는데, 가장 크게는 키밸류 옵저빙과 관련이 있다. 기본적으로 키밸류 옵저빙은 특정 프로퍼티의 setter가 동작하여 프로퍼티의 값이 새로운 다른 값으로 변경되었을 때, 해당 프로퍼티 키에 대해 등록된 옵저버에게 통지 메시지를 보내게 된다. 이것이 one-to-one 관계인 프로퍼티일 때는 별 문제의 소지가 없다. 2였던 값이 3으로 교체될 때 옵저버가 통지를 받는 것이 이러한 관계에서의 가능한 변경의 전부이기 때문이다. 그런데, one-to-many의 경우에는 조금 사정이 다르다.

Person *tom = [[Person alloc] init];
/// tom의 friends 키에 대해 옵저버를 설치한다.
anObserver = [[Observer alloc] init];
[tom addObserver:anObserver forKeyPath:@"friends" options:... context:...];
....
/// tom에게 새 친구가 생겼다.
Person *jane = [[Person alloc] init];
[tom.friends addObject:jane];
/// 이 변경은 옵저버에게 통지를 보내지 않는다.

만약 tomfriends 키에 대한 옵저버가 있을 때, 위 동작을 보자. one-to-many 관계에서 새로운 원소가 추가되는 것은 비즈니스로직 상에서는 의미가 있는 변경이다. 위 코드는 접근자 메소드가 기존의 배열값을 다른 배열값으로 통째로 교체한 변경 (setter를 통한 변경)이 아니다. 따라서 옵저버는 이 변경에 대해서 아무런 통지를 받지 못한다. 물론 필요할 때마다 배열을 조작하고, 그 사본을 만들어서 기존 배열을 통째로 교체하는 식으로 처리하면 KVO는 동작할 것이다. 하지만 배열의 덩치가 매우 크다면 이것은 성능에서 발목을 잡는 요인이 될 수 있다.

배열이나 Set 타입의 프로퍼티 즉, one-to-many 관계의 프로퍼티에서 요소의 추가/삭제/교체가 발생하는 경우에 KVO 통지가 보내질 수 있도록 하고 싶다면 다음의 두 대안 중 하나를 선택할 수 있다.

  1. 해당 to-many 프로퍼티에 대해서 프로퍼티를 직접 액세스하지 않고 배열 프록시를 사용한다.
  2. 해당 키를 집합으로 액세스하는데 필요한 집합 접근자를 직접 작성한다.

 

배열 프록시

to-many 관계에 있는 특정 프로퍼티를 직접 액세스하는 대신에 배열 프록시를 얻어서 조작하게 되면, 원소의 추가, 제거 및 교체에 대해서도 KVO 호환이 가능해진다. 배열 프록시는 마치 NSMuatbleArray 처럼 행동할 수 있는 프록시 객체이다. 따라서 이 프록시를 통해서 배열을 조작하면 프록시는 실제 프로퍼티값을 조작하는 식으로 동작을 연계하고, KVO에 필요한 여러 처리를 자동으로 수행한다. 배열 프록시는 -mutableArrayValueForKey: 라는 메시지를 보내어 얻을 수 있는데, 이 메소드가 프록시를 생성하는 과정은 다음과 같다.

  1. 배열 조작에 필요한 집합 메소드 변경자가 있는지 확인한다. insertObject:in<Key>AtIndex:removeObjectFrom<Key>AtIndex: 메소드가 구현되어 있는지를 체크하게 된다.
  2. 이 각각의 메소드는 배열 프록시가 insertObjectAtIndex:, removeObjectAtIndex: 메시지를 받을 때, 프록시로부터 전달될 메시지들이다.
  3. 1의 메소드들이 없으면, 그 대용으로 insert<Key>:atIndexes:remove<Key>AtIndexes: 가 있는지를 체크한다.
  4. 최소 1개씩의 삽입/삭제 메소드가 발견되면 Objective-C 런타임은 해당 메소드를 사용해서 배열을 조정할 수 있는 배열 프록시 객체를 리턴한다.
  5. 만약 replaceObjectIn<Key>AtIndex:withObject: 도 구현되어 있다면 원소를 교체하는데 있어서 두 번 동작해야 할 것을 한 번만 동작해도 되기 때문에 성능을 높일 수 있다.
  6. 배열에 대한 변경자 메소드를 발견하지 못하면 set<Key>: 메소드를 찾는다. 이 경우, 배열 프록시는 삽입/삭제에 대한 메시지를 받게되면, 원래 객체에게는 set<Key>:를 통해서 배열 전체를 set하는 변경을 수행한다.
  7. 집합 변경자도 없고, setter 메소드도 없다면 해당 객체의 accessInstanceVariablesDirectily 를 호출해본다. 이 값이 YES인 경우 _<key><key>의 이름의 인스턴스 변수를 찾는다. 이러한 변수를 찾으면 해당 변수의 객체로 메시지를 포워딩하는 배열 프록시가 생성되어 리턴된다.
  8. 이 모든 과정에서 실패하면 setValue:forUndefinedKey: 메시지가 원래 객체로 전송된다. (그리고 해당 키에 대해서 KVC 호환이 아니라는 메시지와 함께 예외가 발생한다.)

따라서 위 예에서 tom의 친구가 추가되거나 하는 경우에 KVO 통지를 받으려고 한다면 다음과 같이 프록시를 사용해야 한다.

NSMutableArray* friends = [tom mutableArrayValueForKey:@"friends"];
[friends addObject:jane];

집합 변경자 메소드를 작성하기

to-many 관계에 대해서 집합의 변형 동작에 대한 KVO 호환이 가능하게 하려면 배열 프록시를 사용해야 한다. 그런데 집합 타입의 프로퍼티에 대한 배열 프록시의 기본 동작은 1) 원본에 어떤 변경을 가해서 만들어지는 사본을 생성한 후, 2) 해당 원본 키의 setter를 사용해서 원본을 “수정된 복사본”으로 교체한다는 것이다.

즉 위의 [friends addObjects:jane]은 결국 원래의 배열의 사본을 만들고, 여기에 jane을 더해서 다시 tom.friends에 대입하는 동작으로 실행된다. 이 동작의 효율성을 높이기 위해서는 배열 프록시가 원래의 프로퍼티의 각 원소를 제어할 수 있도록 해야 하며, 이를 위해서 앞서 언급된 집합 변경자 메소드를 구현해야 한다.

최소한 삽입관련 메소드 1개와 삭제 관련 메소드 1개를 구현한다. 많은 경우 프로퍼티 자체가 변경 가능한 배열일 것이므로 래핑하는 수준의 처리만 해주면 된다. 예를 들어서 PersonfriendsNSMuatbleArray 타입일 때에도 다음의 메소드를 추가로 구현해주면된다.

@implementation Person
- (void)insertObject:(NSString*)obj inWordsAtIndex:(NSUInteger)index
{
  [_words insert:obj atIndex:index];
}

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

정리

이상 조금 더 자세한 키밸류 코딩의 동작 방식과, one-to-one 외에 one-to-many 관계로 정의되는 프로퍼티의 변경을 위한 집합 접근자 및 집합 변경자 메소드에 대해서 살펴보았다. 흔히 배열 타입으로 정의되는 to-many 관계의 프로퍼티는 프로퍼티 값 자체가 교체되기 보다는 원소의 추가/삭제/교체등의 변경이 잦다. 기본적인 KVO는 이러한 변경을 감지하지 못하지만, 배열 프록시를 사용하면 이러한 배열 조작 변경에 대한 KVO 탐지가 가능해진다.

이 때 배열 프록시가 배열 전체를 교체하는 것이 아닌, 배열 내부 구조를 직접 변경하게 하기 위해서 KVC 집합 변경자 메소드를 정의해야 할 필요가 있다. 특히 코코아 바인딩에서 사용되는 배열 컨트롤러(NSArrayController)는 원본 모델 키패스에 대해 배열 프록시를 사용하여 KVC/KVO를 구동하므로 이러한 기술에 사용되는 클래스의 경우, 꼭 집합 변경자 메소드를 구현해주도록 하자.

참고자료

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

코코아 바인딩은 뷰와 데이터 모델을 양방향으로 “묶어서(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으로 선언된 프로퍼티여야 한다.