[Design Pattern] 메멘토 패턴

olwooz·2023년 2월 27일
0

Design Pattern

목록 보기
17/22
객체의 구현 세부 사항을 공개하지 않으면서 객체의 이전 상태를 저장하고 복원할 수 있게 해주는 행동 패턴

문제

텍스트 에디터 앱 가정

  • 텍스트에 실행된 작업을 실행 취소하는 기능을 구현해야 하는 상황
    → 작업 실행 전에 앱이 모든 객체의 상태를 저장해 저장소에 저장하도록 하고, 실행 취소를 하면 기록에서 가장 최근 스냅샷을 가져와 모든 객체의 상태를 복원하기로 했음

  • 스냅샷 → 객체의 모든 필드를 살펴보며 저장소에 값들을 저장해야 함 → 대부분 실제 객체들은 중요한 데이터들을 private 필드들에 감춰 놓음

  • 객체들의 모든 상태가 public이라 가정 → 스냅샷 문제는 해결되지만, 필드 추가/제거나 에디터 클래스 리팩토링 등을 하면 이에 영향을 받는 객체들의 상태를 복사하는 클래스들도 바뀌어야 함

  • 스냅샷 - 최소한 실제 텍스트, 커서 좌표, 스크롤 위치 등 값을 컨테이너에 저장해야 함 → 에디터의 상태를 미러링하는 많은 필드들 보유

  • 다른 객체들이 스냅샷 데이터를 읽거나 쓸 수 있게 하려면 필드들이 public이여야 함 → 모든 에디터 상태가 노출됨

  • 다른 클래스들은 스냅샷 클래스의 모든 사소한 변경들에 의존하게 됨

  • 클래스들의 모든 세부 사항을 노출시키거나, 상태 접근을 제한해 스냅샷을 만들 수 없게 되거나, 둘 중 하나의 상황

해결책

위 문제들은 잘못된 캡슐화로 인해 발생

  • 몇몇 객체들은 자신이 해야 할 일보다 더 많은 일을 하려고 함 → 다른 객체들이 작업을 하게 놔두는 대신, 자신이 직접 작업하기 위해 데이터를 얻는 과정에서 다른 객체들의 private 필드들을 침범

메멘토 패턴 - 스냅샷 생성 작업을 실제 상태의 소유자인 오리지네이터에게 위임 → 다른 객체들이 에디터의 상태를 외부에서 복사하려고 하는 대신, 에디터 클래스 스스로가 스냅샷을 만들 수 있음

  • 객체 상태의 복사본을 메멘토라는 특별한 객체에 저장 → 메멘토의 컨텐츠는 해당 컨텐츠를 생산한 객체 이외의 객체들은 접근 불가
  • 다른 객체들은 원본 객체의 상태 말고 스냅샷의 메타데이터를 가져가는 것만을 허용하는 제한된 인터페이스를 통해 메멘토와 소통해야 함

메멘토는 주로 케어테이커라는 객체 내부에 저장됨

  • 케이테이커는 제한된 인터페이스를 통해서만 메멘토와 협업하기 때문에, 메멘토 내부에 저장된 상태에 간섭 불가
  • 오리지네이터는 메멘토 내부 모든 필드에 접근 가능하므로 얼마든지 이전 상태 복원 가능

위 텍스트 에디터 예시

  • 별도의 history 클래스를 만들어 케어테이커 역할 수행
  • 케어테이커 내부에 저장된 메멘토 스택은 에디터가 작업을 수행하기 직전마다 커짐
  • 앱의 UI에 스택을 렌더링해 유저의 이전 작업 기록을 보여줄 수 있음
  • 유저가 실행 취소를 하면 history는 스택에서 가장 최근의 메멘토를 가져와 에디터에게 전달해 롤백 요청, 에디터는 메멘토에 완전한 접근 권한이 있으므로 메멘토에서 가져온 값으로 상태 변경

구조

중첩 클래스 기반 구현

메멘토 패턴의 고전적인 구현 - 중첩 클래스

1. 오리지네이터 - 자신의 상태 스냅샷을 생산하고, 필요에 의해 스냅샷의 상태로 복원할 수 있는 클래스

