Featured image of post [Design Patterns] 16. 메멘토와 비지터: 상태 보존과 연산 분리

[Design Patterns] 16. 메멘토와 비지터: 상태 보존과 연산 분리

객체의 상태를 캡슐화하여 저장하는 Memento 패턴과 구조와 연산을 분리하는 Visitor 패턴의 고급 활용법을 탐구합니다. Undo/Redo 시스템, 상태 스냅샷, 이중 디스패치, 객체 구조 순회 등 복잡한 상태 관리와 연산 확장을 위한 전문가 수준의 설계 기법을 학습합니다.

Memento와 Visitor 패턴을 통해 상태 보존과 연산 분리를 탐구합니다. 과거 상태로의 복원과 객체 구조의 확장 가능한 설계 방법을 학습합니다.

서론: 시간을 저장하고 기능을 분리하다

“Memento는 시간을 객체로 만들어 과거로 돌아갈 수 있게 하고, Visitor는 구조와 연산을 분리하여 무한한 확장을 가능하게 한다.”

소프트웨어 개발에서 우리는 두 가지 근본적인 도전에 직면합니다:

  1. 어떻게 객체의 과거 상태를 안전하게 보존하고 복원할 것인가? (시간적 캡슐화)
  2. 어떻게 객체 구조를 변경하지 않고 새로운 연산을 추가할 것인가? (연산의 확장성)

Memento 패턴“캡슐화를 유지하면서 객체의 상태를 저장하고 복원” 할 수 있게 합니다. 마치 시간을 되돌리는 마법과 같습니다.

Visitor 패턴“객체 구조와 연산을 분리” 하여 기존 클래스를 수정하지 않고도 새로운 기능을 추가할 수 있게 합니다.

이 두 패턴은 객체지향 프로그래밍의 한계를 창의적으로 극복하는 대표적인 예시입니다:

  • Memento: 시간적 복잡성 관리
  • Visitor: 기능적 복잡성 관리

Memento 패턴 - 시간을 되돌리는 마법

Memento 패턴의 핵심 철학

Memento 패턴의 핵심은 “캡슐화를 깨지 않으면서 객체의 내부 상태를 외부에 저장” 하는 것입니다. 이는 시간 여행을 가능하게 하는 마법 같은 패턴입니다.

이 섹션의 모든 예제는 아래 임포트를 전제로 합니다.

1
2
3
4
5
6
import java.util.*;
import java.util.zip.*;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.time.*;
import java.time.format.DateTimeFormatter;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Memento 패턴 없이 구현한다면?
class BadDocumentEditor {
    private String content;
    private int cursorPosition;
    private List<String> contentHistory = new ArrayList<>();
    private List<Integer> positionHistory = new ArrayList<>();
    
    public void saveState() {
        // 😱 내부 상태를 직접 노출
        contentHistory.add(content);
        positionHistory.add(cursorPosition);
    }
    
    public void undo() {
        if (!contentHistory.isEmpty()) {
            // 😱 캡슐화 깨짐, 상태 동기화 문제
            content = contentHistory.remove(contentHistory.size() - 1);
            cursorPosition = positionHistory.remove(positionHistory.size() - 1);
        }
    }
    
    // 😱 새로운 상태가 추가될 때마다 모든 히스토리 관련 코드 수정
    // 😱 외부에서 히스토리에 직접 접근 가능 (캡슐화 위반)
    // 😱 메모리 누수 위험 (히스토리 무제한 증가)
}

Memento 패턴으로 우아하게 해결

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
// Memento 패턴의 우아함
// 1. Memento 인터페이스 (Marker Interface)
interface DocumentMemento {
    // 외부에서는 구체적인 내용을 알 수 없음
    LocalDateTime getTimestamp();
    String getDescription();
}

// 2. Originator - 상태를 가진 원본 객체
class DocumentEditor {
    private StringBuilder content;
    private int cursorPosition;
    private Map<String, Object> properties;
    private LocalDateTime lastModified;
    
    public DocumentEditor() {
        this.content = new StringBuilder();
        this.cursorPosition = 0;
        this.properties = new HashMap<>();
        this.lastModified = LocalDateTime.now();
    }
    
    // 문서 편집 메서드들
    public void insertText(String text) {
        content.insert(cursorPosition, text);
        cursorPosition += text.length();
        lastModified = LocalDateTime.now();
    }
    
    public void deleteText(int length) {
        if (length > 0 && cursorPosition >= length) {
            content.delete(cursorPosition - length, cursorPosition);
            cursorPosition -= length;
            lastModified = LocalDateTime.now();
        }
    }
    
    public void setCursorPosition(int position) {
        if (position >= 0 && position <= content.length()) {
            this.cursorPosition = position;
        }
    }
    
    public void setProperty(String key, Object value) {
        properties.put(key, value);
        lastModified = LocalDateTime.now();
    }
    
    // Memento 생성 - 현재 상태를 저장
    public DocumentMemento createMemento() {
        return new ConcreteDocumentMemento(
            content.toString(),
            cursorPosition,
            new HashMap<>(properties),
            lastModified
        );
    }
    
