하나의 Obsidian Vault에서 여러 Airtable 동시 싱크: Multi-Config 아키텍처 구현기

Airtable 플러그인을 단일 config에서 multi-config 멀티테넌트 구조로 확장한 경험. ConfigInstance와 ConfigManager의 역할 분리, credential-scoped RateLimiter 공유, defensive + idempotent 마이그레이션, 폴더 겹침 검증, Obsidian command palette의 dynamic re-registration까지.

하나의 Obsidian Vault에서 여러 Airtable 동시 싱크: Multi-Config 아키텍처 구현기

1. "하나만 되는 것"의 한계

obsidian-auto-note-importer는 Airtable 테이블 하나를 Obsidian vault에 싱크하는 플러그인입니다. 처음에는 단순했습니다. "API key 하나, base 하나, table 하나, 폴더 하나". Settings 탭 한 페이지에 모든 필드가 평평하게 놓여 있었고, 내부적으로는 AutoNoteImporterSettings라는 인터페이스 하나가 전부를 담고 있었습니다.

하지만 실사용자들의 요청은 점점 이런 식으로 왔습니다.

  • "프로젝트 테이블이랑 독서 테이블을 각각 다른 폴더로 싱크하고 싶어요"
  • "팀 Airtable과 개인 Airtable을 동시에 쓰고 있어요"
  • "같은 base의 두 view를 각자 다른 폴더로 내리고 싶어요"

해결책은 명확했지만 쉽지 않았습니다. 하나의 vault 안에서 여러 설정을 동시에 운영하는 구조로 바꿔야 했습니다. "설정 프로필을 여러 개 저장하고 하나를 선택"하는 방식이 아니라, 여러 개가 동시에 살아서 각자의 폴더를 지켜보고 각자의 스케줄러를 돌리는 멀티테넌트 구조여야 했습니다.

이 글은 v0.8.0 릴리스에서 실제로 적용한 multi-config 아키텍처의 설계 기록입니다. 비슷한 크기의 Obsidian 플러그인이나 VS Code 익스텐션을 multi-tenant로 바꾸려는 분들에게 참고가 되었으면 합니다.

2. 풀어야 했던 7개의 서로 얽힌 문제

리팩토링 직전의 노트에는 이런 문제들이 적혀 있었습니다.

  1. 서비스 격리: 각 config는 자기만의 FileWatcher, SyncQueue, scheduler를 가져야 함. 설정 A가 폴더 A를 감시하면서 설정 B가 폴더 B를 감시해야 하고, 둘이 서로 영향을 주면 안 됨.
  2. 서비스 공유: Rate limiter는 credential 단위로 공유해야 함. 같은 API key를 쓰는 config 3개가 있으면 Airtable의 분당 요청 한계도 3개가 합쳐서 따라야 함.
  3. Credential 분리: API key는 config와 독립적으로 저장해야 함. 같은 key를 여러 config가 참조하려면 key 변경 시 한 곳만 수정하면 됨.
  4. 기존 사용자 마이그레이션: 단일 config를 쓰던 기존 데이터는 설치 후 재시작 한 번만에 multi-config로 변환되고 동작해야 함. 마이그레이션 실패 시 데이터 손실 0.
  5. 폴더 겹침 방지: 두 config가 같은 폴더(또는 부모-자식 관계)를 감시하면 싱크 사이클이 꼬임. 저장 시점에 감지해서 거부해야 함.
  6. Command palette 대응: Obsidian의 command palette에는 config별로 "Sync from remote", "Sync to remote" 같은 명령이 여러 번 나타나야 함 — 그리고 config가 추가/삭제될 때 재시작 없이 갱신돼야 함.
  7. Settings UI 재설계: 여러 config를 동시에 편집할 수 있는 tab 기반 UI. 각 탭 안에서는 collapsible summary card로 섹션을 묶어서 visual fatigue를 줄여야 함.

이 7개는 모두 서로 얽혀 있습니다. "Credential 분리"를 하면 rate limiter 공유 규칙이 바뀌고, "마이그레이션"을 하면 데이터 모델 변경이 필요하고, "서비스 격리"를 하면 command 등록 방식을 다시 짜야 합니다. 한 번에 풀 수 있는 문제가 아니라 하나씩 풀면서 나머지를 깨지 않는 방식으로 진행해야 했습니다.

3. 데이터 모델 — Credential과 Config의 분리

첫 번째 결정은 데이터 모델을 두 개로 쪼개는 것이었습니다.

