Wireframe

[Objective-C] NSInvocation 과 Forward

코코아 앱의 Undo/Redo 기능을 구현하는 부분을 보면, NSUndoManager-prepareWithInvocationTarget: 메소드를 사용해서 Undo 동작시에 호출될 메소드를 기록해두는 코드가 있다. 그런데 이 메소드의 원형을 보면 좀 이상한 부분이 있다. 실제로 Undo를 지원하는 기능을 구현하고 있는 간단한 코드를 보자.

-(void) getHigher {
    // undo 관리자에 실행취소를 위한 동작을 등록
    [[undoManager prepareWithInvocationTarget:self] getLower];  
    self.height -= 1.0;
}

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

-prepareWithIvocationTarget: 메소드는 자기자신, 즉 NSUndoManager의 인스턴스를 리턴한다. 그런데 이 되돌리기 관리자에게 getLower 를 호출한다?

메시지 포워딩

위 예제의 각 메소드는 속성값을 변경하면서, 되돌리기 기능을 지원하도록 undo 관리자에게 역작용에 해당하는 메소드를 호출해야 한다는 것을 알려준다. 그런데 어떻게 이게 작동할 수 있는지 이해가 쉽게 되지 않는다. 이 과정을 이해하려면 두 가지 개념에 대한 이해가 필요하다. 먼저 command 디자인 패턴의 Objective-C 버전인 NSInvocation 클래스이고 다른 하나는 ‘메시지 포워딩’이라는 Objective-C의 기능이다.

Commnad Pattern : NSInvocation

Command 디자인 패턴은 어떤 객체가 수행해야 하는 동작에 관한 정보를 객체로 만드는 것을 말한다. 어떤 객체에 대해서 호출할 메소드와 그 인자들을 객체로 만들어두고, 나중에 이 정보를 사용해서 실행하거나, 다른 곳으로 전달해서 필요한 곳에서 실행하도록 하는 것이다. 물론 나중에 직접 호출하면 되지 않겠냐고 할 수도 있겠지만, 필요한 위치나 시점에서는 해당 메소드를 가지고 있는 객체에 대한 참조점이 없을 수도 있고, 대상이 되는 객체가 매번 달라지는 상황에 일일이 대처하기는 어려울 수도 있을 것이다. 이 패턴은 코코아에서 사용되는 디자인 패턴을 소개하는 애플 개발자 문서에서도 가장 먼저 소개하고 있을만큼 중요하다.

특히 Objective-C에서는 객체의 메소드를 호출하는 것을 “객체에게 메시지를 보낸다”고 표현하는데, 이러한 관점에서 Command 패턴은 아주 특이하고 특별한 것으로 볼 필요도 없는 것 같다.

NSInvocation 클래스는 Command 패턴을 위해 디자인된 클래스로 “셀렉터(셀렉터는 객체의 메소드 이름을 정수값으로 변환한 값이며, SEL 이라는 타입으로 표현한다.)”라는 타입으로 표현되는 메시지와 메시지를 받을 타깃 객체, 그리고 메시지와 함께 전달되는 파라미터들을 모두 저장하게 된다. 심지어 이들은 동적으로 변경할 수 있기 때문에 필요에 따라서는 메시지를 받을 타깃을 변경하여 호출할 수도 있다.

Command 패턴을 사용하는 상황으로는 ‘위임’을 예로 들 수 있다.

포워딩

Objective-C에서는 객체가 어떤 메시지를 받았을 때 다음과 같이 동작한다고 예상할 수 있다.

그런데 두 번째 케이스는 생각보다 살짝 복잡한 과정을 거치게 된다. 객체가 자신은 알 수 없는, 즉 정의되지 않은 메시지를 받았다면 이 메시지를 어떻게 처리할 것인지를 결정하는 과정을 거치게 된다. 이 과정에서 해당 셀렉터에 대해서 다른 함수를 링크하여 새로운 메소드를 동적으로 추가할 수도 있고, 아예 다른 객체에게 메시지를 전달해버릴 수도 있다. 기본적으로 NSObject는 이러한 동작에 대해서는 아무 일도 하지 않으며, -doesNotRecognizeSelector: 메소드를 호출하고 여기서 예외가 발생한다.

