비동기 초기화

현대적인 에디터는 리소스 로딩, 스키마 파싱, 플러그인 설정을 위해 비동기 초기화가 필요합니다. 이는 WebGPU가 비동기 디바이스 초기화를 요구하는 것과 유사합니다.

개요

전통적인 동기식 API와 달리, 현대적인 에디터는 초기화 중 비동기 작업을 처리해야 합니다. 여기에는 네트워크에서 스키마 로딩, 데이터를 가져와야 하는 플러그인 초기화, DOM 조작이 필요한 뷰 레이어 설정이 포함됩니다.

과제는 메인 스레드를 블로킹하지 않으면서 사용 전에 에디터가 완전히 준비되도록 하는 깔끔한 API를 제공하는 것입니다. 이 패턴은 WebGPU가 비동기 디바이스 초기화를 요구하거나, 현대적인 프레임워크가 컴포넌트 마운팅을 처리하는 방식과 유사합니다.

Promise 기반 API 설계

동기식 초기화 대신, 에디터가 준비되었을 때 해결되는 Promise 기반 API를 사용합니다:

초기화 패턴

핵심 패턴은 생성자에서 Promise를 반환하고 getter를 통해 노출하는 것입니다:

class Editor {
  constructor(config) {
    this.#config = config;
    this.#initialized = false;
    
    // Create initialization promise
    this.#initPromise = this.#initialize();
  }
  
  get initialized() {
    return this.#initPromise;
  }
  
  async #initialize() {
    // Load schema
    const schema = await this.#loadSchema(this.#config.schema);
    
    // Initialize plugins
    await this.#initializePlugins();
    
    // Set up view layer
    await this.#setupView();
    
    this.#initialized = true;
    return this;
  }
  
  async #loadSchema(schemaConfig) {
    if (typeof schemaConfig === 'string') {
      // Load from URL
      const response = await fetch(schemaConfig);
      return await response.json();
    }
    return schemaConfig;
  }
  
  async #initializePlugins() {
    const pluginPromises = this.#config.plugins.map(plugin => {
      return plugin.initialize?.(this) || Promise.resolve();
    });
    await Promise.all(pluginPromises);
  }
  
  async #setupView() {
    // Set up DOM event listeners
    // Initialize rendering
    // Set up selection handling
  }
}

// Usage
const editor = await new Editor({
  schema: './schema.json',
  plugins: [new HistoryPlugin(), new LinkPlugin()]
}).initialized;

// Editor is now ready to use
editor.insertText('Hello');

핵심 포인트:

  • Promise는 생성자에서 즉시 생성됩니다
  • Promise.all을 사용하여 여러 비동기 작업을 병렬로 실행할 수 있습니다
  • 에디터 인스턴스는 체이닝을 위해 Promise에서 반환됩니다
  • 모든 초기화는 Promise가 해결되기 전에 완료됩니다

에러 처리

적절한 에러 처리는 비동기 초기화에 중요합니다:

class Editor {
  async #initialize() {
    try {
      // Load schema
      const schema = await this.#loadSchema(this.#config.schema);
      if (!schema) {
        throw new Error('Failed to load schema');
      }
      
      // Initialize plugins
      await this.#initializePlugins();
      
      // Set up view layer
      await this.#setupView();
      
      this.#initialized = true;
      return this;
    } catch (error) {
      // Clean up on error
      this.#cleanup();
      throw error;
    }
  }
  
  #cleanup() {
    // Remove event listeners
    // Clear timers
    // Release resources
  }
}

// Usage with error handling
try {
  const editor = await new Editor({
    schema: './schema.json',
    plugins: [new HistoryPlugin()]
  }).initialized;
  
  // Use editor
  editor.insertText('Hello');
} catch (error) {
  console.error('Failed to initialize editor:', error);
  // Show error to user
}

초기화 생명주기

초기화 생명주기를 이해하면 상태 전이를 관리하고 진행 상황 모니터링을 위한 훅을 제공할 수 있습니다:

생명주기 단계

명확한 상태 전이와 함께 초기화를 구별되는 단계로 나눕니다:

class Editor {
  #state = 'idle'; // idle -> loading -> ready -> error
  
  async #initialize() {
    this.#transition('loading');
    