3.1 이전: 한 덩어리 settings

// Before: src/types/settings.types.ts
export interface AutoNoteImporterSettings {
  apiKey: string;           // ← 인증
  baseId: string;           // ← 설정
  tableId: string;
  folderPath: string;
  // ... 20+ fields, 인증과 설정이 뒤섞임
}

3.2 이후: credential + config 분리

// src/types/credential.types.ts
export type Credential =
  | AirtableCredential       // { id, name, type: 'airtable',   apiKey }
  | SeaTableCredential       // { id, name, type: 'seatable',   apiToken, serverUrl }
  | SupabaseCredential       // { id, name, type: 'supabase',   projectUrl, apiKey }
  | NotionCredential         // { id, name, type: 'notion',     integrationToken }
  | CustomApiCredential;     // { id, name, type: 'custom-api', baseUrl, authHeader, authValue }
// src/types/config.types.ts
export interface ConfigEntry {
  id: string;
  name: string;
  enabled: boolean;
  credentialId: string;      // ← Credential을 참조

  baseId: string;
  tableId: string;
  viewId: string;
  folderPath: string;
  templatePath: string;
  // ... per-config 필드들
}
// src/types/settings.types.ts
export interface AutoNoteImporterSettings {
  version: 2;                      // ← 마이그레이션 버전
  credentials: Credential[];       // ← N개
  configs: ConfigEntry[];          // ← N개
  activeConfigId: string;
  debugMode: boolean;
}

핵심 결정: credential과 config는 N:M 관계입니다. 하나의 credential이 여러 config에서 참조될 수 있습니다. 이 분리가 없으면 같은 API key를 3곳에 복사해서 저장해야 하고, 변경할 때 3번 수정해야 하고, 무엇보다 rate limiter 공유 규칙을 표현할 수 없습니다.

3.3 Credential은 왜 discriminated union인가

Airtable 외의 DB를 지원하려는 계획이 있었기 때문입니다. 각 provider는 필요한 인증 필드가 다릅니다. SeaTable은 apiToken + serverUrl, Supabase는 projectUrl + apiKey. 이걸 옵셔널 필드로 섞어 두면 "SeaTable credential의 apiKey에 접근"하는 버그가 컴파일 타임에 잡히지 않습니다. Discriminated union은 그런 버그를 작성 불가능하게 만듭니다.

이 discriminated union이 실제로 어떻게 동작하는지는 별도 글에서 자세히 다뤘습니다. 이 글에서는 "credential은 type별로 다른 필드를 가진다"만 기억하면 충분합니다.

4. ConfigInstance — config 하나의 완전한 서비스 스택

각 config가 자기만의 서비스 스택을 가지는 구조를 ConfigInstance라는 클래스로 묶었습니다.

4.1 ConfigInstance가 소유하는 것

ConfigInstance
├── RateLimiter (shared per credential via SharedServices.rateLimiters)
├── DatabaseProvider (created via ProviderRegistry.createProvider())
├── ConflictResolver
├── FileWatcher (with per-config debounce + folder scope)
├── SyncOrchestrator
├── SyncQueue
└── Periodic sync scheduler (setInterval)

FileWatcher, SyncQueue, scheduler는 완전히 격리돼 있습니다. config A의 파일 변경이 config B의 SyncQueue에 들어가지 않습니다. 반면 RateLimitercredential 단위로 공유됩니다(곧 설명).

4.2 생성자에서 일어나는 일

// src/core/config-instance.ts
constructor(
  app: App,
  config: ConfigEntry,
  credential: Credential,
  shared: SharedServices,
) {
  this.configId = config.id;
  this.credentialId = credential.id;
  this.settings = buildLegacySettings(config, credential, shared.getDebugMode());

  // 1. Get or create RateLimiter (shared per credential)
  this.rateLimiter = this.getOrCreateRateLimiter(credential.id);

  // 2. Create DatabaseProvider via registry (based on credential.type)
  this.databaseProvider = createProvider(
    credential,
    config,
    this.rateLimiter,
    shared.getDebugMode(),
  );

  // 3. Create ConflictResolver
  this.conflictResolver = new ConflictResolver(this.settings, this.databaseProvider);

  // 4. Create FileWatcher (callback captures syncQueue via closure)
  this.fileWatcher = new FileWatcher(
    this.app, this.settings,
    async (files) => {
      const mode: SyncMode = this.settings.autoSyncFormulas
        ? 'bidirectional' : 'to-airtable';
      await this.syncQueue.enqueue(mode, 'modified', files.map(f => f.path));
    },
  );

  // 5. Create SyncOrchestrator
  this.syncOrchestrator = new SyncOrchestrator(
    this.app, this.settings, this.databaseProvider,
    this.shared.fieldCache, this.shared.frontmatterParser,
    this.fileWatcher, this.conflictResolver, statusBar,
  );

  // 6. Create SyncQueue
  this.syncQueue = new SyncQueue(
    ({ mode, scope, filePaths }) =>
      this.syncOrchestrator.processSyncRequest(mode, scope, filePaths),
    (error) => { /* notice */ },
  );

  // 7. Setup file watcher and scheduler if enabled
  if (config.enabled) {
    this.fileWatcher.setup();
    this.startScheduler(config);
  }
}