    // Memento로부터 상태 복원
    public void restoreFromMemento(DocumentMemento memento) {
        if (memento instanceof ConcreteDocumentMemento) {
            ConcreteDocumentMemento concreteMemento = (ConcreteDocumentMemento) memento;
            
            this.content = new StringBuilder(concreteMemento.content);
            this.cursorPosition = concreteMemento.cursorPosition;
            this.properties = new HashMap<>(concreteMemento.properties);
            this.lastModified = concreteMemento.lastModified;
            
            System.out.println("[OK] Document restored to state: " + memento.getDescription());
        }
    }
    
    // Nested Class로 Memento 구현 (캡슐화 보장)
    private static class ConcreteDocumentMemento implements DocumentMemento {
        private final String content;
        private final int cursorPosition;
        private final Map<String, Object> properties;
        private final LocalDateTime lastModified;
        private final LocalDateTime snapshotTime;
        
        private ConcreteDocumentMemento(String content, int cursorPosition,
                                      Map<String, Object> properties, LocalDateTime lastModified) {
            this.content = content;
            this.cursorPosition = cursorPosition;
            this.properties = properties;
            this.lastModified = lastModified;
            this.snapshotTime = LocalDateTime.now();
        }
        
        @Override
        public LocalDateTime getTimestamp() {
            return snapshotTime;
        }
        
        @Override
        public String getDescription() {
            return String.format("Content: %d chars, Cursor: %d, Modified: %s",
                               content.length(), cursorPosition, 
                               lastModified.format(DateTimeFormatter.ofPattern("HH:mm:ss")));
        }
    }
    
    // 현재 상태 출력
    public void printStatus() {
        System.out.printf("Content: '%s' | Cursor: %d | Length: %d\n",
                         content.toString(), cursorPosition, content.length());
    }
    
    public String getContent() {
        return content.toString();
    }
    
    public int getCursorPosition() {
        return cursorPosition;
    }
}

// 3. Caretaker - Memento 관리자
class DocumentHistory {
    private final Deque<DocumentMemento> undoStack;
    private final Deque<DocumentMemento> redoStack;
    private final int maxHistorySize;
    private final DocumentEditor editor;
    
    public DocumentHistory(DocumentEditor editor, int maxHistorySize) {
        this.editor = editor;
        this.maxHistorySize = maxHistorySize;
        this.undoStack = new ArrayDeque<>();
        this.redoStack = new ArrayDeque<>();
    }
    
    // 현재 상태를 히스토리에 저장
    public void saveState() {
        DocumentMemento memento = editor.createMemento();
        undoStack.addLast(memento);
        redoStack.clear(); // 새로운 상태 저장 시 redo 스택 클리어
        
        // 히스토리 크기 제한
        while (undoStack.size() > maxHistorySize) {
            undoStack.removeFirst();
        }
        
        System.out.println("💾 State saved: " + memento.getDescription());
    }
    
    // Undo 수행
    public boolean undo() {
        if (undoStack.isEmpty()) {
            System.out.println("[Info] No more states to undo");
            return false;
        }
        
        // 현재 상태를 redo 스택에 저장
        redoStack.addLast(editor.createMemento());
        
        // 이전 상태로 복원
        DocumentMemento previousState = undoStack.removeLast();
        editor.restoreFromMemento(previousState);
        
        System.out.println("↶ Undo performed");
        return true;
    }
    
    // Redo 수행
    public boolean redo() {
        if (redoStack.isEmpty()) {
            System.out.println("[Info] No more states to redo");
            return false;
        }
        
        // 현재 상태를 undo 스택에 저장
        undoStack.addLast(editor.createMemento());
        
        // redo 상태로 복원
        DocumentMemento redoState = redoStack.removeLast();
        editor.restoreFromMemento(redoState);
        
        System.out.println("↷ Redo performed");
        return true;
    }
    
    // 히스토리 정보
    public void printHistory() {
        System.out.println("=== Document History ===");
        System.out.println("Undo available: " + undoStack.size());
        System.out.println("Redo available: " + redoStack.size());
        
        if (!undoStack.isEmpty()) {
            System.out.println("Recent states:");
            int count = 0;
            for (DocumentMemento memento : undoStack) {
                if (count++ >= 3) break; // 최근 3개만 표시
                System.out.println("  " + memento.getDescription());
            }
        }
    }
    
    public boolean canUndo() {
        return !undoStack.isEmpty();
    }
    
    public boolean canRedo() {
        return !redoStack.isEmpty();
    }
    
    public void clearHistory() {
        undoStack.clear();
        redoStack.clear();
        System.out.println("🗑️ History cleared");
    }
}

// 4. 고급 Memento - 압축 및 최적화
class OptimizedDocumentMemento implements DocumentMemento {
    private final byte[] compressedContent;
    private final int cursorPosition;
    private final LocalDateTime timestamp;
    private final String description;
    
    public OptimizedDocumentMemento(String content, int cursorPosition) {
        this.compressedContent = compressString(content);
        this.cursorPosition = cursorPosition;
        this.timestamp = LocalDateTime.now();
        this.description = String.format("Compressed: %d bytes, Cursor: %d",
                                        compressedContent.length, cursorPosition);
    }
    
    public String getDecompressedContent() {
        return decompressString(compressedContent);
    }
    
    public int getCursorPosition() {
        return cursorPosition;
    }
    
    @Override
    public LocalDateTime getTimestamp() {
        return timestamp;
    }
    
    @Override
    public String getDescription() {
        return description;
    }
    
