Singleton을 Per-Instance로 바꾼 뒤 찾아온 메모리 누수: 16줄로 잡은 Orphaned RateLimiter

Obsidian 플러그인의 multi-config 리팩토링 중 발견한 조용한 메모리 누수: config를 삭제해도 그 config가 쓰던 RateLimiter가 공유 Map에 그대로 남았던 버그의 원인과 3파일 16줄짜리 fix. Per-instance 서비스 전환 시 주의해야 할 4가지 cleanup 함정을 정리했습니다.

Singleton을 Per-Instance로 바꾼 뒤 찾아온 메모리 누수: 16줄로 잡은 Orphaned RateLimiter

1. 증상 — 이상하게 "같은 credential"이 아닌 것처럼 동작하는 현상

v0.8.0의 multi-config 릴리스 직후, 테스트 중에 조용한 이상 현상이 있었습니다.

  1. Config A와 Config B를 만듭니다. 둘 다 같은 Airtable API key(credential K1)를 참조합니다.
  2. Rate limiter는 K1 단위로 공유되므로, A와 B는 같은 RateLimiter 인스턴스를 씁니다. 분당 요청 한계도 공유됩니다.
  3. Config A를 삭제합니다.
  4. Config A와 같은 이름으로 Config A'를 다시 만듭니다. 같은 credential K1을 참조합니다.
  5. 이 시점에서 Config A'와 Config B가 각자 다른 limiter 인스턴스를 가지고 있는 것 같은 동작이 관찰됩니다.

"같은 것 같은 동작"이라는 표현이 애매한데, 증상 자체가 애매했기 때문입니다. 분명 429 에러가 자주 나오기 시작했고, 특히 A'와 B가 동시에 sync를 시작할 때 집중적으로 나왔습니다. 한 개의 API key가 두 개의 rate budget을 쓰는 것처럼 보였습니다.

이 글은 이 현상의 근본 원인과 3파일 16줄짜리 수정을 정리한 기록입니다. 버그 자체는 작지만, 여기서 배운 원칙은 **"singleton 서비스를 per-instance로 리팩토링할 때 거의 항상 따라오는 함정"**을 이해하는 데 유용합니다.

2. 배경 — 왜 RateLimiter가 per-credential로 바뀌었나

원래 RateLimiter는 플러그인 전체에 단 하나였습니다.

// v0.7.x까지: 단일 인스턴스
export class AutoNoteImporterPlugin extends Plugin {
  private rateLimiter!: RateLimiter;

  async onload() {
    this.rateLimiter = new RateLimiter();
    // 모든 Airtable 요청이 이 하나의 limiter를 거침
  }
}

단일 인스턴스가 맞는 설계였습니다. Airtable은 API key 단위로 5 req/s 제한을 걸고, 플러그인은 API key 하나만 사용했기 때문에 플러그인 전체의 요청량을 하나의 limiter가 관리하면 충분했습니다.

v0.8.0의 multi-config 아키텍처에서는 이 가정이 깨졌습니다. 여러 credential을 동시에 쓸 수 있게 되면서, 각 credential마다 독립적인 rate budget이 있어야 했습니다.

// v0.8.0: SharedServices에 credential별 Map
export interface SharedServices {
  rateLimiters: Map<string, RateLimiter>;  // credential ID → limiter
  // ...
}

ConfigInstance는 이렇게 씁니다.

// 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;
}

"없으면 만들고, 있으면 공유" 패턴입니다. 같은 credential을 쓰는 여러 config는 같은 limiter 인스턴스를 받습니다. 여기까지는 완벽하게 동작했습니다.

3. 함정 — config 삭제는 했지만 limiter는 남았다

문제는 삭제 경로였습니다. ConfigManager.removeConfig()는 처음에 이렇게 생겼었습니다.

// Before (버그)
removeConfig(configId: string): void {
  const instance = this.instances.get(configId);
  if (instance) {
    instance.destroy();                // ← ConfigInstance 정리
    this.instances.delete(configId);    // ← Map에서 제거
    // ← rateLimiters Map은 손대지 않음
  }
}

"ConfigInstance는 destroy했지만 그 instance가 쓰던 limiter는 shared.rateLimiters Map에 그대로 남아 있습니다." 이게 핵심입니다.