순서가 중요합니다. SyncOrchestratorFileWatcherConflictResolver를 주입받아야 하고, SyncQueueSyncOrchestrator.processSyncRequest를 소비해야 합니다. 그래서 7단계 생성 순서가 있습니다. 이 순서가 깨지면 "undefined 참조" 버그가 나옵니다.

4.3 FileWatcher의 closure 주의점

4번째 단계의 FileWatcher 콜백은 this.syncQueue를 참조합니다. 그런데 이 시점에 this.syncQueue아직 assign되지 않았습니다 (6번에서 assign됨). 왜 이게 괜찮을까요?

콜백이 즉시 실행되지 않기 때문입니다. fileWatcher.setup()은 7번에서 호출되고, 실제 파일 변경 이벤트는 비동기적으로 나중에 옵니다. JavaScript closure는 참조 시점 기준이 아니라 호출 시점 기준으로 this.syncQueue를 평가하기 때문에, 콜백이 실제로 실행될 즈음에는 이미 this.syncQueue가 assign돼 있습니다.

이건 코드가 "이상해 보이지만 작동하는" 대표적인 케이스입니다. 혼란을 줄이기 위해 주석으로 명시해 뒀습니다:

// Create FileWatcher (callback captures syncQueue via closure — safe because
// setup() is called after syncQueue is assigned, and callbacks fire asynchronously)

4.4 updateSettings — stable reference 교체 패턴

사용자가 config를 편집하면 어떻게 될까요? 가장 단순한 방법은 ConfigInstance를 destroy하고 새로 만드는 것입니다. 하지만 그러면 참조를 들고 있는 모든 객체(command palette에 등록된 콜백 등)가 stale한 참조를 갖게 됩니다.

대신 같은 인스턴스의 내부 상태만 갈아끼우는 updateSettings() 메서드를 도입했습니다.

updateSettings(config: ConfigEntry, credential: Credential): void {
  this.credentialId = credential.id;
  this.settings = buildLegacySettings(config, credential, this.shared.getDebugMode());

  // Update RateLimiter if credential changed
  const newRateLimiter = this.getOrCreateRateLimiter(credential.id);
  if (newRateLimiter !== this.rateLimiter) {
    this.rateLimiter = newRateLimiter;
  }
  this.rateLimiter.setDebugMode(this.shared.getDebugMode());

  // Propagate settings to all services
  this.databaseProvider.reconfigure(credential, config, this.rateLimiter, this.shared.getDebugMode());
  this.conflictResolver.updateSettings(this.settings);
  this.syncOrchestrator.updateSettings(this.settings);

  // Reconfigure file watcher
  this.fileWatcher.teardown();
  this.fileWatcher.updateSettings(this.settings);
  if (config.enabled) {
    this.fileWatcher.setup();
  }

  // Restart scheduler
  this.stopScheduler();
  if (config.enabled) {
    this.startScheduler(config);
  }
}

핵심은 this.databaseProvider.reconfigure(...)this.rateLimiter를 넘긴다는 점입니다. Credential이 바뀌면 rate limiter도 바뀌는데, 이걸 provider에 명시적으로 알려주지 않으면 provider는 예전 limiter를 계속 참조합니다(이 버그는 provider abstraction 글에서 실제로 한 번 발생했습니다).

5. ConfigManager — 여러 인스턴스의 생명주기

ConfigInstance가 "하나의 config를 위한 서비스 스택"이라면, ConfigManager는 "여러 ConfigInstance의 생명주기를 관리하는 상위 객체"입니다.

// src/core/config-manager.ts
export class ConfigManager {
  private instances = new Map<string, ConfigInstance>();

