Transaction 시스템

모델 기반 에디터에서 일관된 history 관리를 위해 operation을 원자적 단위로 그룹화하는 transaction 사용.

개요

Transaction은 여러 operation을 단일 history entry로 기록되는 원자적 단위로 그룹화합니다. 이를 통해 관련 operation들이 함께 실행 취소/다시 실행되어 문서 일관성을 유지하고 더 나은 사용자 경험을 제공합니다.

주요 장점:

  • 원자적 작업 - 모두 성공하거나 모두 실패
  • 관련 operation에 대한 단일 undo entry
  • 일관된 문서 상태
  • 더 나은 undo/redo 세분성

Transaction과 History

Transaction은 원자적 history 관리의 기초입니다. 각 transaction은 undo/redo 스택의 단일 entry가 됩니다.

원자적 History Entry

관련 operation을 단일 history entry로 그룹화:

interface HistoryEntry {
  id: string;
  timestamp: number;
  operations: Operation[]; // Transaction의 모든 operation
  beforeModel: DocumentModel;
  afterModel: DocumentModel;
  beforeSelection: Selection | null;
  afterSelection: Selection | null;
}

class Transaction {
  #operations: Operation[] = [];
  #editor: Editor;
  #beforeModel: DocumentModel;
  #beforeSelection: Selection | null;
  #isCommitted = false;

  constructor(editor: Editor) {
    this.#editor = editor;
    this.#beforeModel = editor.getModel();
    this.#beforeSelection = editor.getSelection();
  }

  add(operation: Operation) {
    if (this.#isCommitted) {
      throw new Error('커밋된 transaction에 추가할 수 없습니다');
    }
    this.#operations.push(operation);
  }

  async commit(): Promise<boolean> {
    if (this.#isCommitted) {
      return false;
    }

    // 모든 operation 검증
    for (const op of this.#operations) {
      if (!this.#editor.canApply(op)) {
        return false;
      }
    }

    // 모든 operation 적용
    try {
      for (const op of this.#operations) {
        this.#editor.applyOperation(op);
      }

      // 단일 history entry로 기록
      const historyEntry: HistoryEntry = {
        id: generateId(),
        timestamp: Date.now(),
        operations: [...this.#operations],
        beforeModel: this.#beforeModel,
        afterModel: this.#editor.getModel(),
        beforeSelection: this.#beforeSelection,
        afterSelection: this.#editor.getSelection()
      };

      this.#editor.getHistory().push(historyEntry);
      this.#isCommitted = true;
      return true;
    } catch (error) {
      // 오류 시 롤백
      this.rollback();
      return false;
    }
  }

  rollback() {
    this.#editor.setModel(this.#beforeModel);
    this.#editor.setSelection(this.#beforeSelection);
    this.#operations = [];
  }
}

Transaction 경계

사용자 액션을 기반으로 transaction 경계 정의:

class Editor {
  #currentTransaction: Transaction | null = null;

  beginTransaction(): Transaction {
    if (this.#currentTransaction) {
      throw new Error('Transaction이 이미 진행 중입니다');
    }
    this.#currentTransaction = new Transaction(this);
    return this.#currentTransaction;
  }

  async commitTransaction(): Promise<boolean> {
    if (!this.#currentTransaction) {
      return false;
    }
    const success = await this.#currentTransaction.commit();
    this.#currentTransaction = null;
    return success;
  }

  rollbackTransaction() {
    if (this.#currentTransaction) {
      this.#currentTransaction.rollback();
      this.#currentTransaction = null;
    }
  }

  // 현재 transaction에 operation 추가하는 헬퍼
  addOperation(operation: Operation) {
    if (this.#currentTransaction) {
      this.#currentTransaction.add(operation);
    } else {
      // Transaction이 없으면 생성하고 즉시 커밋
      const tx = this.beginTransaction();
      tx.add(operation);
      tx.commit();
    }
  }
}

Transaction 생명주기

완전한 transaction 생명주기를 이해하는 것이 올바른 구현에 중요합니다.

Begin, Commit, Rollback

// 예제: 서식 작업
async function applyBoldFormatting(editor: Editor, selection: Selection) {
  const tx = editor.beginTransaction();
  
  try {
    // 서식을 위한 여러 operation
    tx.add({
      type: 'format',
      path: selection.start,
      format: 'bold',
      value: true
    });
    
    tx.add({
      type: 'format',
      path: selection.end,
      format: 'bold',
      value: true
    });
    
    // 모든 operation이 단일 history entry로 커밋됨
    const success = await tx.commit();
    
    if (!success) {
      console.error('서식 적용 실패');
    }
  } catch (error) {
    tx.rollback();
    throw error;
  }
}

// 예제: 붙여넣기 작업
async function handlePaste(editor: Editor, pastedContent: string) {
  const tx = editor.beginTransaction();
  
  try {
    // 선택된 콘텐츠 삭제
    const selection = editor.getSelection();
    if (!selection.isCollapsed) {
      tx.add({
        type: 'delete',
        path: selection.start,
        length: selection.length
      });
    }
    
    // 붙여넣은 콘텐츠 삽입
    tx.add({
      type: 'insert',
      path: selection.start,
      content: pastedContent
    });
    
    await tx.commit(); // 붙여넣기에 대한 단일 undo entry
  } catch (error) {
    tx.rollback();
    throw error;
  }
}

Transaction 상태 관리

class TransactionManager {
  #activeTransactions: Transaction[] = [];
  #transactionStack: Transaction[] = []; // 중첩 transaction용