왜 문제인가요? Limiter 자체는 state를 가집니다.

  • 마지막 요청 시각 (lastRequestTime)
  • 대기 중인 setTimeout 핸들
  • retry counter
  • 내부 lock flag

이 상태가 다음 세션에 재사용될 때 이상한 일이 일어납니다. 사용자가 같은 credential을 다시 참조하는 새 config를 만들면 getOrCreateRateLimiter()예전 limiter를 그대로 반환합니다. 예전 limiter는 이미 "최근 요청 시각"이 기록돼 있고, 예전 retry count가 살아 있으며, 최악의 경우에는 예전 setTimeout이 고아가 되어 아무도 기다리지 않는 콜백을 fire합니다.

그리고 위에서 얘기한 "한 credential이 두 rate budget을 쓰는 것처럼 보이는" 현상은 이것의 가장 드러나는 형태였습니다. 사용자가 credential을 편집해서 id가 달라진 새 credential을 만든 뒤, 기존 config를 새 credential로 재바인딩했을 때, 기존 limiter는 옛 credential ID로 고아가 됩니다. Map에 남아 있지만 아무도 참조하지 않습니다. 그건 단순한 메모리 누수이고, "이상한 rate 동작"의 원인은 다른 경로였습니다 — 하지만 디버깅 과정에서 먼저 눈에 띈 게 이 누수였기 때문에, 여기부터 수정을 시작했습니다.

3.1 단순한 메모리 누수가 아니라 잠재적 동작 버그

메모리 관점에서만 보면 리모트 limiter 몇 개가 사용자 세션 동안 Map에 쌓이는 정도입니다. 단기적으로는 문제 없어 보입니다.

하지만 앞서 말한 것처럼 limiter는 state를 가집니다. 그리고 이 state는 시간이 지나면서 재미있는 경로로 돌아올 수 있습니다.

시나리오: 사용자가 credential K1을 쓰다가, 이름을 바꾸거나 노트 애플리케이션을 재시작한 뒤, 같은 API key로 "새로운" credential K1'을 만들었다고 칩시다. 마이그레이션 로직에 따라 K1'의 ID가 K1과 같을 수도 있고(예: deterministic ID) 다를 수도 있습니다(예: random ID). 다르다면 K1 limiter는 영영 고아로 남습니다. 같다면 K1 limiter의 예전 state가 K1'로 재사용됩니다.

이 중 어느 쪽도 사용자가 이해할 수 있는 동작이 아닙니다. "고아를 예방하는 것" 한 가지만 지키면 두 시나리오 모두 사라집니다.

4. 수정 — 3파일 16줄

4.1 파일 1: config-instance.ts — credentialId 노출

ConfigManager가 "어떤 credential이 현재 사용 중인가"를 알려면 각 ConfigInstance가 자기 credentialId를 말해 줘야 합니다. 이전에는 ConfigInstance가 credential을 생성자에서만 받고 버렸습니다.

// Before: credentialId가 숨겨져 있음
export class ConfigInstance {
  readonly configId: string;
  // credentialId는 외부에서 조회 불가

  constructor(app: App, config: ConfigEntry, credential: Credential, shared: SharedServices) {
    this.configId = config.id;
    // credential.id를 이후에 다시 알 수 없음
    ...
  }
}

수정: credentialId를 public 프로퍼티로 노출합니다.

// After
export class ConfigInstance {
  readonly configId: string;
  credentialId: string;  // ← 추가 (readonly 아님: updateSettings에서 바뀔 수 있음)

  constructor(app: App, config: ConfigEntry, credential: Credential, shared: SharedServices) {
    this.configId = config.id;
    this.credentialId = credential.id;  // ← 추가
    // ...
  }

  updateSettings(config: ConfigEntry, credential: Credential): void {
    this.credentialId = credential.id;  // ← 추가: credential 교체 시 갱신
    // ...
  }
}

디테일: configIdreadonly이지만 credentialId는 아닙니다. ConfigInstance의 identity는 configId에 묶여 있고, credentialId는 사용자가 편집할 수 있는 가변 속성입니다. 사용자가 config A의 credential을 K1에서 K2로 바꾸면 updateSettings()가 호출되고, this.credentialId도 그때 갱신되어야 pruning 로직이 "K2를 쓰는 config"를 올바르게 감지할 수 있습니다.

