[모던 C++로 다시 쓰는 GoF 디자인 패턴 총정리 #18] 메멘토(Memento) 패턴: 상태 스냅샷을 값 기반으로 캡슐화하고 복원하기

이전 글에서는 미디에이터(Mediator) 패턴을 모던 C++ 관점에서 재해석하며, 상속 기반 구조 없이도 Concepts, std::function, std::expected, coroutine, Ranges 등을 활용해 객체 간 상호작용을 유연하고 타입 안전하게 구현할 수 있음을 확인했습니다. 이번에는 행동(Behavioral) 패턴 중 메멘토(Memento) 패턴을 다룹니다.

메멘토 패턴은 객체 상태를 캡슐화(스냅샷)하여, 이후 필요할 때 원래 상태로 복원하는 기법을 제공합니다. 전통적으로는 Originator(원본 객체), Memento(상태 저장), Caretaker(메멘토 관리) 클래스를 정의하고, Memento 클래스로 상태를 저장/복원하는 상속 기반 구조를 사용했습니다. 그러나 모던 C++20 이상에서는 std::variant, std::expected, std::format, Concepts, coroutine, Ranges 등을 활용해 상속 없이도 값 기반 상태 스냅샷 관리, Undo/Redo 구현, 비동기 상태 복원, 조건부 상태 변환 등 다양한 기능을 단순하고 표현력 있게 구현할 수 있습니다.

패턴 소개: 메멘토(Memento)

의도:

  • 객체의 내부 상태를 캡슐화한 Memento(메멘토) 객체를 만들고, 이를 통해 나중에 객체를 해당 상태로 복원 가능.
  • Originator(원본 객체)가 상태를 메멘토로 스냅샷 떠서 Caretaker가 보관, 필요 시 Originator가 메멘토로부터 상태 복원.
  • Undo/Redo 시스템 구현 시 유용.

전통적 구현 문제점:

  • Memento 인터페이스 + 구체 Memento 클래스, Originator 클래스, Caretaker 클래스 필요
  • 새로운 상태 필드 추가 시 Memento 구조 변경 → 상속 기반 구조로 인해 클래스 증가
  • 비동기 상태 복원, 에러 처리, 조건부 상태 변환 등 고급 기능 구현 복잡

기존 C++ 스타일 구현 (전통적 방식)

예를 들어, TextEditor 상태(텍스트, 커서 위치)를 Memento에 저장:

#include <string>
#include <iostream>
#include <memory>
#include <vector>

struct Memento {
    std::string text;
    int cursorPos;
};

struct TextEditor {
    std::string text;
    int cursorPos = 0;

    Memento createMemento() {
        return Memento{text, cursorPos};
    }

    void restore(const Memento& m) {
        text = m.text;
        cursorPos = m.cursorPos;
    }
};

struct Caretaker {
    std::vector<Memento> history;
    void save(const Memento& m) {
        history.push_back(m);
    }

    std::optional<Memento> undo() {
        if (history.empty()) return std::nullopt;
        auto m = history.back();
        history.pop_back();
        return m;
    }
};

int main() {
    TextEditor editor;
    Caretaker caretaker;

    editor.text = "Hello";
    editor.cursorPos = 5;
    caretaker.save(editor.createMemento());

    editor.text = "Hello World";
    editor.cursorPos = 11;
    caretaker.save(editor.createMemento());

    // Undo
    if(auto m = caretaker.undo()) {
        editor.restore(*m);
        std::cout << editor.text << ", cursor:" << editor.cursorPos << "\n"; // "Hello", cursor:5
    }
}

문제점:

  • Originator, Memento, Caretaker 클래스 필요, 상태 필드 변경 시 Memento 구조 수정 필요
  • 에러 처리나 비동기 상태 복원, 조건부 변환 등 고급 기능 구현 번거로움

모던 C++20 이상의 개선: 값 기반 상태 스냅샷, std::variant, std::expected, Ranges

1. Concepts로 Memento 요구사항 정의

Memento는 단순히 상태를 담은 값일 뿐, 별도 인터페이스 필요 없다. Originator 상태를 std::variant로 관리하거나, 단순 구조체로 스냅샷 가능.

// 예: TextEditor 상태를 단순 구조체로 저장 가능
struct EditorState {
    std::string text;
    int cursorPos;
};

// Memento를 별도 클래스로 정의할 필요 없이 EditorState를 스냅샷으로 사용 가능

Memento 자체가 별도 인터페이스 필요 없이 값 타입으로 관리할 수 있다면 Concepts 필요 없음. 하지만, 필요하다면 "MementoConcept"를 정의해 스냅샷 복원 가능한지 제약할 수 있다:

template<typename M, typename O>
concept MementoConcept = requires(O& originator, const M& m) {
    // restore 가능 여부
    { originator.restore(m) } -> std::convertible_to<std::expected<void,std::string>>;
};

2. Originator를 람다나 함수 객체로 구현?

Originator는 상태를 내부에 갖고 있으며 createMemento()와 restore(memento) 메서드 제공. 상속 없이 구조체로 구현 가능:

#include <expected>

struct Editor {
    std::string text;
    int cursorPos = 0;

    std::expected<EditorState,std::string> createMemento() {
        // 상태 스냅샷
        return EditorState{text, cursorPos};
    }

    std::expected<void,std::string> restore(const EditorState& state) {
        // 복원 시 에러 검증 가능
        if(state.cursorPos > (int)state.text.size()) 
            return std::unexpected("Invalid cursorPos in memento");
        text = state.text;
        cursorPos = state.cursorPos;
        return {};
    }
};

비교:

  • 전통적: Originator 클래스 + Memento 클래스 필요
  • C++11/14/17: Lambdas 가능하지만 std::expected로 에러 처리나 Concepts로 제약하기는 어려움
  • C++20: 단순 구조체/함수로 Originator 구현, std::expected로 에러 처리 명확화

3. Caretaker: 스택/벡터로 Memento 관리

Caretaker는 Memento를 벡터나 스택으로 관리. Undo 시 pop:

struct Caretaker {
    std::vector<EditorState> history;

    std::expected<void,std::string> save(Editor& originator) {
        auto mem = originator.createMemento();
        if(!mem) return std::unexpected(mem.error());
        history.push_back(*mem);
        return {};
    }

    std::expected<void,std::string> undo(Editor& originator) {
        if(history.empty()) return std::unexpected("No state to undo");
        auto m = history.back();
        history.pop_back();
        return originator.restore(m);
    }
};

비교:

  • 전통적: Caretaker + Memento 클래스 필요, Undo/Redo 시 클래스 증가 가능
  • C++20: 단순 값 타입(EditorState)으로 스냅샷 관리, std::expected로 에러 처리

사용 예

int main() {
    Editor editor;
    Caretaker caretaker;

    editor.text = "Hello";
    editor.cursorPos = 5;
    caretaker.save(editor);

    editor.text = "Hello World";
    editor.cursorPos = 11;
    caretaker.save(editor);

    // Undo
    auto res = caretaker.undo(editor);
    if(!res) std::cerr << "Undo error: " << res.error() << "\n";
    else std::cout << editor.text << ", cursor:" << editor.cursorPos << "\n"; // "Hello", cursor:5
}

파이프라인 처리, 로깅, Ranges 적용

Memento 목록을 Ranges로 필터링하거나, 특정 조건 하에서만 Undo 허용 가능. 로깅 추가를 위해 std::format 사용.

auto loggingSave = [&](auto baseSave) {
    return [=](Editor& originator) -> std::expected<void,std::string> {
        std::cout << "[LOG] Saving state...\n";
        auto res = baseSave(originator);
        if(!res) std::cout << "[LOG] Save error: " << res.error() << "\n";
        else std::cout << "[LOG] State saved.\n";
        return res;
    };
};

auto loggingUndo = [&](auto baseUndo) {
    return [=](Editor& originator) -> std::expected<void,std::string> {
        std::cout << "[LOG] Undoing...\n";
        auto res = baseUndo(originator);
        if(!res) std::cout << "[LOG] Undo error: " << res.error() << "\n";
        else std::cout << "[LOG] Undo successful.\n";
        return res;
    };
};

// 장식(데코레이터)한 Caretaker 예
struct DecoratedCaretaker {
    Caretaker base;
    std::function<std::expected<void,std::string>(Editor&)> decoratedSave;
    std::function<std::expected<void,std::string>(Editor&)> decoratedUndo;

    DecoratedCaretaker() {
        decoratedSave = loggingSave([this](Editor& e){return base.save(e);});
        decoratedUndo = loggingUndo([this](Editor& e){return base.undo(e);});
    }
};

int main() {
    Editor editor;
    DecoratedCaretaker caretaker;

    editor.text = "Hello";
    editor.cursorPos = 5;
    caretaker.decoratedSave(editor);

    editor.text = "Hello World";
    editor.cursorPos = 11;
    caretaker.decoratedSave(editor);

    caretaker.decoratedUndo(editor); // Undo with logging
}

비교:

  • 전통적: 로깅 추가 시 Decorator 패턴 사용 → 클래스 증가
  • C++20: 람다 장식으로 로깅 기능 추가, std::expected로 에러 처리, 상속 없음

비동기 복원: Coroutine 예

비동기 상태 복원 시 coroutine으로 구현 가능. 예: 원격 서버에서 이전 상태를 가져와 메멘토를 복원.

// co_restoreState(...) 가상의 coroutine, co_await 네트워크 I/O, 완료 후 std::expected 반환
// 비동기 Undo 가능

비교:

  • 전통적: 비동기 처리 시 별도 쓰레드, Future/Promise 필요
  • C++20: coroutine으로 자연스럽게 비동기 복원, std::expected로 결과 처리 명확

전통적 구현 vs C++11/14/17 vs C++20 이상 비교

전통적 (C++98/03):

  • Memento, Originator, Caretaker 상속 기반 구조
  • 상태 변화 시 Memento 클래스 수정 필요
  • Undo/Redo, 비동기 복원, 로깅, 조건부 상태 변환 등 고급 기능 구현 번거로움

C++11/14/17:

  • 람다, std::unique_ptr로 일부 단순화
  • 여전히 Concepts, std::expected, coroutine, Ranges 부재
  • 정적 타입 제약, 에러 처리 개선 한계

C++20 이상 (모던 C++):

  • 값 기반 상태(예: EditorState)로 Memento 관리, 상속 없음
  • std::expected로 에러 처리 명확, std::format으로 로깅 간단
  • coroutine으로 비동기 Undo/Redo, Ranges로 Memento 히스토리 필터링/조건부 관리 가능
  • 람다와 함수 합성으로 Undo/Redo 기능, 로깅, 조건부 상태 복원 등 다양한 기능 모듈화
  • 유지보수성, 확장성, 타입 안전성 모두 개선, 전통적 구현 대비 큰 이점

결론

메멘토(Memento) 패턴은 객체 상태를 캡슐화해 나중에 복원하는 기법을 제공하며, Undo/Redo 시스템 구현에 특히 유용합니다. 전통적으로는 Memento, Originator, Caretaker 클래스를 상속 기반으로 정의해야 했으나, C++20 이상에서는 값 기반 상태 스냅샷과 람다, Concepts, std::expected, std::format, coroutine, Ranges 등을 활용해 상속 없는 간결하고 타입 안전한 시스템을 구축할 수 있습니다.

이렇게 하면 Undo/Redo, 비동기 상태 복원, 로깅, 조건부 상태 변환, 파이프라인 처리 등 다양한 요구사항에도 쉽게 대응할 수 있으며, 전통적 구현 대비 유지보수성과 확장성이 크게 향상됩니다.

반응형