Skip to main content
Reference status

This page predates the current package split. Use the current package guides for checked installation, public imports, and onboarding examples. The detailed examples below have not all been revalidated.

Before Hooks 안전성 분석

현재 Transaction 구조​

1. Transaction 생성 흐름​

// 1. Command에서 operations 배열 생성
const ops = [
...control(nodeId, [toggleMark('bold', [0, 5])])
];

// 2. TransactionBuilder 생성 (operations 배열 보관)
const builder = transaction(editor, ops);

// 3. commit() 호출 시 TransactionManager.execute() 실행
const result = await builder.commit();

2. TransactionManager.execute() 구조​

async execute(operations: (TransactionOperation | OpFunction)[]): Promise<TransactionResult> {
// 1. Lock 획득
// 2. Transaction 시작
// 3. DataStore overlay 시작
// 4. Operations 실행 (복사본 사용: JSON.parse(JSON.stringify))
// 5. Commit
// 6. History 기록
// 7. 이벤트 emit
}

중요: Operations는 실행 시점에 복사됨 (JSON.parse(JSON.stringify(operation)))


Before Hooks 구현 위치​

옵션 1: TransactionBuilder.commit() 전 (권장)​

// transaction-dsl.ts
class TransactionBuilderImpl {
async commit(): Promise<TransactionResult> {
// Before hooks 호출 (operations 수정 가능)
let finalOps = this.ops;

for (const ext of editor.extensions) {
if (ext.onBeforeTransaction) {
// Transaction 객체 생성 (operations 포함)
const tx = { operations: finalOps, ... };
const result = ext.onBeforeTransaction(editor, tx);

if (result === null) {
// 취소
return { success: false, errors: ['Cancelled by extension'] };
}

if (result && result.operations) {
// 수정된 operations 사용
finalOps = result.operations;
}
}
}

// 수정된 operations로 실행
const tm = new TransactionManager(this.editor);
return tm.execute(finalOps);
}
}

장점:

  • Operations 배열 수정 가능
  • Transaction 실행 전에 가로채기
  • 여러 Extension이 순차적으로 처리 가능

단점:

  • Transaction 객체를 임시로 생성해야 함

옵션 2: TransactionManager.execute() 내부​

async execute(operations: (TransactionOperation | OpFunction)[]): Promise<TransactionResult> {
// Before hooks 호출
let finalOps = operations;

for (const ext of this._editor.extensions) {
if (ext.onBeforeTransaction) {
const tx = { operations: finalOps, ... };
const result = ext.onBeforeTransaction(this._editor, tx);

if (result === null) {
return { success: false, errors: ['Cancelled'] };
}

if (result && result.operations) {
finalOps = result.operations;
}
}
}

// 이후 로직 계속...
}

장점:

  • Lock 획득 전에 가로채기 가능
  • 더 일찍 취소 가능

단점:

  • Lock 획득 전이라 일부 검증 불가

안전성 고려사항​

1. Immutability (불변성)​

문제: Transaction이 mutable하면 여러 Extension이 동시에 수정할 때 충돌 가능

해결:

  • Operations 배열을 복사해서 전달
  • Extension이 반환하는 새로운 배열 사용
  • 원본 배열은 변경하지 않음
// ✅ 안전한 방식
onBeforeTransaction(editor: Editor, transaction: Transaction): Transaction | null {
// 원본 operations 복사
const newOps = [...transaction.operations];

// 수정
newOps.push(additionalOp);

// 새로운 Transaction 반환
return { ...transaction, operations: newOps };
}

// ❌ 위험한 방식
onBeforeTransaction(editor: Editor, transaction: Transaction): Transaction | null {
// 원본 배열 직접 수정 (다른 Extension에 영향)
transaction.operations.push(additionalOp);
return transaction;
}

2. Extension 실행 순서​

문제: 여러 Extension이 동시에 수정하면 순서에 따라 결과가 달라질 수 있음

해결: Priority 기반 순차 실행

// Extension들을 priority 순으로 정렬
const sortedExtensions = editor.extensions.sort((a, b) =>
(a.priority || 100) - (b.priority || 100)
);

let finalOps = operations;

for (const ext of sortedExtensions) {
if (ext.onBeforeTransaction) {
const tx = { operations: finalOps, ... };
const result = ext.onBeforeTransaction(editor, tx);

if (result === null) return null; // 즉시 취소
if (result && result.operations) {
finalOps = result.operations; // 다음 Extension에 전달
}
}
}

예시:

Extension A (priority: 10): operations [1, 2] → [1, 2, 3]
Extension B (priority: 20): operations [1, 2, 3] → [1, 2, 3, 4]
Extension C (priority: 30): operations [1, 2, 3, 4] → 취소 (null)
결과: Transaction 취소

3. Transaction 취소 시점​

문제: 언제 취소해야 하는가?

해결:

  • null 반환 시 즉시 취소
  • 이후 Extension은 호출되지 않음
  • Lock 획득 전에 취소하면 성능상 이점
