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이 라는 점을 컴파일러는 알 수 있다.

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)
}

참고자료

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

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

참고자료

Objective-C의 선언 프로퍼티 (Declared Property)에 대해

Objective-C의 객체 인스턴스에 어떠한 변수 값을 포함하고자 한다면 클래스 내에 인스턴스 변수를 선언하고, 여기에 값을 저장할 수 있다. (흔히 애플 문서등에서는 이런 인스턴스 변수를 ivar라 한다.)

기본적으로 객체의 내부에서 선언되는 인스턴스 변수는 private하며 객체의 외부에서는 내부의 인스턴스 변수값에 액세스하는 것이 차단된다. 따라서 객체의 외부에서 인스턴스 변수의 값을 읽거나 쓰기 위해서는 클래스가 해당 인스턴스를 읽게하거나, 쓰게 해주는 API를 제공해야 한다. 이렇게 객체가 자신의 내부 속성값에 대해 읽거나 쓰게 하기 위해 제공하는 메소드를 접근자(accessor) 메소드라고 한다.

Objective-C의 선언 프로퍼티 (Declared Property)에 대해 더보기

키밸류 옵저빙이란

키밸류 옵저빙

키밸류코딩(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 관련된 내용에 대해서 많은 연습과 연구가 필요하다.

참고자료