  initialize(configs: ConfigEntry[], credentials: Credential[]): void {
    const credentialMap = new Map(credentials.map(c => [c.id, c]));

    for (const config of configs) {
      if (!config.enabled) continue;
      const credential = credentialMap.get(config.credentialId);
      if (!credential) continue;
      this.addConfig(config, credential);
    }
  }

  addConfig(config: ConfigEntry, credential: Credential): ConfigInstance {
    const instance = new ConfigInstance(this.app, config, credential, this.shared);
    this.instances.set(config.id, instance);
    return instance;
  }

  removeConfig(configId: string): void {
    const instance = this.instances.get(configId);
    if (instance) {
      instance.destroy();
      this.instances.delete(configId);
      this.pruneOrphanedRateLimiters();  // ← credential 공유 정책 유지
    }
  }

  updateConfig(
    configId: string,
    config: ConfigEntry,
    credential: Credential,
  ): void {
    const instance = this.instances.get(configId);

    if (instance && config.enabled) {
      instance.updateSettings(config, credential);     // 경로 A: 편집
    } else if (instance && !config.enabled) {
      this.removeConfig(configId);                      // 경로 B: 비활성화
    } else if (!instance && config.enabled) {
      this.addConfig(config, credential);              // 경로 C: 재활성화
    }
  }
}

네 가지 상태 전환updateConfig 하나가 처리합니다.

  1. 비활성 → 비활성: 아무것도 안 함
  2. 비활성 → 활성: addConfig (새 인스턴스 생성)
  3. 활성 → 활성: updateSettings (내부 상태만 갈아끼움)
  4. 활성 → 비활성: removeConfig (인스턴스 destroy + orphan 정리)

네 번째 케이스의 pruneOrphanedRateLimiters()가 흥미로운 부분입니다. 이어서 설명합니다.

6. SharedServices — 공유 vs 격리의 경계

ConfigInstance는 완전히 독립적이지 않습니다. 몇 가지 서비스는 모든 instance 사이에서 공유되어야 합니다.

export interface SharedServices {
  rateLimiters: Map<string, RateLimiter>;  // credential ID → limiter
  fieldCache: FieldCache;                  // 전역 field metadata 캐시
  frontmatterParser: FrontmatterParser;    // stateless 파서
  statusBarFactory: () => HTMLElement;     // Obsidian API bridge
  getDebugMode: () => boolean;             // 전역 debug 플래그
}

6.1 RateLimiter는 왜 credential 단위인가

Airtable의 API rate limit은 base 단위가 아니라 API key 단위입니다. 같은 key를 쓰는 config 3개가 각자 rate limiter를 가지면 분당 15 요청(= 5 × 3)이 나갈 수 있고, 실제 한도인 5 req/s를 넘어 429를 받게 됩니다.

해결책: credential ID를 키로 하는 공유 MapSharedServices.rateLimiters에 둡니다.

// src/core/config-instance.ts
private getOrCreateRateLimiter(credentialId: string): RateLimiter {
  let limiter = this.shared.rateLimiters.get(credentialId);
  if (!limiter) {
    limiter = new RateLimiter();
    limiter.setDebugMode(this.shared.getDebugMode());
    this.shared.rateLimiters.set(credentialId, limiter);
  }
  return limiter;
}

"없으면 만들고, 있으면 공유" 패턴입니다. 5개 config가 같은 credential을 참조하면 limiter는 1개만 만들어지고 5개가 그것을 공유합니다.

6.2 Orphan 방지 — pruneOrphanedRateLimiters

여기에 함정이 있었습니다. Config를 삭제하면 그 config가 쓰던 credential이 더 이상 참조되지 않을 수도 있습니다. 그런데 limiter는 SharedServices.rateLimiters Map에 여전히 남아 있습니다. 이건 메모리 leak이고, 더 나쁘게는 사용자가 같은 credential을 다시 추가했을 때 과거 상태(대기 중이던 요청 큐 등)가 섞인 limiter를 재사용하게 될 수도 있습니다.

수정 전:

// Before: config 제거만 하고 limiter는 그대로
removeConfig(configId: string): void {
  const instance = this.instances.get(configId);
  if (instance) {
    instance.destroy();
    this.instances.delete(configId);
    // ← rateLimiters Map은 손대지 않음
  }
}

수정 후:

// After: orphan pruning 추가
removeConfig(configId: string): void {
  const instance = this.instances.get(configId);
  if (instance) {
    instance.destroy();
    this.instances.delete(configId);
    this.pruneOrphanedRateLimiters();  // ← 추가
  }
}