    // 간단한 압축 시뮬레이션
    private byte[] compressString(String content) {
        try {
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            GZIPOutputStream gzos = new GZIPOutputStream(baos);
            gzos.write(content.getBytes(StandardCharsets.UTF_8));
            gzos.close();
            return baos.toByteArray();
        } catch (Exception e) {
            return content.getBytes(StandardCharsets.UTF_8);
        }
    }
    
    private String decompressString(byte[] compressed) {
        try {
            ByteArrayInputStream bais = new ByteArrayInputStream(compressed);
            GZIPInputStream gzis = new GZIPInputStream(bais);
            return new String(gzis.readAllBytes(), StandardCharsets.UTF_8);
        } catch (Exception e) {
            return new String(compressed, StandardCharsets.UTF_8);
        }
    }
}

// 사용 예시
class MementoPatternDemo {
    public static void main(String[] args) {
        DocumentEditor editor = new DocumentEditor();
        DocumentHistory history = new DocumentHistory(editor, 10);
        
        System.out.println("=== Document Editor Demo ===\n");
        
        // 초기 상태 저장
        history.saveState();
        
        // 텍스트 편집
        editor.insertText("Hello");
        editor.printStatus();
        history.saveState();
        
        editor.insertText(" World");
        editor.printStatus();
        history.saveState();
        
        editor.setCursorPosition(5);
        editor.insertText(" Beautiful");
        editor.printStatus();
        history.saveState();
        
        // 히스토리 출력
        System.out.println();
        history.printHistory();
        
        // Undo 테스트
        System.out.println("\n=== Undo/Redo Test ===");
        
        history.undo();
        editor.printStatus();
        
        history.undo();
        editor.printStatus();
        
        history.redo();
        editor.printStatus();
        
        // 새로운 편집 (redo 스택 클리어 테스트)
        editor.insertText("!");
        editor.printStatus();
        
        System.out.println("\nTrying to redo after new edit:");
        history.redo(); // 실패해야 함
        
        System.out.println();
        history.printHistory();
    }
}

Visitor 패턴 - 연산의 외부화

Visitor 패턴의 핵심 철학

Visitor 패턴은 “객체 구조와 연산을 분리” 하여 기존 클래스를 수정하지 않고도 새로운 기능을 추가할 수 있게 합니다. 이는 개방-폐쇄 원칙의 완벽한 구현입니다.

이 섹션의 모든 예제는 아래 임포트를 전제로 합니다.

1
import java.util.*;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Visitor 패턴 없이 구현한다면?
abstract class BadShape {
    // 😱 새로운 연산을 추가할 때마다 모든 Shape 클래스 수정
    public abstract double calculateArea();
    public abstract void draw();
    public abstract String exportToSVG();
    public abstract void applyTexture();
    // 😱 계속 메서드가 추가됨... 개방-폐쇄 원칙 위배
}

class BadCircle extends BadShape {
    // 😱 모든 연산이 Circle 클래스 안에 존재
    @Override
    public double calculateArea() { /* calculateArea 구현 */ }
    
    @Override
    public void draw() { /* draw 구현 */ }
    
    @Override
    public String exportToSVG() { /* exportToSVG 구현 */ }
    
    @Override
    public void applyTexture() { /* applyTexture 구현 */ }
    
    // 😱 새로운 연산 추가 시 모든 Shape 클래스 수정 필요
}

Visitor 패턴으로 우아하게 해결

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
// Visitor 패턴의 우아함
// 1. Visitor 인터페이스
interface ShapeVisitor {
    void visit(Circle circle);
    void visit(Rectangle rectangle);
    void visit(Triangle triangle);
    void visit(CompoundShape compoundShape);
}

// 2. Element 인터페이스
interface Shape {
    void accept(ShapeVisitor visitor);
    String getName();
}

// 3. Concrete Elements
class Circle implements Shape {
    private final double radius;
    private final Point center;
    
    public Circle(double radius, Point center) {
        this.radius = radius;
        this.center = center;
    }
    
    @Override
    public void accept(ShapeVisitor visitor) {
        visitor.visit(this); // Double Dispatch
    }
    
    @Override
    public String getName() {
        return "Circle";
    }
    
    public double getRadius() { return radius; }
    public Point getCenter() { return center; }
}

class Rectangle implements Shape {
    private final double width;
    private final double height;
    private final Point topLeft;
    
    public Rectangle(double width, double height, Point topLeft) {
        this.width = width;
        this.height = height;
        this.topLeft = topLeft;
    }
    
    @Override
    public void accept(ShapeVisitor visitor) {
        visitor.visit(this);
    }
    
    @Override
    public String getName() {
        return "Rectangle";
    }
    
    public double getWidth() { return width; }
    public double getHeight() { return height; }
    public Point getTopLeft() { return topLeft; }
}

class Triangle implements Shape {
    private final Point[] vertices;
    
    public Triangle(Point p1, Point p2, Point p3) {
        this.vertices = new Point[]{p1, p2, p3};
    }
    
    @Override
    public void accept(ShapeVisitor visitor) {
        visitor.visit(this);
    }
    
    @Override
    public String getName() {
        return "Triangle";
    }
    
    public Point[] getVertices() { return vertices.clone(); }
}

// Composite 패턴과 결합
class CompoundShape implements Shape {
    private final List<Shape> children;
    private final String name;
    
    public CompoundShape(String name) {
        this.name = name;
        this.children = new ArrayList<>();
    }
    