  beginTransaction(editor: Editor): Transaction {
    const tx = new Transaction(editor);
    this.#transactionStack.push(tx);
    this.#activeTransactions.push(tx);
    return tx;
  }

  async commitTransaction(tx: Transaction): Promise<boolean> {
    const index = this.#activeTransactions.indexOf(tx);
    if (index === -1) {
      return false;
    }

    const success = await tx.commit();
    
    if (success) {
      this.#activeTransactions.splice(index, 1);
      const stackIndex = this.#transactionStack.indexOf(tx);
      if (stackIndex !== -1) {
        this.#transactionStack.splice(stackIndex, 1);
      }
    }
    
    return success;
  }

  rollbackTransaction(tx: Transaction) {
    tx.rollback();
    const index = this.#activeTransactions.indexOf(tx);
    if (index !== -1) {
      this.#activeTransactions.splice(index, 1);
    }
    const stackIndex = this.#transactionStack.indexOf(tx);
    if (stackIndex !== -1) {
      this.#transactionStack.splice(stackIndex, 1);
    }
  }

  getCurrentTransaction(): Transaction | null {
    return this.#transactionStack.length > 0
      ? this.#transactionStack[this.#transactionStack.length - 1]
      : null;
  }
}

History 통합

Transaction은 history 관리와 원활하게 통합되어 원자적 undo/redo를 제공합니다.

단일 Undo Entry

class HistoryManager {
  #undoStack: HistoryEntry[] = [];
  #redoStack: HistoryEntry[] = [];

  recordTransaction(transaction: Transaction) {
    if (!transaction.isCommitted()) {
      return;
    }

    const entry: HistoryEntry = {
      id: generateId(),
      timestamp: Date.now(),
      operations: transaction.getOperations(),
      beforeModel: transaction.getBeforeModel(),
      afterModel: transaction.getAfterModel(),
      beforeSelection: transaction.getBeforeSelection(),
      afterSelection: transaction.getAfterSelection()
    };

    this.#undoStack.push(entry);
    this.#redoStack = []; // 새 액션에서 redo 스택 지우기
  }

  undo(): boolean {
    if (this.#undoStack.length === 0) {
      return false;
    }

    const entry = this.#undoStack.pop()!;
    
    // 리버스 Operation 적용
    for (let i = entry.operations.length - 1; i >= 0; i--) {
      const op = entry.operations[i];
      const inverse = this.#getInverseOperation(op);
      this.#editor.applyOperation(inverse);
    }

    // 선택 영역 복원
    this.#editor.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 재적용
    for (const op of entry.operations) {
      this.#editor.applyOperation(op);
    }

    // 선택 영역 복원
    this.#editor.setSelection(entry.afterSelection);

    // undo 스택으로 이동
    this.#undoStack.push(entry);
    return true;
  }
}

Operation 그룹화

// 예제: 사용자가 "Hello" 입력 - 단일 undo entry여야 함
class InputHandler {
  #transaction: Transaction | null = null;
  #lastInputTime = 0;
  #inputTimeout: number | null = null;

  handleInput(e: InputEvent) {
    // 빠른 입력을 단일 transaction으로 그룹화
    const now = Date.now();
    const timeSinceLastInput = now - this.#lastInputTime;

    if (timeSinceLastInput > 500 || !this.#transaction) {
      // 새 transaction 시작
      this.#commitCurrentTransaction();
      this.#transaction = this.#editor.beginTransaction();
    }

    this.#lastInputTime = now;

    // Transaction에 operation 추가
    if (e.inputType === 'insertText') {
      this.#transaction.add({
        type: 'insert',
        path: this.#editor.getSelection().start,
        content: e.data
      });
    }

    // 지연 후 커밋 (디바운스)
    if (this.#inputTimeout) {
      clearTimeout(this.#inputTimeout);
    }
    this.#inputTimeout = setTimeout(() => {
      this.#commitCurrentTransaction();
    }, 500);
  }

  #commitCurrentTransaction() {
    if (this.#transaction) {
      this.#transaction.commit();
      this.#transaction = null;
    }
  }
}

Transaction 패턴

다양한 시나리오에서 transaction을 사용하는 일반적인 패턴입니다.

