키밸류 옵저빙이란

키밸류 옵저빙

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

참고자료

(Cocoa) 날짜와 시간을 다루기

updated

Swift를 사용해서 Date를 다루는 방법은 새로 작성된 글을 참고하세요.

 

들어가며

날짜와 시간을 위한 프로그래밍을 위해서는 기본적으로 NSDate를 사용한다. NSDate는 2001년 1월 1일 자정을 기점으로 현재시간 (혹은 특정 시점)까지의 초단위로 경과한 시간을 저장하고 있는 객체이다.(Epoch가 아니라 21세기의 시작시점을 기준으로 하고 있다!) 이렇게 단순히 누적된 초 시간으로는 두 시점의 선/후 관계를 파악하는 등의 단순 비교 작업은 가능하지만, 구체적인 날짜나 요일에 연관된 작업을 하기는 매우 어렵다. 예를 들어 올해 크리스마스가 무슨 요일인지를 구하는 일은 NSDate 객체 만으로는 사실상 매우 힘들다.

(Cocoa) 날짜와 시간을 다루기 더보기

[OSX] 메인 윈도우 컨트롤러를 앱델리게이트에서 분리하기

iOS 앱의 경우에는 UIApplication 객체가 생성되면 해당 앱의 델리게이트가 런칭 직후의 일을 처리한다. 이 때 앱의 윈도우가 처음으로 표시할 초기 뷰 컨트롤러의 뷰를 윈도우에 보여주게 된다.

그 이후부터는 화면 단위로 각 뷰의 뷰 컨트롤러 및 여러 MVC의 컨트롤러와 여러가지 디자인패턴을 통해 상호작용하면서 앱을 구성하면 된다.

이에 비해 맥용 앱을 Xcode로 작성하고자 하면 이 “메인 컨트롤러”가 다름 아닌 앱 델리게이트 객체라는 점에서 좀 의아하게 생각된다. 즉, 메인 윈도우에 대해서는 별도의 컨트롤러가, 만약 또 다른 윈도를 생성한다면, 역시나 해당 윈도에 대해서는 별도의 컨트롤러가 있어야 하는 것 아니겠냐는 것이다.

이런 화면 단위마다 별도의 MVC를 구성하는 방법은 다음과 같다.

  1. 먼저 인터페이스 빌더 상에 초기에 생성된 MainMenu에 들어있는 윈도는 삭제한다.
  2. 메인 윈도우 뷰 컨트롤러로 쓸 객체를 새로 만든다. MainWindowController 정도?
  3. MainWindowController 객체를 MainMenu 파일에 추가한다. (NSObject를 추가해서 클래스 이름 변경)
  4. nib 파일을 새로 하나 생성한다. 이름은 MainWindow가 되면 되겠다.
  5. IB에서 새로 만든 메인 윈도우 nib의 File’s owner를 새로 생성한 윈도우 컨트롤러로 지정한다. 또한 이 인터페이스 파일의 윈도우를 MainWindowController의 window 아울렛과 연결해준다.
그러면 다음과 같은 순으로 런칭이 시작된다.
  1. 어플리케이션이 런칭되면서 MainMenu.nib 파일을 로딩한다.
  2. nib 파일이 로드되면  nib 파일 내에 정의된 객체를 초기화하고, 이 객체들에게 모두 awakeFromNib 메시지를 보낸다. 따라서 우리가 만든 윈도우 컨트롤러 객체도 이 메시지를 받게 될 것이다.
  3. 윈도 컨트롤러 객체에서 이 메시지를 받으면 메인 윈도우가 들어있는 nib 파일을 로드하고, 윈도를 표시하도록 한다.
즉, 새로 생성한 메인윈도 컨트롤러의 인터페이스 파일에서 window 프로퍼티를 하나 만들어 준다.
@property (assign) IBOutlet NSWindow *window;
이 프로퍼티는 아래와 같이 사용할 수 있다. 이제 메인 윈도의 컨트롤러는 앱 델리게이트와 분리하는데 성공했다.
-(void)awakeFromNib
{
    if (!self.window) [NSBundle loadNib:@"MainWindow" owner:self];
    [self.window makeKeyAndFrontOf:self];
}