    try {
      // Phase 1: Pre-initialization
      await this.#phasePreInit();
      
      // Phase 2: Resource loading
      await this.#phaseLoadResources();
      
      // Phase 3: Plugin initialization
      await this.#phaseInitPlugins();
      
      // Phase 4: View setup
      await this.#phaseSetupView();
      
      // Phase 5: Post-initialization
      await this.#phasePostInit();
      
      this.#transition('ready');
    } catch (error) {
      this.#transition('error', error);
      throw error;
    }
  }
  
  #transition(newState, data = null) {
    const oldState = this.#state;
    this.#state = newState;
    this.emit('stateChange', { from: oldState, to: newState, data });
  }
  
  async #phasePreInit() {
    // Validate configuration
    // Set up internal structures
  }
  
  async #phaseLoadResources() {
    // Load schemas, assets, etc.
  }
  
  async #phaseInitPlugins() {
    // Initialize all plugins
  }
  
  async #phaseSetupView() {
    // Set up DOM, event listeners
  }
  
  async #phasePostInit() {
    // Final validation, emit ready event
  }
}

단계 분석:

  • 사전 초기화: 설정 검증, 내부 구조 설정
  • 리소스 로딩: 스키마, 에셋, 외부 의존성
  • 플러그인 초기화: 의존성 순서대로 플러그인 초기화
  • 뷰 설정: DOM 조작, 이벤트 리스너, 렌더링
  • 사후 초기화: 최종 검증, 준비 이벤트 발생

상태 전이

상태 머신 패턴은 예측 가능한 전이를 보장하고 잘못된 상태를 방지합니다:

  • idle → loading: 초기화 시작
  • loading → ready: 모든 단계가 성공적으로 완료
  • loading → error: 어떤 단계든 실패
  • error → loading: 초기화 재시도

상태 전이는 플러그인이 생명주기 관리를 위해 수신할 수 있는 이벤트를 발생시킵니다.

의존성 해결

플러그인은 종종 다른 플러그인에 의존합니다. 의존성을 올바르게 해결하면 적절한 초기화 순서가 보장됩니다:

의존성 그래프

플러그인 선언에서 의존성 그래프를 구축합니다:

class DependencyResolver {
  resolve(plugins) {
    // Build dependency graph
    const graph = this.#buildGraph(plugins);
    
    // Detect cycles
    if (this.#hasCycle(graph)) {
      throw new Error('Circular dependency detected');
    }
    
    // Topological sort
    return this.#topologicalSort(graph);
  }
  
  #buildGraph(plugins) {
    const graph = new Map();
    
    for (const plugin of plugins) {
      const deps = plugin.dependencies || [];
      graph.set(plugin, deps);
    }
    
    return graph;
  }
  
  #hasCycle(graph) {
    const visited = new Set();
    const recStack = new Set();
    
    const hasCycleDFS = (node) => {
      if (recStack.has(node)) return true;
      if (visited.has(node)) return false;
      
      visited.add(node);
      recStack.add(node);
      
      const deps = graph.get(node) || [];
      for (const dep of deps) {
        if (hasCycleDFS(dep)) return true;
      }
      
      recStack.delete(node);
      return false;
    };
    
    for (const node of graph.keys()) {
      if (hasCycleDFS(node)) return true;
    }
    
    return false;
  }
  
  #topologicalSort(graph) {
    const visited = new Set();
    const result = [];
    
    const visit = (node) => {
      if (visited.has(node)) return;
      
      const deps = graph.get(node) || [];
      for (const dep of deps) {
        visit(dep);
      }
      
      visited.add(node);
      result.push(node);
    };
    
    for (const node of graph.keys()) {
      visit(node);
    }
    
    return result;
  }
}

// Usage
const resolver = new DependencyResolver();
const orderedPlugins = resolver.resolve([
  { name: 'link', dependencies: ['history'] },
  { name: 'history', dependencies: [] },
  { name: 'image', dependencies: ['link'] }
]);
// Result: ['history', 'link', 'image']

핵심 개념:

  • 의존성 그래프: 간선이 의존성을 나타내는 방향 그래프
  • 순환 감지: 무한 루프를 일으킬 수 있는 순환 의존성 방지
  • 위상 정렬: 의존성이 먼저 초기화되도록 플러그인 순서 지정

위상 정렬

위상 정렬은 플러그인이 올바른 순서로 초기화되도록 보장합니다:

  • 의존성이 없는 플러그인이 먼저 초기화됩니다
  • 의존성이 있는 플러그인은 의존성 이후에 초기화됩니다
  • 여러 독립적인 플러그인은 병렬로 초기화할 수 있습니다
  • 순환 의존성은 감지되어 에러로 보고됩니다