사용자 액션 Transaction

// 패턴: 각 사용자 액션은 transaction
class UserActionHandler {
  async handleUserAction(action: UserAction) {
    const tx = this.#editor.beginTransaction();

    try {
      switch (action.type) {
        case 'type':
          tx.add({ type: 'insert', path: action.position, content: action.text });
          break;
        case 'delete':
          tx.add({ type: 'delete', path: action.position, length: action.length });
          break;
        case 'format':
          tx.add({ type: 'format', path: action.selection.start, format: action.format });
          break;
      }

      await tx.commit();
    } catch (error) {
      tx.rollback();
      throw error;
    }
  }
}

서식 Transaction

// 패턴: 서식 operation을 함께 그룹화
async function applyFormatting(
  editor: Editor,
  selection: Selection,
  format: Format
) {
  const tx = editor.beginTransaction();

  // 전체 선택 영역에 서식 적용
  const nodes = editor.getNodesInRange(selection);
  
  for (const node of nodes) {
    tx.add({
      type: 'format',
      path: node.path,
      format: format.name,
      value: format.value
    });
  }

  await tx.commit(); // 전체 서식에 대한 단일 undo
}

// 패턴: 서식 토글
async function toggleFormatting(
  editor: Editor,
  selection: Selection,
  format: string
) {
  const tx = editor.beginTransaction();
  const hasFormat = editor.hasFormat(selection, format);

  if (hasFormat) {
    // 서식 제거
    const nodes = editor.getNodesInRange(selection);
    for (const node of nodes) {
      tx.add({
        type: 'format',
        path: node.path,
        format: format,
        value: false
      });
    }
  } else {
    // 서식 적용
    const nodes = editor.getNodesInRange(selection);
    for (const node of nodes) {
      tx.add({
        type: 'format',
        path: node.path,
        format: format,
        value: true
      });
    }
  }

  await tx.commit();
}

붙여넣기 Transaction

// 패턴: 단일 transaction으로 붙여넣기
async function handlePaste(
  editor: Editor,
  clipboardData: DataTransfer
) {
  const tx = editor.beginTransaction();
  const selection = editor.getSelection();

  try {
    // 1. 선택된 콘텐츠 삭제
    if (!selection.isCollapsed) {
      tx.add({
        type: 'delete',
        path: selection.start,
        length: selection.length
      });
    }

    // 2. 붙여넣은 콘텐츠 파싱 및 삽입
    const pastedContent = await parseClipboardData(clipboardData);
    
    for (const item of pastedContent) {
      if (item.type === 'text') {
        tx.add({
          type: 'insert',
          path: selection.start,
          content: item.text
        });
      } else if (item.type === 'node') {
        tx.add({
          type: 'insert',
          path: selection.start,
          node: item.node
        });
      }
    }

    await tx.commit(); // 전체 붙여넣기에 대한 단일 undo
  } catch (error) {
    tx.rollback();
    throw error;
  }
}

중첩 Transaction

복잡한 작업을 위한 중첩 transaction 지원.

class NestedTransactionManager {
  #transactionStack: Transaction[] = [];

  beginTransaction(editor: Editor): Transaction {
    const tx = new Transaction(editor);
    this.#transactionStack.push(tx);
    return tx;
  }

  async commitTransaction(tx: Transaction): Promise<boolean> {
    const index = this.#transactionStack.indexOf(tx);
    if (index === -1) {
      return false;
    }

    // 최상위 transaction인 경우에만 커밋
    if (index === this.#transactionStack.length - 1) {
      const success = await tx.commit();
      if (success) {
        this.#transactionStack.pop();
      }
      return success;
    } else {
      // 중첩 transaction - 커밋된 것으로만 표시
      // 부모가 커밋할 때 커밋됨
      return true;
    }
  }

  getCurrentTransaction(): Transaction | null {
    return this.#transactionStack.length > 0
      ? this.#transactionStack[this.#transactionStack.length - 1]
      : null;
  }
}

모범 사례

  • 관련 operation 그룹화: 함께 실행 취소되어야 하는 operation은 같은 transaction에 있어야 합니다
  • 사용자 액션 경계: 각 사용자 액션(키 입력, 붙여넣기, 서식)은 일반적으로 자체 transaction이어야 합니다
  • 빠른 입력 디바운스: 더 나은 undo 세분성을 위해 빠른 텍스트 입력을 단일 transaction으로 그룹화
  • 오류 시 항상 롤백: 커밋이 실패하면 transaction 상태가 복원되도록 보장
  • 커밋 전 검증: 커밋하기 전에 모든 operation이 적용 가능한지 확인
  • 선택 영역 보존: 적절한 undo/redo를 위해 transaction에 선택 영역 상태 저장
  • 중첩 transaction 피하기: 필요하지 않으면 transaction 구조를 단순하게 유지