[Objective-C] NSInvocation 과 Forward

forwards

Undo/Redo 기능을 구현할 때, NSUndoManager의 -prepareWithInvocationTarget: 메소드를 사용해서 undo 시 호출할 메소드의 정보를 기록하는 것은 이전 글에서 잠깐 살펴보았다. 근데 코드에 좀 이상한 부분이 있지 않던가?

다시 짧은 예제를 보도록 하자.

-(void)getHeigher {
    [[undoManager prepareWithInvocationTarget:self] getLower];
    self.height -= 1.0;
}

-(void)getLower {
    [[undoManager prepareWithInvocationTarget:self] getHigher];
    self.height += 1.0;
}

위 메소드는 객체의 height 값을 1씩 올리고 내리는 기능을 수행하면서 undo 를 지원하도록 undo manager에게 역기능을 하는 메소드를 호출할 것을 알려주게 된다. 여기서 사용된 prepareWithInvocationTarget: 메소드는 NSUndoManager의 인스턴스 자신을 리턴한다. 결국 저 구문은 [undoManager getLower]를 호출하게 되는 셈인데… 아니, undoManager에는 -getLower 메소드가 없는데 호출한다? 그리고 요상하게도 undo를 실행하면 저 메소드가 진짜로 실행된다?

이 원리를 이해하려면 두 가지 개념을 이해할 필요가 있다. 하나는 command 디자인 패턴의 Objective-C 구현인 NSInvocation과 다른 하나는 흔치 않게 사용되는 forward라는 Objective-C의 기능이다.

NSInvocation

command 디자인 패턴은 객체가 수행해야 하는 동작 자체를 동결건조시켜 객체로 만든다는 것을 골자로 하고 있다. 즉, 어떤 메소드에 실행에 필요한 정보를 객체에 담아두면, 이는 다른 객체와 마찬가지로 어딘가에 저장해두었다가 꺼내 쓸 수도 있고, 다른 곳으로 전달해서 나중에 실행할 수도 있다는 것이다. 나중에 필요한 시점에 직접 호출하면 되지 않겠냐고 생각할 수도 있지만, undo 구현을 볼 때 매번 되돌리기를 할 때 수행해야 하는 동작을 코드 상에서 일일이 기억하고 정리해두기란 쉽지 않은 탓에 유용하게 사용할 수 있는 방법이다. (애플 개발자 문서 중 코코아 디자인 패턴에서도 이 패턴을 가장 먼저 소개하고 있다.) 특히, Objective-C에서는 객체의 메소드를 호출하는 것을, “객체에게 메시지를 보낸다”라고 표현하고 있는데 이런 표현을 자연스럽게 받아들이다보면 이 command 디자인 패턴이 대수롭지 않게 여겨질 수 있다.

NSInvocation은 파운데이션 프레임워크에서 제공하는 "메시지"를 저장할 수 있는 객체 타입이다. 여기에는 "셀렉터"의 형태로 전달된 메소드(=메시지)와 메시지를 받을 타깃, 그리고 메시지에 넘겨질 파라미터들을 모두 저장할 수 있다. NSInvocation에 저장된 메시지는 다른 곳으로 전달되거나 나중에 실행하기 위해서 남겨질 수 있으며, 심지어 타깃을 변경하여 실행하는 것도 가능하다.

Objective-C의 메소드들은 내부적으로 정수값으로 인식된다.1 셀렉터에 대한 데이터 형은 SEL로 정의되어 있으며, SEL 타입을 객체의 프로퍼티로 선언하는 것도 가능하다.

invocation 객체는 크게 두 가지 면에서 중요한 용도를 갖는데, 하나는 앞서 언급한 것처럼 메시지를 저장하여 나중에 실행할 수 있도록 보관하는 것이다. 여기에는 메소드 실행에 필요한 모든 정보 (타깃, 메소드의 셀렉터, 넘겨지는 모든 인자값들)가 포함되어 있으므로, 이러한 정보를 모두 나중을 위해 유지, 관리하는 것보다 편리하다.