초기화 훅

훅을 사용하여 플러그인이 초기화에 참여할 수 있도록 합니다:

훅 통합

초기화 프로세스에 훅을 통합합니다:

class Editor {
  constructor(config) {
    this.#hooks = {
      init: new SyncHook(),
      initAsync: new AsyncParallelHook(),
      ready: new SyncHook()
    };
    
    this.#initPromise = this.#initialize();
  }
  
  async #initialize() {
    // Synchronous initialization hooks
    this.#hooks.init.call();
    
    // Asynchronous initialization hooks
    await this.#hooks.initAsync.promise();
    
    // Ready hook
    this.#hooks.ready.call();
    
    return this;
  }
  
  get hooks() {
    return this.#hooks;
  }
}

// Plugin can hook into initialization
class DatabasePlugin {
  apply(editor) {
    editor.hooks.initAsync.tapPromise(async () => {
      // Load data from database
      const data = await this.loadFromDatabase();
      editor.setInitialContent(data);
    });
  }
  
  async loadFromDatabase() {
    const response = await fetch('/api/document');
    return await response.json();
  }
}

// Usage
const editor = await new Editor({
  plugins: [new DatabasePlugin()]
}).initialized;

플러그인 초기화

플러그인은 비동기 초기화를 수행할 수 있습니다:

class Plugin {
  constructor(config) {
    this.#config = config;
  }
  
  async initialize(editor) {
    // Load plugin resources
    await this.#loadResources();
    
    // Set up plugin state
    this.#setupState(editor);
    
    // Register hooks
    this.#registerHooks(editor);
  }
  
  async #loadResources() {
    if (this.#config.resources) {
      const promises = this.#config.resources.map(url => fetch(url));
      await Promise.all(promises);
    }
  }
  
  #setupState(editor) {
    this.#editor = editor;
    this.#state = new Map();
  }
  
  #registerHooks(editor) {
    editor.hooks.beforeOperation.tap((operation) => {
      this.#handleOperation(operation);
    });
  }
}

// Plugin with async initialization
class ImageUploadPlugin extends Plugin {
  async initialize(editor) {
    await super.initialize(editor);
    
    // Initialize upload service
    this.#uploadService = await this.#createUploadService();
  }
  
  async #createUploadService() {
    // Set up cloud storage connection
    return new UploadService({
      apiKey: this.#config.apiKey,
      endpoint: this.#config.endpoint
    });
  }
}

리소스 로딩

초기화 중 리소스를 효율적으로 로딩합니다:

스키마 로딩

다양한 소스에서 스키마를 로딩합니다:

class SchemaLoader {
  async load(schemaConfig) {
    if (typeof schemaConfig === 'string') {
      if (this.isURL(schemaConfig)) {
        return await this.loadFromURL(schemaConfig);
      }
      if (this.isLocalPath(schemaConfig)) {
        return await this.loadFromFile(schemaConfig);
      }
      return await this.loadFromRegistry(schemaConfig);
    }
    return schemaConfig;
  }
  
  isURL(str) {
    return str.startsWith('http://') || str.startsWith('https://');
  }
  
  isLocalPath(str) {
    return str.startsWith('./') || str.startsWith('../');
  }
  
  async loadFromURL(url) {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error('Failed to load schema from URL');
    }
    return await response.json();
  }
  
  async loadFromFile(path) {
    const response = await fetch(path);
    return await response.json();
  }
  
  async loadFromRegistry(name) {
    const registry = await this.getRegistry();
    return registry.get(name);
  }
}

플러그인 로딩

플러그인을 동적으로 로딩합니다:

class PluginLoader {
  async loadPlugins(pluginConfigs) {
    const plugins = [];
    
    for (const config of pluginConfigs) {
      if (typeof config === 'string') {
        // Load plugin module
        const plugin = await this.#loadPluginModule(config);
        plugins.push(plugin);
      } else if (config.module) {
        // Load from module
        const plugin = await this.#loadPluginModule(config.module);
        plugins.push(new plugin(config.options));
      } else {
        // Already a plugin instance
        plugins.push(config);
      }
    }
    
    return plugins;
  }
  