    public void addShape(Shape shape) {
        children.add(shape);
    }
    
    @Override
    public void accept(ShapeVisitor visitor) {
        visitor.visit(this);
        // 자식 Shape들도 방문
        for (Shape child : children) {
            child.accept(visitor);
        }
    }
    
    @Override
    public String getName() {
        return name;
    }
    
    public List<Shape> getChildren() {
        return new ArrayList<>(children);
    }
}

왜 4개의 서로 다른 Visitor가 공존하는가

AreaCalculatorVisitor, DrawingVisitor, SVGExportVisitor, ValidationVisitor는 모두 같은 Shape 계층(Circle, Rectangle, Triangle, CompoundShape)을 순회하지만, 서로의 존재를 전혀 모릅니다. 이것이 Visitor 패턴의 핵심 이득입니다 — “넓이 계산"과 “그리기"와 “SVG 내보내기"와 “유효성 검증"은 서로 관련 없는 별개의 관심사인데, Visitor 없이 구현했다면(BadShape처럼) 이 네 가지 연산이 전부 Circle 클래스 안에 메서드로 뒤섞여 있어야 합니다. Visitor로 분리하면 각 연산은 자신만의 상태(totalArea, canvas StringBuilder, svg StringBuilder, errors/warnings 리스트)를 독립적으로 들고 다니며, 하나를 수정해도 나머지 세 개에 영향을 주지 않고, 다섯 번째 연산(예: JSON 직렬화)을 추가할 때도 Circle이나 Rectangle의 소스 코드를 단 한 줄도 건드릴 필요가 없습니다. 즉 4개의 Visitor는 “우연히 네 개가 된 것"이 아니라, 하나의 고정된 구조 위에서 독립적으로 확장·테스트·배포될 수 있는 연산의 개수만큼 자연스럽게 늘어난 것입니다.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
// 4. Concrete Visitors - 다양한 연산 구현
class AreaCalculatorVisitor implements ShapeVisitor {
    private double totalArea = 0;
    private final Map<String, Double> areaByShape = new HashMap<>();
    
    @Override
    public void visit(Circle circle) {
        double area = Math.PI * circle.getRadius() * circle.getRadius();
        totalArea += area;
        areaByShape.put("Circle_" + System.identityHashCode(circle), area);
        
        System.out.printf("📐 Circle area: %.2f (radius: %.2f)\n", area, circle.getRadius());
    }
    
    @Override
    public void visit(Rectangle rectangle) {
        double area = rectangle.getWidth() * rectangle.getHeight();
        totalArea += area;
        areaByShape.put("Rectangle_" + System.identityHashCode(rectangle), area);
        
        System.out.printf("📐 Rectangle area: %.2f (%.2f x %.2f)\n", 
                         area, rectangle.getWidth(), rectangle.getHeight());
    }
    
    @Override
    public void visit(Triangle triangle) {
        // 신발끈 공식 (Shoelace formula) 사용
        Point[] vertices = triangle.getVertices();
        double area = Math.abs(
            (vertices[0].x * (vertices[1].y - vertices[2].y) +
             vertices[1].x * (vertices[2].y - vertices[0].y) +
             vertices[2].x * (vertices[0].y - vertices[1].y)) / 2.0
        );
        
        totalArea += area;
        areaByShape.put("Triangle_" + System.identityHashCode(triangle), area);
        
        System.out.printf("📐 Triangle area: %.2f\n", area);
    }
    
    @Override
    public void visit(CompoundShape compoundShape) {
        System.out.printf("📐 Compound shape '%s' contains %d children\n",
                         compoundShape.getName(), compoundShape.getChildren().size());
    }
    
    public double getTotalArea() {
        return totalArea;
    }
    
    public Map<String, Double> getAreaByShape() {
        return new HashMap<>(areaByShape);
    }
    
    public void reset() {
        totalArea = 0;
        areaByShape.clear();
    }
}

class DrawingVisitor implements ShapeVisitor {
    private final StringBuilder canvas = new StringBuilder();
    private int indentLevel = 0;
    
    @Override
    public void visit(Circle circle) {
        addIndent();
        canvas.append(String.format("🔵 Drawing Circle: center(%.1f, %.1f), radius=%.1f\n",
                                   circle.getCenter().x, circle.getCenter().y, circle.getRadius()));
    }
    
    @Override
    public void visit(Rectangle rectangle) {
        addIndent();
        canvas.append(String.format("⬜ Drawing Rectangle: top-left(%.1f, %.1f), size=%.1fx%.1f\n",
                                   rectangle.getTopLeft().x, rectangle.getTopLeft().y,
                                   rectangle.getWidth(), rectangle.getHeight()));
    }
    
    @Override
    public void visit(Triangle triangle) {
        addIndent();
        Point[] vertices = triangle.getVertices();
        canvas.append(String.format("🔺 Drawing Triangle: vertices[(%.1f,%.1f), (%.1f,%.1f), (%.1f,%.1f)]\n",
                                   vertices[0].x, vertices[0].y,
                                   vertices[1].x, vertices[1].y,
                                   vertices[2].x, vertices[2].y));
    }
    
    @Override
    public void visit(CompoundShape compoundShape) {
        addIndent();
        canvas.append(String.format("📦 Drawing Compound Shape: '%s'\n", compoundShape.getName()));
        indentLevel++;
    }
    