4.2 파일 2: config-manager.ts — pruneOrphanedRateLimiters

이제 ConfigManager가 "고아 limiter"를 식별하고 정리할 수 있습니다.

// After
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);
    }
  }
}

알고리즘:

  1. 현재 살아 있는 ConfigInstance들의 credentialId를 모두 Set에 모은다.
  2. shared.rateLimiters Map을 순회하며, key가 그 Set에 없으면 삭제한다.

복잡도는 O(n + m)(n = active config 수, m = limiter 수). 둘 다 한 자릿수라 실용적으로 상수 시간입니다.

정확성 고려: Set 생성을 Array.from(this.instances.values()).map(i => i.credentialId)로 씁니다. 이건 this.instances.delete(configId)이미 호출된 뒤에 실행되기 때문에 중요합니다. "방금 삭제한 config의 credential"이 자연스럽게 Set에서 빠져서, 그 credential이 더 이상 다른 config에서 안 쓰이면 해당 limiter가 pruning 대상이 됩니다.

4.3 파일 3: config.types.ts — 부수적 fix

이 fix와 직접적인 관련은 없지만 같은 커밋에 포함된 수정이 하나 있었습니다.

// src/types/config.types.ts
export const DEFAULT_CONFIG_ENTRY: Omit<ConfigEntry, 'id' | 'name' | 'credentialId'> = {
  // ...
  autoSyncFormulas: false,
-  formulaSyncDelay: 3000,
+  formulaSyncDelay: 1500,
  // ...
};

DEFAULT_CONFIG_ENTRYDEFAULT_LEGACY_SETTINGSformulaSyncDelay 기본값이 다르게 설정돼 있었습니다(3000 vs 1500). 새 config는 3초 지연을, 마이그레이션된 config는 1.5초 지연을 썼습니다. 이건 pruning과는 무관하지만 "하나의 커밋에서 발견된 동일 계열의 불일치"였기 때문에 함께 고쳤습니다.

이런 **"덤으로 나온 작은 fix"**는 별도 커밋으로 쪼개는 게 원칙적으로 더 깨끗합니다. 하지만 3줄짜리 상수 값 교정 때문에 PR을 하나 더 만드는 비용이 아까웠고, 커밋 메시지 본문에 명시적으로 분리해서 기록했습니다.

5. 전체 diff

 // src/core/config-instance.ts
 export class ConfigInstance {
   readonly configId: string;
+  credentialId: string;
 
   constructor(app: App, config: ConfigEntry, credential: Credential, shared: SharedServices) {
     this.configId = config.id;
+    this.credentialId = credential.id;
     // ...
   }

   updateSettings(config: ConfigEntry, credential: Credential): void {
+    this.credentialId = credential.id;
     // ...
   }
 }
 // src/core/config-manager.ts
 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);
+    }
+  }
+}
 // src/types/config.types.ts
 export const DEFAULT_CONFIG_ENTRY = {
-  formulaSyncDelay: 3000,
+  formulaSyncDelay: 1500,
 };

총 3파일, +16 / −1 라인. 테스트 추가는 이 커밋에서는 하지 않았고, 후속 리뷰 라운드에서 tests/core/config-manager.test.ts의 기존 removeConfig 케이스에 limiter 검증을 덧붙였습니다.

6. 일반화 — "Per-Instance 전환 시 항상 따라오는 4가지 cleanup 함정"

이 버그는 "singleton 서비스를 per-instance로 바꾼 뒤에 반드시 생각해야 하는 4가지 질문"을 알려줍니다.

6.1 함정 1: 생성 경로만 테스트하고 삭제 경로는 잊는다

getOrCreateRateLimiter()는 꼼꼼하게 테스트됐습니다. "없으면 만들고, 있으면 재사용" 동작이 네 가지 시나리오에서 검증됐습니다. 그런데 **"더 이상 필요 없는 limiter는 어떻게 되는가?"**는 아무도 물어보지 않았습니다.

원칙: 모든 생성 경로에는 대응되는 삭제 경로가 있어야 한다. 그 쌍이 한 쌍의 테스트로 검증돼야 합니다. "생성 → 사용 → 삭제 → 상태 확인"이 하나의 통합 테스트가 되어야 합니다.

6.2 함정 2: 공유 Map이 "사실상 전역 변수"가 된다