2. 메멘토 - 오리지네이터 상태의 스냅샷 역할을 하는 값 객체
   - 메멘토는 주로 불변으로 만들어 생성자를 통해 한 번만 데이터 전달
    
3. 케어테이커 - 오리지네이터의 상태를 ‘언제’ 그리고 ‘왜’ 저장할지, 언제 복원할 지 알고 있음
   - 메멘토 스택을 통해 오리지네이터의 기록 추적, 오리지네이터가 과거로 돌아가야 할 때 
     케어테이커가 스택의 최상단에 위치한 메멘토를 가져와 오리지네이터의 복원 메서드로 전달
    
- 이 구현에서 메멘토 클래스는 오리지네이터 내부에 중첩되어 있음 
  → 오리지네이터가 메멘토의 private 필드와 메서드에 엑세스 가능
- 케어테이커는 메멘토의 필드와 메서드에 대해 매우 제한된 접근 권한을 가짐 
  → 메멘토 저장은 가능하지만 상태 변경은 불가능

중간 인터페이스 기반 구현

중첩 클래스를 지원하지 않는 언어에 알맞는 구현

1. 중첩 클래스가 없으므로 케어테이커가 명시적으로 선언된 중간 인터페이스를 통해서만 
   메멘토와 협업할 수 있게 해서 메멘토의 필드에 대한 접근 제한
   - 중간 인터페이스는 메멘토의 메타데이터와 관련된 메서드만 선언
   
2. 오리지네이터는 메멘토 객체에 선언된 필드와 메서드에 접근하며 메멘토와 직접 협업
   - 메멘토의 모든 멤버를 public 선언해야 한다는 단점 존재

더 엄격한 캡슐화 기반 구현

다른 클래스들이 메멘토를 통해 오리지네이터의 상태에 접근하는 일말의 가능성조차 없애려 할 때

1. 이 구현에선 다양한 유형의 오리지네이터와 메멘토를 가질 수 있음, 
   각 오리지네이터는 대응되는 메멘토 클래스와 협업함, 오리지네이터와 메멘토 둘 다 누구에게도 상태를 노출하지 않음

2. 케어테이커는 명시적으로 메멘토에 저장된 상태를 변경할 수 없게 됨
   - 복원 메서드가 메멘토 클래스에 정의되어 있기 때문에 케어테이커 클래스는 오리지네이터에 의존하지 않게 됨
   
3. 각 메멘토는 자신을 생성한 오리지네이터와 연결됨, 
   오리지네이터는 자기 자신을 상태 값들과 함께 메멘토의 생성자에 전달
   - 메멘토와 오리지네이터의 가까운 관계로 인해, 오리지네이터가 적절한 setter를 정의했다면 
     메멘토는 오리지네이터의 상태를 복원할 수 있음

적용

객체 상태의 스냅샷을 생성해 객체의 이전 상태를 복원하려는 경우

- 메멘토 패턴 - 객체의 private 필드를 포함한 완전한 상태의 복사본을 만들어 객체와 별도로 저장할 수 있게 해줌
- 많은 사람들이 실행 취소로 이 패턴을 기억하지만, 트랜잭션 처리에도 이 패턴이 필요 (에러 발생 시 롤백)

객체의 필드/getter/setter에 직접 접근하는 것이 캡슐화를 위반하는 경우

- 메멘토는 객체가 스스로의 상태 스냅샷을 생성하게 하고 다른 객체들이 스냅샷을 읽을 수 없게 해 
  원본 객체 상태 데이터를 안전하게 만듦

구현방법

1. 오리지네이터 역할을 할 클래스 결정, 
   프로그램이 해당 타입의 중심 객체 하나를 사용할 지 여러 개의 작은 객체들을 사용할 지 알아야 함

2. 메멘토 클래스 생성, 오리지네이터 클래스 내부에 선언된 필드들을 미러링하는 필드 집합 선언

3. 메멘토 클래스를 불변으로 만듦, 메멘토는 데이터를 생성자를 통해 단 한 번만 받아야 함, setter가 없어야 함

