성능 최적화

대용량 문서는 부드러운 편집 성능을 유지하기 위해 신중한 최적화가 필요합니다.

개요

성능 최적화는 대용량 문서를 처리하는 에디터에 중요합니다. 주요 전략에는 증분 업데이트, 배칭, 가상 DOM, 지연 렌더링이 포함됩니다.

증분 DOM 업데이트

Diff 알고리즘

실제로 변경된 DOM 부분만 업데이트합니다:

class IncrementalRenderer {
  #nodeMap = new Map(); // nodeId -> DOM element
  
  update(document, previousDocument) {
    // Find differences
    const diff = this.#diff(previousDocument, document);
    
    // Apply only the changes
    diff.forEach(change => {
      switch (change.type) {
        case 'insert':
          this.#insertNode(change.node, change.path);
          break;
        case 'delete':
          this.#deleteNode(change.path);
          break;
        case 'update':
          this.#updateNode(change.path, change.attrs);
          break;
        case 'move':
          this.#moveNode(change.path, change.newPath);
          break;
      }
    });
  }
  
  #diff(oldDoc, newDoc) {
    const changes = [];
    
    // Compare node trees using longest common subsequence
    const lcs = this.#longestCommonSubsequence(
      oldDoc.children,
      newDoc.children
    );
    
    let oldIndex = 0;
    let newIndex = 0;
    let lcsIndex = 0;
    
    while (oldIndex < oldDoc.children.length || newIndex < newDoc.children.length) {
      if (lcsIndex < lcs.length && 
          oldDoc.children[oldIndex]?.id === lcs[lcsIndex].id) {
        // Node unchanged, recurse into children
        this.#compareNodes(
          oldDoc.children[oldIndex],
          newDoc.children[newIndex],
          [oldIndex],
          changes
        );
        oldIndex++;
        newIndex++;
        lcsIndex++;
      } else if (oldIndex < oldDoc.children.length &&
                 (lcsIndex >= lcs.length || 
                  oldDoc.children[oldIndex].id !== lcs[lcsIndex].id)) {
        // Node deleted
        changes.push({
          type: 'delete',
          path: [oldIndex]
        });
        oldIndex++;
      } else {
        // Node inserted
        changes.push({
          type: 'insert',
          node: newDoc.children[newIndex],
          path: [newIndex]
        });
        newIndex++;
      }
    }
    
    return changes;
  }
}

패치 적용

패치를 효율적으로 적용합니다:

class PatchApplier {
  applyPatches(container, patches) {
    // Sort patches by index (reverse order for deletions)
    const sorted = patches.sort((a, b) => {
      if (a.type === 'delete' && b.type === 'delete') {
        return b.index - a.index; // Delete from end
      }
      return a.index - b.index;
    });
    
    sorted.forEach(patch => {
      switch (patch.type) {
        case 'insert':
          const newNode = this.#renderNode(patch.node);
          container.insertBefore(newNode, container.children[patch.index]);
          break;
        case 'delete':
          container.removeChild(container.children[patch.index]);
          break;
        case 'update':
          this.#updateNodeAttributes(
            container.children[patch.index],
            patch.attrs
          );
          break;
        case 'move':
          const node = container.children[patch.from];
          container.insertBefore(node, container.children[patch.to]);
          break;
      }
    });
  }
}

배치 업데이트

업데이트 배칭

불필요한 렌더를 피하기 위해 여러 작업을 배치합니다:

class Editor {
  #pendingOperations = [];
  #renderScheduled = false;
  
  applyOperation(operation) {
    // Add to pending operations
    this.#pendingOperations.push(operation);
    
    // Schedule render (debounced)
    this.#scheduleRender();
  }
  
  #scheduleRender() {
    if (this.#renderScheduled) return;
    
    this.#renderScheduled = true;
    
    // Use requestAnimationFrame for smooth updates
    requestAnimationFrame(() => {
      this.#flushOperations();
      this.#renderScheduled = false;
    });
  }
  
  #flushOperations() {
    if (this.#pendingOperations.length === 0) return;
    
    // Apply all pending operations
    const operations = this.#pendingOperations;
    this.#pendingOperations = [];
    
    // Batch apply
    this.#batchApply(operations);
    
    // Single render for all operations
    this.render();
  }
  
  // Manual batching API
  batch(callback) {
    const wasBatching = this.#isBatching;
    this.#isBatching = true;
    
    try {
      callback();
    } finally {
      this.#isBatching = wasBatching;
      if (!this.#isBatching) {
        this.#flushOperations();
      }
    }
  }
}