  async #loadPluginModule(name) {
    // Dynamic import
    const module = await import(`./plugins/${name}.js`);
    return module.default || module[name];
  }
}

// Usage
const loader = new PluginLoader();
const plugins = await loader.loadPlugins([
  'history',
  'link',
  { module: 'image', options: { uploadUrl: '/api/upload' } }
]);

타임아웃 및 재시도 전략

네트워크 요청과 외부 리소스는 실패하거나 타임아웃될 수 있습니다. 견고한 재시도 및 타임아웃 메커니즘을 구현합니다:

타임아웃 처리

초기화가 무한정 멈추는 것을 방지합니다:

  • 각 초기화 단계에 대해 합리적인 타임아웃을 설정합니다
  • Promise.race를 사용하여 타임아웃을 강제합니다
  • 다른 작업에 대해 다른 타임아웃 값을 제공합니다
  • 모니터링 및 디버깅을 위해 타임아웃 이벤트를 발생시킵니다

재시도 메커니즘

지수 백오프 재시도 전략을 구현합니다:

class InitializationManager {
  async initializeWithTimeout(initFn, timeout = 5000) {
    return Promise.race([
      initFn(),
      new Promise((_, reject) => 
        setTimeout(() => reject(new Error('Initialization timeout')), timeout)
      )
    ]);
  }
  
  async initializeWithRetry(initFn, options = {}) {
    const { maxRetries = 3, delay = 1000, backoff = 2 } = options;
    let lastError;
    
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      try {
        return await initFn();
      } catch (error) {
        lastError = error;
        
        if (attempt < maxRetries) {
          const waitTime = delay * Math.pow(backoff, attempt);
          await this.#sleep(waitTime);
        }
      }
    }
    
    throw lastError;
  }
  
  async initializeWithTimeoutAndRetry(initFn, options = {}) {
    const { timeout = 5000, maxRetries = 3 } = options;
    
    return this.initializeWithRetry(
      () => this.initializeWithTimeout(initFn, timeout),
      { maxRetries }
    );
  }
  
  #sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

// Usage
const manager = new InitializationManager();

try {
  const editor = await manager.initializeWithTimeoutAndRetry(
    () => new Editor(config).initialized,
    { timeout: 10000, maxRetries: 3 }
  );
} catch (error) {
  console.error('Failed after retries:', error);
}

재시도 전략:

  • 지수 백오프: 재시도 간 지연을 지수적으로 증가시킵니다
  • 최대 재시도: 무한 루프를 방지하기 위해 시도 횟수를 제한합니다
  • 선택적 재시도: 일시적인 에러(네트워크, 타임아웃)에만 재시도합니다
  • 지터: thundering herd 문제를 방지하기 위해 무작위성을 추가합니다

모범 사례

비동기 초기화를 위한 모범 사례:

  • 항상 초기화를 기다립니다: 초기화가 완료되기 전에 에디터를 사용하지 마세요
  • 에러를 우아하게 처리합니다: 의미 있는 에러 메시지를 제공하고 실패 시 정리합니다
  • 리소스를 병렬로 로딩합니다: 가능한 경우 Promise.all을 사용합니다
  • 로딩 상태를 제공합니다: 초기화 중 사용자에게 진행 상황을 표시합니다
  • 초기화된 상태를 캐시합니다: 이미 초기화된 경우 다시 초기화하지 마세요
  • 지연 초기화를 지원합니다: 필요할 때까지 초기화를 연기할 수 있도록 합니다
class Editor {
  async #initialize() {
    // Check if already initialized
    if (this.#initialized) {
      return this;
    }
    
    // Show loading state
    this.#setLoadingState(true);
    
    try {
      // Load resources in parallel
      const [schema, plugins] = await Promise.all([
        this.#loadSchema(this.#config.schema),
        this.#loadPlugins(this.#config.plugins)
      ]);
      
      // Initialize sequentially (plugins may depend on schema)
      await this.#setupSchema(schema);
      await this.#setupPlugins(plugins);
      await this.#setupView();
      
      this.#initialized = true;
      this.#setLoadingState(false);
      
      return this;
    } catch (error) {
      this.#setLoadingState(false);
      this.#setErrorState(error);
      throw error;
    }
  }
  
  #setLoadingState(loading) {
    // Emit loading event
    this.emit('loading', { loading });
  }
  
  #setErrorState(error) {
    // Emit error event
    this.emit('error', { error });
  }
}