SharedServices.rateLimiters는 Map이지만 플러그인 lifetime 동안 사실상 전역 변수입니다. 전역 변수는 **"누가 등록하는가"**만큼 **"누가 해제하는가"**도 명확해야 합니다. 이 경우에는 "ConfigInstance가 등록하고, ConfigManager가 해제"하는 구조로 책임을 분리했습니다.

주의: 등록자와 해제자가 같은 객체가 아닌 경우가 많습니다. 등록하는 쪽은 "생성자에서 넣어두자"라고 생각하고, 해제하는 쪽은 "내가 만든 것도 아닌데 왜 내가 지워?"라고 생각합니다. 이 소유권 공백이 orphan의 전형적 원인입니다.

6.3 함정 3: 상태가 있는 객체를 "key로 재사용"하는 게 위험하다

Limiter는 state를 가집니다. 같은 key로 재조회했을 때 새 인스턴스가 오는지 예전 상태가 남아 있는 인스턴스가 오는지가 정확히 정의돼 있어야 합니다. 이 경우에는 "같은 credential ID면 같은 limiter를 재사용"이 의도였지만, cleanup이 없으면 의도를 벗어난 상황에서도 재사용이 발생합니다.

Stateless 객체(예: FrontmatterParser 같은 순수 파서)는 재사용되든 새로 만들어지든 동작이 같기 때문에 이 문제가 없습니다. State가 있는 객체를 공유 Map에 넣을 때는 lifecycle을 반드시 명시해야 합니다.

6.4 함정 4: "소유권이 분산된 상태"를 데이터 모델에서 추적하지 않는다

버그의 근본 원인은 ConfigInstancecredentialId를 숨기고 있었던 것입니다. ConfigManager가 "어떤 credential이 현재 사용 중인가"를 알 방법이 없었습니다.

교훈: 공유 자원을 참조하는 객체는 그 참조를 명시적인 프로퍼티로 노출해야 합니다. "생성자에 들어갔다가 내부로 사라지는" 값은 나중에 lifecycle 관리가 불가능해집니다.

이 원칙을 적용하면 fix는 굉장히 자연스럽게 나옵니다:

  1. "어떤 상태가 고아가 될 수 있는가?" → rateLimiters Map의 entry
  2. "고아 여부는 어떤 정보로 판단하는가?" → 현재 살아 있는 ConfigInstance들의 credentialId
  3. "그 정보가 외부에 노출돼 있는가?" → (버그 전) 아니오
  4. "노출한 뒤에는 어떻게 검사하는가?" → Set과 Map의 keys를 대조

7. Non-leak 동작 버그와의 관계

섹션 3에서 언급한 "한 credential이 두 rate budget을 쓰는 것처럼 보이는" 원래 증상은, 사실 이 orphan 누수와는 다른 경로에서 발생했습니다. 그 경로는 "credential이 재할당됐을 때 AirtableClient.reconfigure()가 새 limiter로 rebind하지 않는" 버그였고, 이것은 별도 리뷰 라운드에서 provider abstraction 리팩토링 중에 별개로 고쳐졌습니다.

그럼 이 글의 fix는 어떤 user-visible 버그를 고쳤나요? 솔직히 말하면 "사용자가 직접 보는 증상은 없었을 가능성이 큰" 예방적 수정입니다. 하지만 그게 이 fix의 가치를 깎아내리지 않습니다.

  • 메모리 누수는 작더라도 시간이 지남에 따라 누적됩니다.
  • 상태를 가진 limiter의 예전 state가 새 세션에서 재사용될 가능성은 **"reproducible하지 않은 이상한 동작"**의 씨앗이 됩니다.
  • 나중에 SeaTable/Supabase처럼 추가 provider가 들어오면, 그 provider의 limiter는 추가적인 state(예: 토큰 refresh 상태)를 가질 수 있고, orphan은 그때 훨씬 드러나는 버그가 됩니다.

예방적 fix는 "지금 당장 증상이 있는가"가 아니라 "이 상태가 나중에 어떤 버그의 재료가 될 수 있는가"를 기준으로 판단해야 합니다.

8. 베스트 프랙티스 체크리스트

