21. Memento — 아 치즈 빼주세요, 아니다 다시 넣어주세요
작성일:2026.05.21|수정일:2026.08.04|조회수:0

에이든 피자의 시범 운영이 막바지로 향하고 있다. 플라이웨이트 패턴 덕분에 메모리 걱정은 덜었지만, 이번에는 홀 직원이 한숨을 내쉬며 나를 찾아왔다. 손님들의 변덕이 극에 달했기 때문이다.
"페퍼로니 피자에 올리브 추가해주세요." "아, 잠시만요. 역시 치즈를 더 넣고 올리브는 빼주세요." "음... 아까 올리브 넣었을 때가 나은 것 같아요. 다시 올리브 넣어주시고 치즈는 아까처럼 해주세요."
직원은 포스기 앞에서 주문을 수정하다가 결국 폭발하고 말았다. 주문을 하나 수정할 때마다 이전 상태가 무엇이었는지 일일이 기억해내야 했기 때문이다. 코드로 따지면 Order 객체의 필드를 하나하나 백업해두었다가 다시 덮어씌우는 고단한 작업이다. 그렇다고 주문 객체의 내부 속성을 외부로 다 공개해서 관리하자니, 객체지향의 자존심인 '캡슐화'가 울고 갈 일이었다.
무엇이 불편한가
지금의 가장 큰 문제는 객체의 상태를 되돌리고 싶은데, 그 상태를 저장할 마땅한 장소가 없다는 것이다. Order 클래스 밖에 previousToppings, previousSize 같은 변수들을 주렁주렁 매달아 둘 수는 없다. 만약 Order의 내부 구조가 바뀌기라도 하면, 이 백업 로직들도 줄줄이 고장 날 것이다.
우리는 상태를 되돌리는 기능을 구현하면서도, Order 객체의 내부 구조는 비밀로 유지하고 싶었다. 외부에서는 그저 "이 주문의 지금 상태를 스냅샷으로 찍어줘"라고 말하고, 나중에 "이 스냅샷으로 되돌려줘"라고 시키기만 하면 되는 구조 말이다. 스냅샷 안에 무엇이 들어있는지, 어떻게 복원되는지는 오직 Order 본인만 알면 된다.
Memento 패턴
Memento 패턴은 객체의 상태 스냅샷을 저장하고 복원하여, 캡슐화를 위반하지 않고 Undo(되돌리기) 기능을 구현하는 패턴이다. GoF의 원래 의도는 다음과 같다.
캡슐화를 위반하지 않은 채 객체 내부 상태를 잡아내어 실체화해 둠으로써, 나중에 객체가 그 상태로 복구 가능하게 한다.
이 패턴에는 세 명의 주인공이 등장한다.
- Originator (작성자): 자신의 상태를 찍어 메멘토를 만들고, 메멘토를 받아 자신의 상태를 복원한다. 여기선
Order클래스다. - Memento (기념품): 객체의 상태를 담고 있는 작은 봉투다. 내부 데이터는 작성자만 읽을 수 있도록 꽁꽁 싸매져 있어야 한다.
- Caretaker (관리자): 메멘토를 보관하고 관리한다. 하지만 메멘토 안을 들여다보거나 만질 수는 없다. 그저 봉투를 쌓아두었다가 필요할 때 작성자에게 돌려줄 뿐이다. 여기선
OrderHistory클래스가 그 역할을 한다.
시간을 되돌리는 주문서
이제 에이든 피자의 주문 시스템에 '타임머신'을 달아보자. 먼저 상태를 담을 메멘토 클래스다.
class OrderMemento { constructor( private readonly toppings: string[], private readonly size: string, private readonly notes: string ) {} // TypeScript에서는 Originator만 호출한다는 컨벤션으로 둔다 getState() { return { toppings: [...this.toppings], size: this.size, notes: this.notes }; }}다음은 이 스냅샷을 만들고 복원할 책임을 가진 Order 클래스다. 4편에서 배운 Prototype 패턴의 정신을 살려, 배열 같은 참조 타입은 깊은 복사로 안전하게 저장한다.
class Order { private toppings: string[] = []; private size: string = "medium"; private notes: string = ""; save(): OrderMemento { // 현재 상태를 복사해서 메멘토에 담는다 return new OrderMemento([...this.toppings], this.size, this.notes); } restore(memento: OrderMemento) { // 메멘토에서 상태를 꺼내 자신에게 덮어쓴다 (호출은 Originator의 책임) const state = memento.getState(); this.toppings = state.toppings; this.size = state.size; this.notes = state.notes; }}마지막으로 이 메멘토들을 스택(Stack)처럼 쌓아두며 관리할 OrderHistory다. 이 녀석은 메멘토 안에 치즈가 들었는지 페퍼로니가 들었는지 관심이 없다. 그저 "가장 최근 스냅샷 주세요"라는 요청에 응답할 뿐이다.
class OrderHistory { private history: OrderMemento[] = []; constructor(private order: Order) {} backup() { this.history.push(this.order.save()); } undo() { const memento = this.history.pop(); if (memento) this.order.restore(memento); }}이제 손님이 "아까 그 상태로 되돌려주세요"라고 하면, 우리는 history.undo() 버튼을 누르면 된다. Order 객체는 마치 아무 일도 없었다는 듯 과거의 모습으로 돌아간다. 캡슐화는 지켜졌고, 직원의 스트레스도 조금은 줄었다.
TypeScript 포인트
메멘토 패턴의 핵심은 Caretaker가 메멘토의 상태를 읽거나 바꾸지 않는 것이다. Java 같은 언어라면 패키지 경계를 활용해 이를 더 좁힐 수 있지만, TypeScript의 private은 클래스 단위 제한이며 별도의 friend/package-private 접근 제어는 제공하지 않는다.
따라서 이 예시의 getState()는 기술적으로 Caretaker도 호출할 수 있다. 필드를 private으로 숨기고, getState()는 Originator만 호출한다는 컨벤션으로 역할을 지키는 실용적 타협이다. 같은 모듈에 두고 메멘토 클래스를 export하지 않거나, 클로저·Symbol을 이용하면 노출 범위를 더 줄일 수 있지만 TypeScript만으로 Originator에게만 읽기 권한을 강제할 수는 없다.
또한 12편에서 배운 State 패턴과 조합하면 더 강력해진다. 단순한 데이터뿐만 아니라 주문의 '상태'(접수됨, 조리 중 등)까지 메멘토에 담아두면, 실수로 '조리 중'으로 넘긴 주문을 다시 '접수됨'으로 되돌리는 로직도 깔끔하게 구현할 수 있다.
트레이드오프
메멘토 패턴은 복잡한 Undo/Redo 기능을 구현할 때 최고의 선택이다. 객체의 캡슐화를 유지하면서 상태를 저장하고 복원할 수 있게 해주며, 복원 로직이 작성자 클래스 한곳에 모여 있어 유지보수가 쉽다.
하지만 '기억'에는 비용이 따른다. 상태를 자주 저장하거나 저장해야 할 데이터의 양이 크다면 메모리 사용량이 급증할 수 있다. 피자 주문서 정도라면 수백 개를 저장해도 큰 문제가 없겠지만, 수메가바이트의 이미지 편집 상태를 매 초마다 저장한다면 서버가 금방 비명을 지를 것이다. 이럴 때는 전체 상태가 아니라 '변경된 부분(Delta)'만 저장하는 전략을 고민해야 한다.
또한 메멘토의 생명주기를 잘 관리해야 한다. Order 객체가 사라졌는데 OrderHistory에 메멘토들이 남아있다면 메모리 누수의 원인이 된다. 타임머신을 운영하려면 연료(메모리) 관리에 각별히 신경 써야 한다는 뜻이다.
전체 코드
/** * Memento: 객체의 상태 스냅샷 */class OrderMemento { constructor( private readonly toppings: string[], private readonly size: string, private readonly notes: string ) {} getState() { return { toppings: [...this.toppings], size: this.size, notes: this.notes, }; }}/** * Originator: 상태 작성자 */class Order { private toppings: string[] = []; private size: string = "medium"; private notes: string = ""; constructor(public id: string) {} addTopping(topping: string) { this.toppings.push(topping); console.log(`[Order] 토핑 추가: ${topping}`); } setSize(size: string) { this.size = size; console.log(`[Order] 사이즈 변경: ${size}`); } setNotes(notes: string) { this.notes = notes; console.log(`[Order] 요청사항 변경: ${notes}`); } save(): OrderMemento { console.log(`[Order] 현재 상태 스냅샷 저장`); return new OrderMemento([...this.toppings], this.size, this.notes); } restore(memento: OrderMemento) { const state = memento.getState(); this.toppings = state.toppings; this.size = state.size; this.notes = state.notes; console.log(`[Order] 상태 복원 완료`); } showDetails() { console.log(`--- 주문 상세 (${this.id}) ---`); console.log(`사이즈: ${this.size}`); console.log(`토핑: ${this.toppings.join(", ") || "없음"}`); console.log(`요청사항: ${this.notes || "없음"}`); }}/** * Caretaker: 메멘토 관리자 */class OrderHistory { private history: OrderMemento[] = []; constructor(private readonly order: Order) {} backup() { this.history.push(this.order.save()); } undo() { const memento = this.history.pop(); if (!memento) { console.log("[History] 되돌릴 이력이 없습니다."); return; } console.log("[History] Undo 실행"); this.order.restore(memento); }}/** * 시뮬레이션 */function runMementoDemo() { const order = new Order("ORD-2026"); const history = new OrderHistory(order); order.setSize("large"); order.addTopping("페퍼로니"); history.backup(); // 1번 저장 order.addTopping("올리브"); history.backup(); // 2번 저장 order.setNotes("치즈 빼주세요 (앗 실수)"); order.showDetails(); console.log("\n(손님이 방금 전 요청을 취소합니다)"); history.undo(); order.showDetails(); console.log("\n(손님이 한 번 더 되돌려달라고 합니다)"); history.undo(); order.showDetails();}runMementoDemo();메멘토 패턴 덕분에 이제 에이든 피자는 변덕스러운 손님의 요청에도 어느 정도 유연하게 대처할 수 있게 되었다. 실수를 해도 되돌릴 수 있다는 안도감은 주방과 홀 모두에게 작은 평화를 가져다주었다.
하지만 평화도 잠시, 이번에는 Order 클래스 자체가 너무 무거워졌다는 지적이 들려온다. 가격 계산, 칼로리 계산, 영수증 출력, 알레르기 체크... 이 모든 로직이 Order 클래스 안에 들어있다 보니, 새로운 분석 기능이 추가될 때마다 주문서를 수정해야 하는 상황이다. 주문서 클래스는 건드리지 않으면서, 그 안에 담긴 데이터를 이용해 새로운 연산만 슥 추가할 방법은 없을까?
댓글
댓글을 불러오는 중...