(한글) Key-Value Observing at runtime
9 min read

(한글) Key-Value Observing at runtime

이 글은 Mike Ash가 개인 블로그에 올린 내용을 번역한 것이다.

KVO가 뭔데?

이 글을 읽고 있는 대부분이 이미 뭔지는 알겠지만, 줄여서 설명하면 KVO는 Cocoa Bindings를 가능하게 하고 객체들에 변화가 생겼을때 알려줄수 있는 기능을 제공한다. 한 객체가 다른 객체의 키를 관찰한다. 관찰하는 객체가 그 키에 해당하는 값을 바꾸면 관찰자는 알림을 받는다. 상당히 간단하지 않은가? 여기서 어려운 부분은 KVO는 대부분의 상황에서 관찰하려고 하는 객체나 관찰을 하고 있는 객체에 추가적인 코드 수정이 필요없다는 것이다.

요약

어떻게 하면 관찰을 하는 객체에 코드를 추가하지 않고 이걸 가능하게 할수 있을까? 정답은 Objective-C의 런타임이다. 특정 클래스의 객체를 처음 관찰하려고 할때, KVO는 런타임에서 해당 클래스를 상속하는 새로운 클래스를 생성한다. 그 새로운 클래스에서는 관찰되어야하는 키들의 set 함수를 override한다. 그 뒤에, 해당 객체의 isa 포인터(Objective-C 런타임에서 특정메모리가 어떤 클래스인지 알려주는 포인터)를 바꿔서 객체를 새로운 클래스로 변환시킨다.

관찰자들에게 알림을 주는 일은 이 override된 set 함수들에서 일어난다. 특정 키의 값이 변화하려면 해당 key의 set함수가 호출되어야 하기 때문이다. Override된 set함수는 이 동작을 중간에 가로채서 notification을 보낸다. (물론 객체의 변수를 직접 바꾼다면 key의 set함수 호출 없이도 값을 바꿀수 있을것이다. KVO를 사용하는 클래스는 이런 동작을 하지 않던가, ivar 접근에 notification호출을 직접 추가해야 한다).

근데 여기서 더 어려운점이 있다: 애플은 이런 로직을 굳이 알리고 싶어 하지 않는다. Setter와도 별개로 변환된 클래스들의 -class 함수도 기존의 클래스를 반환해야 할것이다. 굳이 자세하게 보지 않는다면 KVO를 사용하는 객체와 사용하지 않는 객체의 차이점을 눈치채기 쉽지 않을 것이다.

더 자세히 보자

KVO의 동작을 보여주는 코드를 한번 짜보았다. 동적 KVO 클래스들은 자신의 존재를 숨기기 때문에, 이 예제에서는 Objective-C 런타임 함수들을 사용해서 더 많은 정보를 확인한다.

    // gcc -o kvoexplorer -framework Foundation kvoexplorer.m
    
    #import <Foundation/Foundation.h>
    #import <objc/runtime.h>
    
    
    @interface TestClass : NSObject
    {
        int x;
        int y;
        int z;
    }
    @property int x;
    @property int y;
    @property int z;
    @end
    @implementation TestClass
    @synthesize x, y, z;
    @end
    
    static NSArray *ClassMethodNames(Class c)
    {
        NSMutableArray *array = [NSMutableArray array];
        
        unsigned int methodCount = 0;
        Method *methodList = class_copyMethodList(c, &methodCount);
        unsigned int i;
        for(i = 0; i < methodCount; i++)
            [array addObject: NSStringFromSelector(method_getName(methodList[i]))];
        free(methodList);
        
        return array;
    }
    
    static void PrintDescription(NSString *name, id obj)
    {
        NSString *str = [NSString stringWithFormat:
            @"%@: %@\n\tNSObject class %s\n\tlibobjc class %s\n\timplements methods <%@>",
            name,
            obj,
            class_getName([obj class]),
            class_getName(obj->isa),
            [ClassMethodNames(obj->isa) componentsJoinedByString:@", "]];
        printf("%s\n", [str UTF8String]);
    }
    
    int main(int argc, char **argv)
    {
        [NSAutoreleasePool new];
        
        TestClass *x = [[TestClass alloc] init];
        TestClass *y = [[TestClass alloc] init];
        TestClass *xy = [[TestClass alloc] init];
        TestClass *control = [[TestClass alloc] init];
        
        [x addObserver:x forKeyPath:@"x" options:0 context:NULL];
        [xy addObserver:xy forKeyPath:@"x" options:0 context:NULL];
        [y addObserver:y forKeyPath:@"y" options:0 context:NULL];
        [xy addObserver:xy forKeyPath:@"y" options:0 context:NULL];
        
        PrintDescription(@"control", control);
        PrintDescription(@"x", x);
        PrintDescription(@"y", y);
        PrintDescription(@"xy", xy);
        
        printf("Using NSObject methods, normal setX: is %p, overridden setX: is %p\n",
              [control methodForSelector:@selector(setX:)],
              [x methodForSelector:@selector(setX:)]);
        printf("Using libobjc functions, normal setX: is %p, overridden setX: is %p\n",
              method_getImplementation(class_getInstanceMethod(object_getClass(control),
                                       @selector(setX:))),
              method_getImplementation(class_getInstanceMethod(object_getClass(x),
                                       @selector(setX:))));
        
        return 0;
    }