4. 언어에서 중첩 클래스를 지원한다면 메멘토를 오리지네이터 내부에 중첩시킴, 
   아니라면 메멘토 클래스에서 빈 인터페이스를 추출해 다른 객체들이 해당 인터페이스를 통해 메멘토를 참조하게 함
   - 인터페이스에 메타데이터 작업들을 추가해도 되지만 오리지네이터의 상태를 노출시키면 안 됨
   
5. 오리지네이터 클래스에 메멘토 생성 메서드 추가, 
   오리지네이터는 자신의 상태를 메멘토 생성자의 인수를 통해 메멘토에게 전달해야 함
   - 만약 이전 단계에서 인터페이스를 추출했다면 해당 메서드의 반환 타입은 인터페이스 타입이어야 함
   - 메멘토 생성 메서드는 메멘토 클래스와 직접 협업해야 함
   
6. 오리지네이터의 상태를 복원하는 메서드를 오리지네이터 클래스에 추가, 메멘토 객체를 인수로 받아야 함
   - 이전 단계에서 인터페이스를 추출했다면 인터페이스를  파라미터의 타입으로 만들어야 함 
     → 오리지네이터는 객체에 대한 완전한 접근 권한이 필요하므로 들어오는 객체를 메멘토 클래스로 타입 변환해야 함
   
7. 케어테이커는 언제 오리지네이터로부터 새 메멘토를 요청할 지, 어떻게 저장할 지, 
   언제 오리지네이터를 특정 메멘토로 복원할 지 알아야 함

8. 케어테이커와 오리지네이터 사이의 연결은 메멘토 클래스 내부로 이동 가능
   - 각 메멘토는 자신을 생성한 오리지네이터와 연결되어 있어야 함
   - 복원 메서드 또한 메멘토 클래스로 이동
   - 메멘토 클래스가 오리지네이터 내부에 중첩되어 있거나 오리지네이터 클래스가 
     자신의 상태를 override할 충분한 setter를 제공하는 경우에만 가능

장단점

장점

- 캡슐화를 위반하지 않고 객체의 상태 스냅샷 생성 가능
- 케어테이커가 오리지네이터의 상태 history를 유지하게 해 오리지네이터의 코드를 단순화할 수 있음

단점

- 클라이언트들이 메멘토를 너무 자주 만들면 많은 RAM 소모
- 오래된 메멘토를 파괴하려면 케어테이커가 오리지네이터의 생명 주기를 추적해야 함
- PHP, Python, JavaScript같은 동적 언어는 대부분 메멘토 내부의 상태가 그대로 유지된다는 보장이 없음

다른 패턴과의 관계

- undo 구현 시 커맨드 패턴과 메멘토 패턴을 함께 사용할 수 있음
  - 커맨드 - 대상 객체에 작업을 수행하는 역할
  - 메멘토 - 커맨드가 실행되기 직전에 객체의 상태를 저장하는 역할
  
- 메멘토를 이터레이터와 함께 사용해 현재 순회 상태를 저장하고 필요한 경우 롤백 가능

- 때로 프로토타입 패턴이 메멘토 패턴의 간단한 대안이 될 수 있음 
  → 상태를 기록에 저장하려는 객체가 간단하고 외부 리소스에 대한 링크가 없거나 링크를 재설정하기 쉬운 경우에 가능

TypeScript 예제

/**
 * The Originator holds some important state that may change over time. It also
 * defines a method for saving the state inside a memento and another method for
 * restoring the state from it.
 */
class Originator {
    /**
     * For the sake of simplicity, the originator's state is stored inside a
     * single variable.
     */
    private state: string;

    constructor(state: string) {
        this.state = state;
        console.log(`Originator: My initial state is: ${state}`);
    }

    /**
     * The Originator's business logic may affect its internal state. Therefore,
     * the client should backup the state before launching methods of the
     * business logic via the save() method.
     */
    public doSomething(): void {
        console.log('Originator: I\'m doing something important.');
        this.state = this.generateRandomString(30);
        console.log(`Originator: and my state has changed to: ${this.state}`);
    }

