본문 바로가기

CS/디자인패턴

[디자인패턴] 메멘토 패턴

Memento 패턴을 설명하는 가장 기본적인 그림
인터페이스 기반의 Memento 패턴 구현 방식 ex) 타입스크립트 환경

 설명

캡슐화를 위배하지 않으면서 특정 객체의 상태를 따로 실체화해 둠으로써 나중에 객체의 상태를 복원할 수 있게 한다. 보통 다음과 같은 동작이 요구된다.

  1. 상태를 이전으로 되돌릴 수 있음
  2. 상태 객체에 대한 접근은 데이터에 대한 원 객체만 가능해야 함

 어떤 프로그램은 undo(되감기) 기능을 요구하며, 이를 위해서는 현재 객체의 상태를 따로 분리하여 저장해둬야 한다. 이때 분리된 객체 메멘토가 가지고 있는 상태는 원 객체의 이전 정보를 담고 있으므로 (1) 원 객체만 상태 정보에 접근할 수 있어야 하며, (2) 중간에 악의적인 사용자가 마음대로 이전 상태 정보를 수정하여 반영할 수 있어서는 안 된다. 이를 위해서는 원 객체에서만 메멘토에 접근할 수 있게 하는 구현이 요구된다.

 전체적인 메멘토 패턴의 동작 순서는 다음과 같다.

  1. 원 객체(Originator)의 상태가 변한 후 적절한 시점에 메멘토를 관리하는 Caretaker 객체가 원 객체에게 스냅샷을 요청한다.
  2. 원 객체가 만든 스냅샷이 Caretaker 객체의 적절한 자료구조에 의해 저장된다.
  3. 사용자가 연산을 취소할 때 Caretaker에 저장된 메멘토 객체를 꺼내 원 객체에게 돌려준다.
  4. 원 객체는 받은 메멘토 객체를 이용하여 기존 상태로 되돌린다.

 메멘토 패턴을 구현의 요점은 원 객체만이 메멘토 객체에 접근할 수 있도록 보장하는 데 있다. 이를 위해서는 원 객체만이 메멘토 객체를 생성할 수 있어야 하는데, 언어 차원에서 캡슐화를 위해 지원하는 기능이 서로 다르기 때문에 자연스럽게 구현 방식도 크게 달라진다. 몇 가지 언어에서 사용되는 구현 방식은 다음과 같다

  1. C++: 메멘토 클래스의 생성자와 메서드를 private로 지정하여 외부의 생성 및 접근을 막고, Originator을 friend 키워드로 등록하여 접근을 캡슐화한다.
  2. java: 생성자와 메서드를 private로 지정하되 Originator 클래스 내에 메멘토 클래스를 public으로 정의하여(inner class) Originator에서만 접근할 수 있도록 만든다.
  3. typescript: 내용을 최대한 숨기기 위해 메멘토 클래스의 상태 정보를 가져오기 위한 인터페이스를 따로 정의한다. Originator은 해당 인터페이스를 기반으로 외부와 통신한다. 외부에서 구체적인 메멘토 클래스 접근을 제한하여 생성을 통한 상태 스냅샷의 갱신을 막지만, 상태에 대한 접근은 인터페이스에 정의되므로 막기 힘들다.

 위 경우 이외에 다양한 구현 방식이 있겠지만, 일반적으로 (1) Memento 클래스는 공개하되, 내부 요소는 private / protected로 막고 (2) 언어에서 지원하는 기능을 통해 Originator 객체에만 공개하는 형식을 따른다. 거의 대부분의 언어는 (1)을 지원하기 때문에 Memento 객체를 숨기는 것에는 큰 문제가 없으나, (2)가 존재하지 않는 경우가 있다. 이 케이스에 속하는 경우 위 typescript 부분에서 언급한 것처럼 인터페이스를 이용하여 현재 패턴의 요구사항을 일부 만족할 수는 있다.

협력 방식

  •  Caretaker(보관자) 객체는 Originator(원 객체)에게 스냅샷(Memento)을 요구한다. 원 객체는 스냅숏을 생성하여 보관자에게 넘긴다. 이때 보관자 자체만으로는 스냅숏을 생성하거나, 스냅숏의 상태에 접근할 수 없다(private).
  • 원 객체의 상태를 이전으로 되돌려야 하는 경우 보관자에 저장된 스냅샷을 원 객체에게 넘긴다. 원 객체 내부에서는 스냅숏을 이용하여 자신의 상태를 변경할 수 있다.
  • 메멘토 객체는 원 객체의 메시지에 따라 동작하므로 수동적인 모습을 보인다.