private pruneOrphanedRateLimiters(): void {
  const usedCredentialIds = new Set(
    Array.from(this.instances.values()).map(i => i.credentialId),
  );
  for (const credId of this.shared.rateLimiters.keys()) {
    if (!usedCredentialIds.has(credId)) {
      this.shared.rateLimiters.delete(credId);
    }
  }
}

알고리즘: 현재 살아있는 ConfigInstance들의 credentialId를 모두 모은 뒤, rateLimiters Map에 있는 key 중 그 Set에 없는 것을 제거합니다. O(n × m)이지만 n, m 모두 작아서 실용적으로 상수 시간입니다.

이 문제를 감지하기 위해 ConfigInstancereadonly credentialId: string을 추가했습니다. 이전에는 ConfigInstance가 credential 자체를 숨기고 있었는데, orphan 추적에 필요한 최소한의 "keying 정보"를 노출한 셈입니다.

7. 무중단 v1→v2 마이그레이션

v0.8.0이 릴리스되던 시점에는 이미 수백 명의 사용자가 v1 settings 형식(단일 config)을 디스크에 저장하고 있었습니다. 그들을 건드리면 안 됩니다. 재시작 한 번에 자동으로 v2로 변환돼야 합니다.

7.1 플러그인 부팅 시퀀스

// src/main.ts — pseudo
async onload() {
  const data = await this.loadData();  // ← 디스크에서 읽기

  // 1. 마이그레이션 시도
  const migrated = migrateSettings(data);
  if (migrated) {
    this.settings = migrated;
    await this.saveData(this.settings);  // ← 즉시 v2로 저장
  } else if (data?.version === 2) {
    this.settings = data;
  } else {
    this.settings = createFreshSettings();
  }

  // 2. ConfigManager 초기화
  this.configManager = new ConfigManager(this.app, shared);
  this.configManager.initialize(this.settings.configs, this.settings.credentials);

  // 3. Command 등록
  this.registerCommands();
}

세 가지 분기: 마이그레이션 성공 / 이미 v2 / 완전히 새 설치.

7.2 migrateSettings의 설계

// src/utils/migration.ts
export function migrateSettings(data: unknown): AutoNoteImporterSettings | null {
  if (data == null) return null;                // 새 설치 — 마이그레이션 불필요
  if (typeof data !== 'object') return null;    // 이상한 데이터 — 무시
  const record = data as Record<string, unknown>;
  if (record['version'] === 2) return null;     // 이미 v2 — 마이그레이션 불필요

  const credentialId = generateId();
  const configId = generateId();

  const credential: Credential = {
    id: credentialId,
    name: 'Airtable',
    type: 'airtable',
    apiKey: typeof record['apiKey'] === 'string' ? record['apiKey'] : '',
  };

  const config: ConfigEntry = {
    id: configId,
    name: 'Default',
    enabled: true,
    credentialId,
    baseId: typeof record['baseId'] === 'string' ? record['baseId'] : '',
    tableId: typeof record['tableId'] === 'string' ? record['tableId'] : '',
    // ... 20+ 필드를 전부 defensive 파싱
  };

  return {
    version: 2,
    credentials: [credential],
    configs: [config],
    activeConfigId: configId,
    debugMode: typeof record['debugMode'] === 'boolean' ? record['debugMode'] : false,
  };
}

세 가지 설계 원칙이 있습니다.

첫째, return null이 세 상황에 공통입니다. "마이그레이션 불필요"의 세 케이스(새 설치, 이상 데이터, 이미 v2)가 모두 null로 표현되고, 호출자는 이 세 경우를 각자 다르게 처리합니다. 마이그레이션 함수 자체가 "이미 v2인지" "새 설치인지" 같은 고수준 분기를 알 필요가 없습니다.

둘째, typeof record['x'] === 'string' ? ... : '' 같은 defensive 파싱을 20개 필드에 일일이 적용했습니다. 지저분해 보이지만, 이 방어가 있어야 어떤 이상한 과거 데이터(수동 편집, 다른 버전에서 온 이식, 일부 필드 누락)에도 절대 예외를 던지지 않습니다. 마이그레이션 실패는 데이터 손실이고, 데이터 손실은 사용자가 용서하지 않습니다.

셋째, version: 2를 반환 객체에 포함합니다. 다음 부팅에서 if (record['version'] === 2) return null이 걸려서 마이그레이션이 idempotent하게 됩니다. 마이그레이션 함수를 여러 번 호출해도 같은 결과가 나옵니다.

