Obsidian 플러그인을 Multi-Database로 리팩토링하기: DatabaseProvider 추상화와 FieldType 정규화
Airtable에 강결합돼 있던 Obsidian 플러그인 서비스 레이어를 provider-agnostic DatabaseProvider 인터페이스로 추상화한 2단계 리팩토링(PR #49, #50) 기록. 런타임 capability flag, 5-way discriminated union credential, 29→11 StandardFieldType 정규화까지 395/395 테스트를 유지하며 진행한 설계 결정을 공유합니다.
1. 왜 지금 추상화가 필요했나
obsidian-auto-note-importer는 원래 Airtable 전용 플러그인이었습니다. 2026년 3월의 v0.8.0 릴리스에서 하나의 vault 안에서 여러 Airtable base/table을 동시에 싱크할 수 있는 multi-config 아키텍처를 도입했는데, 그 시점부터 "여러 DB를 섞어 쓸 수 있는가?"라는 질문이 자연스럽게 따라붙었습니다.
- config A는 Airtable의 프로젝트 테이블
- config B는 SeaTable의 채용 트래커
- config C는 Supabase의 메모 데이터베이스
이런 그림을 그리려면 서비스 레이어가 Airtable API를 "모르는" 형태가 돼야 합니다. 하지만 실제 코드는 AirtableClient가 여기저기 박혀 있었고, SyncOrchestrator와 ConflictResolver가 구체 클래스에 직접 결합돼 있었습니다. src/constants/field-types.ts에는 Airtable 필드 타입 문자열(singleLineText, formula, ...)이 하드코딩된 배열로 누워 있었습니다.
이 글은 그 결합을 풀어낸 2단계 리팩토링(PR #49, #50)의 설계 기록입니다. 각 단계마다 어떤 의사결정이 있었고, 어떤 함정을 만났고, TypeScript의 어떤 기능이 실제로 쓸모가 있었는지 정리했습니다.
리팩토링의 목표
- 구체 provider로부터 상위 레이어 분리:
SyncOrchestrator/ConflictResolver가 "Airtable"을 모르게 만들기 - Credential별 타입 안전성: 각 provider가 요구하는 인증 필드가 다르다는 사실을 컴파일 타임에 강제
- 런타임 capability advertise:
instanceof체크 대신provider.capabilities.bidirectional같은 플래그로 분기 - 필드 타입 정규화: Airtable의 29개 필드 타입을 provider 무관한 표준 taxonomy로 줄이기
- 제로 마이그레이션: 기존 사용자의 설정 파일을 건드리지 않기
그리고 이 모든 변경 중에도 기존 테스트(395개)는 한 개도 깨뜨리지 않는 것이 암묵적 요구사항이었습니다.
2. Phase 1 — DatabaseProvider 인터페이스 설계
2.1 문제: Airtable에 강결합된 서비스 레이어
리팩토링 전의 구조는 이랬습니다.
// Before: SyncOrchestrator가 AirtableClient를 직접 받음
export class SyncOrchestrator {
constructor(
private readonly client: AirtableClient, // ← 구체 클래스 참조
private readonly settings: LegacySettings,
) {}
async pushAll(files: TFile[]): Promise<void> {
const batch = this.buildBatch(files);
await this.client.batchUpdate(batch); // ← Airtable 배치 API만 안다
}
}
ConflictResolver, ConfigInstance, settings UI 곳곳에서 AirtableClient 타입을 그대로 import하고 있었습니다. 새로운 provider를 추가하려면 이 모든 참조를 뒤지고 고쳐야 했습니다.
2.2 첫 단추: DatabaseProvider 인터페이스
상위 레이어가 볼 계약을 하나 정의합니다. 여기서 가장 중요한 결정은 **"어디까지를 인터페이스에 담을 것인가"**였습니다.
// src/types/database.types.ts
export interface DatabaseProvider {
readonly providerType: CredentialType;
readonly capabilities: ProviderCapabilities;
readonly fieldTypeMapper: FieldTypeMapper;
fetchNotes(): Promise<RemoteNote[]>;
fetchRecord(recordId: string): Promise<RemoteNote | null>;
updateRecord(
recordId: string,
fields: Record<string, unknown>,
): Promise<SyncResult>;
batchUpdate(updates: BatchUpdate[]): Promise<SyncResult[]>;
reconfigure(
credential: Credential,
config: ConfigEntry,
rateLimiter: RateLimiter,
debugMode: boolean,
): void;
}
이 인터페이스에는 네 가지 설계 원칙이 숨어 있습니다.
첫째, record shape를 provider 무관하게 정규화했습니다. RemoteNote, SyncResult, BatchUpdate는 모두 database.types.ts에 있고 Airtable 특유의 필드가 없습니다.
export interface RemoteNote {
id: string;
primaryField: string;
fields: Record<string, unknown>; // ← provider별 타입은 여기서 Any-ish
}
export type SyncResult =
| { success: true; recordId: string; updatedFields: Record<string, unknown> }
| { success: false; recordId: string; error: string };
Airtable 원시 응답 타입(AirtableField, AirtableBase)은 airtable.types.ts에 남겨둬서 이 경계가 침식되지 않도록 물리적으로 분리했습니다.
둘째, reconfigure()로 stable reference 패턴을 도입했습니다. 설정이 바뀔 때마다 provider 인스턴스를 새로 만들면 ConflictResolver, SyncOrchestrator가 들고 있던 참조가 모두 고아가 됩니다. reconfigure()로 같은 인스턴스의 내부 상태만 갈아끼우면 상위 레이어가 재연결 없이 살아남습니다.
셋째, rateLimiter 파라미터가 인터페이스에 들어갔습니다. 이건 단순히 API 호출 제한을 위한 게 아닙니다. Multi-config에서는 credential 단위로 rate limiter를 공유합니다(하나의 API key가 여러 config에서 공유되기 때문). config가 다른 credential로 재할당되면 provider도 새로운 limiter에 다시 묶여야 합니다. 이걸 reconfigure의 계약에 명시적으로 넣어야 나중에 잊어버리지 않습니다.
넷째, providerType과 capabilities, fieldTypeMapper가 readonly 프로퍼티입니다. 이건 다음 섹션의 핵심입니다.
2.3 ProviderCapabilities — instanceof 대신 런타임 플래그
상위 레이어가 provider별로 분기를 해야 하는 순간은 반드시 옵니다. 예를 들어:
- 읽기 전용 provider에는
batchUpdate()를 부르지 않아야 함 - computed field(formula/rollup/lookup)가 있는 provider는 push 후 서버 계산을 기다렸다가 값을 pull-back 해야 함 (formula-wait phase)
- 배치 크기 상한은 provider마다 다름 (Airtable 10, 다른 API는 50 혹은 100)
이런 분기를 instanceof AirtableClient로 하면 새 provider를 추가할 때마다 상위 레이어를 뜯어야 합니다. 대신 provider가 자기 능력을 런타임에 advertise하게 만들었습니다.
// src/types/database.types.ts
export interface ProviderCapabilities {
/** Supports writing records back to the database. */
bidirectional: boolean;
/** Has fields whose values are computed server-side (formulas, rollups, lookups). */
hasComputedFields: boolean;
/**
* Maximum records per batch update call.
* Read-only providers (`bidirectional: false`) must still report a
* positive number (e.g. `1`) — callers guarantee they won't call
* `batchUpdate()` when `bidirectional` is false.
*/
batchUpdateMaxSize: number;
}
Airtable 구현체는 이렇게 선언합니다.
// src/services/airtable-client.ts
const AIRTABLE_CAPABILITIES: ProviderCapabilities = {
bidirectional: true,
hasComputedFields: true,
batchUpdateMaxSize: AIRTABLE_BATCH_SIZE, // 10
};
export class AirtableClient implements DatabaseProvider {
readonly providerType: CredentialType = 'airtable';
readonly capabilities: ProviderCapabilities = AIRTABLE_CAPABILITIES;
readonly fieldTypeMapper: FieldTypeMapper = airtableFieldMapper;
// ...
}
상위 레이어는 이렇게 쓸 수 있습니다.
// src/core/sync-orchestrator.ts — pseudo
if (this.provider.capabilities.hasComputedFields && mode === 'bidirectional') {
await this.waitForFormulaComputation(); // ← formula-wait phase
}
const chunkSize = this.provider.capabilities.batchUpdateMaxSize;
const chunks = chunk(updates, chunkSize);
for (const c of chunks) {
await this.provider.batchUpdate(c);
}
instanceof 체크 0개. 새로운 provider가 들어와도 SyncOrchestrator는 바뀌지 않습니다.
2.4 Credential 5-way discriminated union
Credential은 단순한 "API key 문자열" 이상이어야 했습니다. SeaTable은 apiToken + serverUrl이 필요하고, Supabase는 projectUrl + apiKey, Notion은 integrationToken, Custom API는 baseUrl + authHeader + authValue. 필드 구조가 다 다릅니다.
전에는 이랬습니다.
// Before: 전부 optional
export type CredentialType = 'airtable';
export interface Credential {
id: string;
name: string;
type: CredentialType;
apiKey: string; // ← 여기에만 의미 부여
}
TypeScript의 discriminated union으로 타입별 필드를 분리하면 컴파일러가 누락을 잡아줍니다.
// src/types/credential.types.ts
export const CREDENTIAL_TYPES = [
'airtable',
'seatable',
'supabase',
'notion',
'custom-api',
] as const;
export type CredentialType = (typeof CREDENTIAL_TYPES)[number];
interface BaseCredential {
id: string;
name: string;
}
export interface AirtableCredential extends BaseCredential {
type: 'airtable';
apiKey: string;
}
export interface SeaTableCredential extends BaseCredential {
type: 'seatable';
apiToken: string;
serverUrl: string;
}
export interface SupabaseCredential extends BaseCredential {
type: 'supabase';
projectUrl: string;
apiKey: string;
}
export interface NotionCredential extends BaseCredential {
type: 'notion';
integrationToken: string;
}
export interface CustomApiCredential extends BaseCredential {
type: 'custom-api';
baseUrl: string;
authHeader: string;
authValue: string;
}
export type Credential =
| AirtableCredential
| SeaTableCredential
| SupabaseCredential
| NotionCredential
| CustomApiCredential;
이제 credential.type === 'seatable' 분기 안에서는 TypeScript가 credential.apiToken과 credential.serverUrl의 존재를 보장합니다. 반대로 credential.apiKey에 접근하려고 하면 컴파일 에러가 납니다.
function buildAuthHeader(credential: Credential): string {
switch (credential.type) {
case 'airtable':
return `Bearer ${credential.apiKey}`; // ✅ OK
case 'seatable':
return `Token ${credential.apiToken}`; // ✅ OK — apiToken만 존재
case 'supabase':
// return `Bearer ${credential.apiToken}`; // ❌ Property 'apiToken' does not exist
return `Bearer ${credential.apiKey}`; // ✅ OK
// 나머지도 전부 강제됨. `default:` 없이도 exhaustiveness 체크 가능.
}
}
이건 단순한 편의가 아닙니다. 인증 필드를 잘못된 provider로 보내는 클래스의 버그가 구조적으로 불가능해졌습니다. 과거였다면 "SeaTable credential에 apiKey가 undefined라서 런타임에 TypeError" 같은 버그가 있었을 텐데, 지금은 아예 작성이 안 됩니다.
2.5 ProviderRegistry — 1개 파일이 모든 factory를 안다
credential type과 concrete provider 클래스의 매핑은 한 곳에 모아야 합니다. src/services/provider-registry.ts가 그 역할을 합니다.
// src/services/provider-registry.ts
export type ProviderFactory = (
credential: Credential,
config: ConfigEntry,
rateLimiter: RateLimiter,
debugMode: boolean,
) => DatabaseProvider;
const factories = new Map<CredentialType, ProviderFactory>();
const fieldTypeMappers = new Map<CredentialType, FieldTypeMapper>();
export function registerProvider(
type: CredentialType,
factory: ProviderFactory,
): void {
factories.set(type, factory);
}
export function createProvider(
credential: Credential,
config: ConfigEntry,
rateLimiter: RateLimiter,
debugMode: boolean,
): DatabaseProvider {
const factory = factories.get(credential.type);
if (!factory) {
throw new Error(
`No provider registered for credential type: ${credential.type}`,
);
}
return factory(credential, config, rateLimiter, debugMode);
}
// ─── Built-in provider registrations ─────────────────────────────────
registerProvider('airtable', (credential, config, rateLimiter, debugMode) => {
return new AirtableClient(
buildLegacySettings(config, credential, debugMode),
rateLimiter,
);
});
registerFieldTypeMapper('airtable', airtableFieldMapper);
모듈 bottom에서 side-effect로 registration이 실행되기 때문에, 이 파일 하나만 import되면 Airtable factory가 자동으로 등록됩니다. 새 provider는 아래에 한 줄 추가하면 됩니다. ConfigInstance는 이 팩토리 구조를 이용해 credential에 맞는 provider를 만듭니다.
// src/core/config-instance.ts
constructor(app: App, config: ConfigEntry, credential: Credential, shared: SharedServices) {
this.rateLimiter = this.getOrCreateRateLimiter(credential.id);
// Create DatabaseProvider via registry (based on credential.type)
this.databaseProvider = createProvider(
credential,
config,
this.rateLimiter,
shared.getDebugMode(),
);
this.conflictResolver = new ConflictResolver(this.settings, this.databaseProvider);
// ...
}
ConfigInstance는 credential.type을 분기하지 않습니다. Registry가 알아서 올바른 provider를 생성합니다.
2.6 함정: reconfigure와 RateLimiter rebind
여기서 리뷰 5라운드 동안 고쳐야 했던 가장 미묘한 버그가 나왔습니다.
Multi-config 아키텍처는 "credential 당 rate limiter 공유"가 원칙입니다. 같은 API key를 쓰는 config 3개가 있으면 limiter도 1개만 만들어서 함께 쓰도록 설계했습니다. SharedServices.rateLimiters라는 map이 이 상태를 관리합니다.
문제는 사용자가 config를 저장할 때 다른 credential로 재할당하는 시나리오였습니다. config A가 원래 credential K1을 쓰다가 K2로 바뀐다면:
ConfigInstance는 새 rate limiter (K2의 것)를 얻어와야 함- 기존
AirtableClient인스턴스는 살려두되, 내부 limiter 참조를 K2의 것으로 바꿔야 함 - K1을 더 이상 쓰는 config가 없다면 K1 limiter는 pruning 대상
1번과 3번은 ConfigManager가 처리했지만, 2번이 처음엔 빠져 있었습니다. reconfigure()가 credential과 config만 받고 rate limiter는 받지 않았던 것입니다. 그 결과:
// Before (버그): credential을 바꿔도 provider는 예전 limiter를 계속 씀
provider.reconfigure(newCredential, newConfig, debugMode);
// provider.rateLimiter는 여전히 K1의 것
// 결과: 새 credential에서 rate limit이 공유되지 않고 과도 요청 발생
수정 후:
// After: rate limiter도 rebind 계약에 포함
reconfigure(
credential: Credential,
config: ConfigEntry,
rateLimiter: RateLimiter, // ← 추가
debugMode: boolean,
): void;
// 구현
reconfigure(credential, config, rateLimiter, debugMode) {
if (credential.type !== 'airtable') {
throw new Error(
`AirtableClient cannot be reconfigured with a ${credential.type} credential`,
);
}
this.settings = buildLegacySettings(config, credential, debugMode);
this.rateLimiter = rateLimiter; // ← 핵심: 새 limiter로 교체
}
교훈은 단순합니다. "상태를 교체하는 메서드의 시그니처에, 교체되어야 할 모든 상태를 명시적으로 받아라." 파라미터로 받지 않으면 나중에 누가 빠뜨려도 모릅니다.
2.7 settings-bridge — 3개 call site의 중복 제거
리팩토링 중반에 눈에 밟히는 패턴이 있었습니다. ConfigEntry + Credential → LegacySettings 변환 로직이 세 곳에 동일하게 반복되고 있었습니다.
ConfigInstance생성자AirtableClient.reconfigure()provider-registry.ts의 airtable factory
이걸 헬퍼로 모았습니다.
// src/utils/settings-bridge.ts
export function buildLegacySettings(
config: ConfigEntry,
credential: Credential,
debugMode: boolean,
): LegacySettings {
const apiKey = credential.type === 'airtable' ? credential.apiKey : '';
return { ...config, apiKey, debugMode };
}
Airtable 외 credential은 apiKey를 빈 문자열로 둡니다. 그들의 provider는 credential에서 직접 인증 정보를 읽기 때문에 이 legacy 필드를 소비하지 않습니다. 이 규약을 한 곳에서 문서화하고 모든 call site에서 공유하는 것이 중요합니다.
buildLegacySettings는 단 7줄짜리 함수지만, 이게 있어야 세 call site가 drift하지 않습니다. 이후 PR #50에서 Airtable 필드 타입 시스템을 추출할 때도 이 헬퍼가 안정적인 기준점 역할을 해줬습니다.
3. Phase 2 — Field Type Taxonomy 정규화
3.1 문제: 상수 파일에 갇힌 29개 Airtable 타입
Phase 1 이후에도 Airtable 특유의 것이 하나 남아 있었습니다. src/constants/field-types.ts가 이렇게 생겼었습니다.
// Before: src/constants/field-types.ts
export const SUPPORTED_FIELD_TYPES = [
'singleLineText',
'singleSelect',
'number',
'formula',
] as const;
export const READ_ONLY_FIELD_TYPES = [
'formula',
'rollup',
'count',
'lookup',
'createdTime',
'lastModifiedTime',
'createdBy',
'lastModifiedBy',
'autoNumber',
] as const;
export function isReadOnlyFieldType(type: string): boolean {
return READ_ONLY_FIELD_TYPES.includes(type as any);
}
export function isFieldTypeSupported(type: string): boolean {
return SUPPORTED_FIELD_TYPES.includes(type as any);
}
이 상수는 FrontmatterParser, settings-tab, 여러 테스트에서 직접 import되어 쓰였습니다. 문제는:
- 문자열이 전부 Airtable 어휘. SeaTable의
"text", Supabase의"varchar", Notion의"title"을 여기 넣을 수 없음. - 29개 Airtable 타입 중 13개만 알고 있음.
richText,email,currency,percent,rating같은 타입은 "unknown"으로 취급되어 push payload에서 잘못 처리됨. - 상위 레이어가
isReadOnlyFieldType()을 전역 함수로 부름. provider가 바뀌면 이 함수가 거짓말을 함.
3.2 StandardFieldType — 11-type union
먼저 provider 무관한 taxonomy를 정의했습니다.
// src/types/field-types.types.ts
export type StandardFieldType =
| 'text'
| 'number'
| 'date'
| 'boolean'
| 'single-select'
| 'multi-select'
| 'attachment'
| 'link'
| 'computed' // ← formula / rollup / lookup (read-only)
| 'system' // ← createdTime / autoNumber 등 (read-only)
| 'unknown'; // ← 알 수 없는 타입
11개만 있으면 플러그인이 신경 써야 하는 모든 구분이 표현됩니다. 파일명 생성에 쓸 수 있는지(text/number/single-select)·충돌 해결에 숫자 비교를 해야 하는지(number)·배열 diff가 필요한지(multi-select) 같은 질문은 standard taxonomy만으로 답할 수 있습니다.
3.3 FieldTypeMapper 인터페이스
Provider마다 자기 네이티브 타입을 이 11개 중 하나로 매핑하고, 플러그인이 자주 묻는 질문에 답할 책임을 집니다.
// src/types/field-types.types.ts
export interface FieldTypeMapper {
/**
* Normalizes a provider-specific type string to the standard taxonomy.
* Unknown types map to `'unknown'`.
*/
mapToStandardType(providerType: string): StandardFieldType;
/**
* Returns true if the field type is read-only.
* Used by frontmatter-parser to exclude fields from push payloads.
*/
isReadOnly(providerType: string): boolean;
/**
* Returns true if the field type is usable as a filename or subfolder value.
*/
isFilenameSafe(providerType: string): boolean;
getFilenameSafeTypes(): readonly string[];
getReadOnlyTypes(): readonly string[];
}
상위 레이어가 전역 함수를 호출하는 대신 mapper.isReadOnly(type)을 호출하면, provider마다 답이 달라지는 질문이 자동으로 올바른 응답을 돌려받습니다.
3.4 AirtableFieldMapper — 29 → 11 매핑 테이블
Airtable의 29개 필드 타입을 전부 standard taxonomy로 매핑한 테이블을 만들었습니다.
// src/services/airtable-field-mapper.ts
const TYPE_TO_STANDARD: Record<string, StandardFieldType> = {
// text
singleLineText: 'text',
multilineText: 'text',
richText: 'text',
email: 'text',
phoneNumber: 'text',
url: 'text',
barcode: 'text',
// number
number: 'number',
currency: 'number',
percent: 'number',
rating: 'number',
duration: 'number',
// date
date: 'date',
dateTime: 'date',
// boolean
checkbox: 'boolean',
// select
singleSelect: 'single-select',
multipleSelects: 'multi-select',
multipleCollaborators: 'multi-select',
singleCollaborator: 'single-select',
// attachment
multipleAttachments: 'attachment',
// link
multipleRecordLinks: 'link',
// computed (read-only server-side)
formula: 'computed',
rollup: 'computed',
count: 'computed',
lookup: 'computed',
externalSyncSource: 'computed',
aiText: 'computed',
button: 'computed',
// system (read-only metadata)
createdTime: 'system',
lastModifiedTime: 'system',
createdBy: 'system',
lastModifiedBy: 'system',
autoNumber: 'system',
};
이 테이블에는 이전에 플러그인이 "모르는 타입"으로 취급하던 17개가 새로 포함됩니다. 예전에는 currency를 가진 레코드를 push하면 값이 그대로 실려 가서 Airtable이 "네, 숫자 맞아요"라고 받아줬습니다. 하지만 타입 지식이 없으니 filename generation이나 충돌 diff에서 문자열로 취급되는 경우가 많았습니다. 지금은 currency → 'number'로 정규화되어 숫자 비교 경로를 탑니다.
3.5 Fail-closed의 힘
isReadOnly 구현에는 보수적인 선택을 했습니다.
isReadOnly(providerType: string): boolean {
if (!(providerType in TYPE_TO_STANDARD)) return true; // ← fail-closed
return (READ_ONLY_TYPES as readonly string[]).includes(providerType);
}
"모르는 타입은 read-only로 간주". Airtable은 꾸준히 새 타입을 추가합니다(최근 aiText, button이 그 예). 플러그인이 모르는 타입을 push payload에 포함해서 422 에러로 전체 배치를 실패시키는 것보다, 그 필드 하나만 건너뛰는 쪽이 훨씬 낫습니다. Fail-open은 디버그하기 어려운 버그를 만들고, fail-closed는 눈에 보이는 "Why isn't this field syncing?" 문제를 만듭니다. 후자가 사용자에게 훨씬 친절합니다.
3.6 Mapper Registry — provider 인스턴스 없이도 조회
Settings UI는 흥미로운 edge case였습니다. 사용자가 credential을 "Airtable"로 선택하면, UI는 "이 타입에서 filename으로 쓸 수 있는 필드는 뭐지?"를 알아야 합니다. 그런데 이 시점에는 AirtableClient 인스턴스가 아직 만들어져 있지 않을 수도 있습니다(새 config 생성 중).
이 문제를 풀기 위해 provider-registry.ts에 field type mapper를 provider 인스턴스 없이 조회할 수 있는 경로를 추가했습니다.
// src/services/provider-registry.ts
const fieldTypeMappers = new Map<CredentialType, FieldTypeMapper>();
export function registerFieldTypeMapper(
type: CredentialType,
mapper: FieldTypeMapper,
): void {
fieldTypeMappers.set(type, mapper);
}
export function getFieldTypeMapper(type: CredentialType): FieldTypeMapper {
const mapper = fieldTypeMappers.get(type);
if (!mapper) {
throw new Error(
`No field type mapper registered for credential type: ${type}`,
);
}
return mapper;
}
// 모듈 bottom에서 side-effect 등록
registerFieldTypeMapper('airtable', airtableFieldMapper);
Settings UI는 이렇게 씁니다.
// src/ui/settings-tab.ts — pseudo
const mapper = getFieldTypeMapper(credential.type);
const allowedTypes = mapper.getFilenameSafeTypes();
const filenameFields = cachedFields.filter((f) => mapper.isFilenameSafe(f.type));
Provider 인스턴스 lifecycle에 독립적이라는 점이 핵심입니다. Mapper는 stateless singleton이기 때문에 UI가 자유롭게 조회해도 부작용이 없습니다.
3.7 Legacy 파일 완전 제거
매퍼가 도입되자 src/constants/field-types.ts는 역할이 없어졌습니다. 파일을 완전 삭제하고 테스트도 이관했습니다.
- ❌
src/constants/field-types.ts(삭제) - ❌
src/constants/index.ts에서 관련 export 제거 - ❌
tests/constants/field-types.test.ts(삭제) - ✅
tests/services/airtable-field-mapper.test.ts(신규, 23개 테스트) - ✅
tests/services/provider-registry.test.ts에 mapper registry 테스트 4건 추가
중요한 건 "deprecated 주석 남기고 수개월 유지" 같은 완화책을 쓰지 않았다는 점입니다. 이 파일을 참조하는 코드가 트리 전체에 12개 있었는데, 전부 한 번에 매퍼 호출로 교체했습니다. 리팩토링 중에 참조를 남겨두면 결국 "옛날 방식"과 "새 방식"이 공존하는 상태가 영구화됩니다.
4. 검증 — 리팩토링 동안 테스트를 몇 개 깼나요?
0개입니다.
- PR #49 (DatabaseProvider 도입): unit 395/395, E2E 42/42 passed
- PR #50 (FieldType 정규화): unit 380/380, E2E 42/42 passed
PR #50에서 테스트 숫자가 395에서 380으로 줄어든 건 tests/constants/field-types.test.ts(15 tests)를 삭제하고 tests/services/airtable-field-mapper.test.ts(23 tests)를 새로 쓴 결과입니다. 기존 테스트 커버리지는 유지하면서 taxonomy 매핑 검증이 추가됐습니다.
리뷰는 총 5라운드. 그중 눈에 띄는 것들:
- Round 2: rate limiter rebind 버그 발견 →
reconfigure()시그니처 수정 - Round 3: settings-bridge 중복 제거로 simplification
- Round 4: UI polish (credential type 드롭다운의 "Not yet supported" 분기)
- Round 5: fail-closed 정책 명문화, 테스트 커버리지 강화
리뷰 라운드를 거치는 동안 한 번도 "테스트 다시 설계해야 하는데요" 소리가 나오지 않았습니다. 인터페이스 계약이 처음부터 명확했기 때문입니다.
5. 핵심 설계 교훈
5.1 런타임 capability flag는 상위 레이어를 보호한다
// ❌ instanceof: 새 provider가 생기면 모든 분기 수정
if (client instanceof AirtableClient) {
await client.waitForFormulas();
}
// ✅ capability flag: provider가 스스로 설명
if (this.provider.capabilities.hasComputedFields) {
await this.waitForFormulas();
}
새 provider 추가에 드는 비용 = (새 client 구현 + factory 등록). 그 외에는 아무것도 바꿀 필요가 없습니다.
5.2 Discriminated union은 편의가 아니라 보호막이다
Credential의 5-way discriminated union 덕분에 "SeaTable에 apiKey를 넣는" 류의 실수가 작성 시점에 거부됩니다. 이건 타입 힌트가 아니라 구조적 불가능성입니다.
// 컴파일러가 두 가지를 모두 확인함:
// 1. 각 credential type이 필요한 필드를 가지고 있는가
// 2. 잘못된 필드를 참조하려는 시도가 있는가
function buildAuthHeader(credential: Credential): string {
switch (credential.type) {
case 'airtable': return credential.apiKey; // ✅
case 'seatable': return credential.apiToken; // ✅
// credential.apiToken 접근을 'airtable' case에서 시도하면 ❌
}
}
5.3 Taxonomy 정규화는 호출자를 줄인다
매핑 테이블 하나로 "Airtable 필드 타입을 아는 코드"가 딱 하나로 수렴했습니다. FrontmatterParser, settings-tab, SyncOrchestrator 어느 곳도 singleLineText 같은 문자열을 직접 비교하지 않습니다.
| 이전 | 이후 |
|---|---|
| 29개 문자열을 모르는 코드가 여기저기 흩어짐 | airtableFieldMapper 1개 파일이 모든 지식을 보관 |
| 새 타입이 Airtable에 추가되면 여러 파일 수정 | TYPE_TO_STANDARD 한 줄 추가로 끝 |
| SeaTable 지원 불가 | seatable-field-mapper.ts 추가로 가능 |
5.4 Stable reference + reconfigure는 ripple을 막는다
Provider를 매번 새로 만들면 ConflictResolver, SyncOrchestrator, 여러 subscriber가 전부 참조를 갱신해야 합니다. reconfigure()로 내부 상태만 갈아끼우면 이 ripple이 사라집니다. **"참조 안정성이 상위 레이어의 재연결 비용을 죽인다"**는 건 오래된 패턴이지만 multi-config처럼 상태 전환이 빈번한 시스템에서 진가를 발휘합니다.
5.5 Fail-closed가 fail-open보다 사용자에게 친절하다
isReadOnly(providerType: string): boolean {
if (!(providerType in TYPE_TO_STANDARD)) return true; // 모르면 건너뜀
return READ_ONLY_TYPES.includes(providerType);
}
422 에러로 전체 배치가 실패하는 것보다, 필드 하나가 "왜 싱크 안 되지?" 질문을 만드는 게 훨씬 디버그하기 쉽습니다. 후자는 GitHub Issue로 이어지고 "아 이 타입 추가했어야 했구나"로 끝납니다. 전자는 사용자가 포기합니다.
6. 제로 마이그레이션 원칙
사용자가 이 리팩토링을 전혀 알아차리지 못해야 합니다. 기존 Airtable 데이터는 그대로, 설정 파일도 그대로, 플러그인 재시작 후 아무 일 없이 동작해야 합니다.
이게 가능했던 이유는:
- Credential 구조의 structural compatibility: 기존
{ id, name, type: 'airtable', apiKey }는 새AirtableCredential과 정확히 같은 shape. JSON 마이그레이션 0줄. - LegacySettings 유지: 서비스 내부는 여전히
LegacySettings를 소비.buildLegacySettings()가 변환만 해줌. - ProviderRegistry side-effect registration: 플러그인 로드 시
airtablefactory가 자동 등록되므로 사용자 설정에 "provider type: airtable"이 이미 있으면 즉시 작동.
이 원칙은 오픈소스 플러그인에서 특히 중요합니다. "업그레이드하면 설정이 날아갑니다" 같은 메시지를 본 사용자는 다음 업그레이드를 미룹니다.
7. 새 provider 하나를 추가하는 데 필요한 4단계
이 추상화의 진짜 테스트는 "새 provider를 얼마나 쉽게 추가할 수 있는가"입니다. 현재 구조에서는 네 단계면 됩니다.
Step 1: credential 타입 추가
// src/types/credential.types.ts에 이미 정의돼 있음
// SeaTableCredential, SupabaseCredential, NotionCredential, CustomApiCredential
Step 2: client 구현
// src/services/seatable-client.ts (예시)
const SEATABLE_CAPABILITIES: ProviderCapabilities = {
bidirectional: true,
hasComputedFields: false, // ← SeaTable은 computed field 없음
batchUpdateMaxSize: 50,
};
export class SeaTableClient implements DatabaseProvider {
readonly providerType: CredentialType = 'seatable';
readonly capabilities: ProviderCapabilities = SEATABLE_CAPABILITIES;
readonly fieldTypeMapper: FieldTypeMapper = seatableFieldMapper;
reconfigure(credential, config, rateLimiter, debugMode) {
if (credential.type !== 'seatable') {
throw new Error(`SeaTableClient cannot be reconfigured with ${credential.type}`);
}
// credential.apiToken, credential.serverUrl 직접 사용
}
// fetchNotes, batchUpdate 등 구현
}
Step 3: field type mapper 구현
// src/services/seatable-field-mapper.ts
const TYPE_TO_STANDARD: Record<string, StandardFieldType> = {
'text': 'text',
'long-text': 'text',
'number': 'number',
'checkbox': 'boolean',
// ...
};
export const seatableFieldMapper: FieldTypeMapper = new SeatableFieldMapperImpl();
Step 4: registry에 등록
// src/services/provider-registry.ts 하단
registerProvider('seatable', (credential, config, rateLimiter, debugMode) => {
return new SeaTableClient(credential, config, rateLimiter, debugMode);
});
registerFieldTypeMapper('seatable', seatableFieldMapper);
이게 전부입니다. SyncOrchestrator, ConflictResolver, ConfigInstance, settings-tab은 한 줄도 바뀌지 않습니다.
8. FAQ
Q: 왜 바로 다 구현하지 않고 Airtable만 먼저 남겼나요?
A: 인터페이스가 첫 번째 제작에서 완벽할 리 없습니다. 한 provider만 구현해두고 리팩토링 대상 상위 레이어를 모두 옮긴 뒤, 두 번째 provider를 구현하면서 인터페이스가 정말 충분한지 검증하는 게 더 안전합니다. 지금 두 번째 provider가 들어오면 거의 확실히 ProviderCapabilities에 필드가 추가되거나 fetchNotes가 페이지네이션 파라미터를 받도록 바뀝니다. 그 때 고치는 게 맞습니다.
Q: instanceof 체크를 아예 금지해야 하나요?
A: 대부분의 경우 yes, 하지만 예외가 있습니다. 테스트에서 mock client를 주입한 뒤 "이거 진짜 AirtableMock인지" 확인하는 경우처럼 테스트 유틸리티 내부에서는 instanceof가 유용합니다. 프로덕션 경로에서는 capabilities flag로 대체해야 합니다.
Q: Discriminated union 대신 type 없이 optional 필드로 해도 됐을 텐데요?
A: 동작은 하지만 컴파일러가 보호해주지 않습니다. Credential.apiKey가 optional이면 SeaTable credential에서도 참조할 수 있고, 런타임에 undefined 에러가 나야 잡힙니다. Discriminated union은 그런 버그를 작성 불가능하게 만듭니다. 이건 런타임 퍼포먼스가 아니라 개발자 심리의 문제입니다 — 안 되는 걸 안 되게 해주면, 걱정할 일이 하나 줄어듭니다.
Q: Field type mapper가 왜 registry에 등록돼야 하나요? provider에서 바로 꺼내 쓰면 안 되나요?
A: Settings UI는 provider 인스턴스가 아직 없는 시점에 mapper가 필요합니다(새 config 생성 flow). Provider lifecycle과 독립된 조회 경로가 필요해서 registry에 별도 map을 뒀습니다. Mapper는 stateless singleton이라 이 분리가 부작용 없이 가능합니다.
Q: 상위 레이어가 provider.capabilities.hasComputedFields를 수백 번 호출하면 느리지 않나요?
A: 프로퍼티 접근은 nanoseconds 단위입니다. Map lookup도 아니고 그냥 객체 프로퍼티입니다. 성능은 이 구조의 문제가 아닙니다.
Q: 이 패턴을 다른 타입의 플러그인에도 적용할 수 있나요?
A: 네. "외부 시스템과 통신하는 플러그인"이면 대부분 적용됩니다. Git provider(GitHub/GitLab/Bitbucket), LLM provider(OpenAI/Anthropic/Google), 클라우드 storage provider(S3/GCS/Azure)도 동일한 패턴이 잘 맞습니다. 핵심은 capabilities를 runtime에 advertise하는 것과 credential을 discriminated union으로 쪼개는 것입니다.
9. 참고 자료
- TypeScript Handbook: Discriminated Unions
- Obsidian Plugin Developer Docs
- Airtable Web API — Field Types
- 검색 키워드: "provider pattern typescript discriminated union"
- 검색 키워드: "runtime capabilities vs instanceof typescript"
10. 다음 글 예고
이 글은 obsidian-auto-note-importer의 Phase 1 리팩토링 기록입니다. 다음 글에서는 이 추상화의 전제가 되는 Multi-Config 아키텍처(v0.8.0)를 다룹니다 — 하나의 vault에서 여러 Airtable config를 동시에 싱크하기 위해 서비스 스택을 어떻게 격리했는지, 설정 마이그레이션(v1→v2)을 어떻게 무중단으로 돌렸는지 등이 주제입니다.