결과

  1. 캡슐화 보장: 메멘토라는 클래스로 상태를 분리했음에도 원 객체에서만 접근이 가능하므로 캡슐화가 보장된다. 
  2. Originator 클래스 단순화: 스냅샷에 해당하는 메멘토 객체 관리 책임을 Caretaker이라는 별개의 클래스로 분리하므로 원 객체는 단순한 구조를 가지게 된다.
  3. Originator 클래스가 많은 정보를 저장하거나 메멘토가 빈번하게 반환되는 경우 오버헤드로 작용한다. 만약 Originator 클래스의 상태의 보관 및 복구를 위한 비용이 높다면 메멘토 패턴을 사용하면 안 된다.
  4. typescript의 경우처럼 어떤 언어에서는 원 객체만이 메멘토에 접근하도록 캡슐화하는 게 어려울 수 있다.
  5. Caretaker가 메멘토를 관리(저장, 제거 등)할 때 적잖은 비용이 요구될 수 있다.

구현 시 고려사항

  1.  언어가 지원하는 기능에 따라 메멘토 패턴이 보장하는 캡슐화 정도가 달라질 수 있다. 예를 들어 C++은 friend 키워드를 이용하여 외부에서 Memento 객체를 생성하거나 상태에 접근하는 행위를 모두 막을 수 있다. 반면 적절한 기능이 없는 경우 인터페이스를 이용하여 메멘토 패턴을 구현해야 하는데, 이 경우 객체 생성은 막을 수 있으나 외부에 getState 메서드는 공개되므로 외부에서 상태를 관측하는 것은 가능한 구조가 된다.
  2. Memento 객체에 현재 상태 전체를 복사하는 것이 아니라, 변경된 부분만 저장하는 구조도 고려할 수 있다.

예시 코드

 여태까지 작성했던 typescript 코드만으로는 메멘토 패턴이 요구하는 캡슐화를 완전히 구현할 수 없으므로, 이번에는 자바 언어로 작성된 코드를 함께 준비했다. 우선 일반적인 구조에 대한 코드이다.

자바 코드

//Originator 및 Memento 클래스
package pak;

public class Originator {
    private String state;

    public Originator(String state) {
        this.state = state;
    }

    public Memento createMemento(){
        return new Memento(this.state);
    }

    public String getState() {
        return this.state;
    }

    public void setState(String state) {
        this.state = state;
    }

    public void restore(Memento memento) {
        this.state = memento.getState();
    }

    public static class Memento {
        private final String state;

        private Memento(String state) {
            this.state = state;
        }

        private String getState() {
            return this.state;
        }
    }
}

// Caretaker 클래스

package pak;

import java.util.Deque;

public class Caretaker {
    private Deque<Originator.Memento> mementos;
    private Originator originator;
    public Caretaker(Originator originator) {
        this.originator = originator;
    }

    public void backup() {
        mementos.add(originator.createMemento());
    }

    public void undo() {
        if(!mementos.isEmpty()) {
            final var memento = mementos.pop();
            originator.restore(memento);
        }
    }
}

 자바의 경우 inner class을 이용하여 메멘토 패턴을 구현한다. Memento 클래스는 Originator 클래스 내부에 선언되어 있다. 이때 Memento 클래스의 필드 및 메서드는 모두 private으로 지정되어 Originator 외부에서는 조작할 수 없다.

 여기서 중요한 부분은 Memento 클래스 자체는 public으로 선언되어 있다는 점이다. 외부에 어떤 기능도 노출하지는 않지만, Memento 객체가 존재한다는 사실은 알려야 Caretaker에서 관리할 수 있기 때문이다. Caretaker 입장에서는 Memento를 조작 또는 생성할 수 없으며 단순히 보관만 가능한 구조이다.

 Memento 클래스는 일반적으로 불변이므로 위 예시에서는 state을 final 키워드를 이용하여 read only 필드로 설정했다. 그러나 구현에 따라 Memento 클래스의 상태를 변경하는 예시도 존재하므로, 상황에 따라 유연하게 판단하자.

 각 클래스는 다음과 같은 메서드를 구현하고 있다.

  • Originator
    • createMemento: 현재 상태에 대한 메멘토를 생성하는 메서드. Caretaker에게 스냅숏을 제공할 때 사용된다.
    • restore: 기존 메멘토 객체를 기반으로 현재 상태를 복원한다.
  • Memento
    • getState: Origiator가 restore 메서드를 실행했을 때 기존 상태를 복원할 수 있도록 getter을 제공한다.
  • Caretaker
    • backup: Originator의 createMemento를 호출하여 얻은 스냅샷을 내부 자료구조에 저장하기 위한 메서드
    • undo: 외부에서 Originator의 상태 복구를 요구할 때 사용하는 메서드