    private generateRandomString(length: number = 10): string {
        const charSet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';

        return Array
            .apply(null, { length })
            .map(() => charSet.charAt(Math.floor(Math.random() * charSet.length)))
            .join('');
    }

    /**
     * Saves the current state inside a memento.
     */
    public save(): Memento {
        return new ConcreteMemento(this.state);
    }

    /**
     * Restores the Originator's state from a memento object.
     */
    public restore(memento: Memento): void {
        this.state = memento.getState();
        console.log(`Originator: My state has changed to: ${this.state}`);
    }
}

/**
 * The Memento interface provides a way to retrieve the memento's metadata, such
 * as creation date or name. However, it doesn't expose the Originator's state.
 */
interface Memento {
    getState(): string;

    getName(): string;

    getDate(): string;
}

/**
 * The Concrete Memento contains the infrastructure for storing the Originator's
 * state.
 */
class ConcreteMemento implements Memento {
    private state: string;

    private date: string;

    constructor(state: string) {
        this.state = state;
        this.date = new Date().toISOString().slice(0, 19).replace('T', ' ');
    }

    /**
     * The Originator uses this method when restoring its state.
     */
    public getState(): string {
        return this.state;
    }

    /**
     * The rest of the methods are used by the Caretaker to display metadata.
     */
    public getName(): string {
        return `${this.date} / (${this.state.substr(0, 9)}...)`;
    }

    public getDate(): string {
        return this.date;
    }
}

/**
 * The Caretaker doesn't depend on the Concrete Memento class. Therefore, it
 * doesn't have access to the originator's state, stored inside the memento. It
 * works with all mementos via the base Memento interface.
 */
class Caretaker {
    private mementos: Memento[] = [];

    private originator: Originator;

    constructor(originator: Originator) {
        this.originator = originator;
    }

    public backup(): void {
        console.log('\nCaretaker: Saving Originator\'s state...');
        this.mementos.push(this.originator.save());
    }

    public undo(): void {
        if (!this.mementos.length) {
            return;
        }
        const memento = this.mementos.pop();

        console.log(`Caretaker: Restoring state to: ${memento.getName()}`);
        this.originator.restore(memento);
    }

    public showHistory(): void {
        console.log('Caretaker: Here\'s the list of mementos:');
        for (const memento of this.mementos) {
            console.log(memento.getName());
        }
    }
}

/**
 * Client code.
 */
const originator = new Originator('Super-duper-super-puper-super.');
const caretaker = new Caretaker(originator);

caretaker.backup();
originator.doSomething();

caretaker.backup();
originator.doSomething();

caretaker.backup();
originator.doSomething();

console.log('');
caretaker.showHistory();

console.log('\nClient: Now, let\'s rollback!\n');
caretaker.undo();

console.log('\nClient: Once more!\n');
caretaker.undo();
// Output.txt

Originator: My initial state is: Super-duper-super-puper-super.

Caretaker: Saving Originator's state...
Originator: I'm doing something important.
Originator: and my state has changed to: qXqxgTcLSCeLYdcgElOghOFhPGfMxo

Caretaker: Saving Originator's state...
Originator: I'm doing something important.
Originator: and my state has changed to: iaVCJVryJwWwbipieensfodeMSWvUY

Caretaker: Saving Originator's state...
Originator: I'm doing something important.
Originator: and my state has changed to: oSUxsOCiZEnohBMQEjwnPWJLGnwGmy

Caretaker: Here's the list of mementos:
2019-02-17 15:14:05 / (Super-dup...)
2019-02-17 15:14:05 / (qXqxgTcLS...)
2019-02-17 15:14:05 / (iaVCJVryJ...)

Client: Now, let's rollback!

Caretaker: Restoring state to: 2019-02-17 15:14:05 / (iaVCJVryJ...)
Originator: My state has changed to: iaVCJVryJwWwbipieensfodeMSWvUY

Client: Once more!

Caretaker: Restoring state to: 2019-02-17 15:14:05 / (qXqxgTcLS...)
Originator: My state has changed to: qXqxgTcLSCeLYdcgElOghOFhPGfMxo

참고 자료: Refactoring.guru

0개의 댓글