Claude와 OpenAI, 같은 코드로 제어하기: 인터페이스 추상화 전략
AI 코딩 면접 시뮬레이터를 만들었다. 면접관 AI가 지원자의 코드 변화를 지켜보고, 막혔을 때 개입하며, 면접 종료 후 리포트를 생성한다.
그런데 “어떤 AI를 쓸 건가요?”라는 질문에서 막혔다.
Claude를 쓰면 OpenAI 사용자가 떠나고, OpenAI만 쓰면 Claude가 더 나은 케이스를 포기하는 셈이다. 그래서 둘 다 지원하기로 했고, 그 과정에서 추상화의 필요성을 처음으로 몸으로 이해했다.
1. 문제 상황
코딩 면접 시뮬레이터는 AI를 상당히 집약적으로 사용한다.
- 면접 시작 시 시스템 프롬프트 + 인사 메시지 전송
- 코드 에디터의 변화를 주기적으로 감지해 AI에게 분석 요청
- 지원자 발언이 있을 때마다 대화 이력 전체를 컨텍스트로 전달
- 면접 종료 후 전체 대화를 분석해 JSON 리포트 생성
이 네 가지 시나리오가 모두 chat(messages) 형태의 API 호출로 귀결된다.
그런데 Claude와 OpenAI는 API 구조가 다르다.
// OpenAI
{
model: "gpt-4o-mini",
messages: [{ role: "system",
content: "..." }, { role: "user",
content: "..." }]
}
// Claude
{
model: "claude-3-haiku-20240307",
system: "...", // system
메시지가 별도 필드
messages: [{ role: "user",
content: "..." }]
}
특히 Claude는 system 메시지를 messages 배열에 넣으면 안 되고, 별도 필드로 분리해야 한다. 이 차이를 모든 호출 지점에서 직접 처리하면, Claude/OpenAI 분기 로직이 코드 곳곳에 퍼진다.
2. 내가 처음 한 생각
처음엔 라이브러리를 쓰면 해결될 거라 생각했다.
Vercel AI SDK는 @ai-sdk/anthropic, @ai-sdk/openai로 두 프로바이더를 동일한 인터페이스로 다룰 수 있고, LangChain.js는 더 강력한 추상화를 제공한다. LobeChat은 실제 서비스에서 이 패턴을 어떻게 구현했는지 볼 수 있는 레퍼런스였다.
그런데 이 프로젝트는 브라우저에서 직접 AI API를 호출하는 구조였다. Vercel AI SDK는 서버 환경을 기반으로 설계되어 있고, 클라이언트에서 직접 쓰기엔 번들 크기와 환경 제약이 있었다. LangChain은 더 무거웠다.
그래서 결론은 “직접 만들자”였다.
3. 실제로 해 본 선택
핵심 아이디어는 단순했다. 호출하는 쪽은 어떤 AI인지 몰라도 된다.
// types.ts
interface AIClient {
chat(
messages: { role: string; content: string }[],
options?: {
maxTokens?: number;
},
): Promise<AIResponse>;
testConnection(): Promise<boolean>;
}
이 인터페이스 하나로 Claude와 OpenAI를 동일하게 다룬다. 그리고 프로바이더별로 구현체를 분리했다.
// claude.ts - system 메시지 분리 처리
const systemMessage = messages.find((m) => m.role === "system")?.content || "";
const chatMessages = messages.filter((m) => m.role !== "system");
// openai.ts - messages 그대로 전달
const response = await fetch(`${this.baseUrl}/chat/completions`, {
body: JSON.stringify({ model: this.model, messages }),
});
그리고 팩토리 함수로 인스턴스를 생성한다.
// index.ts
export function createAIClient(provider: AIProvider, apiKey: string): AIClient {
switch (provider) {
case "openai":
return new OpenAIClient(apiKey);
case "claude":
return new ClaudeClient(apiKey);
}
}
사용하는 쪽은 이렇게 된다.
const client = createAIClient(provider, apiKey);
const response = await client.chat(messages, { maxTokens: 300 });
프로바이더가 Claude든 OpenAI든 호출 코드는 똑같다.
비용 계산도 분리
면접 한 세션의 토큰 사용량과 비용을 추적해야 했다. 프로바이더마다 단가가 다르기 때문에 cost.ts에 별도로 관리했다.
const COST_PER_MILLION = {
openai: { input: 0.15, output: 0.6 },
claude: { input: 0.25, output: 1.25 },
};
이 역시 호출 지점에서 분기하지 않고, 프로바이더 이름만 넘기면 계산된다.
프롬프트도 AI와 분리
프롬프트 로직은 AI 클라이언트와 완전히 분리해서 prompts.ts에 넣었다. 같은 프롬프트를 Claude에도, OpenAI에도 그대로 쓸 수 있어야 했기 때문이다.
// prompts.ts
export function getSystemPrompt(style: InterviewerStyle, problemInfo: ProblemInfo): string { ... }
export function getCodeAnalysisPrompt(prev: string, curr: string, pause: number): string { ... }
export function getReportPrompt(problem, messages, code, duration): string { ... }
프롬프트 구성 → AI 호출 → 응답 파싱이 각자의 레이어에서 독립적으로 존재한다.
4. 결과
프로바이더 교체가 한 줄이다.
// 이것만 바꾸면 Claude ↔ OpenAI 전환
const client = createAIClient("claude", apiKey);
const client = createAIClient("openai", apiKey);
면접 로직, 코드 분석 로직, 리포트 생성 로직은 AI가 무엇인지 전혀 모른다. 프롬프트를 수정해도 AI 호출 코드를 건드릴 필요가 없고, 새 프로바이더를 추가해도 기존 코드를 수정하지 않아도 된다.
하지만 아쉬운 점 역시 존재한다.
SDK 없이 raw fetch를 쓰다 보니 API 버전 관리가 수동이다. Anthropic이 anthropic-version 헤더를 업데이트하거나 응답 구조를 바꾸면 직접 코드를 수정해야 한다. Vercel AI SDK를 썼다면 패키지 업데이트 한 번으로 끝났을 것이다.
또한 스트리밍 응답을 지원하지 않는다. 현재는 응답이 완성된 후 한 번에 받는데, 면접관의 긴 답변이 오는 동안 UI가 정지된 것처럼 보인다. 스트리밍을 추가하려면 AIClient 인터페이스 자체를 바꿔야 한다.
5. 지금 다시 해본다면?
인터페이스 설계 방향 자체는 유지한다. “호출하는 쪽은 프로바이더를 몰라도 된다”는 원칙은 맞다.
다만 raw fetch 대신 각 SDK를 내부적으로 활용할 것이다.
// claude.ts 내부에서만 Anthropic SDK 사용
import Anthropic from "@anthropic-ai/sdk";
export class ClaudeClient implements AIClient {
private client = new Anthropic({ apiKey, dangerouslyAllowBrowser: true });
async chat(messages, options) {
const response = await this.client.messages.create({ ... });
// 공통 AIResponse 형태로 변환해서 반환
}
}
바깥 인터페이스는 그대로 두고, 내부 구현만 SDK로 교체하는 것이다. API 버전 관리와 타입 안전성은 SDK가 처리하고, 추상화 레이어는 내가 유지한다.
LobeChat이 Vercel AI SDK 위에 자체 AgentRuntime 레이어를 올린 것처럼, 라이브러리를 쓰면서도 인터페이스를 직접 제어하는 게 현실적인 균형점이다.