//코드 출처: https://www.youtube.com/watch?v=_Q5rXfGuyLQ

package pak2;
/**
 * Originator에 대응되는 클래스
 */ 
public class TextArea {
    private String text;

    public void setText(String text) {
        this.text = text;
    }

    public String getText() {
        return this.text;
    }

    public Memento takeSnapshot() {
        return new Memento(this.text);
    }

    public void restore(Memento memento) {
        this.text = memento.getSavedText();
    }
    /**
     * Memento에 대응되는 클래스
     * */
    public static class Memento {
        private final String text;

        private Memento(String text) {
            this.text = text;
        }

        private String getSavedText() {
            return this.text;
        }
    }
}

package pak2;

import java.util.Deque;

/**
 * Caretaker에 대응되는 클래스
 */
public class Editor {
    private Deque<TextArea.Memento> history;
    private TextArea textarea;

    public Editor(TextArea textArea) {
        this.textarea = textArea;
    }

    public void write(String text) {
        textarea.setText(text);
        history.push(textarea.takeSnapshot()); // 스냅샷을 찍어서 저장
    }

    public void undo() {
        if(!this.history.isEmpty())
            textarea.restore(this.history.pop());
    }
}

 위 코드는 앞에서 설명한 Memento 패턴에 대한 java 예시다. Editor을 이용하여 텍스트를 작성하는 코드로, 앞서 제시한 클래스 별 메서드를 충실하게 구현한다. 특징은 Caretaker에 해당하는 Editor 클래스에서 write 메서드를 통해 값을 입력받아 textArea 및 memento를 갱신하고 있다는 점이다.

타입스크립트 코드

 타입스크립트는 friend 키워드도, inner class도 지원하지 않으므로 인터페이스 기반 구현 방식을 채택한다. Memento 객체를 생성한 후 꼭 필요한 기능만 인터페이스에 노출하고 나머지는 모두 감춰 외부에서의 생성을 막는다. 

// TextStuff.ts 파일

/**
 * Originator에 대응되는 클래스
 */
export class TextArea {
    private text: string = '';

    setText(text: string) {
        this.text = text;
    }

    getText() {
        return this.text;
    }

    // 외부에 구체적인 메멘토 객체를 노출하지 않기 위해 Memento 인터페이스로 반환.
    createMemento(): Memento { 
        return new History(this.text); // 내부에서는 구체적인 메멘토 클래스 사용
    }

    restore(memento: Memento) {
        this.text = memento.getSavedText();
    }
}

/**
 * Memento 클래스를 캡슐화하기 위한 인터페이스
 */
export interface Memento {
    getSavedText(): string;
}

/**
 * 구체적인 Memento 클래스.   
 * export 없이 현재 파일 내에서만 접근 가능하도록 유도
 */
class History implements Memento {
    constructor(
        private readonly text: string
    ) { }

    getSavedText(): string {
        return this.text;
    }
}

// Editor.ts 파일

import { Memento, TextArea } from "./TextStuff";
/**
 * Caretaker에 대응되는 클래스
 */
export class Editor {
    private readonly mementos: Memento[];
    private textArea: TextArea;
    
    constructor(textArea: TextArea) {
        this.mementos = [];
        this.textArea = textArea;
    }

    write(text: string) {
        this.textArea.setText(text);
        this.mementos.push(this.textArea.createMemento());
    }

    get canUndo() {
        return this.mementos.length > 0;
    }