알 수 없는 메시지를 처리하는 과정에서 메시지를 포워딩할지를 결정하는 단계는 -forwardInvocation: 메소드를 호출하는 부분에 해당한다. 따라서 어떤 클래스를 작성할 때 이 메소드를 적절하게 오버라이드하면 특정한 셀렉터에 대해서는 자신이 아닌 다른 객체가 이를 실행하도록 동작을 위임할 수 있게 되는 것이다. 그리고 이 기능을 사용하기 위해서는 호출된 메시지는 NSInvocation 인스턴스로 만들어진다.

메시지를 포워딩하기 위해서 NSInvocation 인스턴스로 만드는 과정은 Objective-C 런타임에서 처리한다. 단, 이 과정에서 실제 셀렉터의 타입 시그니처 정보가 필요하다. 이를 위해 런타임은 methodSignatureForSelector: 메소드를 호출할 것이다. 따라서 이 메소드 또한 오버라이딩하여 적절히 구현해주어야 한다. 보통은 위임받는 객체에게 같은 메시지를 전달하여 물어보면 된다.

다음 예제에서 메시지 포워딩을 가능하게 하는 방법을 제시한다.

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

@implemetation MyObject
@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];
    }
}

NSUndoManager의 경우

소스가 공개되어 있지 않아서 정확한 내용은 알 수 없지만 대략 다음과 같은 방식으로 작동할 것으로 보인다.

  1. -prepareWithInvocationTarget: 메시지를 받으면 여기서 전달된 타깃을 self.surroagte 등의 프로퍼티에 할당하고 self를 리턴한다.
  2. [self getLower];에 의해 -getLower 메시지를 받게 되는데, 이 메시지는 undo 매니저가 알 지 못한다. 따라서 포워딩 매커니즘이 발동하고, forwardInvocation: 이 호출된다.
  3. Undo 관리자의 -forwardInvocation: 에서는 전달받은 invocation의 타깃을 self.surrogate로 변경하고, 이를 내부의 스택에 저장한다.
  4. 사용자가 되돌리기 기능을 호출하여 Undo 관리자가 -undo 메시지를 받는다. 그러면 내부 invocation 스택에서 하나를 꺼내 -invoke를 호출한다. 그러면 최근에 작동한 되돌리기 가능한 기능의 역기능이 순서대로 실행된다.

이해가 되시는지? 이렇게하여 NSUndoManager는 생각보다 간단한 코드로 구현되어 있을 것이다.

프록시

이러한 Command 패턴이 적극적으로 사용되는 또 다른 예로는 프록시를 들 수가 있다. 프록시는 일종의 객체에 대한 Placeholder 라 이행할 수 있다. 자기 자신은 실제로 아무런 처리를 하지 않으면서 수신한 메시지들을 다른 객체들에게 전달해주는 역할을 한다. 코코아에서 프록시는 NSProxy 클래스를 사용하여 구현되는데, 이 클래스는 유일하게 NSObject로부터 상속받지 않는 클래스이다. 자체적인 초기화 메소드를 제공하지 않으므로 직접 작성하여야 한다.

다음 예제는 프록시 객체를 통해서 하나의 객체가 배열로도, 문자열로도 작동하는 것처럼 보이게 하는 것이다.

#import <Foundation/Foundation.h>

@interface MyProxy: NSProxy
{
    id realObj1, realObj2;
}
-(id)initWithObj1:(id)obj1 obj2:(id)obj2;
@end

@implemetation MyProxy

-(id)initWithObj1:(id)obj1 obj2:(id)obj2
{
    realObj1 = [obj1 retain];
    realObj2 = [obj2 retain];
    return self;
}

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

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

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

-(void)dealloc
{
    [realObj1 dealloc];
    [realObj2 dealloc];
    [super dealloc];
}
Exit mobile version