자, 위에서부터 한 줄씩 보자.

먼저, 변수 3개를 가지고 있는 TestClass를 선언한다. (KVO는 @property 로 선언 되지 않은 변수에도 작동을 하지만, 이 방식이 setter와 getter를 선언하기 가장 간편하다.)

그 뒤에 몇개의 유틸리티 함수를 선언한다. ClassMethodNames 는 Objective-C 런타임 함수를 사용해서 클래스가 가지고 있는 모든 함수를 반환한다. 여기선 해당 클래스가 가지고 있는 함수만 반환하고 부모 클래스에 선언된 함수는 반환을 하지 않는다. PrintDescription 는 제공된 객체의 모든 정보를 출력한다.

이제 진짜 실험을 시작한다. 4개의 TestClass 객체를 생성하고, 각기 다른 방식으로 관찰을 한다. x 객체는 x 변수가 관찰될 것이고, yy, 그리고 xy는 두 변수 모두 관찰한다. z 키는 비교를 위해 관찰하지 않는다. 마지막으로 control 객체는 비교를 위해서 아무런 키도 관찰하지 않는다.

이제 이 4개의 객체들의 정보를 출력한다.

그 다음에는 조금 더 나아가서, override된 setter의 실제 구현부의 주소를 확인한다. 하지만 methodForSelector 함수는 override된 함수를 보여주지 않기 때문에 Objective-C 런타임을 사용해서 실제 값을 확인한다.

결과

결과를 한번 보자:

control: <TestClass: 0x104b20>
    NSObject class TestClass
    libobjc class TestClass
    implements methods <setX:, x, setY:, y, setZ:, z>
x: <TestClass: 0x103280>
    NSObject class TestClass
    libobjc class NSKVONotifying_TestClass
    implements methods <setY:, setX:, class, dealloc, _isKVOA>
y: <TestClass: 0x104b00>
    NSObject class TestClass
    libobjc class NSKVONotifying_TestClass
    implements methods <setY:, setX:, class, dealloc, _isKVOA>
xy: <TestClass: 0x104b10>
    NSObject class TestClass
    libobjc class NSKVONotifying_TestClass
    implements methods <setY:, setX:, class, dealloc, _isKVOA>
Using NSObject methods, normal setX: is 0x195e, overridden setX: is 0x195e
Using libobjc functions, normal setX: is 0x195e, overridden setX: is 0x96a1a550

첫번째로 control 객체이다. 예상했던 대로 그 객체는 TestClass 타입을 가지고 있고, 우리가 선언한 6개의 함수를 가지고 있다.

다음은 3개의 각기 다른 변수를 observe한 객체들이다. -class 는 여전히 TestClass 클래스를 반환하고 있지만, object_getClass 를 사용하면 객체의 실제 값을 확인할수 있다: NSKVONotifying_TestClass 타입을 가지고 있다. 저게 동적으로 생성된 클래스이다!