7.3 테스트 커버리지

마이그레이션은 실패하면 안 되는 로직이라 테스트를 매우 꼼꼼하게 작성했습니다.

  • 새 설치 (null, undefined) → null 반환
  • 문자열 데이터 ('invalid') → null 반환
  • v1 데이터 → 올바른 v2 구조
  • 일부 필드 누락 (baseId 없음) → 빈 문자열 fallback
  • 잘못된 타입 (syncInterval: 'abc') → 기본값 fallback
  • 이미 v2 데이터 → null 반환 (idempotent)

테스트를 통과하지 못한 edge case는 릴리스되지 못했습니다. 이 부분에 들인 시간이 사용자 후기의 "업그레이드 후 설정 그대로 있어요" 한 줄로 보상받았습니다.

8. 폴더 겹침 검증 — 사이클 방지

Multi-config에서 가장 황당한 사고는 이것입니다.

  • Config A: folder Notes/Projects
  • Config B: folder Notes/Projects/Active

Config A의 FileWatcherNotes/Projects/Active/foo.md의 변경을 감지합니다. Push를 트리거합니다. 그런데 이 파일은 Config B의 소유이기도 해서 Config B의 FileWatcher도 변경을 감지합니다. 같은 파일이 두 개의 base로 push되고, 각 base가 다르게 응답하면 충돌 루프가 돌기 시작합니다.

예방 수단은 저장 시점에 폴더 겹침을 거부하는 것입니다.

// src/utils/validation.ts
export function validateFolderPath(
  configId: string,
  folderPath: string,
  configs: ConfigEntry[],
): string | null {
  const normalized = normalizePath(folderPath);
  for (const config of configs) {
    if (config.id === configId) continue;  // 자기 자신은 제외
    const other = normalizePath(config.folderPath);
    if (!other) continue;
    if (
      normalized === other ||
      normalized.startsWith(other + '/') ||      // 자식 관계
      other.startsWith(normalized + '/')          // 부모 관계
    ) {
      return `Folder conflicts with "${config.name}" configuration`;
    }
  }
  return null;
}

세 가지 겹침 케이스를 검사합니다.

  1. 동일: Notes/A vs Notes/A (명백)
  2. 자식: Notes/A vs Notes/A/Sub (내가 상대의 부모)
  3. 부모: Notes/A/Sub vs Notes/A (내가 상대의 자식)

중요한 디테일: 비활성(enabled: false) config도 검증에 포함합니다. 사용자가 나중에 재활성화했을 때 문제가 생기지 않도록 미리 방지하는 것입니다.

또 하나: normalizePath를 써서 ./Notes/A/, Notes/A, Notes\A 같은 변형이 모두 같은 path로 정규화됩니다. 이 normalization 없이는 false negative가 쌓입니다.

9. Config별 동적 Command 등록

Obsidian의 command palette는 플러그인이 addCommand()로 등록한 명령을 보여줍니다. 단일 config 시절에는 이게 단순했습니다.

// Before: static registration
this.addCommand({
  id: 'sync-from-remote-all',
  name: 'Sync all notes from remote',
  callback: () => this.syncAll(),
});

Multi-config에서는 config마다 명령 세트가 따로 나와야 합니다:

  • Sync from remote: Projects table
  • Sync from remote: Reading list
  • Sync to remote: Projects table
  • Sync to remote: Reading list
  • ...

그리고 config가 추가/삭제/이름변경될 때 재시작 없이 갱신돼야 합니다.

9.1 Config별 registration

// src/main.ts
private registerCommands(): void {
  for (const config of this.settings.configs) {
    this.registerCommandsForConfig(config);
  }
  this.commandFingerprint = this.getCommandFingerprint();
}

private registerCommandsForConfig(config: ConfigEntry): void {
  const configId = config.id;

  this.addCommand({
    id: `sync-from-remote-current-${configId}`,
    name: `${config.name}: Sync current from remote`,
    checkCallback: (checking) => {
      if (!this.isActiveFileInConfigFolder(config)) return false;
      if (!checking) {
        this.configManager
          .getInstance(configId)
          ?.enqueueSyncRequest('from-airtable', 'current');
      }
      return true;
    },
  });

  // ... 나머지 명령들
}

각 명령의 ID에 configId suffix를 붙여서 고유성을 보장합니다. 이름에는 config.name을 써서 사용자가 command palette에서 구분할 수 있게 합니다.

9.2 checkCallback의 두 역할