    undo() {
        if(this.canUndo) {
            this.textArea.restore(this.mementos.pop()!);
        }
    }
}

 자바 코드와 달라진 점은 Memento 인터페이스가 생겼다는 점이다. 타입스크립트에서는 Memento 클래스의 생성자 및 메서드를 private로 지정하면 외부에서 접근할 방법이 존재하지 않는다. 다행히도 export을 하지 않는 요소는 해당 모듈(파일)에 종속되는 특징이 있으므로 이런 특성을 인터페이스와 조합하여 어느 정도까지는 메멘토 패턴을 구현할 수 있다.

 위 코드에서 중요한 부분은 TextArea의 createMemento 부분이다. 해당 메서드는 내부에서는 History라는 구체적인 메멘토 객체를 생성하지만, 외부에는 인터페이스인 Memento로 노출한다. 이를 통해 외부에서 History 객체를 접근할 수 없도록 제한한다. 아래 코드에서는 위 구현을 실행한다.

import readline from 'readline/promises';
import { Editor } from './editor/Editor.js';
import { TextArea } from './editor/TextStuff.js';

const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
    terminal: false // 입력 값 터미널에 출력하는거 막기
});

export async function main() {
    let buffer: string = "   ";
    const textArea = new TextArea();
    const editor = new Editor(textArea);
    console.log("Read While you write nothing...");

    do {
        buffer = await rl.question('> ');
        if(buffer.length > 0) {
            editor.write(buffer);
        }
    } while(buffer.length > 0);
    rl.close();

    while(editor.canUndo) {
        editor.undo();
        console.log(textArea.getText());
    }
    // console.log(textArea.getText());

    const memento = textArea.createMemento();
    interface T {
        new (...args:any): any
    }
    const ctor = memento.constructor as T;

    const newMemento = new ctor("hello");
    console.log(newMemento.getSavedText());

}

 위 코드에서는 nodejs의 readline 모듈을 이용하여 사용자로부터 문자열을 입력받는다. 만약 사용자가 문자열 입력을 멈추면 상태를 되감으면서 여태까지의 상태를 역순으로 출력한다. 결과는 다음과 같다.

출력된 결과

  코드의 아랫부분에서는 textArea의 createMemento 메서드로 메멘토 객체를 얻은 후 해당 객체의 생성자를 추출하여 History 객체를 생성, 내용을 출력하는 모습을 보여주고 있다. 이는 자바스크립트에서 모든 객체의 부모인 Object에 constructor이 정의되어 있어 객체를 만들 때 사용된 생성자를 추출할 수 있기 때문에 가능하다. 생성자는 자바스크립트의 분류 상 Function 타입에 속하므로 name 속성을 가지는데, 이를 이용하여 현재 객체의 생성자 이름까지도 알 수 있다. 

 이때 TextArea의 restore 메서드는 public로 지정되어 있으므로 생성자 메서드를 추출하여 외부에서 생성한 Memento 객체를 삽입하여 상태를 변경할 수 있기 때문에 아쉽게도 캡슐화의 장점은 퇴색된다. 이것도 방어할 수 있는 방법이 있기는 하다. History 객체를 생성한 후 해당 객체의 'constructor' 프로퍼티를 null로 설정하는 것이다.

// TextArea의 메서드 수정.
// defineProperty를 이용하여 constructor을 null로 초기화.
// 특정 상황에서 에러가 발생할지도 모름
// 외부에 구체적인 메멘토 객체를 노출하지 않기 위해 Memento 인터페이스로 반환.
createMemento(): Memento {
    const history = new History(this.text); // 내부에서는 구체적인 메멘토 클래스 사용
    Object.defineProperty(history, 'constructor', { // 생성자를 null로 정의
        value: null
    });

    return history;
}
    
// main 함수 내 변경사항. ctor === null인 경우도 포함하여 가정하고 있음.
const ctor = memento.constructor as T;
if(ctor != null) {
    const newMemento = new ctor("In fact, we can make History class");
    console.log(newMemento.getSavedText());
    console.log(`the name of ctor is ${ctor.name}`);
} else {
    console.log("cannot get constructor of memento");
}

실행 결과

 이 방식을 따르면 createMemento에서 생성된 History 객체가 constructor 정보를 손실하여 외부에서 History 객체를 생성할 수 없다. 다만 자바스크립트 객체의 동작 방식을 거스르는 느낌이 있어서 개인적으로는 거시기하다.

 위와 같은 측면에서 보면 타입스크립트 환경에서 메멘토 패턴에 요구하는 캡슐화를 달성하기는 쉽지 않다. 자바스크립트로 컴파일하면 private, readonly, 인터페이스 같은 개념이 손실된다는 점을 고려하면, 메멘토 패턴의 "캡슐화" 기능보다는 "상태 관리" 측면이 절실한 상황에 사용하는 것이 맞다고 본다.