[Cocoa] Undo/Redo 구현하는 법

Undo 구현하기

코코아앱 (Foundation 명령줄 도구에서도 사용할 수 있다)에서 실행취소(Undo) 기능을 구현하는 방법에 대해 알아보자. 실행 취소를 구현하는 방법은 “이전 상태를 그대로 저장”하였다가 되돌리는 방법을 생각해 볼 수 있는데, “이전 상태”에 대한 명확한 범위를 정하기가 생각보다 쉽지 않다. 또한, 앱이 나중에 확장, 변형된다면 “상태”를 매번 업데이트해야 하고 이는 관리나 유지보수 측면에서도 그리 바람직한 방법이 아니다.

객체지향적인 접근방법은 데이터나 상태에 변경이 가해지는 작업을 객체로 만드는 것이다. 어떤 데이터나 상태를 변경하고 이에 되돌리기를 적용하기 위해서는 그와 반대되는 동작이 정의되어 있다고 보는 것이다. 따라서 특정 상태를 변경하는 명령을 수행하기 전에 변경 이전의 값으로 되돌리는 액션을 객체로 만들어 어딘가 저장해 두었다가, 되돌릴 때는 이 명령을 실행하면 되는 것이다.

코코아의 Undo/Redo는 이런 접근방법을 사용하고 있다. 데이터에 변경이 가해질 때마다, 매 단계의 변경을 되돌릴 수 있는 명령을 객체로 만들고 이들을 스택에 쌓아두었다가 되돌리기를 할 때 하나씩 꺼내서 실행한다. 그러면 여태껏 가한 변경 사항을 역순으로 하나씩 되돌릴 수 있게 된다. 이런 작업은 코코아의 NSUndoManager에 의해 수월하게 처리할 수 있다.

Undo Manager

“취소하기”, “되돌리기” 등의 표현이 있지만, Undo/Redo의 표현을 한글로 옮기기가 좀 부족한 감이 있어 Undo, Redo, Undo Manager 등의 표현은 그대로 사용합니다.

코코아에서는 이러한 undo/redo 작업의 관리를 위해서 NSUndoManager라는 클래스를 제공한다. 이 클래스는 내부적으로 두 개의 스택을 가지고 있다. 하나는 undo stack, 다른 하나는 redo stack이다.

주요 매커니즘은 다음과 같다.

  1. 기본적으로 undo / redo 가 가능한 액션은 반대로 작용하는 액션이 존재하는 기능이다. 어떤 값을 더했다면, 반대로 빼는 액션이 있어야한다.
  2. 어떤 객체의 상태값은 보통 프로퍼티로 설정되고, 결국 프로퍼티에 대한 값 변경은 setter 메소드를 통해서 이루어 지게 된다. 이 때 setter 메소드에 undo manager에 반대 액션 혹은 원래 값으로 되돌릴 수 있는 액션을 지정해서 등록해준다.
  3. 2의 방법으로 차곡차곡 변경사항이 저장된다.
  4. Undo를 실행하면, 차곡차곡 쌓인 “변경사항을 되돌리는 액션”을 하나씩 꺼내서 실행하면 된다. undo stack에 쌓인 액션들은 최근에 실행한 순서대로 쌓이므로 이를 하나씩 수행해주면 단계별로 이전 상태로 되돌아 갈 수 있다.

응 의외로 간단한데? 여기에는 “메소드의 실행을 캡슐화하여 객체로 만든다”는 개념이 들어간다. 어떤 객체가 수행해야 할 명령 자체를 객체로 만들고 이를 나중에 실행할 수 있도록 보관하는 게 핵심 아이디어이다. 이러한 아이디어를 구현한 클래스로는 NSInvocation 이나 NSOperation 같은 것들이 있는데, 실제로 NSUndoManager는 내부적으로 NSInvocation으로 역방향 작업을 냉동건조하여 보관하게 된다.

그럼, Undo를 취소하는 Redo는 어떻게 구현하면 될까? Undo Manager가 undo 작업을 수행할 때, undo stack에서 작업을 하나씩 꺼내어 실행하게 되는데, 이 때 작업 역시 “역으로 수행하는 작업”이 있을 것이다.(왜냐면 이 작업이 특정 작업의 역방향 작업이니까) NSUndoManager는 이 “역의 역방향” 작업을 다시 객체로 만들어서 redo stack에 저장한다. 결국 Redo와 Undo를 계속 반복하면, undo manager는 이 두 스택에서 계속 작업을 꺼내서 실행하고 반대쪽 스택에 넣는 일을 수행해준다. 이렇게 생각하면 간단하다.

심플한 버전

가장 심플한 버전은 NSUndoManager-registerUndoWithTarget:selector:object:를 사용하는 것이다. 이 메소드는 undo manager에게 실행해야할 메소드와 이 메시지를 받을 객체, 그리고 전달되는 값을 감싸는 객체를 넘겨주고, undo stack에 작업을 등록하도록 한다. 다음 코드는 그러한 간단한 예인데, 그 전에 몇 가지 참고해야 할 점이 있다.

undo manager에게 undo/redo 작업을 관리하도록 위임하는 객체를 클라이언트라고 하는데, 보통은 객체들을 관리하는 컨트롤러가 클라이언트가 되기도 하고, 때로는 모델 객체 스스로가 클라이언트가 되기도 한다. 이들 예제에서는 모델 객체 스스로가 클라이언트가 된다.