이제 앱을 빌드하고 실행해보면 해당 윈도우가 표시될 것이다.

키밸류 코딩이란

NSObject는 Objective-C의 표준 라이브러리라 할 수 있는 Foundation에서 가장 기본이 되는 최상위 클래스에 해당한다. 커스텀 클래스를 만들 때 아무 생각없이 상속받는 이 클래스는 Objective-C에서 클래스라는 것이 마땅히 갖추어야 하는 여러 가지 기능들을 미리 구현해둔 것이 아주 많이 있다. 그 중에서도 키밸류 코딩이라는 기술을 위한 기본적인 기능이 NSKeyValueCoding이라는 비정규 프로토콜에 정의되어 있고, NSObjects는 이를 따르고 있다. 따라서 몇가지 간단한 규칙을 지키면서 프로퍼티를 정의하기만 하면, 우리가 작성하는 모든 클래스의 프로퍼티들이 키밸류 코딩 호환이 될 수 있다. 그렇다면 키밸류 코딩은 무엇이고, 또 어떻게 활용되는 것인지에 대해서 살펴보자.

프로퍼티

키밸류 코딩은 어떠한 객체의 프로퍼티 값에 대해서 미리 정해진 접근자가 아닌 해당 프로퍼티의 이름 키를 사용해서 특정한 객체의 프로퍼티를 액세스하는 것을 말한다. 예를 들어서 어떤 클래스 Foo 에서 bar 라는 프로퍼티를 가지고 있다고 가정하고, 클래스 Foo를 작성하는 과정을 살펴보자. 먼저 Objective-C에서 어떤 클래스가 임의의 값을 저장하고 있으려면 그 값을 저장할 스토리지 변수가 필요하다. Objective-C의 클래스는 본질적으로 그 내부를 알 수 없는 불투명 구조체의 포인터이며, 구조체 내부의 멤버 변수는 인터페이스 선언부 최상단에 블럭을 사용해서 선언한다. 그리고 이렇게 선언된 멤버 변수는 외부와 완전히 격리되면서 외부에서는 액세스할 수 없고, 어떤 멤버 변수를 가지고 있는지 조차 알 수 없다. (이렇게 선언된 멤버 변수는 인스턴스 변수라 하고 흔히 ivar 라 지칭한다.) 따라서 이 변수에 값을 세팅하거나, 변수 값을 알아낼 수 있는 두 개의 메소드가 필요하다.

@interface Foo: NSObject
{
  NSString* _bar;
}
- (NSString*)bar;
- (void)setBar:(NSString*)newValue;
@end

Foo의 외부에서 해당 프로퍼티를 bar라는 이름으로 액세스하고, bar를 세팅하는 메소드를 -setBar:라고 이름붙였다. 멤버변수의 이름을 사실상 무엇이 되더라도 무관한데, 관습적으로는 getter의 이름과 똑같이 하거나 그 앞에 언더스코어를 붙인다. (언더스코어를 붙이는 이름이 멤소드 이름과 혼동을 줄이기 때문에 조금 더 권장된다.)

만약 이 bar라는 프로퍼티가 copy 시멘틱을 따른다고 하면, 두 메소드의 구현은 다음과 같이 작성될 것이다.

@implementation Foo
/// 초기화 시에 ivar를 초기화한다.
- (instancetype)init {
  self = [super init];
  _bar = nil;
}

- (NSString*)bar { return _bar; }
- (void)setBar:(NSString*)newValue]
{
  NSString* newBar = [[newValue copy] retain];
  [_bar release];
  _bar = newBar;
}
...
@end

