[Cocoa] 키-밸류 옵저빙

전형적인 MVC 모델에서 컨트롤러는 모델 객체로부터 적절한 값을 받아오거나 (이 값으로 UI를 업데이트), 모델 객체의 어떤 값을 업데이트 하는 일(UI에서 값을 변경했을 때)을 하는 일종의 중개인 역할을 한다. 하지만 이는 앱의 한가지 측면에서의 관점일 수 있다. 앱의 기능이 많아지고 덩치가 커지면 두 개 이상의 MVC 캠프가 서로 연결되는 일도 일어날 수 있는 것이다. 따라서 다른 컨트롤러에 의해서 모델의 정보가 업데이트 되었을 때 이를 UI에 반영하기 위해서는 컨트롤러는 모델의 데이터가 변경되었음을 감지할 필요가 있다.

이를 위해 코코아에서는 notification이라는 개념을 사용한다. 특정한 객체가 내부의 변동 사항이 있을 때 이에 대한 내용을 마치 라디오 방송처럼 발송하면 이 ‘방송’을 수신하는 객체가 그 변화를 인지하는 것이다. 또한 델리게이트도 이와 비슷한 역할을 할 수 있다. 즉 모델에서 어떤 변화가 일어날 때 델리게이트에게 특정한 메시지를 보내 다른 처리를 할 수 있도록 하는 것이다.

하지만 이를 위해서는 모델 객체가 이러한 방송이나 델리게이트 메서드를 일일이 호출하도록 구현되어 있어야 하는데, 이는 매우 번거로운 일이다. 키-밸류 옵저빙은 프레임워크 수준에서 이러한 값에 대한 변경 사항을 추적하도록 해주는 특별한 매커니즘이다. 어떤 객체의 키 혹은 키패스에 대해 옵저버를 등록하면, 이 키가 변경될 때 자동으로 옵저버에게 통보가 되는 매커니즘이다.

특히 코코아 바인딩은 이런 키-밸류 옵저빙에 전적으로 의존하고 있으며, 단순히 모델값의 변화를 UI에 반영하고, 반대로 UI의 변경사항이 고스란히 모델 데이터에 반영되는 구조의 앱은 코코아 바인딩을 사용하면 코드 작성을 최소화할 수도 (심지어는 거의 한 줄의 코드도 작성하지 않고) 구현할 수 있다.

키-밸류 옵저빙

키-밸류 옵저빙이라는 이름은 “키-밸류 코딩”을 근간으로 하기 때문에 붙은 이름이다. 키-밸류 옵저빙을 사용하려면 다음 두 조건이 충족되어야 한다.

  • 옵저빙의 대상이 되는 객체는 키-밸류 코딩 규칙을 준수해야 한다. (키-밸류 옵저빙이니깐)
  • 해당 키의 값은 키-밸류 코딩에서 사용하는 방식으로 변경되어야 한다. 이는
    • setValue:forKey: 를 사용하거나
    • 접근자 메서드를 사용해서 변경된다.

즉, 관찰의 대상이 되는 객체의 키가 만약 인스턴스 변수라면, 이를 프로퍼티로 사용하여 변경해주거나, setValue:forKey:를 통해 변경해야 한다. 즉 someVar = 1; 이라는 대입문은 옵저버에게 통지를 보낼 수 없지만 self.someVar = 1; 이라고 쓴 구문은 옵저버에게 someVar의 값이 1이 되었다는 통지를 보낼 수 있는 것이다. ‘아무것도 아닌’ 키-밸류 코딩은 정말 사소해 보일 수 있지만 코코아 내에서는 상당히 큰 편의성을 제공해준다.

키-밸류 옵저빙을 사용하기 위해서는 두 가지 준비를 해줘야 하는데, 1) 먼저 대상이 되는 객체에 대해 옵저버를 등록해 주어야 하고, 2) 옵저버에서는 통지를 받았을 때, 각 키패스에 대해 처리해야 할 동작을 지정해 주어야 한다. 또한 관찰대상이 되는 객체가 해제되기 전에 등록된 옵저버를 해제해야 한다.

옵저버로 등록하기

특정 객체에 대해 옵저버를 등록하기 위해서는 그 객체에게 addObserver:forKeyPath:options:context: 메시지를 보내면 된다. 예를 들어 account 라는 객체의 lastModifiedDate라는 키를 변경할 때 통지를 받고자 한다면 다음과 같이 할 수 있다.

-(void)registerAsObserver {
  [account addObserver:sef
            forKeyPath:@"lastModifiedDate"
               options:(NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld)
               context:NULL];
}

옵저버로 등록할 때 별도의 정보를 담은 컨텍스트 객체 (혹은 C 포인터)를 함께 등록할 수 있다. 이는 추후에 활용할 수 있는데 그리 많이 사용할 일은 없을 것 같다.

변경에 대한 알림 받기