    private void addIndent() {
        for (int i = 0; i < indentLevel; i++) {
            canvas.append("  ");
        }
    }
    
    public String getCanvas() {
        return canvas.toString();
    }
    
    public void reset() {
        canvas.setLength(0);
        indentLevel = 0;
    }
}

class SVGExportVisitor implements ShapeVisitor {
    private final StringBuilder svg = new StringBuilder();
    private boolean headerAdded = false;
    
    public SVGExportVisitor() {
        addSVGHeader();
    }
    
    @Override
    public void visit(Circle circle) {
        if (!headerAdded) addSVGHeader();
        
        svg.append(String.format(
            "  <circle cx=\"%.1f\" cy=\"%.1f\" r=\"%.1f\" fill=\"blue\" stroke=\"black\" stroke-width=\"1\"/>\n",
            circle.getCenter().x, circle.getCenter().y, circle.getRadius()
        ));
    }
    
    @Override
    public void visit(Rectangle rectangle) {
        if (!headerAdded) addSVGHeader();
        
        svg.append(String.format(
            "  <rect x=\"%.1f\" y=\"%.1f\" width=\"%.1f\" height=\"%.1f\" fill=\"red\" stroke=\"black\" stroke-width=\"1\"/>\n",
            rectangle.getTopLeft().x, rectangle.getTopLeft().y,
            rectangle.getWidth(), rectangle.getHeight()
        ));
    }
    
    @Override
    public void visit(Triangle triangle) {
        if (!headerAdded) addSVGHeader();
        
        Point[] vertices = triangle.getVertices();
        String points = String.format("%.1f,%.1f %.1f,%.1f %.1f,%.1f",
                                     vertices[0].x, vertices[0].y,
                                     vertices[1].x, vertices[1].y,
                                     vertices[2].x, vertices[2].y);
        
        svg.append(String.format(
            "  <polygon points=\"%s\" fill=\"green\" stroke=\"black\" stroke-width=\"1\"/>\n",
            points
        ));
    }
    
    @Override
    public void visit(CompoundShape compoundShape) {
        svg.append(String.format("  <!-- Compound Shape: %s -->\n", compoundShape.getName()));
    }
    
    private void addSVGHeader() {
        svg.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
        svg.append("<svg width=\"800\" height=\"600\" xmlns=\"http://www.w3.org/2000/svg\">\n");
        headerAdded = true;
    }
    
    public String getSVG() {
        return svg.toString() + "</svg>\n";
    }
}

class ValidationVisitor implements ShapeVisitor {
    private final List<String> errors = new ArrayList<>();
    private final List<String> warnings = new ArrayList<>();
    
    @Override
    public void visit(Circle circle) {
        if (circle.getRadius() <= 0) {
            errors.add("Circle has invalid radius: " + circle.getRadius());
        }
        if (circle.getRadius() > 1000) {
            warnings.add("Circle has very large radius: " + circle.getRadius());
        }
    }
    
    @Override
    public void visit(Rectangle rectangle) {
        if (rectangle.getWidth() <= 0 || rectangle.getHeight() <= 0) {
            errors.add("Rectangle has invalid dimensions: " + 
                      rectangle.getWidth() + "x" + rectangle.getHeight());
        }
        if (rectangle.getWidth() * rectangle.getHeight() > 1000000) {
            warnings.add("Rectangle has very large area");
        }
    }
    
    @Override
    public void visit(Triangle triangle) {
        Point[] vertices = triangle.getVertices();
        // 삼각형의 세 점이 일직선상에 있는지 확인
        double area = Math.abs(
            (vertices[0].x * (vertices[1].y - vertices[2].y) +
             vertices[1].x * (vertices[2].y - vertices[0].y) +
             vertices[2].x * (vertices[0].y - vertices[1].y)) / 2.0
        );
        
        if (area < 0.001) {
            errors.add("Triangle vertices are collinear");
        }
    }
    
    @Override
    public void visit(CompoundShape compoundShape) {
        if (compoundShape.getChildren().isEmpty()) {
            warnings.add("Compound shape '" + compoundShape.getName() + "' is empty");
        }
    }
    
    public List<String> getErrors() {
        return new ArrayList<>(errors);
    }
    
    public List<String> getWarnings() {
        return new ArrayList<>(warnings);
    }
    
    public boolean isValid() {
        return errors.isEmpty();
    }
    
    public void reset() {
        errors.clear();
        warnings.clear();
    }
}

// Point 클래스
class Point {
    final double x, y;
    
    public Point(double x, double y) {
        this.x = x;
        this.y = y;
    }
}

