History 관리 최적화

전체 model clone을 피하고: 더 나은 메모리와 성능을 위한 operation 기반 undo/redo 사용.

개요

모든 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, 잘못된 경로, 경계 조건 고려