Singleton → per-instance 전환을 할 때 다음을 확인하세요.

  • [ ] 생성 API와 쌍이 되는 삭제 API가 존재하는가? getOrCreate가 있다면 removeOrDetach 같은 것도 있어야 함.
  • [ ] 공유 Map / 공유 cache에 등록되는 모든 entry는 누가 해제할 책임을 지는가? 문서로 남겨라.
  • [ ] 공유 객체를 참조하는 측은 그 참조를 외부에서 조회 가능한 프로퍼티로 노출하는가? Private이면 cleanup 로직이 외부에서 짜일 수 없다.
  • [ ] 삭제 경로의 통합 테스트가 있는가? "생성 → 삭제 → 공유 Map 상태 확인"까지 하나의 테스트로.
  • [ ] 상태를 가진 객체를 "key로 재사용"할 때, 재사용된 객체의 state가 초기화되는 시점이 명시돼 있는가? 아니면 "항상 새로 만드는" 선택을 고려하라.
  • [ ] reconfigure() 같은 in-place update 메서드는 교체해야 할 모든 참조를 파라미터로 받는가? 누락되면 조용한 stale reference가 생긴다.

9. FAQ

Q: 이 정도 누수면 그냥 두고 나중에 고쳐도 되는 거 아닌가요?

A: 규모가 작을 때는 그렇게 보입니다. 문제는 "나중에"가 언제냐입니다. Config 삭제 → 재생성 시나리오는 사용자가 UI를 실험하다가 자연스럽게 발생하고, 누수된 상태가 일으키는 버그는 "내가 뭘 했지?"로 시작하는 디버깅이 됩니다. 작을 때 고치는 게 비용이 훨씬 작습니다.

Q: WeakMap을 쓰면 자동으로 해결되지 않나요?

A: WeakMap은 GC가 key 객체를 수거할 때 entry를 같이 지우는 구조입니다. 이 경우에는 key가 **문자열(credential ID)**이라 WeakMap이 못 씁니다(WeakMap key는 object여야 함). ConfigInstance를 key로 쓰면 WeakMap 가능하지만, 그러면 "여러 instance가 같은 credential을 공유"를 표현할 수 없습니다.

Q: 대신 ConfigInstancedestroy()에서 자기 limiter를 바로 삭제하면 안 되나요?

A: 안 됩니다. 그 limiter는 다른 ConfigInstance가 아직 공유 중일 수 있기 때문입니다. "공유 자원의 해제 책임은 공유자가 아니라 관리자(ConfigManager)"가 가져야 한다는 게 이 설계의 원칙입니다. ConfigInstance는 자기가 쓰던 limiter의 reference count를 알지 못하므로 해제 여부를 판단할 수 없습니다.

Q: Reference count를 명시적으로 관리하면 어떨까요?

A: 가능하고 실제로 그게 "교과서적" 해결책입니다. 다만 이 플러그인의 scale에서는 "삭제 시점에 한번 sweep"이 충분히 효율적이고 구현이 더 단순합니다. Config 수는 한 자릿수, limiter 수도 한 자릿수라 O(n × m) sweep의 비용이 reference counting의 복잡성보다 훨씬 작습니다.

Q: updateSettings()에서 credential이 바뀔 때도 orphan이 생길 수 있나요?

A: 네. Config A가 credential K1을 쓰다가 K2로 바뀌면, K1을 쓰는 마지막 config였던 경우에 K1 limiter가 고아가 됩니다. 이 커밋 뒤에 updateSettings() 경로에도 pruning을 추가하는 걸 고려했지만, 실제로 updateSettings 안에서 ConfigInstance가 destroy되지 않으므로 pruning을 호출하면 "아직 쓰는 줄 아는" credential까지 함께 삭제될 위험이 있었습니다. 대신 removeConfig와 같은 지점(ConfigInstance가 정말로 사라질 때)에서만 pruning을 돌리는 단순한 규칙을 유지했습니다. Credential 교체 시 생기는 누수는 감수하고, 다음 remove 시점에 정리되도록 위임했습니다.

Q: 이 패턴은 Rate limiter 말고 다른 per-credential 서비스에도 적용되나요?

A: 네. Token bucket, OAuth refresh 스케줄러, WebSocket 연결 pool, 인증 캐시 — 어떤 stateful 공유 자원이든 동일한 구조가 적용됩니다. 핵심은 **"공유 Map + 소유자 추적 + 삭제 시 sweep"**의 세 축입니다.

10. 참고 자료

11. 관련 글