본문 바로가기
프로젝트/AIKit

AIKit #2 - Adapter Pattern으로 멀티 프로바이더 지원하기

by 루까(Luka) 2026. 2. 6.
반응형

AIKit — 아키텍처 설계

핵심 문제

OpenAI, Claude, Gemini의 응답 구조가 전부 다르다.

// OpenAI → choices[0].message.content
data.choices[0].message.content

// Claude → content[0].text
data.content[0].text

// Gemini → candidates[0].content.parts[0].text
data.candidates[0].content.parts[0].text

사용자가 이 차이를 알 필요 없이 response.content로 통일하고 싶었다. Adapter Pattern이 정확히 이 문제를 해결한다.


4계층 아키텍처

사용자 코드
  ↓ ai.chat()
AIKit 메인 클래스
  ↓ 캐시 → 어댑터 → 검증 → 비용추적
Provider Adapters (OpenAI / Claude / Gemini)
  ↓ fetch()
외부 AI API 서버
계층 역할 파일
AIKit 오케스트레이터 core/AIKit.js
Adapters 프로바이더별 구현 providers/*.js
Utilities 캐시, 비용, 검증 utils/*.js
External AI API 서버

BaseAdapter — 추상 클래스

모든 어댑터의 부모 클래스다. JavaScript에는 abstract 키워드가 없어서 new.target으로 직접 인스턴스화를 방지한다.

class BaseAdapter {
    constructor() {
        if (new.target === BaseAdapter) {
            throw new Error('BaseAdapter is an abstract class');
        }
    }

    // 자식이 반드시 구현해야 하는 메서드
    async chat(request) {
        throw new Error('chat() must be implemented by subclass');
    }

    // 공통 응답 정규화
    normalizeResponse(rawResponse) {
        return {
            text: this.extractText(rawResponse),
            raw: rawResponse,
            usage: this.extractUsage(rawResponse),
            model: this.extractModel(rawResponse),
            finishReason: this.extractFinishReason(rawResponse)
        };
    }

    // 공통 재시도 로직 (exponential backoff)
    async withRetry(fn, maxRetries = 3) {
        for (let i = 0; i < maxRetries; i++) {
            try {
                return await fn();
            } catch (error) {
                if (i === maxRetries - 1) throw error;
                const isRateLimit = error.message.includes('429');
                if (!isRateLimit) throw error;
                await new Promise(r => setTimeout(r, Math.pow(2, i) * 1000));
            }
        }
    }
}

normalizeResponse()withRetry()는 공통이고, extractText() 등은 자식이 오버라이드한다.


3개의 어댑터

OpenAIAdapter

class OpenAIAdapter extends BaseAdapter {
    constructor() {
        super();
        this.baseURL = 'https://api.openai.com/v1';
        this.defaultModel = 'gpt-4o-mini';
    }

    async chat(message, options = {}) {
        const response = await fetch(`${this.baseURL}/chat/completions`, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': `Bearer ${apiKey}`
            },
            body: JSON.stringify({
                model: options.model || this.defaultModel,
                messages: [{ role: 'user', content: message }]
            })
        });
        const data = await response.json();
        return {
            content: data.choices[0].message.content,
            model: data.model,
            usage: data.usage
        };
    }
}

ClaudeAdapter

Claude만의 차이점: x-api-key 헤더, anthropic-version 필수, max_tokens 필수 파라미터.

class ClaudeAdapter extends BaseAdapter {
    constructor() {
        super();
        this.baseURL = 'https://api.anthropic.com/v1';
        this.defaultModel = 'claude-3-5-sonnet-20241022';
        this.apiVersion = '2023-06-01';
    }

    async chat(message, options = {}) {
        const response = await fetch(`${this.baseURL}/messages`, {
            headers: {
                'x-api-key': apiKey,
                'anthropic-version': this.apiVersion  // ← OpenAI에는 없는 필수 헤더
            },
            body: JSON.stringify({
                model: options.model || this.defaultModel,
                max_tokens: options.maxTokens || 1024,  // ← 필수
                messages: [{ role: 'user', content: message }]
            })
        });
        const data = await response.json();
        return {
            content: data.content[0].text,  // ← choices가 아닌 content
            model: data.model,
            usage: {
                input_tokens: data.usage.input_tokens,
                output_tokens: data.usage.output_tokens
            }
        };
    }
}

GeminiAdapter

Gemini만의 차이점: API 키를 URL 파라미터로 전달, contents/parts 구조.

class GeminiAdapter extends BaseAdapter {
    constructor() {
        super();
        this.baseURL = 'https://generativelanguage.googleapis.com/v1beta';
        this.defaultModel = 'gemini-1.5-flash';
    }

    async chat(message, options = {}) {
        const model = options.model || this.defaultModel;
        // API 키를 URL에 넣는다 (유일하게 이런 방식)
        const url = `${this.baseURL}/models/${model}:generateContent?key=${apiKey}`;

        const response = await fetch(url, {
            body: JSON.stringify({
                contents: [{              // ← messages가 아님
                    parts: [{ text: message }]  // ← content가 아님
                }]
            })
        });
        const data = await response.json();
        return {
            content: data.candidates[0].content.parts[0].text,
            model: model
        };
    }
}

어댑터 비교

항목 OpenAI Claude Gemini
인증 Authorization: Bearer x-api-key URL ?key=
요청 구조 messages messages + max_tokens 필수 contents.parts
응답 텍스트 choices[0].message.content content[0].text candidates[0].content.parts[0].text
토큰 사용량 usage 객체 자동 포함 usage 객체 자동 포함 별도 요청 필요

이 차이를 어댑터가 흡수한다. 사용자는 response.content만 쓰면 된다.


AIKit 메인 클래스의 처리 파이프라인

async chat(message, options = {}) {
    // 1. 캐시 확인
    if (this.cache) {
        const cached = this.cache.get(cacheKey);
        if (cached) return { ...cached, fromCache: true };
    }

    // 2. API 호출 (어댑터 사용)
    const response = await this.adapter.chat(requestData);

    // 3. 응답 검증 (옵션)
    if (validation) {
        const result = this.validator.validate(response, validation);
        if (!result.isValid) throw new Error(result.errors.join(', '));
    }

    // 4. 비용 추적
    if (this.costTracker) {
        this.costTracker.track({ provider, tokens: response.usage });
    }

    // 5. 캐시 저장
    if (this.cache) this.cache.set(cacheKey, response);

    // 6. 통계 업데이트
    this.stats.successfulRequests++;

    return response;
}

실패하면 exponential backoff로 재시도하고, autoFallback이 켜져 있으면 다음 프로바이더로 자동 전환한다.


설계 원칙

원칙 적용
단일 책임 (SRP) 각 클래스가 하나의 역할만 담당
개방-폐쇄 (OCP) 새 프로바이더 추가 시 기존 코드 수정 없음
의존성 역전 (DIP) AIKit은 BaseAdapter에 의존, 구체 클래스를 모름
인터페이스 분리 (ISP) 어댑터는 chat()만 구현하면 됨

새 프로바이더 추가하기

예를 들어 Mistral을 추가한다면:

class MistralAdapter extends BaseAdapter {
    async chat(message, options) {
        // Mistral API 호출
        return { content: '...', model: '...', usage: { ... } };
    }
}

기존 코드를 건드릴 필요 없다. loadAdapter()의 맵에 한 줄 추가하면 끝이다. 테스트도 BaseAdapter의 공통 테스트를 그대로 재사용할 수 있다.


마무리

Adapter Pattern 덕분에 공통 로직 50% + 프로바이더별 구현 50% 구조가 만들어졌다. 새 프로바이더 추가에 약 1시간이면 충분하고, 기존 코드에 대한 수정은 0이다.

다음 편에서는 QA 관점에서 만든 응답 검증과 에러 핸들링을 다룬다.


기술 스택: Vanilla JavaScript, Adapter Pattern, SOLID Principles
소스 코드: GitHub

반응형