// 사용 예시
class VisitorPatternDemo {
    public static void main(String[] args) {
        // 도형 생성
        Circle circle = new Circle(5.0, new Point(10, 10));
        Rectangle rectangle = new Rectangle(8.0, 6.0, new Point(0, 0));
        Triangle triangle = new Triangle(
            new Point(0, 0), new Point(4, 0), new Point(2, 3)
        );
        
        // 복합 도형 생성
        CompoundShape group = new CompoundShape("Main Group");
        group.addShape(circle);
        group.addShape(rectangle);
        group.addShape(triangle);
        
        System.out.println("=== Visitor Pattern Demo ===\n");
        
        // 1. 넓이 계산
        System.out.println("1. Area Calculation:");
        AreaCalculatorVisitor areaCalculator = new AreaCalculatorVisitor();
        group.accept(areaCalculator);
        System.out.printf("Total area: %.2f\n\n", areaCalculator.getTotalArea());
        
        // 2. 그리기
        System.out.println("2. Drawing:");
        DrawingVisitor drawer = new DrawingVisitor();
        group.accept(drawer);
        System.out.println(drawer.getCanvas());
        
        // 3. SVG 내보내기
        System.out.println("3. SVG Export:");
        SVGExportVisitor svgExporter = new SVGExportVisitor();
        group.accept(svgExporter);
        System.out.println(svgExporter.getSVG());
        
        // 4. 유효성 검증
        System.out.println("4. Validation:");
        ValidationVisitor validator = new ValidationVisitor();
        group.accept(validator);
        
        if (validator.isValid()) {
            System.out.println("[OK] All shapes are valid");
        } else {
            System.out.println("[Error] Validation errors found:");
            validator.getErrors().forEach(error -> System.out.println("  - " + error));
        }
        
        if (!validator.getWarnings().isEmpty()) {
            System.out.println("[Warning] Warnings:");
            validator.getWarnings().forEach(warning -> System.out.println("  - " + warning));
        }
    }
}

Memento와 Visitor의 현대적 활용

Git의 커밋 시스템 (Memento 패턴)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Git의 커밋이 Memento 패턴의 실제 구현
class GitRepository {
    private WorkingDirectory workingDir;
    private List<Commit> commitHistory;
    
    public Commit createCommit(String message) {
        // 현재 작업 디렉토리 상태를 Memento로 저장
        return new Commit(workingDir.createSnapshot(), message);
    }
    
    public void checkout(String commitHash) {
        // 특정 커밋의 Memento로 작업 디렉토리 복원
        Commit commit = findCommit(commitHash);
        workingDir.restoreFromSnapshot(commit.getSnapshot());
    }
}

class Commit {
    private final TreeSnapshot snapshot; // Memento
    private final String hash;
    private final String message;
    private final LocalDateTime timestamp;
    
    // Git의 각 커밋이 파일 시스템 상태의 Memento
}

컴파일러의 AST 처리 (Visitor 패턴)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 컴파일러에서 AST 노드 처리
interface ASTVisitor {
    void visit(BinaryOperationNode node);
    void visit(VariableNode node);
    void visit(FunctionCallNode node);
}

class OptimizationVisitor implements ASTVisitor {
    @Override
    public void visit(BinaryOperationNode node) {
        // 상수 접기 최적화
        if (node.isConstantExpression()) {
            node.replaceWithConstant(node.evaluate());
        }
    }
    
    // 새로운 최적화를 추가해도 AST 노드 클래스는 수정하지 않음
}

class CodeGeneratorVisitor implements ASTVisitor {
    @Override
    public void visit(BinaryOperationNode node) {
        // 바이트코드 생성
        emit("LOAD " + node.getLeft());
        emit("LOAD " + node.getRight());
        emit(getOpcode(node.getOperator()));
    }
}

한눈에 보는 Memento & Visitor 패턴

Memento vs Visitor 핵심 비교

비교 항목Memento 패턴Visitor 패턴
핵심 목적객체 상태 저장 및 복원객체 구조에 새 연산 추가
캡슐화 보호내부 상태 노출 없이 저장구조 변경 없이 연산 추가
확장 대상상태 이력 관리연산/기능 확장
OCP 준수상태 저장에 대해연산 추가에 대해
복잡도 증가Memento 클래스 추가Visitor + Element 클래스
사용 빈도중간 (Undo/Redo)낮음 (컴파일러, 분석 도구)

Memento 패턴 핵심 참여자

참여자역할책임
Originator원본 객체상태 저장/복원, Memento 생성
Memento상태 보관함내부 상태 저장 (불변)
Caretaker관리자Memento 보관, 이력 관리

Visitor 패턴 핵심 참여자

참여자역할책임
Visitor연산 인터페이스visit(Element) 메서드 정의
ConcreteVisitor구체 연산각 Element 타입별 처리
Element요소 인터페이스accept(Visitor) 정의
ConcreteElement구체 요소Visitor 수용
ObjectStructure구조요소 컬렉션 관리

적용 시나리오 비교

시나리오MementoVisitor
Undo/Redo 기능OX
게임 저장/로드OX
트랜잭션 롤백OX
AST 분석/변환XO
문서 내보내기 (HTML, PDF)XO
파일 시스템 용량 계산XO
에디터 스냅샷OX

Double Dispatch 메커니즘 (Visitor)

단계호출결정 요소
1단계element.accept(visitor)Element 타입
2단계visitor.visit(this)Visitor 타입

두 단계의 호출이 각각 다른 타입에 의해 결정되기 때문에 “이중” 디스패치라고 부른다. 1단계는 element의 실제 런타임 타입에 따라 오버라이드된 accept()가 선택되고, 2단계는 그 accept() 안에서 visitor.visit(this)를 호출할 때 컴파일 타임에 this의 정적 타입(예: Circle)으로 오버로드된 visit()가 선택된다. 단일 디스패치만 지원하는 자바에서 두 타입의 조합에 따라 실행할 코드를 결정하려면 이렇게 두 번의 가상 호출을 거쳐야 한다.