즉 어떤 오브젝트가 그 내부에 어떤 값을 저장할 수 있고, 객체 외부에서 그 값을 액세스하려고 한다면 이 클래스는 다음의 세 가지 조건을 갖추어야 한다.

  1. 값을 저장할 수 있는 스토리지 변수
  2. 스토리지 변수를 액세스할 수 있는 getter 접근자
  3. 스토리지 변수를 업데이트할 수 있는 setter 접근자

만약 getter/setter 접근자가 모두 없는 경우라면, 해당 ivar는 클래스 내부에서만 참조할 수 있고, 외부에서는 액세스할 수 없는 값이 된다. 또 getter 메소드만 제공되는 경우라면, 객체 외부에서는 그 값을 getter 메소드를 통해서 읽을 수는 있지만 업데이트를 할 수 없는 읽기 전용의 값이 될 것이다. 이것이 Objective-C의 선언 프로퍼티의 핵심 내용이다.

따라서 어떤 클래스가 bar 라는 프로퍼티를 가지고 있다는 것은 그 프로퍼티가 -bar 혹은 -setBar: 라는 접근자 메소드를 가지고 있음을 의미한다. 그리고 그 객체에서 해당 프로퍼티를 액세스하는 것은 해당 접근자 메소드를 호출해야 하는 일이고, 따라서 객체로부터 어떤 값을 얻어와서 사용한다는 것은 “하드 코딩된 코드에서 미리 정해진 접근자 메소드를” 사용해야 한다는 것이다.

키밸류 코딩 – 문자열 기반 이름으로 동적인 프로퍼티 액세스

그런데, 임의의 객체 인스턴스 zoo 가 있다고 하자. 이 객체로부터 어떤 프로퍼티를 액세스해서 그 값을 얻으려고 한다. 그런데 어떤 프로퍼티를 가져올 것인지 혹은 갱신할 것인지가 컴파일 타임에 결정되지 않는다면 어떻게 해야 할까? 즉 “어떤 접근자 메소드를 호출할 것인지”를 코드를 작성하는 시점에 알 수 없는 것이다. “pee”라는 이름의 프로퍼티일 수도 있고, “tee”라는 이름의 프로퍼티 일수도 있는 것이다. 물론 객체 zoo가 이러한 접근자 메소드를 갖고 있는지 아닌지 여부조차 알 수 없을 수도 있다.

이처럼 컴파일 타임에 정의되지 않은 접근자 이름을 사용해서 런타임에 특정한 이름의 프로퍼티에 접근할 수 있는 기술이 키밸류 코딩이다. 키 밸류 코딩은 간단히 다음의 네 개의 메소드에 의존한다.

  • - (id)valueForKey:(NSString*)key / -(id)valueForKeyPath:(NSString*)keyPath
  • - (void)setValue:(id)obj forKey:(NSString*)key / - (void)setValue:(id)obj forKeyPath:(NSString*)keyPath

이 메소드들은 NSObject에 의해서 이미 구현되어 있다. 이 메소드들을 호출하여 성공적으로 특정한 프로퍼티에 액세스하기 위해서는 처음에 프로퍼티를 정의할 때, ivar와 접근자 메소드들의 이름이 중요하다.

  • valueForKey: 에서 키이름이 getter 메소드와 같거나
  • 키 이름과 동일한 ivar 혹은 앞에 언더스코어가 붙은 키 이름의 ivar가 있다.
  • setValue:forKey:는 키 이름을 첫글자를 대문자로 바꾸고 그 앞에 set-을 붙인 setter 메소드가 있다.

이러한 가정을 두고 있는 것이다. 만약 [zoo getValueForKey:@"bar"] 라고 했을 때,   zoo 가 Foo의 인스턴스라면, 이 메시지는 Objective-C 런타임 내부에서 [zoo bar] 로 번역될 것이다. 그리고 [zoo setValue:@"hello" forKey:@"bar"]라는 메시지를 받는다면 이는 다시 [zoo setBar:@"hello"];로 변경되어 호출될 것이다.1

키밸류 코딩을 따르는 방법