// Usage
editor.batch(() => {
  editor.insertText('Hello');
  editor.insertText(' ');
  editor.insertText('World');
  // Single render after batch completes
});

디바운싱

빈번한 업데이트를 디바운스합니다:

class DebouncedRenderer {
  #pendingRender = null;
  #delay = 16; // ~60fps
  
  scheduleRender() {
    if (this.#pendingRender) {
      clearTimeout(this.#pendingRender);
    }
    
    this.#pendingRender = setTimeout(() => {
      this.render();
      this.#pendingRender = null;
    }, this.#delay);
  }
  
  // Throttle for critical updates
  throttleRender() {
    if (this.#lastRender && Date.now() - this.#lastRender < this.#delay) {
      return;
    }
    
    this.render();
    this.#lastRender = Date.now();
  }
}

가상 DOM 패턴

VDOM 구현

DOM을 건드리기 전에 가상 표현을 사용하여 변경 사항을 계산합니다:

class VirtualDOM {
  #root = null;
  
  render(document) {
    return this.#renderNode(document);
  }
  
  #renderNode(node) {
    return {
      type: node.type,
      props: node.attrs || {},
      key: node.id,
      children: node.children?.map(child => this.#renderNode(child)) || []
    };
  }
  
  patch(oldTree, newTree) {
    // Compare virtual trees
    const patches = this.#diff(oldTree, newTree);
    
    // Apply patches to actual DOM
    this.#applyPatches(patches);
  }
  
  #diff(oldNode, newNode) {
    const patches = [];
    
    if (oldNode.type !== newNode.type) {
      // Node type changed, replace
      patches.push({
        type: 'replace',
        oldNode,
        newNode
      });
    } else {
      // Same type, check props and children
      const propPatches = this.#diffProps(oldNode.props, newNode.props);
      if (propPatches.length > 0) {
        patches.push({
          type: 'props',
          node: oldNode,
          patches: propPatches
        });
      }
      
      // Diff children
      const childPatches = this.#diffChildren(oldNode.children, newNode.children);
      patches.push(...childPatches);
    }
    
    return patches;
  }
}

재조정

효율적인 재조정 알고리즘:

class Reconciler {
  reconcile(oldChildren, newChildren) {
    // Use keys for efficient matching
    const oldKeyMap = new Map(
      oldChildren.map((child, i) => [child.key || i, i])
    );
    const newKeyMap = new Map(
      newChildren.map((child, i) => [child.key || i, i])
    );
    
    const patches = [];
    
    // Find moved nodes
    for (const [key, newIndex] of newKeyMap) {
      const oldIndex = oldKeyMap.get(key);
      if (oldIndex !== undefined && oldIndex !== newIndex) {
        patches.push({
          type: 'move',
          key,
          from: oldIndex,
          to: newIndex
        });
      }
    }
    
    // Find inserted nodes
    for (const [key, newIndex] of newKeyMap) {
      if (!oldKeyMap.has(key)) {
        patches.push({
          type: 'insert',
          node: newChildren[newIndex],
          index: newIndex
        });
      }
    }
    
    // Find deleted nodes
    for (const [key, oldIndex] of oldKeyMap) {
      if (!newKeyMap.has(key)) {
        patches.push({
          type: 'delete',
          index: oldIndex
        });
      }
    }
    
    return patches;
  }
}

지연 렌더링

보이는 콘텐츠만 렌더링합니다:

class LazyRenderer {
  #viewport = { top: 0, bottom: window.innerHeight };
  #renderedNodes = new Set();
  
  render(document) {
    // Only render nodes in viewport
    const visibleNodes = this.#getVisibleNodes(document);
    
    visibleNodes.forEach(node => {
      if (!this.#renderedNodes.has(node.id)) {
        this.#renderNode(node);
        this.#renderedNodes.add(node.id);
      }
    });
    
    // Remove nodes outside viewport
    this.#renderedNodes.forEach(nodeId => {
      if (!visibleNodes.find(n => n.id === nodeId)) {
        this.#removeNode(nodeId);
        this.#renderedNodes.delete(nodeId);
      }
    });
  }
  
  #getVisibleNodes(document) {
    // Calculate which nodes are in viewport
    const nodes = [];
    this.#traverse(document, (node, rect) => {
      if (this.#isInViewport(rect)) {
        nodes.push(node);
      }
    });
    return nodes;
  }
  
  #isInViewport(rect) {
    return rect.top < this.#viewport.bottom && 
           rect.bottom > this.#viewport.top;
  }
}