sequenceDiagram
    participant Client
    participant Circle as "circle: Circle"
    participant Visitor as "visitor: AreaCalculatorVisitor"

    Client->>Circle: accept(visitor)
    Note over Circle: 1단계 디스패치
Circle의 accept() 선택 Circle->>Visitor: visit(this) Note over Visitor: 2단계 디스패치
Circle 오버로드 선택 Visitor-->>Circle: (Circle 전용 연산 수행) Circle-->>Client: 반환

메모리 관리 전략 (Memento)

전략설명장단점
전체 상태 저장모든 상태 복사단순하나 메모리 낭비
차분 저장변경분만 저장메모리 효율적, 구현 복잡
주기적 스냅샷일정 간격 전체 저장균형 잡힌 접근
LRU 캐시오래된 Memento 삭제메모리 제한 가능

DocumentHistoryundo()/redo()는 두 스택(undoStack, redoStack) 사이에서 Memento를 주고받으며 상태를 이동시킨다. 편집 동작이 일어나면 redo 스택은 비워지는데, 이는 “되돌린 뒤 새로 편집하면 이전에 되돌렸던 미래는 더 이상 유효하지 않다"는 불변식을 강제하기 위함이다. 아래 다이어그램은 이 세 가지 전이(저장, undo, redo)가 두 스택의 상태를 어떻게 바꾸는지 보여준다.

stateDiagram-v2
    [*] --> Empty
    Empty --> HasUndo: saveState()
undoStack.push(memento) HasUndo --> HasUndo: saveState()
undoStack.push(memento) HasUndo --> HasBoth: undo()
redoStack.push(current)
restore(undoStack.pop()) HasBoth --> HasUndo: undo()
redoStack.push(current)
restore(undoStack.pop()) HasBoth --> HasBoth: redo()
undoStack.push(current)
restore(redoStack.pop()) HasBoth --> HasUndoOnly: 새 편집 발생
redoStack.clear() HasUndoOnly --> HasBoth: undo()
redoStack.push(current)
restore(undoStack.pop()) HasUndoOnly --> HasUndoOnly: saveState()
undoStack.push(memento)

장단점 비교

패턴장점단점
Memento캡슐화 유지, Undo 지원, 상태 이력메모리 사용량, Memento 클래스 필요
Visitor새 연산 쉽게 추가, 관련 연산 집중새 Element 추가 어려움, 캡슐화 위반 가능

현대적 대안 비교

패턴전통적 구현현대적 대안
Memento직접 구현Event Sourcing, 불변 객체 + 이력
Visitor직접 구현패턴 매칭 (Kotlin sealed class), Stream API

적용 체크리스트

Memento 체크 항목Visitor 체크 항목
객체 상태를 저장/복원해야 하는가?구조 변경 없이 연산 추가 필요?
캡슐화를 유지하며 스냅샷 필요?다양한 타입에 동일 연산 적용?
Undo/Redo 기능이 필요한가?관련 연산을 한 곳에 집중?
메모리 관리 전략을 수립했는가?Element 타입이 안정적인가?

평가 기준

이 글을 읽고 나면 다음 항목들을 스스로 점검해볼 수 있습니다.

  • Memento 패턴이 Originator/Memento/Caretaker 세 역할로 캡슐화를 유지하며 상태를 저장·복원하는 원리를 설명할 수 있다.
  • Visitor 패턴의 Double Dispatch가 단일 디스패치 언어(Java)에서 왜 두 번의 가상 호출을 필요로 하는지 설명할 수 있다.
  • Undo 스택과 Redo 스택이 편집·undo·redo 각 동작에서 어떻게 상태를 주고받는지, 그리고 새 편집이 redo 스택을 비우는 이유를 설명할 수 있다.
  • Memento와 Visitor 중 어떤 패턴을 적용할지 “적용 시나리오 비교”·“적용 체크리스트” 표를 근거로 판단할 수 있다.
  • Visitor 패턴이 새로운 Element 타입 추가에는 취약하고 새로운 연산 추가에는 강한 이유(개방-폐쇄 원칙이 적용되는 축이 다름)를 설명할 수 있다.

흔한 오개념

오해 1: “Memento는 항상 전체 상태를 깊은 복사해야 한다”

DocumentEditor.createMemento()content 전체를 매번 복사하는 모습만 보면 Memento는 항상 전체 스냅샷을 떠야 하는 패턴처럼 보입니다. 하지만 GoF의 정의는 “캡슐화를 유지하며 상태를 저장·복원할 것"만 요구할 뿐, 저장 방식은 규정하지 않습니다. 이 글의 “메모리 관리 전략” 표에서 다룬 차분(diff) 저장, 주기적 스냅샷, LRU 캐시, 그리고 OptimizedDocumentMemento의 GZIP 압축까지 모두 유효한 Memento 구현입니다. 실무에서는 상태 변경 빈도와 크기에 따라 전체 복사가 차분 계산·재생 비용보다 오히려 저렴할 수 있어, “무조건 깊은 복사"도 “무조건 차분 저장"도 아닌 상황별 트레이드오프 문제로 다뤄야 합니다.

오해 2: “Visitor는 새 Element 추가에도 유연하다”

