C/C++ 디자인 패턴 (17) — 메멘토(Memento)

게임에서 보스 방 앞에 체크포인트를 만들거나, 설정 화면에서 “취소”를 누르면 이전 값으로 돌아가야 한다. 객체 상태를 바깥에서 저장하려면 private 멤버를 공개하거나 getter/setter를 잔뜩 만들어야 하는데, 그러면 캡슐화가 깨지고 저장하는 쪽이 내부 구조에 묶인다. 메멘토(Memento)는 캡슐화를 깨지 않고 객체의 내부 상태를 외부에 저장해 두었다가 나중에 그 상태로 복원하게 한다. 상태를 읽고 쓰는 것은 원래 객체(Originator)뿐이고, 보관하는 쪽(Caretaker)은 내용을 모르는 꾸러미만 들고 있다. 이 글에서는 C++의 불투명 메멘토, 전체 스냅샷과 변경분 메멘토의 메모리 비교, 버전이 있는 직렬화 메멘토, C의 불완전 타입 메멘토를 다룬다.

이 시리즈의 다른 글

구조

참여자역할예제
Originator자기 상태로 메멘토를 만들고, 메멘토로 상태를 복원Character, Settings, struct counter
MementoOriginator의 내부 상태를 담은 불투명 객체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)를 다룬다.

참고

답글 남기기