개요
전통적인 동기식 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 });
}
}