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
'프로젝트 > AIKit' 카테고리의 다른 글
| AIKit #6 - React & Vue에서 AIKit 사용하기 (0) | 2026.02.08 |
|---|---|
| AIKit #5 - PHP 프로젝트에서 AIKit 연동하기 (0) | 2026.02.08 |
| AIKit #4 - 5분 만에 AI 챗봇 만들기 (0) | 2026.02.07 |
| AIKit #3 - QA 개발자가 만든 AI 라이브러리 (0) | 2026.02.07 |
| AIKit #1 - 왜 AI API 통합 라이브러리를 만들었나 (0) | 2026.02.05 |