추적하는 키패스에 대해 변경이 발생할 때, 객체는 등록된 옵저버에 대해 observeValueForKeyPath:ofObject:change:context: 메시지를 보낸다. 이 때 ofObject는 변경이 발생한 객체 자신이며, change는 옵저버를 등록할 때 명시한 키밸류 옵저빙 옵션이다. 옵션에 따라서 변경되기 이전의 값과 변경된 이후의 값을 모두 받아볼 수 있게 된다.

따라서 위의 코드에 대응되는 account  클래스는 다음과 같은 코드를 구현해주어야 한다.

-(void)observeValueForKeyPath:(NSString *)keypath
                     ofObject:(id)object
                       change:(NSDictionary *)change
                      context:(void *)context
{
if ([keyPath isEqualToString @"lastModifiedDate"]) {
// 변경에 따른 해야 할 일
}
// 이 메소드는 수퍼클래스에 오버라이딩되었으므로, 여기서 원하는 추적값이 아닌 경우에는 이를 넘겨주어야 한다.
[super observeValueForKeyPath:keyPath
                       ofObject:object
                         change:change
                        context:context];
}

 키-밸류 옵저빙 옵션

키-밸류 옵저빙 옵션은 명시된 옵션에 대해서 change 객체에 반드시 포함될 키를 지정한다.

  • NSKeyValueObservingOptioinNew – change는 NSKeyValueChangeNewKey 키에 대해 변경된 새 값을 저장한다.
  • NSKeyValueObservingOptionOld – change에는 NSKeyValueChangeOldKey 키에 변경되기 이전 값을 저장한다.
이에 대한 자세한 설명은 NSKeyValueObserving 프로토콜 레퍼런스를 참고하면 된다.

키밸류 옵저빙 호환 여부

키밸류 옵저빙을 따르는 클래스는 다음과 같은 조건을 가진다.

  1. 키-밸류 코딩 규칙을 따를 것
  2. 관찰되는 키가 변경될 때 통지를 보낼 것
일반적으로 키-밸류 코딩 규칙을 잘 따르면 통지는 자동으로 보내진다. 인스턴스 변수를 관찰하는 경우에, 이 변수가 프로퍼티가 아니거나, 키밸류 코딩 문법에 의해 수정되지 못한 경우에는 수동으로 통지를 보내야 한다.
어쨌거나 키-밸류 코딩을 따르는 것은 거의 ‘별도의 신경을 쓰지 않고서도’ 키밸류 옵저빙을 가능하게 한다는 것이 중요하겠다.

인스턴스 변수 값의 변경과 키밸류 옵저빙

인스턴스 변수에 setValue:forKey: 를 사용하지 않고 바로 값을 대입하는 변경은 키-밸류 코딩을 따르는 방식이 아니므로 키밸류 옵저빙 통지를 보내지 않는다. 이 경우에는 다음과 같이 수동으로 통지를 보낼 수 있다. (이 때 someVar는 프로퍼티로 선언되지 않은 인스턴스 변수이다.)

-(void)setSomeVarToVale:(float)newValue {
    [self willChangeValueForKey:@"someVar"];
    someVar = newValue;
    [self didChangeValueForKey:@"someVar"];
}

 의존하는 키에 대해 추적하기

어떤 키들은 독립적으로 존재하기 보다는 다른 키에 의해 값이 변경될 수 있다. 예를 들면 fullName은 firstName 과 lastName의 조합인데, 이 둘 중 어느 하나가  변경되면 fullName도 변경된다. 이 경우 fullName은 인스턴스 변수가 없는 프로퍼티로 선언될 수 있다.

// Person.h
@property (nonatomic, readonly) NSString *fullName;

// Person.m
// @synthesize getter를 직접 구현하므로 synthesize는 생략해도 된다.
-(NSString *)fullName {
    return [NSString stringWithFormat:@"%@ %@",self.firstName, self.lastName];
}

하지만 실제로 firstName/lastName이 변경되어도 fullName에 대한 통지는 자동으로 보내지지 않는다. 이는 키-밸류 코딩 매커니즘에서 이 키가 다른 두 키에 의존적이라는 사실을 알지 못하기 때문인데, 이는 다음과 같이 정의해서 의존성을 명시할 수 있다. NSObject의 클래스메소드인 +keyPathForValueAffectingValueForKey:를 사용한다.

+(NSSet*)keyPathForValuesAffectingValueForKey:(NSString *)key
{
    NSSet *keyPaths = [super keyPathForValuesAffectingValuesForKey:key];
    if ([key isEqualToStrinig:@"fullName"]) {
        NSSet *affectingKeys = [NSSet setWithObjects:@"lastName", @"firstName", nil];
        keyPaths = [keyPaths setByAppendingObejctsFromSet:affectingKeys];
    }
    return keyPaths;
}

이렇게 특정 키 패스에 영향을 주는 다른 키 패스들을 세트로 지정하면, firstName  이나 lastName 중 하나가 변경될 때 fullName은 자동으로 변경되는 것으로 인식되고, 다른 객체에서 person의 fullName을 추적할 때, 변경에 대한 통지를 보낼 수 있게 된다.