개요
모든 history entry에 대해 전체 model snapshot을 저장하는 것은 메모리 집약적이고 느립니다. Operation 기반 history 관리는 operation과 리버스 Operation만 저장하여 메모리 사용량을 크게 줄이고 성능을 향상시킵니다.
문제: 전체 model snapshot은 비용이 큼
- 큰 메모리 사용량 (전체 문서 × history 크기)
- 큰 문서에 대한 느린 clone 작업
- 가비지 컬렉션 압력
- 문서 크기에 따른 확장성 부족
전체 Model Snapshot의 문제점
각 history entry에 대해 완전한 model clone 저장:
// ❌ 나쁨: 전체 model snapshot 저장
interface HistoryEntry {
operations: Operation[];
beforeModel: DocumentModel; // 전체 clone - 비용이 큼!
afterModel: DocumentModel; // 전체 clone - 비용이 큼!
beforeSelection: Selection;
afterSelection: Selection;
}
class HistoryManager {
#undoStack: HistoryEntry[] = [];
record(operations: Operation[]) {
const beforeModel = this.model.clone(); // 비용이 큼!
// Operation 적용
operations.forEach(op => this.model.apply(op));
const afterModel = this.model.clone(); // 비용이 큼!
this.#undoStack.push({
operations,
beforeModel, // 전체 문서 저장
afterModel, // 전체 문서 저장
// ...
});
}
undo() {
const entry = this.#undoStack.pop()!;
// 전체 model 복원 - 역시 비용이 큼
this.model = entry.beforeModel.clone();
}
}
// 메모리 사용량: O(history_size × document_size)
// 1MB 문서에 50개 history entry = 50MB+ 메모리이 접근법은 확장되지 않습니다. 10,000개 노드와 50개 history entry가 있는 문서는 500,000개 노드 복사본을 저장해야 합니다.
Operation 기반 History
Operation만 저장하고 리버스 Operation을 적용하여 undo:
리버스 Operation 사용
// ✅ 좋음: Operation 기반 history
interface HistoryEntry {
operations: Operation[];
inverseOperations: Operation[]; // 사전 계산된 리버스 Operation
beforeSelection: Selection;
afterSelection: Selection;
// Model snapshot 없음!
}
class HistoryManager {
#undoStack: HistoryEntry[] = [];
#redoStack: HistoryEntry[] = [];
#model: DocumentModel; // 단일 현재 model
record(operations: Operation[]) {
const beforeSelection = this.#model.getSelection();
// Operation 적용
operations.forEach(op => this.#model.apply(op));
const afterSelection = this.#model.getSelection();
// 리버스 Operation 계산
const inverseOperations = operations
.slice()
.reverse()
.map(op => this.#getInverseOperation(op));
this.#undoStack.push({
operations,
inverseOperations,
beforeSelection,
afterSelection
});
this.#redoStack = []; // 새 액션에서 redo 지우기
}
undo(): boolean {
if (this.#undoStack.length === 0) {
return false;
}
const entry = this.#undoStack.pop()!;
// 리버스 Operation 적용 (역순으로)
for (let i = entry.inverseOperations.length - 1; i >= 0; i--) {
this.#model.apply(entry.inverseOperations[i]);
}
this.#model.setSelection(entry.beforeSelection);
// redo 스택으로 이동
this.#redoStack.push(entry);
return true;
}
redo(): boolean {
if (this.#redoStack.length === 0) {
return false;
}
const entry = this.#redoStack.pop()!;
// Operation 재적용
entry.operations.forEach(op => {
this.#model.apply(op);
});
this.#model.setSelection(entry.afterSelection);
// undo 스택으로 이동
this.#undoStack.push(entry);
return true;
}
#getInverseOperation(op: Operation): Operation {
switch (op.type) {
case 'insertText':
return {
type: 'deleteText',
path: op.path,
length: op.text.length,
deletedContent: op.text // redo를 위해 저장
};
case 'deleteText':
return {
type: 'insertText',
path: op.path,
text: op.deletedContent || ''
};
case 'insertNode':
return {
type: 'deleteNode',
path: op.path,
deletedNode: op.node // redo를 위해 저장
};
case 'deleteNode':
return {
type: 'insertNode',
path: op.path,
node: op.deletedNode
};
case 'applyFormat':
return {
type: 'removeFormat',
path: op.path,
length: op.length,
format: op.format,
previousValue: this.#model.getFormatAt(op.path, op.format)
};
case 'removeFormat':
return {
type: 'applyFormat',
path: op.path,
length: op.length,
format: op.format,
value: op.previousValue
};
default:
throw new Error(`알 수 없는 operation: ${op.type}`);
}
}
}
// 메모리 사용량: O(history_size × operation_size)
// 각각 약 10개 operation이 있는 50개 history entry = 약 수 KB
// vs 전체 snapshot의 50MB+!메모리 효율성
Operation 기반 접근법은 수준이 다른 효율성을 제공합니다:
// 예제: 사용자가 "Hello World" 입력
// 전체 snapshot 접근법:
// - beforeModel: ~1MB (전체 문서)
// - afterModel: ~1MB (전체 문서)
// 총: entry당 ~2MB
// Operation 기반 접근법:
const entry = {
operations: [
{ type: 'insertText', path: [0, 5], text: 'H' },
{ type: 'insertText', path: [0, 6], text: 'e' },
{ type: 'insertText', path: [0, 7], text: 'l' },
{ type: 'insertText', path: [0, 8], text: 'l' },
{ type: 'insertText', path: [0, 9], text: 'o' },
{ type: 'insertText', path: [0, 10], text: ' ' },
{ type: 'insertText', path: [0, 11], text: 'W' },
{ type: 'insertText', path: [0, 12], text: 'o' },
{ type: 'insertText', path: [0, 13], text: 'r' },
{ type: 'insertText', path: [0, 14], text: 'l' },
{ type: 'insertText', path: [0, 15], text: 'd' }
],
inverseOperations: [/* 11개 리버스 Operation */]
};
// 총: ~수백 바이트 vs 2MB!
// 메모리 절약: 99.9%+ 감소Hybrid 접근법
매우 긴 undo 체인의 경우, operation 기반 history와 주기적 snapshot을 결합:
주기적 Snapshot
class HybridHistoryManager {
#undoStack: HistoryEntry[] = [];
#checkpoints: Map<number, DocumentModel> = new Map();
#checkpointInterval = 20; // 20개 entry마다 snapshot 생성
record(operations: Operation[]) {
const entryIndex = this.#undoStack.length;
// 주기적으로 checkpoint 생성
if (entryIndex % this.#checkpointInterval === 0) {
this.#checkpoints.set(entryIndex, this.#model.clone());
}
// Operation 저장 (전체 model 아님)
const entry: HistoryEntry = {
operations,
inverseOperations: this.#computeInverses(operations),
checkpointIndex: entryIndex,
// ...
};
this.#undoStack.push(entry);
}
undo(): boolean {
if (this.#undoStack.length === 0) {
return false;
}
const entry = this.#undoStack.pop()!;
// 가까운 checkpoint 확인
const nearestCheckpoint = this.#findNearestCheckpoint(entry.checkpointIndex);
if (nearestCheckpoint && this.#shouldUseCheckpoint(entry, nearestCheckpoint)) {
// Checkpoint에서 복원하고 operation 재생
this.#restoreFromCheckpoint(nearestCheckpoint, entry);
} else {
// 리버스 Operation 사용 (일반적인 경우)
this.#applyInverseOperations(entry.inverseOperations);
}
return true;
}
#shouldUseCheckpoint(entry: HistoryEntry, checkpoint: number): boolean {
// 많은 operation을 undo해야 하는 경우 checkpoint 사용
const distance = entry.checkpointIndex - checkpoint;
return distance > 10; // Checkpoint 사용 임계값
}
#restoreFromCheckpoint(checkpointIndex: number, targetEntry: HistoryEntry) {
const checkpoint = this.#checkpoints.get(checkpointIndex)!;
this.#model = checkpoint.clone();
// Checkpoint에서 target까지 operation 재생
const entries = this.#undoStack.slice(checkpointIndex, targetEntry.checkpointIndex);
entries.forEach(e => {
e.operations.forEach(op => this.#model.apply(op));
});
}
}Checkpoint 시스템
// Checkpoint 전략: 전략적 지점에 snapshot 저장
class CheckpointManager {
#checkpoints: Map<number, DocumentModel> = new Map();
#maxCheckpoints = 5; // Checkpoint 수 제한
createCheckpoint(index: number, model: DocumentModel) {
// 제한에 도달하면 가장 오래된 checkpoint 제거
if (this.#checkpoints.size >= this.#maxCheckpoints) {
const oldest = Math.min(...this.#checkpoints.keys());
this.#checkpoints.delete(oldest);
}
this.#checkpoints.set(index, model.clone());
}
getNearestCheckpoint(targetIndex: number): [number, DocumentModel] | null {
let nearest: [number, DocumentModel] | null = null;
let minDistance = Infinity;
for (const [index, model] of this.#checkpoints) {
if (index <= targetIndex) {
const distance = targetIndex - index;
if (distance < minDistance) {
minDistance = distance;
nearest = [index, model];
}
}
}
return nearest;
}
// 오래된 checkpoint 정리
cleanup(beforeIndex: number) {
for (const index of this.#checkpoints.keys()) {
if (index < beforeIndex) {
this.#checkpoints.delete(index);
}
}
}
}성능 비교
| 지표 | 전체 Snapshot | Operation 기반 | Hybrid |
|---|---|---|---|
| Entry당 메모리 | ~1-10MB | ~1-10KB | ~1-10KB + 주기적 snapshot |
| 기록 시간 | 느림 (clone) | 빠름 (operation만) | 빠름 (주기적 clone) |
| Undo 시간 | 빠름 (복원) | 빠름 (리버스 Operation 적용) | 빠름 (checkpoint 또는 리버스 Operation) |
| 확장성 | 나쁨 (O(n×m)) | 우수 (O(n)) | 우수 (O(n)) |
구현
완전한 구현 예제:
class OptimizedHistoryManager {
#undoStack: HistoryEntry[] = [];
#redoStack: HistoryEntry[] = [];
#model: DocumentModel;
#maxSize = 100;
constructor(model: DocumentModel) {
this.#model = model;
}
recordTransaction(transaction: Transaction) {
const operations = transaction.getOperations();
const beforeSelection = transaction.getBeforeSelection();
const afterSelection = transaction.getAfterSelection();
// 리버스 Operation 계산
const inverseOperations = this.#computeInverseOperations(operations);
const entry: HistoryEntry = {
id: generateId(),
timestamp: Date.now(),
operations,
inverseOperations,
beforeSelection,
afterSelection
// Model snapshot 없음!
};
this.#undoStack.push(entry);
// 스택 크기 제한
if (this.#undoStack.length > this.#maxSize) {
this.#undoStack.shift();
}
this.#redoStack = [];
}
undo(): boolean {
if (this.#undoStack.length === 0) {
return false;
}
const entry = this.#undoStack.pop()!;
// 역순으로 리버스 Operation 적용
for (let i = entry.inverseOperations.length - 1; i >= 0; i--) {
const inverseOp = entry.inverseOperations[i];
this.#model.apply(inverseOp);
}
this.#model.setSelection(entry.beforeSelection);
this.#redoStack.push(entry);
return true;
}
redo(): boolean {
if (this.#redoStack.length === 0) {
return false;
}
const entry = this.#redoStack.pop()!;
// Operation 재적용
for (const op of entry.operations) {
this.#model.apply(op);
}
this.#model.setSelection(entry.afterSelection);
this.#undoStack.push(entry);
return true;
}
#computeInverseOperations(operations: Operation[]): Operation[] {
// 역순으로 리버스 Operation 계산 필요
// 각 operation 전 상태 캡처
const inverses: Operation[] = [];
for (let i = operations.length - 1; i >= 0; i--) {
const op = operations[i];
const inverse = this.#getInverseOperation(op);
inverses.unshift(inverse); // 시작 부분에 추가
}
return inverses;
}
#getInverseOperation(op: Operation): Operation {
// 구현은 operation 타입에 따라 다름
// 필요한 상태 캡처 필요 (예: 삭제된 콘텐츠)
switch (op.type) {
case 'insertText':
return {
type: 'deleteText',
path: op.path,
length: op.text.length
};
case 'deleteText':
// 원본 operation에 deletedContent가 저장되어 있어야 함
return {
type: 'insertText',
path: op.path,
text: op.deletedContent!
};
// ... 기타 operation 타입
}
}
// 선택사항: 현재 model 상태 가져오기 (디버깅용)
getCurrentModel(): DocumentModel {
return this.#model;
}
// 선택사항: History 지우기
clear() {
this.#undoStack = [];
this.#redoStack = [];
}
}모범 사례
- 항상 리버스 Operation 저장: 기록 시 리버스 Operation을 사전 계산하여 undo 중 계산 방지
- 삭제된 콘텐츠 저장: Delete operation은 적절한 undo를 위해 삭제된 콘텐츠를 저장해야 함
- 이전 값 저장: Format operation은 이전 format 값을 저장해야 함
- History 크기 제한: 무한 메모리 증가를 방지하기 위해 오래된 entry 제거
- 긴 체인에 checkpoint 사용: 매우 긴 undo 체인의 경우 주기적 snapshot과 함께 hybrid 접근법 사용
- Operation 검증: 리버스 Operation 적용 전에 operation이 유효한지 확인
- 엣지 케이스 처리: 빈 operation, 잘못된 경로, 경계 조건 고려