또 다른 invocation의 용도는 '위임'이다. invocation 객체를 넘긴 후, 이 객체에게 -invoke 메시지만 보내면 담겨있는 해당 메소드가 호출된다. 이는 타깃, 메소드, 인자값이 될 모든 정보를 넘겨주는 것보다도 훨씬 편리하다. 그리고 다른 객체로 "메소드 호출을 위임2"할 수 있는 매커니즘이 구현 가능해 지는데, 이 글에서 살펴보고자 하는 것은 바로 이 위임에 대한 것이다.

forward(2)

만약 어떤 객체에게 객체에 정의되지 않은 메시지를 보내면 어떻게 될까? 컴파일시에는 메소드의 정의가 발견되지 않았다는 경고가 표시될 것이고, 실행해보면 인식할 수 없는 셀렉터가 보내졌다면서 예외를 뱉어내게 된다. 이 과정을 Objective-C 런타임이 처리하는 방식으로 살펴보자.

객체가 자신이 인식할 수 없는 메시지를 받게 되면 즉시 오류를 뿜어내는 것은 아니다. 런타임은 객체가 받은 메시지를 정의하고 있지 않을 때, 객체에게 이 메시지를 어떻게 처리할 것이냐고 묻고 최종 처리를 할 수 있는 기회를 다시 한 번 주게 된다. 이 것은 이 객체에게 -forwardInvocation: 메시지를 보내는 것으로 처리된다.

대부분의 클래스는 이 메소드를 오버라이드하지 않으므로 통상 이 처리는 최상위 클래스인 NSObject에게까지 거슬러 올라가게 된다. NSObject는 디폴트로 이 메시지를 받으면 doesNotRecognizeSelector: 메소드를 호출하면서 invocation의 셀렉터 정보를 넘겨 예외를 발생시킨다.

만약, 어떤 객체를 디자인하면서 이 객체의 forwardInvocation: 메소드를 오버라이딩하여 다른 객체로 하여금 받은 메소드를 처리할 수 있도록 한다면, 마치 자신이 구현하고 있는 것처럼 보일 수 있는 것이다.

또한 NSProxy 객체를 사용하는 것도 이와 같은 방식으로 실제 객체를 통해서 처리하게 된다.

forwardInvocation: 메소드를 사용하기 위해서는 methodSignatureForSelector: 메소드를 함께 오버라이드 해야 한다. 런타임은 forwardInvocation:을 호출하기 전에 이 메소드를 호출해서 전달하려는 메시지 정보를 invocation 객체로 만들고 이를 forwardInvocation: 메소드의 인자로 넘긴다.

다음의 간단한 예를 보자.

@interface MyObject : NSObject
@property (strong, nonatomic) id surrogate;
@end

@implementation
@synthesize surrogate;

-(NSMethodSignature*)methodSignatureForSelector:SEL aSelector {
    NSMethodSignature *sig;
    sig = [super methodSignatureForSelector:aSelector];
    if(!sig) {
        sig = [self.surrogate methodSignatureForSelector:aSelector];
    }
    return sig;
}

-(void)forwardInvocation:(NSInvocation*)anInvocation {
    if ([self.surrogate respondsToSelector:[anInvocation selector]]) {
        [anInvocation invokeWithTarget:self.surrogate];
    } else {
        [super forwardInvocation:anInvocation];
    }
}

MyObject는 별다른 고유의 메소드가 없다. 대신 surrogate 프로퍼티를 통해 객체를 하나 소유하게 되는데, 만약 NSObject에서 정의하지 않은 메시지를 받게 된다면, surrogate가 해당 메소드에 응답할 수 있는지를 확인하고, 만약 대리인 객체가 이를 처리할 수 있다면 해당 invocation 객체의 타깃을 바꾸어 invoke한다. urrogate 객체가 처리할 수 없다면 부모 클래스의 메소드를 호출한다. (그리고 부모도 이를 처리하지 않는다면 NSObject까지 거슬러 올라가면서 예외가 발생될 것이다.)

다중 상속과 포워딩

포워딩은 단순히 객체가 자신이 처리하지 못하는 메소드를 다른 객체에 위임하여 처리하도록 하는 것이지만, 이는 다중상속과 유사한 개념으로 확장될 수 있다. 기본적으로 Objective-C는 다중 상속을 허용하지 않지만, (물론 프로토콜을 통해서도 어느 정도 커버할 수는 있다.) 포워딩을 통해서는 다른 클래스의 객체로 전달하는 방식으로 상속받은 것과 유사한 동작을 수행해 낼 수 있다. 이는 뒤에서 다시 프록시를 통한 예제에서 살펴보기로 하자.

