게임에서 보스 방 앞에 체크포인트를 만들거나, 설정 화면에서 “취소”를 누르면 이전 값으로 돌아가야 한다. 객체 상태를 바깥에서 저장하려면 private 멤버를 공개하거나 getter/setter를 잔뜩 만들어야 하는데, 그러면 캡슐화가 깨지고 저장하는 쪽이 내부 구조에 묶인다. 메멘토(Memento)는 캡슐화를 깨지 않고 객체의 내부 상태를 외부에 저장해 두었다가 나중에 그 상태로 복원하게 한다. 상태를 읽고 쓰는 것은 원래 객체(Originator)뿐이고, 보관하는 쪽(Caretaker)은 내용을 모르는 꾸러미만 들고 있다. 이 글에서는 C++의 불투명 메멘토, 전체 스냅샷과 변경분 메멘토의 메모리 비교, 버전이 있는 직렬화 메멘토, C의 불완전 타입 메멘토를 다룬다.
이 시리즈의 다른 글
- C/C++ 디자인 패턴 (15) — 반복자(Iterator)
- C/C++ 디자인 패턴 (16) — 중재자(Mediator)
- C/C++ 디자인 패턴 (18) — 옵저버(Observer)
- C/C++ 디자인 패턴 (19) — 상태(State)
구조
| 참여자 | 역할 | 예제 |
|---|---|---|
| Originator | 자기 상태로 메멘토를 만들고, 메멘토로 상태를 복원 | Character, Settings, struct counter |
| Memento | Originator의 내부 상태를 담은 불투명 객체 | Character::Memento |
| Caretaker | 메멘토를 보관하지만 내용을 읽거나 바꾸지 않음 | SaveSlots, main.c |
C++ 불투명 메멘토
메멘토를 Originator 안의 중첩 클래스로 만들고 생성자와 멤버를 private으로 두면, friend인 Originator만 접근할 수 있다.
// memento.cpp — Originator만 내용을 읽고 쓰는 불투명 스냅샷, Caretaker는 보관만 한다
#include <iostream>
#include <string>
#include <vector>
class Character { // Originator
public:
class Memento { // 바깥에서는 만들 수도, 읽을 수도 없다
friend class Character;
Memento(int hp, int x, int y, std::vector<std::string> inv)
: hp_(hp), x_(x), y_(y), inv_(std::move(inv)) {}
int hp_, x_, y_;
std::vector<std::string> inv_;
};
Memento save() const { return Memento(hp_, x_, y_, inventory_); }
void restore(const Memento &m) { hp_ = m.hp_; x_ = m.x_; y_ = m.y_; inventory_ = m.inv_; }
void move(int dx, int dy) { x_ += dx; y_ += dy; }
void hit(int dmg) { hp_ -= dmg; }
void pick(std::string item) { inventory_.push_back(std::move(item)); }
void print() const
{
std::cout << " hp=" << hp_ << " pos=(" << x_ << ',' << y_ << ") items=" << inventory_.size() << '\n';
}
private:
int hp_ = 100, x_ = 0, y_ = 0;
std::vector<std::string> inventory_;
};
class SaveSlots { // Caretaker: 내용을 모른 채 보관
public:
void push(Character::Memento m) { slots_.push_back(std::move(m)); }
const Character::Memento &last() const { return slots_.back(); }
std::size_t size() const { return slots_.size(); }
private:
std::vector<Character::Memento> slots_;
};
int main()
{
Character hero;
SaveSlots slots;
hero.pick("sword");
slots.push(hero.save()); // 보스 방 앞에서 저장
std::cout << "checkpoint:\n"; hero.print();
hero.move(10, 3); hero.hit(95); hero.pick("key");
std::cout << "after boss fight:\n"; hero.print();
hero.restore(slots.last());
std::cout << "restored:\n"; hero.print();
#ifdef SHOW_ERROR
auto m = hero.save();
std::cout << m.hp_; // Caretaker/클라이언트는 내부를 볼 수 없다
#endif
}$ g++ -std=c++20 -Wall -Wextra -O2 -o memento memento.cpp && ./memento
checkpoint:
hp=100 pos=(0,0) items=1
after boss fight:
hp=5 pos=(10,3) items=2
restored:
hp=100 pos=(0,0) items=1
$ g++ -std=c++20 -DSHOW_ERROR -c memento.cpp 2>&1 | grep error
memento.cpp:56:24: error: ‘int Character::Memento::hp_’ is private within this context
SaveSlots는 메멘토를 저장하고 돌려줄 뿐이고, 내부 필드 접근은 컴파일 시점에 막힌다. 체력·위치·인벤토리가 한 번에 체크포인트로 돌아갔다.
전체 스냅샷 vs 변경분
메멘토를 매번 전체 상태 복사본으로 만들면 큰 객체에서는 메모리가 금방 바닥난다. 1MiB 문서에 작은 편집을 100번 하면서 두 방식을 비교했다.
// snapshot_vs_diff.cpp — 큰 문서에 작은 편집 100번: 전체 스냅샷 vs 변경분 메멘토
#include <iostream>
#include <string>
#include <vector>
class Document {
public:
explicit Document(std::size_t n) : text_(n, '.') {}
struct Snapshot { std::string text; }; // 전체 복사
Snapshot snapshot() const { return {text_}; }
void restore(const Snapshot &s) { text_ = s.text; }
struct Delta { std::size_t pos; std::string before; }; // 바뀌는 부분만
Delta replace(std::size_t pos, const std::string &s)
{
Delta d{pos, text_.substr(pos, s.size())};
text_.replace(pos, s.size(), s);
return d;
}
void undo(const Delta &d) { text_.replace(d.pos, d.before.size(), d.before); }
const std::string &text() const { return text_; }
private:
std::string text_;
};
int main()
{
const std::size_t size = 1 << 20; // 1 MiB 문서
Document a(size), b(size);
std::vector<Document::Snapshot> snaps;
std::vector<Document::Delta> deltas;
std::size_t snapBytes = 0, deltaBytes = 0;
for (int i = 0; i < 100; ++i) {
std::string edit = "edit#" + std::to_string(i);
snaps.push_back(a.snapshot());
snapBytes += snaps.back().text.capacity();
a.replace(i * 1000, edit);
deltas.push_back(b.replace(i * 1000, edit));
deltaBytes += sizeof(Document::Delta) + deltas.back().before.capacity();
}
std::cout << "snapshots: " << snapBytes / 1024 << " KiB for " << snaps.size() << " undo steps\n"
<< "deltas : " << deltaBytes / 1024 << " KiB for " << deltas.size() << " undo steps\n";
a.restore(snaps.front());
for (auto it = deltas.rbegin(); it != deltas.rend(); ++it)
b.undo(*it);
std::cout << "both restored to original: " << std::boolalpha
<< (a.text() == std::string(size, '.') && b.text() == a.text()) << '\n';
}$ g++ -std=c++20 -Wall -Wextra -O2 -o snapshot_vs_diff snapshot_vs_diff.cpp && ./snapshot_vs_diff
snapshots: 102400 KiB for 100 undo steps
deltas : 5 KiB for 100 undo steps
both restored to original: true
전체 스냅샷은 되돌리기 100단계에 100MiB를 쓰지만, 바뀌기 전 조각만 담은 변경분 메멘토는 5KiB로 충분했다. 대신 변경분은 역순으로 하나씩 적용해야 하고, 중간 단계로 바로 건너뛸 수 없다.
저장 가능한 메멘토와 버전
메멘토를 바이트열로 만들면 파일에 저장해 프로그램을 다시 시작한 뒤에도 복원할 수 있다. 형식에 버전을 두면 오래된 저장 파일도 읽을 수 있고, 잘못된 입력에서는 기존 상태를 유지한다.
// persist.cpp — 메멘토를 바이트로 저장하고 다시 읽기: 버전 필드와 강한 예외 보장
#include <fstream>
#include <iostream>
#include <iterator>
#include <sstream>
#include <stdexcept>
#include <string>
class Settings {
public:
struct Memento { std::string bytes; };
Memento save() const
{
return {"SETv2\nvolume=" + std::to_string(volume_) + "\ntheme=" + theme_ + "\n"};
}
void restore(const Memento &m)
{
std::istringstream in(m.bytes);
std::string magic, vol, theme = "theme=light"; // v1에는 theme가 없다
if (!std::getline(in, magic) || !std::getline(in, vol))
throw std::runtime_error("truncated memento");
if (magic == "SETv2" && !std::getline(in, theme))
throw std::runtime_error("truncated memento");
if (magic != "SETv1" && magic != "SETv2")
throw std::runtime_error("unknown memento format: " + magic);
int v = std::stoi(vol.substr(vol.find('=') + 1)); // 실패하면 여기서 예외
volume_ = v; // 전부 성공한 뒤에만 반영
theme_ = theme.substr(theme.find('=') + 1);
}
void set(int v, std::string t) { volume_ = v; theme_ = std::move(t); }
void print() const { std::cout << " volume=" << volume_ << " theme=" << theme_ << '\n'; }
private:
int volume_ = 50;
std::string theme_ = "light";
};
int main()
{
Settings s;
s.set(80, "dark");
std::ofstream("settings.mem") << s.save().bytes;
std::ifstream f("settings.mem");
std::string bytes{std::istreambuf_iterator<char>(f), {}};
Settings loaded;
loaded.restore({bytes});
std::cout << "from file (v2):\n"; loaded.print();
loaded.restore({"SETv1\nvolume=30\n"});
std::cout << "from old file (v1):\n"; loaded.print();
for (std::string bad : {"SETv9\nvolume=1\n", "SETv2\nvolume=1\n", "SETv1\nvolume=abc\n"}) {
try { loaded.restore({bad}); }
catch (const std::exception &e) { std::cout << "rejected: " << e.what() << '\n'; }
}
std::cout << "state kept:\n"; loaded.print();
}$ g++ -std=c++20 -Wall -Wextra -O2 -o persist persist.cpp && ./persist && cat settings.mem
from file (v2):
volume=80 theme=dark
from old file (v1):
volume=30 theme=light
rejected: unknown memento format: SETv9
rejected: truncated memento
rejected: stoi
state kept:
volume=30 theme=light
SETv2
volume=80
theme=dark
v1 파일은 없는 필드를 기본값으로 채워 읽었다. 알 수 없는 버전, 잘린 데이터, 숫자가 아닌 값은 모두 거부되었고, 모든 검사가 끝난 뒤에만 멤버를 바꾸므로 상태는 직전 값 그대로 남았다(강한 예외 보장).
C 구현: 불완전 타입 메멘토
/* counter.h — 공개 API: 메멘토는 불완전 타입이라 호출자는 내용을 볼 수 없다 */
#ifndef COUNTER_H
#define COUNTER_H
struct counter;
struct counter_memento;
struct counter *counter_new(void);
void counter_add(struct counter *c, int n);
int counter_total(const struct counter *c);
struct counter_memento *counter_save(const struct counter *c);
void counter_restore(struct counter *c, const struct counter_memento *m);
void counter_memento_free(struct counter_memento *m);
void counter_free(struct counter *c);
#endif/* counter.c — Originator: 메멘토의 실제 구조는 이 파일만 안다 */
#include <stdlib.h>
#include <string.h>
#include "counter.h"
struct counter { int total; int calls; int history[8]; };
struct counter_memento { struct counter state; }; /* 내부 상태의 복사본 */
struct counter *counter_new(void) { return calloc(1, sizeof(struct counter)); }
void counter_add(struct counter *c, int n)
{
c->history[c->calls++ % 8] = n;
c->total += n;
}
int counter_total(const struct counter *c) { return c->total; }
struct counter_memento *counter_save(const struct counter *c)
{
struct counter_memento *m = malloc(sizeof(*m));
if (m)
memcpy(&m->state, c, sizeof(*c)); /* 포인터 멤버가 없어 얕은 복사로 충분 */
return m;
}
void counter_restore(struct counter *c, const struct counter_memento *m) { *c = m->state; }
void counter_memento_free(struct counter_memento *m) { free(m); }
void counter_free(struct counter *c) { free(c); }/* main.c — Caretaker: 메멘토 포인터만 보관한다 */
#include <stdio.h>
#include "counter.h"
int main(void)
{
struct counter *c = counter_new();
struct counter_memento *cp;
if (!c)
return 1;
counter_add(c, 10);
counter_add(c, 5);
cp = counter_save(c);
if (!cp)
return 1;
counter_add(c, 1000); /* 잘못된 입력 */
printf("before restore: %d\n", counter_total(c));
counter_restore(c, cp);
printf("after restore : %d\n", counter_total(c));
counter_memento_free(cp);
counter_free(c);
return 0;
}$ gcc -std=c11 -Wall -Wextra -O2 -fsanitize=address -o counter main.c counter.c && ./counter
before restore: 1015
after restore : 15
/* peek.c — 메멘토 안을 들여다보려 하면 */
#include "counter.h"
int peek(const struct counter_memento *m) { return m->state.total; }$ gcc -std=c11 -c peek.c 2>&1 | grep error
peek.c:3:53: error: invalid use of undefined type ‘const struct counter_memento’
헤더에는 struct counter_memento; 선언만 있어서, 호출자는 포인터를 보관하고 넘기는 것 외에는 아무것도 할 수 없다. C++의 friend 대신 번역 단위 경계가 캡슐화를 보장한다.
메멘토와 커맨드 undo
| 구분 | 메멘토(상태 저장) | 커맨드(연산 역적용) |
|---|---|---|
| 저장하는 것 | 상태(전체 또는 변경분) | 연산과 되돌리는 방법 |
| 되돌리기 구현 | 복원 한 번 | 연산마다 undo() 작성 |
| 메모리 | 상태 크기에 비례 | 연산 크기에 비례 |
| 잘 맞는 경우 | 체크포인트, 설정 취소, 트랜잭션 롤백 | 편집기 단계별 undo/redo |
둘을 함께 쓰는 경우도 많다. 커맨드 편의 Erase가 실행 시점에 지운 문자열을 보관한 것도 작은 메멘토다.
주의사항
- 깊은 복사가 필요한 상태: 상태에 포인터나
shared_ptr가 있으면 메멘토가 원본과 같은 객체를 가리키게 된다. 복원 후에도 공유되면 안 되는 자원은 메멘토를 만들 때 복제한다(프로토타입 편의 얕은·깊은 복사 참고). - 외부 자원은 메멘토로 되돌릴 수 없다: 파일에 쓴 내용, 보낸 네트워크 패킷, 다른 객체에 넘긴 참조는 객체 상태를 복원해도 돌아오지 않는다.
- Caretaker의 메모리 관리: 무제한으로 쌓으면 위의 100MiB처럼 커진다. 단계 수 제한, 변경분 방식, 오래된 메멘토 병합을 정책으로 둔다.
- 저장 형식은 계약이다: 파일로 남긴 메멘토는 프로그램 버전이 바뀌어도 읽혀야 한다. 버전 필드를 처음부터 두고, 구버전 읽기 테스트를 유지한다.
- 복원은 원자적으로: 복원 도중 실패해 상태가 반쯤 바뀌면 원래 상태도 새 상태도 아닌 객체가 남는다. 예제처럼 검증을 마친 뒤 한 번에 반영한다.
마무리
메멘토는 “상태를 꺼내되 공개하지 않는다”는 모순을 접근 권한으로 푼다. C++에서는 중첩 클래스와 friend, C에서는 불완전 타입이 그 경계를 만든다. 다음 글에서는 상태 변화를 관심 있는 객체들에게 알리는 옵저버(Observer)를 다룬다.
참고
- Erich Gamma 외, 『Design Patterns: Elements of Reusable Object-Oriented Software』(GoF), Memento
- cppreference — Exceptions (exception safety guarantees)