스택큐힙리스트

메멘토(Memento) 패턴 완전 분석: “되돌리기(Undo)”를 가장 깔끔하게 본문

개발

메멘토(Memento) 패턴 완전 분석: “되돌리기(Undo)”를 가장 깔끔하게

스택큐힙리스트 2025. 8. 8. 13:03
반응형

복잡한 편집기나 결제 플로우에서 “방금 전으로 돌아가줘”는 거의 필수 기능이죠. 이걸 매번 if/else로 땜빵하다 보면 상태 관리가 산으로 갑니다. 메멘토 패턴은 객체의 내부 구현을 까지 않고(캡슐화 유지) 이전 상태 스냅샷을 저장·복원하게 해 주는 정석 해법이에요.


왜 메멘토인가? (문제 → 해법)

  • 문제: 객체가 복잡해질수록 “원래대로” 돌리는 로직이 여기저기 흩어짐 → 사이드이펙트·버그 증가
  • 해법: 현재 상태를 Memento(스냅샷) 로 외부에 맡겨 두고, 필요할 때 Originator 가 그 스냅샷으로 복원. 스냅샷 묶음은 Caretaker 가 관리. 구조가 단순하고 테스트가 쉬워집니다.

구성 요소 한 번에 이해하기

  • Originator: “상태 주인공”. 스냅샷을 만들고(save()), 나중에 되돌리는(restore()) 역할.
  • Memento: 스냅샷 그 자체. 보통 불변(immutable) 으로 설계.
  • Caretaker: 스냅샷 보관·관리(스택/큐/DB). Undo/Redo 스택도 여기서 처리.

언제 쓰면 베스트인가

  • 에디터 Undo/Redo: 텍스트/이미지/도면 편집기의 기본 UX.
  • 상태 머신 롤백: 다단계 가입·결제 플로우에서 오류 시 이전 단계로 복귀.
  • 게임 저장·로드: 체크포인트마다 스냅샷 저장, 로드로 즉시 복원.
  • 알림·예약 취소: “N초 안엔 취소 가능” 같은 임시 상태 보관.

Kotlin 미니 예제 – 텍스트 에디터 Undo/Redo

// --- Memento ---
@JvmInline value class EditorState(val text: String)

// --- Originator ---
class TextEditor {
    var content: String = ""
        private set

    fun write(s: String) { content += s }
    fun save(): EditorState = EditorState(content)
    fun restore(m: EditorState) { content = m.text }
}

// --- Caretaker ---
class History {
    private val undo = ArrayDeque<EditorState>()
    private val redo = ArrayDeque<EditorState>()

    fun push(s: EditorState) { undo.addLast(s); redo.clear() }
    fun canUndo() = undo.isNotEmpty()
    fun canRedo() = redo.isNotEmpty()

    fun undo(current: EditorState): EditorState? =
        if (undo.isEmpty()) null
        else undo.removeLast().also { redo.addLast(current) }

    fun redo(current: EditorState): EditorState? =
        if (redo.isEmpty()) null
        else redo.removeLast().also { undo.addLast(current) }
}

// --- 사용 ---
val editor = TextEditor()
val history = History()

editor.write("Hello, "); history.push(editor.save())
editor.write("World!");  history.push(editor.save())

val prev = history.undo(editor.save())
if (prev != null) editor.restore(prev)   // "Hello, "

핵심은 복원 로직이 오직 restore() 한 줄이라는 것. 여기저기 뒤엉킨 롤백 코드를 걷어낼 수 있어요.


실무에서 바로 쓰는 팁 (안드로이드·백엔드 공통)

1) 스냅샷 최소화 & 깊은 복사

스냅샷에 꼭 필요한 필드만 담으세요(재생 위치, 선택된 언어 등). 참조 타입은 깊은 복사를 고려하지 않으면 복원 후 값이 바뀌는 유령 버그가 생깁니다.

2) 보관 전략(메모리 vs. 영속화)

  • 가벼운 편집: ArrayDeque 스택만으로 OK.
  • 큰 상태/장시간 작업: Room/파일/직렬화로 오프로드. (Refactoring.Guru 한국어 문서도 직렬화를 일반적 방법으로 언급)

3) Undo/Redo UX 규칙

  • 사용자 행위 단위에 맞춰 저장(입력 30자마다 X, “문단 완료”나 onPause 시점 O).
  • Redo 무효화: Undo 뒤 편집이 발생하면 Redo 스택은 비웁니다(편집기 표준 UX).

4) Command 패턴과 혼동 금지

  • Command: “행위를 객체화”해서 실행/취소를 기록(redo/undo 가능).
  • Memento: “상태 스냅샷”을 저장·복원. 둘을 함께 쓰기도 합니다(커맨드 실행 전 스냅샷 저장).

5) 테스트 전략

  • 단위 테스트: 여러 번의 write() 후 undo() → 기대 텍스트를 정확히 검증.
  • 회귀 테스트: 깊은 복사 누락 케이스(리스트/맵 포함)를 따로 만든다.
@Test
fun `undo restores previous snapshot`() {
    val e = TextEditor(); val h = History()
    e.write("A"); h.push(e.save())
    e.write("B"); h.push(e.save())
    val prev = h.undo(e.save())
    assertEquals("A", prev?.text)
}

장단점 총정리

  • 장점
    • 캡슐화 유지: 내부 구현을 외부에 노출하지 않고 복원 가능.
    • 단순한 롤백: 복원은 restore() 한 번으로 끝.
  • 단점
    • 메모리 사용: 스냅샷이 크거나 잦으면 용량 부담.
    • 깊은 복사 비용: 안전한 스냅샷을 위한 추가 비용이 들 수 있음.
반응형
Comments