보면 2개의 observed setter가 있다. 이건 좀 흥미로운데 KVO가 -setZ: 도 setter인데도 불구하고 아무도 관찰하지 않았기에 override하지 않았기 때문이다. 우리가 만약에 z에도 observer를 추가하면 NSKVONotifying_TestClass-setZ: 함수도 생성했을 것이다. 추가적으로 3개 객체 모두 같은 클래스를 바라보고 있고, x와 y에 대한 override를 가지고 있다. x와 y객체는 한개의 변수만 관찰하고 있지만, KVO의 효율성에 의해 다른 변수들을 관찰하고 있어도 한 클래스에 대한 subclass는 하나만 생성이 된다. 객체마다 다른 subclass가 생성되었다면 키를 관찰하는게 부담이 될게 분명해서, 이건 올바른 것 같다.

다음으론 3개의 다른 함수들이 있다. 동적 클래스의 존재를 숨기기 위한 -class 함수가 존재한다. 객체 소멸을 위해 -dealloc 함수가 존재한다. 그리고 -_isKVOA 라는 신기한 함수가 존재하는데, 애플 내부 코드에서 해당 객체가 동적으로 생성된 하위 클래스인지 확인하는 함수인듯 싶다.

그 다음줄에서는 -setX: 의 구현부를 출력한다. -methodForSelector: 는 두 객체에 대해 같은 값은 반환한다. 생성된 동적 클래스에는 해당 함수의 override가 존재하지 않는데, -methodForSelector: 함수는 -class 를 내부적으로 사용하기 때문에 이렇게 틀린 값을 반환하는 것 같다.

다음은 Objective-C 런타임을 사용해서 구현부를 출력하는데, 여기서는 좀 다른 점이 보인다. 첫번째 함수는 (당연하지만) 이전의 -methodForSelector: 와 같은 값을 반환하는데 두번째 함수는 완전 다른 값을 반환한다.

자 그러면 디버거를 사용해서 두번째 함수가 뭔지 좀 더 자세히 살펴보자:

(gdb) print (IMP)0x96a1a550
$1 = (IMP) 0x96a1a550 <_NSSetIntValueAndNotify>

Obervser notification을 담당하는 내부 함수인듯 하다. Foundation의 nm -a 를 사용해서 이 모든 내부 함수들을 확인해볼수 있다:

0013df80 t __NSSetBoolValueAndNotify
000a0480 t __NSSetCharValueAndNotify
0013e120 t __NSSetDoubleValueAndNotify
0013e1f0 t __NSSetFloatValueAndNotify
000e3550 t __NSSetIntValueAndNotify
0013e390 t __NSSetLongLongValueAndNotify
0013e2c0 t __NSSetLongValueAndNotify
00089df0 t __NSSetObjectValueAndNotify
0013e6f0 t __NSSetPointValueAndNotify
0013e7d0 t __NSSetRangeValueAndNotify
0013e8b0 t __NSSetRectValueAndNotify
0013e550 t __NSSetShortValueAndNotify
0008ab20 t __NSSetSizeValueAndNotify
0013e050 t __NSSetUnsignedCharValueAndNotify
0009fcd0 t __NSSetUnsignedIntValueAndNotify
0013e470 t __NSSetUnsignedLongLongValueAndNotify
0009fc00 t __NSSetUnsignedLongValueAndNotify
0013e620 t __NSSetUnsignedShortValueAndNotify

위에 리스트에 신기한 것 몇가지가 있다. 첫번째론 애플이 모든 primitive변수들에 대해 하나씩 함수를 구현해 놓았다. Objective-C 객체를 위해선 _NSSetObjectValueAndNotify 만 필요하지만, 다른 모든건 기본 타입을 위해서 있다. 근데 또 보면 long double 이나 _Bool 같은 타입을 위한 함수가 없다. 추가적으로 제네릭 포인터 타입을 위한 함수도 없다. Cocoa 구조체를 위한 함수가 몇개 있긴 하지만, 내부적으로 구현되지 않은 함수가 수십개는 될것 같다. 여기 나와있지 않은 타입들은 자동으로 KVO 알림을 받을수 없다는 뜻이니 사용에 유의하길 바란다!

KVO는 사용에 따라 매우 강력한 도구가 될수 있다. 그래도 이제 KVO가 내부적으로 어떻게 동작하고, 뭔가가 잘못되면 어떤것을 디버깅 해봐야할지 안다.

만약 KVO를 직접 앱에서 사용해보고자 한다면 내가 쓴 KVO 제대로 하기(Key-Value Observing Done Right) 글을 읽어보는걸 추천한다.