이전 글에서는 미디에이터(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, 비동기 상태 복원, 로깅, 조건부 상태 변환, 파이프라인 처리 등 다양한 요구사항에도 쉽게 대응할 수 있으며, 전통적 구현 대비 유지보수성과 확장성이 크게 향상됩니다.