checkCallback은 Obsidian의 pattern입니다. 두 가지 일을 합니다.

  1. 가시성 제어: checking === true일 때 호출되며, false를 반환하면 command palette에서 명령이 숨겨집니다.
  2. 실행: checking === false일 때 호출되며, 실제 명령을 실행합니다.

이 코드에서는 "현재 활성 파일이 이 config의 폴더 안에 있는가"를 체크해서 관련 없는 config의 current-scope 명령을 숨깁니다. 사용자가 "Reading list" 폴더의 파일을 열고 있을 때 "Projects table" config의 "Sync current" 명령이 팔레트에 나오지 않도록 합니다.

9.3 stale reference 함정 — dynamic lookup

여기에 교묘한 함정이 있었습니다. 처음 작성은 이랬습니다:

// Before (버그): instance를 closure에 캡처
private registerCommandsForConfig(config: ConfigEntry): void {
  const instance = this.configManager.getInstance(config.id);  // ← 현재 인스턴스 캡처

  this.addCommand({
    id: `sync-from-remote-all-${config.id}`,
    name: `${config.name}: Sync all from remote`,
    callback: () => instance.enqueueSyncRequest('from-airtable', 'all'),  // ← stale
  });
}

문제: 사용자가 config를 수정하면 ConfigInstance가 새로 만들어질 수 있습니다(예를 들어 비활성화 → 재활성화). 그런데 command palette의 callback은 예전 instance를 여전히 참조하고 있어서, 새 instance가 아닌 이미 destroy된 옛 instance에 싱크 요청을 보냅니다. 이벤트는 아무 일도 일어나지 않은 듯 실패합니다.

수정:

// After: 매 실행 시점마다 lookup
this.addCommand({
  id: `sync-from-remote-all-${configId}`,
  name: `${config.name}: Sync all from remote`,
  checkCallback: (checking) => {
    if (!checking) {
      this.configManager
        .getInstance(configId)   // ← dynamic lookup
        ?.enqueueSyncRequest('from-airtable', 'all');
    }
    return true;
  },
});

configId문자열 scalar이라 closure에 캡처돼도 stale하지 않습니다. Instance는 콜백 실행 시점에 configManager.getInstance(configId)로 다시 찾습니다.

9.4 Re-registration on config change

Config가 추가/삭제/이름변경되면 Obsidian의 command 목록을 전부 다시 등록합니다.

private reregisterAllCommands(): void {
  const commandPrefix = this.manifest.id;
  const commands = (this.app as any).commands?.commands;

  // 1. 기존 command 제거
  if (commands) {
    const registeredIds = Object.keys(commands).filter(
      id => id.startsWith(`${commandPrefix}:`) && id !== commandPrefix,
    );
    for (const fullId of registeredIds) {
      delete commands[fullId];
    }
  }

  // 2. 현재 config 목록으로 재등록
  for (const config of this.settings.configs) {
    this.registerCommandsForConfig(config);
  }
}

(this.app as any).commands?.commands는 Obsidian의 private API를 건드립니다. 공식 API에는 "등록한 command를 해제"하는 메서드가 없기 때문에 private map을 직접 조작했습니다. 이건 fragility가 있는 코드이지만(Obsidian 업데이트에서 깨질 수 있음), 대안이 없었습니다.

언제 호출되나: Settings UI에서 config가 추가/삭제/토글/이름변경될 때. commandFingerprint로 "정말로 변경이 있었는지"를 비교해서 불필요한 재등록을 생략합니다.

private getCommandFingerprint(): string {
  return this.settings.configs
    .map(c => `${c.id}:${c.name}:${c.enabled}:${c.bidirectionalSync}`)
    .join('|');
}

bidirectionalSync가 fingerprint에 들어있는 이유는, 이 플래그에 따라 표시되는 명령 세트가 달라지기 때문입니다(양방향 명령이 bidirectionalSync: false일 때 숨겨짐).

10. 배운 것 5가지

10.1 "공유 vs 격리"의 경계를 데이터 모델에 박아라

"어떤 상태가 credential 단위이고, 어떤 상태가 config 단위인가"를 코드에 섞어 놓으면 나중에 반드시 꼬입니다. SharedServices 인터페이스 하나가 "credential 단위로 공유되는 것"과 "plugin 단위로 공유되는 것"을 명시적으로 구분해 줍니다. 이 명시성이 orphan 버그의 근본 원인을 쉽게 보게 만들어 줍니다.