클라이언트가되는 객체는 NSUndoManager의 인스턴스를 프로퍼티나 인스턴스 변수로 가지게 된다.(소유한다.)

그럼 다음 코드를 보자. 객체가 내부 속성을 변경하는 일종의 접근자인데, 되돌리기를 지원할 수 있도록 한다.

-(void)setMyObjectTitle:(NSString)newTitle {
    NSString currentTitle = [myObject title];
    if (newTitle != currentTitle) {
        [self.undoManager registerUndoWithTarget:self selector:@selector(setMyObjectTitle:) object:currentTitle];
        [self.undoManager setActionName:NSLocalizedString(@"Title Chage", @"title undo")];
        [myObject setTitle:newTitle];
    }   
}

이 코드는 객체의 타이틀 속성을 변경할 때, “지금 타이틀값을 맡겨둘테니 Undo 시에 이 값으로 타이틀을 변경해주세요”라고 위탁한 다음, 새 타이틀로 자신의 타이틀을 변경한다. 후에 Undo 요청을 받으면 undoManager는 지정된 객체(현재 객체 자신)에게 지정된 메시지(셀렉터로 전달된 현재 메소드)를 지정된 객체(되돌리기 시 들어와야 할 현재 타이틀 값)를 주고 실행하게 된다. 즉 이 작업 직후에 undo가 수행되면 title 속성은 기존 값으로 되돌려진다. (그리고 undo manager는 redo에 되돌리기 이전 값으로 타이틀을 넣을 수 있도록 준비를 한다.)

이 방법은 “한 번에 단 하나의 변경”만을 수행할 수 있다는 단점이 있다. (물론 넘겨 받는 인자를 NSDictionary 같은 걸로 만들 수도 있지만, 너무 불편하지 않을까.)
조금 복잡한(?) 방법

이번에는 덜 단순한, 조금 복잡한 방법을 보자. 이 방법은 invocation-based undo 라고 한다. 처음 언급한대로, 현재 상태를 반대로 되돌리는 명령을 등록해두는 것이다.

-(void)setMyObjectWidth:(CGFloat)newWidth height:(CGFloat)newHeight {
    float currentWidth = [myObject size].width;
    float currentHeight = [myObject size].height;
    if( (newWidth != currentWidth) || (newHeight != currentHeight)) {
        [[self.undoManager prepareWithInvocationTarget:self] setMyObjectWidth:currentWidth height:currentHeight];
        [self.undoManager setActionName:NSLocalizedString:(@"Size change", @"size undo")];
    [myObject setSize:NSMakeSize(newWidth, newHeight)];
    }
}

NSUndoManager는 전달받은 정보를 통해 NSInvocation 객체를 만들고 이를 undo stack에 등록한다.

여기서 중요한 점! [undoManager prepareWithInvocationTarget:self]는 undoManager 자체를 리턴한다. 그런데 -setMyObjectWidth:heght:는 self의 메소드로, undoManager는 이 메소드를 호출할 수 없다. (클래스에 정의된 바가 없으니깐) 그런데 어째서 이게 문법적으로 문제가 없다는 것일까?

코코아 런타임이 제공하는 메시지 포워딩이 여기서 활용된다. NSObject는 기본적으로 정의되지 않은 메시지를 받으면 알 수 없는 셀렉터를 받았다는 예외를 던지게 되는데, 그 직전에 이 메시지를 다른 객체로 포워딩할 수 있는 기회를 얻게 된다. 즉 -forwardInvocation: 메시지를 받게 된다. 그럼 어떤 클래스는 이 메소드를 오버라이드 하면서 자신이 알고 있는 다른 객체에게 메시지를 다시 전달해 줄 수 있다. (NSObject는 이 메소드에서 예외를 일으키도록 되어 있을 것이다.)

- (void)forwardInvocation:(NSInvocation *)anInvocation 
{
    [anInvocation invokeWithTarget:self.delegate];
}

위 코드는 알 수 없는 메시지를 받았을 때 자신의 델리게이트에게 이 메시지를 실행하라고 전달해 주는 효과를 낸다. 간단하지?

-forwardInvocation:에 인수로 전달되는 액션은 NSInvocation 객체로 만들어지는데, 이 객체를 만드는 것은 런타임에 의해서 수행된다. 또한 런타임이 invocation 객체를 만들기 위해서는 메시지를 수신하는 객체에 -methodSignatureForSelector:이 오버라이드 되어 있어야 한다. 이 메소드는 NSMethodSignature를 리턴하는데 이는 호출할 함수의 타입, 인자개수와 타입들을 나타내는 인코딩 정보이다. 이들에 관계에 대해서는 별도의 포스팅에서 다루도록 하자.

이렇게 invocation을 사용하면 어떤 메소드 호출이라는 명령 자체를 캡슐화하여 보관할 수 있게 된다. 그리고 undo manager는 이 캡슐화된 명령을 undo stack에 쌓아두게 된다. 그리고 되돌리기를 수행하면 캡슐화된 명령들이 최근 것부터 차례로 호출되고 단계별로 데이터의 상태가 되돌려진다. 물론 이 때 되돌리기로 부터 호출된 메소드도 ‘역변환 동작을 undo manager에 등록’하게 되는데, 이 때는 undo manager가 이를 redo stack에 쌓게된다.

이렇게 undo/redo의 구현 원리는 조금 복잡해 보이나, 사용하는 입장에서는 undo manager에게 되돌리기할 때 호출되어야 할 메소드를 알려만 주면 된다는 점에서 큰 애로사항은 없을 것이다.