키밸류 코딩 호환 클래스를 작성하는 방법은 간단하다. 키밸류 코딩은 결국 키 이름을 기반으로 그에 매칭되는 접근자 메소드 및 인스턴스 변수를 런타임이 동적으로 찾아서 액세스해주는 기술이기 때문에 어떤식으로 프로퍼티 이름을 짓느냐는 것만, 관습을 따르면 되며, 그 관습이란 앞서 소개한 Foo의 bar와 같다.

  • 기본적으로 getter 이름이 프로퍼티 이름이며, 이것이 곧 키 이다.
  • setter 이름은 setKeyName: 과 같은 식으로 작성한다. getter이름의 첫글자를 대문자로 바꾸고 앞에 set을 붙인다.
  • ivar 이름은 getter이름과 똑같거나, 앞에 언더스코어를 붙인다.

그리고 이 관습은 @property 문법을 쓰면 자동으로 지켜진다.

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

이상의 코드만으로 키밸류 코딩에서 요구하는 인스턴스변수, getter 메소드, setter 메소드를 모두 작성한 것과 다름없는 결과를 얻을 수 있다. 이것은 언어의 기능이라기보다는 컴파일러가 소스코드를 처리하기 직전에 자동으로 관련 코드를 만들어서 삽입해준다고 보면 된다. (이전에는 @synthesize bar; 같은 구문을 구현부에 써야했는데, LLVM 컴파일러는 이런 처리도 모두 자동으로 해주기 때문에 굳이 쓸 필요없다.)

키밸류 코딩은 왜 중요한가

그렇다면 키밸류 코딩은 왜 중요한가? 이것은 특정한 프로퍼티가 변경될 때, 자동으로 옵저버들에게 통지가 가는 키밸류 옵저빙을 비롯하여, 이 기술을 기반으로 하고 있는 코코아 바인딩등에서 기본 가정으로 “모든 참여 객체가 KVC/KVO 호환이다”라는 것을 가정하기 때문이다.

  • 키밸류 코딩 이름 규칙을 지원하면 valueForKey:, setValueForKey:는 따로 구현하지 않더라도 자동으로 지원된다.
  • 키밸류 코딩 규칙을 따르더라도 _bar = @"hello";와 같이 인스턴스 변수를 직접 변경해버리면 이는 KVO와 호환되지 않는다.
  • KVO에서는 반드시 [foo setValue:@"hello" forKey:@"bar"]를 쓰지 않아도 된다. [foo setBar:@hello]라고만 써도, 런타임에서 자동으로 통지를 보낼 수 있다. self.bar = @"hello"; 역시 setter 메소드 호출과 1:1로 치환되므로 KVO 호환이 된다. 이는 KVC 호환인 메소드는 필요한 경우 런타임에 의해 자동으로 다른 내부 메소드로 치환되기 때문에 적용가능하다. 물론 메소드 이름이 정해진 규칙을 벗어나면 이러한 기능은 지원되지 않는다.

기본적인 키밸류 코딩은 특정한 단일 값 프로퍼티의 변경을 런타임에서 동적으로 관리하는 수준에서 적용된다. 하지만 Foundation에서는 배열이나 Set과 같은 집합형식 자료 구조에 대해서도 KVC/KVO를 지원한다. 이는 단순히 이름 규칙만으로는 지원될 수 없으며, 별도의 메소드들을 추가로 작성해주어야 하는데 (대부분 NSMutableArray, NSMutableSet의 메소드들 간단힌 래핑하는 수준의 구현이다.) 이를 지원하도록 하는 것은 다음 기회에 추가로 소개하도록 하겠다.

참고자료

 

 

 

 

 


  1.   물론 키밸류 코딩은 이렇게 간단한 일차원적 변환 이상의 것이다. 실질적으로 @property 문법이 확립되어 적용되기 이전부터 존재해온 기술이기 때문에 탐색 패턴은 좀 더 많은 경우를 순차적으로 따르게 된다.