10.2 마이그레이션은 defensive + idempotent가 기본

  • Defensive: 모든 필드를 개별적으로 type 체크하고 fallback 값을 준비. 한 필드 실패가 전체 마이그레이션을 깨지 않게.
  • Idempotent: 이미 마이그레이션된 데이터에 대해 다시 호출해도 같은 결과. version: 2 체크 하나로 구현됨.

10.3 Stable reference + reconfigure > recreate

ConfigInstance를 매번 새로 만들면 참조를 가진 모든 subscriber(command palette, status bar, 외부 API)가 stale해집니다. updateSettings()로 내부 상태만 교체하면 이 ripple이 0이 됩니다.

10.4 Dynamic lookup > closure capture

Command callback이나 event handler에서는 "현재 유효한 인스턴스"를 실행 시점에 조회하는 게 좋습니다. Closure로 캡처하면 오브젝트 교체 시 stale reference 버그가 조용히 숨어듭니다.

10.5 폴더 겹침은 "부모-자식 양쪽"을 모두 검사

Path 관계 검사에서 세 가지 케이스(동일, 자식, 부모)를 모두 검사해야 합니다. 한 쪽만 검사하면 false negative가 쌓이고, 그게 싱크 루프로 이어집니다. normalizePath로 입력을 정규화하는 것도 필수입니다.

11. FAQ

Q: 왜 서로 다른 Obsidian 플러그인 인스턴스로 분리하지 않았나요?

A: Obsidian은 플러그인당 하나의 인스턴스만 허용합니다. 같은 manifest.id의 플러그인이 여러 개 살 수 없기 때문에 "multi-tenant를 플러그인 내부에서" 구현하는 수밖에 없었습니다.

Q: Config 숫자가 많아지면 성능에 영향이 있나요?

A: ConfigInstance 생성 자체는 O(1)이고, 메모리 사용은 config당 수백 KB 수준입니다(FileWatcher, SyncQueue 등). 10~20개 config까지는 체감상 문제가 없습니다. Limit은 사용자 vault의 파일 숫자에 따른 FileWatcher 성능에 주로 좌우됩니다.

Q: 같은 credential을 쓰는 여러 config가 동시에 sync 요청을 보내면 어떻게 되나요?

A: 각 config의 SyncQueue는 독립적입니다. 하지만 실제 HTTP 요청은 RateLimiter에서 직렬화되므로 Airtable의 분당 요청 한계를 넘지 않습니다. 순서는 "먼저 enqueue된 것"이 아니라 "rate limiter에 먼저 도착한 것"이 기준이라 약간의 non-determinism이 있습니다.

Q: v1→v2 마이그레이션이 실패하면 어떻게 되나요?

A: migrateSettings()가 throw하지 않도록 설계돼 있습니다. 모든 필드가 defensive 파싱을 거치고, 타입이 이상하면 기본값으로 fallback합니다. 최악의 경우에도 "credential 하나 + config 하나"의 유효한 v2 구조가 반환되고, 원본 데이터는 Default credential/config의 필드로 보존됩니다.

Q: Config를 삭제했는데 파일은 남아 있나요?

A: 네. Config 삭제는 싱크 설정을 지우는 것이고, vault의 실제 .md 파일은 건드리지 않습니다. 사용자가 원하면 수동으로 폴더를 삭제할 수 있습니다. 이 설계는 "실수로 config를 지웠을 때 데이터 손실을 만들지 않기" 위한 보수적인 선택입니다.

Q: 이 패턴을 VS Code 익스텐션이나 다른 플러그인에도 적용할 수 있나요?

A: 네. "하나의 플러그인이 여러 워크스페이스/설정을 동시에 관리"하는 상황이면 동일한 패턴이 적용됩니다. VS Code의 경우 workspace 단위로 설정이 분리되는 경향이 있어서 SharedServices의 범위가 조금 달라질 수 있지만, ConfigInstance ↔ ConfigManager ↔ SharedServices의 세 레이어 구조는 이식 가능합니다.

12. 참고 자료

13. 다음 글 예고

이 글은 obsidian-auto-note-importer v0.8.0의 Multi-Config 아키텍처를 다뤘습니다. 이 구조를 만드는 과정에서 실제로 발생한 RateLimiter orphan 누수 버그는 다음 글에서 상세히 다룹니다 — 3파일 16줄짜리 fix의 배경과, "per-instance 서비스 전환 시 항상 따라오는 cleanup 함정"을 어떻게 일반화할 수 있는지가 주제입니다.