onBeforeTransaction(editor: Editor, transaction: Transaction): Transaction | null {
if (shouldCancel(transaction)) {
return null; // 즉시 취소, 이후 Extension 호출 안 됨
}
return transaction; // 계속 진행
}

4. Operation 복사 안전성​

현재 구조: TransactionManager._executeOperation()에서 이미 복사함

// transaction.ts:242
const operationCopy = JSON.parse(JSON.stringify(operation));

안전성:

  • ✅ Operations는 실행 시점에 복사되므로 Before hooks에서 수정해도 안전
  • ✅ Extension이 반환한 operations도 복사됨
  • ✅ 원본 operations는 변경되지 않음

5. 순환 참조 방지​

문제: Extension A가 수정 → Extension B가 다시 수정 → 무한 루프?

해결:

  • Before hooks는 한 번만 실행
  • 수정된 transaction은 다음 Extension에만 전달
  • 재귀 호출 방지
// ✅ 안전: 한 번만 실행
let finalOps = operations;
for (const ext of extensions) {
const result = ext.onBeforeTransaction(editor, { operations: finalOps });
if (result) finalOps = result.operations;
}

// ❌ 위험: 재귀 호출 가능
onBeforeTransaction(editor, transaction) {
// 또 다른 transaction 실행 → 무한 루프 가능
editor.executeCommand('someCommand');
}

ProseMirror/Tiptap 비교​

ProseMirror의 방식​

// ProseMirror는 Transaction이 immutable
const newTr = oldTr.insertText('Hello');
// oldTr은 변경되지 않음, newTr은 새로운 객체

// dispatchTransaction에서 가로채기
view.dispatchTransaction = (tr) => {
// tr을 수정하거나 취소 가능
const modified = modifyTransaction(tr);
view.updateState(view.state.apply(modified));
};

차이점:

  • ProseMirror: Transaction이 immutable, 새로운 Transaction 반환
  • Barocss: Operations 배열을 수정 가능, 새로운 배열 반환

공통점:

  • 모두 원본을 변경하지 않고 새로운 객체/배열 반환
  • 취소는 null 또는 빈 transaction 반환

권장 구현 방식​

1. TransactionBuilder.commit() 전에 가로채기​

// transaction-dsl.ts
class TransactionBuilderImpl {
async commit(): Promise<TransactionResult> {
// Before hooks 호출
let finalOps = this.ops;

const extensions = (this.editor as any)._extensions || [];
const sorted = extensions.sort((a: any, b: any) =>
(a.priority || 100) - (b.priority || 100)
);

for (const ext of sorted) {
if (ext.onBeforeTransaction) {
// Transaction 객체 생성
const tx: Transaction = {
sid: `tx-${Date.now()}`,
operations: finalOps,
timestamp: new Date()
};

const result = ext.onBeforeTransaction(this.editor, tx);

// 취소 확인
if (result === null) {
return {
success: false,
errors: [`Transaction cancelled by extension: ${ext.name}`],
operations: []
};
}

// 수정된 operations 사용
if (result && result.operations) {
finalOps = result.operations;
}
}
}

// 수정된 operations로 실행
const tm = new TransactionManager(this.editor);
return tm.execute(finalOps);
}
}

2. Extension 인터페이스​

interface Extension {
onBeforeTransaction?(
editor: Editor,
transaction: Transaction
): Transaction | null | void;
// - Transaction 반환: 수정된 transaction 사용
// - null 반환: transaction 취소
// - void: 그대로 진행
}

3. 안전한 사용 예시​

class SanitizeExtension implements Extension {
onBeforeTransaction(editor: Editor, transaction: Transaction): Transaction | null {
// 원본 operations 복사
const newOps = transaction.operations.map(op => {
if (op.type === 'insertText') {
// 수정된 operation 반환
return {
...op,
payload: {
...op.payload,
text: this.sanitize(op.payload.text)
}
};
}
return op; // 그대로
});

// 새로운 Transaction 반환
return {
...transaction,
operations: newOps
};
}
}

결론​

안전성 평가​

  1. Immutable 처리: ✅ Operations 배열 복사 후 수정
  2. 순차 실행: ✅ Priority 기반 순차 처리
  3. 취소 안전성: ✅ null 반환 시 즉시 취소
  4. 복사 안전성: ✅ 실행 시점에 이미 복사됨
  5. 순환 참조: ✅ 한 번만 실행, 재귀 방지

권장사항​

  1. TransactionBuilder.commit() 전에 가로채기 (옵션 1)

    • Lock 획득 전에 취소 가능
    • Operations 수정 가능
    • 구현이 간단
  2. Immutable 패턴 준수

    • 원본 operations 변경 금지
    • 새로운 배열 반환
    • Extension 간 독립성 보장
  3. Priority 기반 순차 실행

    • 예측 가능한 실행 순서
    • 디버깅 용이
    • 충돌 최소화

결론: Before hooks는 안전하게 구현 가능하며, ProseMirror/Tiptap과 유사한 패턴을 따르면 문제없음.