NSUndoManager의 포워딩 구현

정확한 소스를 확인하지는 못했으나, 대략 아래와 같은 방식으로 처리하게 될 것 같다.

  1. -prepareWithInvocationTarget: 메시지를 받으면 target을 내부의 프로퍼티에 저장한다.
  2. 이후 받게 되는 target 객체의 메소드는 undo manager가 처리할 수 없으므로, forwardInvocation: 이 호출된다.
  3. 여기서 invocation 객체의 타깃을 'target' 프로퍼티로부터 구해 지정해준다.
  4. 이 'invocation' 객체를 내부 프로퍼티인 _undoStack에 밀어넣는다.

이제 이해가 좀 가는지? undo 매니저는 되돌릴 메소드를 가지고 있는 타깃 객체를 내부 스택에 함께 저장한다. 그리고 이렇게 저장된 객체는 unde/redo 액션을 실행할 때 역동작할 메시지의 target으로 바꿔치기 된다.

NSMethodSignature

methodSignature에 대해서도 잠깐 짚고 넘어가자. 메소드 시그니처는 메소드의 형식과 관련한 일종의 인코딩된 정보이다. 이 정보에는 메소드의 반환값과 모든 파라미터들의 타입에 대한 정보가 담겨있다.

이 시그니처값을 통해서 invocation 객체를 만드는데는 +invocationWithMethodSignature:클래스 메소드를 사용하면 된다. 이때 필요한 메소드 시그니처는 다시 NSObject의 methodSignatureForSelector:메소드를 통해서 구할 수 있다.

그외에도 시그니처를 만드는 방법에는 +signatureWithObjCTypes:가 있는데 이는 인코딩된 데이터타입 값으로 시그니처 정보를 생성해 낸다.

또한 invocation 객체를 만들 때는 메소드의 셀렉터, 리턴타입, 파라미터의 갯수 및 각 파라미터의 타입등을 토대로 만들게 되는데, 이 때 리턴타입과 파라미터타입들은 내부적으로 인코딩된 값으로 처리한다. 그리고 invocation 객체는 vargs (printf 와 같이 가변 인자를 사용하는 함수)를 통해서는 만들 수 없으며, C 공용체(union)를 파라미터로 받는 경우에도 만들 수 없다.

인코딩

컴파일러가 런타임을 위해 각 데이터 타입을 C문자열배열로 축약하여 정보를 생성하는 것이 인코딩이다. 이는 명시적으로 @encode() 지시어를 써서 인코딩을 할 수 있다. 예를 들면 char 타입은 *로, int 타입은 i로 인코딩 된다.

char *e1 = @encode(float[12]);

는 실수형 배열을 인코드하는데 결과는 [12^f] 가 된다. 배열은 대괄호로 둘러싸여지며, 그 속에는 원소의 갯수와 타입이 나열된다. 즉 12개의 원소를 갖는 float 형 포인터 라는 의미이다.

typedef struct example {
    id anObject;
    char *aString;
    int anInt;
} Example;

char *2 = @encode(Example);

위 코드는 구조체를 인코드하는데, 구조체는 중괄호 속에 고구조체 태그명과 멤버 변수들을 각각 인코딩한 문자열을 넣어주게 된다. 따라서 인코딩한 결과는 {example=@*i} 가 된다. 만약 Expample *을 인코딩하면 ^{example=@*i}가 되겠지? 각각의 타입이 인코딩 되는 결과는 다음 목록을 참고하도록 하자.

  • c : char
  • i : integet
  • s : short
  • l : long
  • q : long long
  • C : unsigned char
  • I : unsigned int
  • S : unsigned short
  • L : unsigned long
  • Q : unsigned long long
  • f : float
  • d : double
  • B : C++의 bool 이나 C99의 _Bool
  • v : void
  • * : char *
  • @: id (Objective-C 객체)
  • # : Class
  • : : selector
  • [array type] : 객체. 내부에 원소의 개수와 원소의 타입을 기재
  • {name=type...} : 구조체. 내부에 구조체 태그명과 멤버의 타입을 기재
  • (name=type...) : 공용체. 태그명과 각 타입을 기재
  • b숫자 : 숫자값만큼의 비트 필드
  • ^타입 : 타입의 포인터
  • ? : 알 수없는 타입. 함수 포인터에 사용된다.