Visitor는 “확장에 열려 있다"는 개방-폐쇄 원칙의 대표 사례로 자주 소개되지만, 이 유연함은 새 연산 축에만 적용됩니다. ShapeVisitor 인터페이스에 새로운 도형(예: Polygon)을 추가하려면 visit(Polygon) 메서드를 인터페이스에 추가해야 하고, 그 순간 이미 존재하는 AreaCalculatorVisitor, DrawingVisitor, SVGExportVisitor, ValidationVisitor 네 클래스 전부가 컴파일 에러를 내며 새 오버로드 구현을 요구합니다. 즉 Visitor는 “연산 축"에서는 개방-폐쇄 원칙을 지키지만 “타입(Element) 축"에서는 정확히 반대로 위반합니다. 이 비대칭이 바로 Visitor가 Element 계층은 안정적이고 연산만 자주 추가되는 도메인(컴파일러 AST 분석, 문서 포맷 변환기)에 적합하고, 반대로 Element 타입이 자주 늘어나는 도메인에는 적합하지 않은 이유입니다.

결론: 시간과 기능의 마법사들

Memento와 Visitor 패턴은 객체지향 프로그래밍의 한계를 창의적으로 극복하는 패턴들입니다:

패턴별 핵심 가치:

Memento 패턴:

  • 시간적 캡슐화 - 과거 상태 보존
  • 캡슐화 유지 - 내부 구조 노출 없이 상태 저장
  • Undo/Redo 시스템 구현
  • 버전 관리스냅샷 기능

Visitor 패턴:

  • 연산의 외부화 - 구조와 기능 분리
  • 개방-폐쇄 원칙 실현
  • Double Dispatch 메커니즘
  • 타입별 다형성 처리

적용 시 주의사항

어느 패턴을 적용할지는 앞서 정리한 “적용 시나리오 비교"와 “적용 체크리스트” 표를 기준으로 판단하면 충분하며, 여기서는 두 패턴 모두에 공통되는 실무 주의점만 짚는다. Memento는 상태 전체를 복사해 저장하므로 히스토리가 길어질수록 메모리 사용량이 늘어나 압축이나 차분 저장 같은 최적화가 필요하고, Visitor는 새로운 Element 타입이 하나 추가될 때마다 모든 ConcreteVisitor의 visit() 오버로드를 함께 수정해야 하므로 Element 계층이 자주 바뀌는 도메인에는 적합하지 않다. 두 패턴 모두 과도하게 적용하면 필요 이상의 클래스 계층만 늘어나는 오버엔지니어링으로 이어질 수 있다는 점도 공통적이다.

함수형 프로그래밍과의 비교:

1
2
3
4
5
6
7
Memento vs Immutability:
- Memento: 가변 객체 + 상태 저장
- Immutable: 불변 객체 + 새 인스턴스 생성

Visitor vs Pattern Matching:
- Visitor: 객체지향적 Double Dispatch
- Pattern Matching: 함수형 언어의 네이티브 지원

두 패턴 모두 **“시간의 조작”**과 **“기능의 확장”**이라는 복잡한 문제를 우아하게 해결하는 마법 같은 도구입니다. 현대 소프트웨어에서도 여전히 강력한 도구로 활용되고 있다.

다음 글에서는 패턴의 조합과 상호작용을 탐구하겠습니다. 여러 패턴을 함께 사용할 때의 시너지 효과와 아키텍처 설계 전략을 살펴보겠습니다.

토론 주제

  1. 캡슐화 vs 편의성: Memento를 Originator의 Nested Class로 구현하면 캡슐화는 지켜지지만 Originator와 강하게 결합된다. Serializable 기반 직렬화로 Memento를 구현하면 이 트레이드오프가 어떻게 달라지는가?
  2. Redo 스택 초기화: saveState()가 항상 redo 스택을 비우는 설계는 사용자의 기대와 일치하는가? 되돌린 뒤 다른 분기로 편집을 이어가야 하는 브랜치형 히스토리가 필요하다면 이 스택 기반 설계로 충분한가?
  3. Visitor의 확장 축: Element 계층과 연산 집합 중 어느 쪽이 더 자주 바뀔지 예측하기 어려운 도메인이라면, Visitor 대신 어떤 대안(패턴 매칭, instanceof 분기 등)을 검토할 수 있는가? 각 대안이 개방-폐쇄 원칙을 지키는 축은 Visitor와 같은가, 다른가?
  4. 메모리와 복원 비용의 트레이드오프: 차분(diff) 저장으로 Memento를 최적화하면 메모리는 절약되지만 복원 시 여러 차분을 순차 재생해야 한다. 어떤 상황에서 이 트레이드오프가 정당화되는가?
  5. Command와의 경계: Command 패턴도 역연산 실행으로 Undo를 구현할 수 있다. Memento 기반 Undo와 Command 기반 Undo는 각각 어떤 상황에서 더 적합한 선택이 되는가?

참고 자료

  • Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994) — Memento, Visitor 챕터. 이 시리즈가 다루는 패턴 정의의 1차 출처.
  • Joshua Bloch, Effective Java (3rd Edition, Addison-Wesley, 2018) — 불변 객체 설계와 방어적 복사 원칙(Memento의 불변 스냅샷 설계와 직결).

핵심 메시지: “Memento는 시간을 되돌리는 마법을, Visitor는 기능을 무한 확장하는 마법을 제공한다. 두 패턴 모두 객체지향의 한계를 창의적으로 극복하며, 현대 프로그래밍에서도 여전히 강력한 도구로 활용되고 있다.”