특히 객체는 구조체로 취급되기 때문에 NSObject 의 클래스를 인코딩하면 {NSObject=#}으로 인코딩된다.

Proxy

여기까지 메시지 포워딩에 대해서 살펴보았는데, 마지막으로 이 기능을 가장 많이 활용하는 부분인 Proxy 객체에 대해서 살짝 살펴보자. 프록시 객체는 실체가 없는 (사실은 실체가 있지만) 객체로 일종의 placeholder 처럼 동작하는 객체이다.

이 객체는 유일하게 NSObject 로부터 상속받지 않는 클래스이기도 하다. NSProxy는 init 메소드를 지원하지 않으므로, 이 클래스를 서브 클래싱하면 반드시 초기화 메소드를 구현해야 하고, 이 때 [super init]을 호출할 수 없다. 다음은 프록시 객체를 하나 생성해서, 배열과 문자열 객체의 특성을 동시에 보이도록 하는 예제이다.

#import <Foundation/Foundation.h>

@interface MyProxy : NSProxy
{
    id realObj1, realObj2;
}
-(id) initWithTarget:(id)target1 target2:(id)target2
@end

@implementation MyProxy
-(id)initWithTarget:(id)target1 target2:(id)target2
{
    realObj1 = [target1 retain];
    realObj2 = [target2 retain];
    return self;
}

-(void)dealloc
{
    [realObj1 release];
    [realObj2 release];
    [super dealloc];
}

-(NSMethodSignature*)methodSignatureForSelector:SEL aSelector {
    NSMethodSignature *sig;
    sig = [realObj1 methodSignatureForSelector:aSelector];
    if(!sig) {
        sig = [realObj2 methodSignatureForSelector:aSelector];
    }
    return sig;
}

-(BOOL)respondsToSelector:(SEL)aSelector {
    if( [realObj1 respondsToSelector:aSelector]) return YES;
    return [realObj2 respondsToSelector:aSelector];
}

-(void)forwardInvocation:(NSInvocation*)anInvocation {
    id target = [realObj1 methodSignatureForSelector:[anInvocation selector]] ? realObj1 : realObj2;
    [anInvocation invokeWithTarget:target];
}
@end

int main(void) {
    @autoreleasepool {
        NSMutableArray *a = [NSMutableArray array];
        NSMutableString *s = [NSMutableString string];
        MyProxy *m = [[MyProxy alloc] initWithTarget1:a target2:b];

        [m appendString:@"This "];
        [m addObject:s]
        [m appendString:@"works"];

        NSLog(@"%d", [m count]);
        NSLog(@"%d", [m length]);
        NSLog(@"%@", [m objectAtIndex:0]);

        [m release];
    }
    return 0;
}

이 예제는 프록시 객체에 실제 객체 2개를 할당해준 다음, 받게되는 메시지에 대해 우선 기본적으로 첫번째 실제 객체가 이를 처리할 수 있으면 이 객체로 하여금 처리하도록 하고, 그렇지 않은 경우에 두 번째 객체에게 이를 처리하도록 한다. 따라서 하나의 객체가 NSMutableString 과 NSMutableArray에 정의된 메소드들을 모두 처리할 수 있도록 한다. 이는 마치 이 객체가 실체 객체 2개를 동시에 상속받은 것 같은 효과를 준다. 이를 이용해서 실제로는 언어에서 지원되지 않는 복수 클래스 상속을 흉내낼 수 있다.


  1. 셀렉터는 메시지를 지칭하는 고유의 정수값이며, @selector 지시어를 사용하여 메소드 이름을 셀렉터 값으로 변환할 수 있다. 
  2. 문맥상, '위임'은 'delegation'에 대한 번역이 더 적합할텐데, Objective-C는 메소드를 메시지라고 표현하므로, forward 라는 표현을 쓴다. 실제로 invocation객체를 포워딩하는 것이나 메시지를 포워딩하는 것은 개념 상 거의 유사한 동작이라고 볼 수 있어서, 애초에 Objective-C 언어 디자인시에 이러한 패턴을 염두에 둔 것은 아니었을까 하고 추측해 본다.