AI 앱에서의 벤더 락인은 보통 한 번에 일어나지 않습니다. 살며시 스며듭니다 — 여기서 직접적인 import openai, 저기서 하드코딩된 모델 이름, 다른 제공자도 같은 필드를 반환하는지 확인하지 않은 채 파싱하는 응답 필드. 6개월 뒤 제공자를 바꾸려면 백엔드의 절반을 다시 써야 합니다.
락인이 일어나는 네 가지 방식
대부분의 개발자는 락인을 “OpenAI SDK를 쓰고 있다”로 생각합니다. 그건 가장 덜 위험한 종류입니다. 진짜 함정은 더 미묘합니다:
| 락인 유형 | 발생 방식 | 결과 |
|---|---|---|
| SDK 락인 | from openai import OpenAI 를 곳곳에서 사용 | SDK 교체 시 모든 파일을 수정해야 함 |
| 모델 이름 락인 | model="gpt-4o" 를 비즈니스 로직에 하드코딩 | 모델 변경마다 코드 변경 필요 |
| 매개변수 락인 | logprobs, n>1, 또는 reasoning_effort 사용 | Claude나 Gemini에는 존재하지 않음 |
| 응답 포맷 락인 | 제공자별 전용 응답 필드를 파싱 | 제공자마다 반환 형태가 다름 |
목표는 이 모든 것을 없애는 게 아닙니다 — 일부는 수용 가능한 트레이드오프입니다. 목표는 어떤 락인을 감수하는지 인지하는 것입니다.
OpenAI 호환 엔드포인트를 추상화 계층으로 사용하세요
SDK 락인을 피하는 가장 깔끔한 방법은 여러 제공자를 라우팅하는 단일 OpenAI 호환 엔드포인트를 사용하는 것입니다. OpenAI SDK는 그대로 두고, 백엔드는 어떤 제공자든 될 수 있습니다.
CometAPI가 이 방식을 제공합니다 — 하나의 엔드포인트, 하나의 키, OpenAI, Anthropic, Google, DeepSeek, xAI 등 500+ 모델:
import osfrom openai import OpenAIfrom dotenv import load_dotenvload_dotenv()api_key = os.environ.get("AI_API_KEY")if not api_key: raise ValueError("AI_API_KEY environment variable is not set")client = OpenAI( base_url=os.environ.get("AI_BASE_URL", "https://api.cometapi.com/v1"), api_key=api_key,)
GPT에서 Claude, 다시 Gemini로 바꾸는 것도 한 줄이면 됩니다:
# Beforeresponse = client.chat.completions.create(model="gpt-5.4", messages=[...])# After — same code, different modelresponse = client.chat.completions.create(model="claude-sonnet-4-6", messages=[...])
참고: gpt-5.4, claude-sonnet-4-6 같은 모델 이름은 CometAPI의 플랫폼 식별자입니다 — 이는 https://api.cometapi.com/v1에서만 동작하며, OpenAI나 Anthropic의 API에서는 직접 동작하지 않습니다. 전체 카탈로그와 가격은 전체 모델 목록을 참고하세요.
모델 이름을 비즈니스 로직에서 분리하세요
코드 전반에 모델 이름이 흩어져 있는 것이 가장 흔한 락인입니다. 해결책은 환경 변수에서 읽는 중앙 구성입니다:
# config.py — one place to change model assignmentsimport osMODEL_CONFIG = { "summarize": os.environ.get("MODEL_SUMMARIZE", "claude-opus-4-7"), "code": os.environ.get("MODEL_CODE", "gpt-5.4"), "classify": os.environ.get("MODEL_CLASSIFY", "claude-haiku-4-5"), "chat": os.environ.get("MODEL_CHAT", "gpt-5.4-mini"),}# Validate at startup — fail fast rather than getting mysterious API errorsfor task, model in MODEL_CONFIG.items(): if not model: raise ValueError(f"Model config for '{task}' is not set")
비즈니스 로직은 모델 이름을 직접 참조하지 않습니다:
from config import MODEL_CONFIGdef summarize(text: str) -> str: response = client.chat.completions.create( model=MODEL_CONFIG["summarize"], messages=[{"role": "user", "content": f"Summarize: {text}"}], max_tokens=300 # move to config in production ) return response.choices[0].message.content
앱 전체에서 요약 모델을 바꾸려면 환경 변수 한 곳만 바꾸면 됩니다. grep도, 일괄 치환도 필요 없습니다.
제공자별 응답 필드에 의존하지 않도록 응답을 래핑하세요
제공자마다 응답 형태가 조금씩 다릅니다. 코드베이스 전반에서 원시 API 응답을 파싱하면 그 제공자의 포맷에 묶입니다.
정규화된 데이터클래스로 감싸세요:
from dataclasses import dataclassfrom typing import Optionalfrom openai import OpenAI, APIStatusError, APIConnectionError, APITimeoutErrorfrom openai.types.chat import ChatCompletionimport logging@dataclassclass AIResponse: content: str model: str input_tokens: int output_tokens: intdef call_model(task: str, messages: list, **kwargs) -> AIResponse: """ Single entry point for all LLM calls. Returns a normalized AIResponse regardless of which model handled it. Raises on 4xx (client errors). Logs and re-raises on 5xx/network errors. """ model = MODEL_CONFIG.get(task, "gpt-5.4-mini") if not model: raise ValueError(f"No model configured for task '{task}'") try: response: ChatCompletion = client.chat.completions.create( model=model, messages=messages, **kwargs ) except APIStatusError as e: logging.error(f"API error for task={task} model={model}: {e.status_code} {e.message}") raise except (APIConnectionError, APITimeoutError) as e: logging.error(f"Network error for task={task} model={model}: {e}") raise # content is None when the model triggers a tool call instead of returning text content = response.choices[0].message.content or "" # usage is None in streaming mode — default to 0 if not available usage = response.usage input_tokens = usage.prompt_tokens if usage else 0 output_tokens = usage.completion_tokens if usage else 0 logging.info( f"task={task} model={model} " f"input_tokens={input_tokens} output_tokens={output_tokens}" ) return AIResponse( content=content, model=response.model, input_tokens=input_tokens, output_tokens=output_tokens, )
이제 비즈니스 로직은 원시 API 응답이 아니라 AIResponse 객체를 다룹니다. 제공자가 응답 포맷을 바꾸더라도 한 곳만 고치면 됩니다.
래퍼에 스트리밍 지원을 추가하세요
채팅 인터페이스에는 스트리밍이 필요할 수 있습니다. 래퍼에서 별도 경로로 처리하세요:
from typing import Iteratordef stream_model(task: str, messages: list, **kwargs) -> Iterator[str]: """ Stream tokens from the routed model. Note: streaming doesn't return usage data. Fallback is not supported in streaming mode — you've already started yielding tokens before you know if the full request succeeds. """ model = MODEL_CONFIG.get(task, "gpt-5.4-mini") if not model: raise ValueError(f"No model configured for task '{task}'") stream = client.chat.completions.create( model=model, messages=messages, stream=True, **kwargs ) for chunk in stream: delta = chunk.choices[0].delta.content if delta: yield delta# Usagefor token in stream_model("chat", [{"role": "user", "content": "Hello"}]): print(token, end="", flush=True)
어떤 매개변수가 락인을 만드는지 파악하세요
일부 매개변수는 특정 제공자에서만 지원됩니다. 사용하는 건 괜찮지만 — 그게 의도적인 선택임을 인지하세요:
| 매개변수 | 동작 대상 | 락인 위험 |
|---|---|---|
| logprobs | GPT만 | 높음 — Claude나 Gemini에 동등 기능 없음 |
| n > 1 | GPT, Gemini(Claude는 아님) | 중간 — Claude는 루프가 필요 |
| reasoning_effort | GPT o-series만 | 높음 — 다른 곳엔 없음 |
| temperature > 1.0 | GPT, Gemini(Claude는 아님) | 낮음 — Claude는 1.0으로 제한 |
| tools | 주요 모든 제공자 | 없음 — 사용해도 안전 |
| response_format | 주요 모든 제공자 | 낮음 — 스키마 차이 미미 |
만약 logprobs로 신뢰도 점수를 계산한다면, 그 기능에 대해서는 GPT에 종속됩니다. 합리적인 트레이드오프일 수 있습니다 — 다음 개발자가 이유를 알 수 있도록 문서화하세요.
제공자 엔드포인트를 구성 가능하게 하세요
base_url="https://api.cometapi.com/v1" 를 하드코딩하는 것도 일종의 락인입니다. 환경 변수로 만드세요:
# .env — using CometAPIAI_BASE_URL=https://api.cometapi.com/v1AI_API_KEY=your_cometapi_key# To switch to OpenAI directly, change two lines:# AI_BASE_URL=https://api.openai.com/v1# AI_API_KEY=your_openai_key
1단계의 클라이언트 초기화는 이미 이 변수들을 읽습니다. CometAPI와 직접 제공자 연결 간 전환은 코드 변경이 아니라 구성 변경이 됩니다.
Node.js 버전
import OpenAI from 'openai';const apiKey = process.env.AI_API_KEY;if (!apiKey) throw new Error('AI_API_KEY is not set');const client = new OpenAI({ baseURL: process.env.AI_BASE_URL ?? 'https://api.cometapi.com/v1', apiKey,});// Model IDs are CometAPI platform identifiers — see cometapi.com/modelsconst MODEL_CONFIG = { summarize: process.env.MODEL_SUMMARIZE ?? 'claude-opus-4-7', code: process.env.MODEL_CODE ?? 'gpt-5.4', classify: process.env.MODEL_CLASSIFY ?? 'claude-haiku-4-5', chat: process.env.MODEL_CHAT ?? 'gpt-5.4-mini',};// Validate at startupfor (const [task, model] of Object.entries(MODEL_CONFIG)) { if (!model) throw new Error(`Model config for '${task}' is not set`);}/** * Single entry point for all LLM calls. * Returns normalized response. Raises on 4xx, logs and re-raises on 5xx/network. */async function callModel(task, messages, options = {}) { const model = MODEL_CONFIG[task] ?? 'gpt-5.4-mini'; let response; try { response = await client.chat.completions.create({ model, messages, ...options, }); } catch (err) { // Don't swallow errors — log and re-raise console.error(`API error task=${task} model=${model}:`, err.message); throw err; } // content is null when model triggers a tool call const content = response.choices[0].message.content ?? ''; // usage may be absent in some configurations const inputTokens = response.usage?.prompt_tokens ?? 0; const outputTokens = response.usage?.completion_tokens ?? 0; console.log(`task=${task} model=${model} input=${inputTokens} output=${outputTokens}`); return { content, model: response.model, inputTokens, outputTokens };}/** * Stream tokens from the routed model. * Usage data is not available in streaming mode. */async function* streamModel(task, messages, options = {}) { const model = MODEL_CONFIG[task] ?? 'gpt-5.4-mini'; const stream = await client.chat.completions.create({ model, messages, stream: true, ...options, }); for await (const chunk of stream) { const delta = chunk.choices[0]?.delta?.content; if (delta) yield delta; }}// Usage — blockingconst result = await callModel('classify', [ { role: 'user', content: 'Positive or negative? "Loved it!"' }]);console.log(result.content);// Usage — streamingfor await (const token of streamModel('chat', [ { role: 'user', content: 'Hello' }])) { process.stdout.write(token);}
허용 가능한 락인은 무엇인가
- 사용 OpenAI SDK — 업계 사실상 표준입니다. 대부분의 제공자가 이를 지원합니다. 낮은 위험의 락인입니다.
- 정말 필요한 제공자 전용 기능 —
logprobs가 필요하면 쓰세요. 해당 코드를 격리해 두면 나중에 찾고 교체하기 쉽습니다. - 파인튜닝된 모델 — 파인튜닝 모델은 본질적으로 하나의 제공자에 묶입니다. 당연한 일입니다.
피해야 할 락인은 의도치 않게 생기는 락인입니다 — 비즈니스 로직 곳곳에 흩어진 모델 이름, 여러 파일에 퍼진 원시 응답 파싱, 소스에 하드코딩된 API 키 같은 것들입니다.
다음 단계
이제 제공자 세부사항을 비즈니스 로직에서 분리해 주는 추상화 계층을 갖췄습니다. 시리즈의 마지막 글에서는 문제가 생겼을 때 무슨 일이 일어나는지 다룹니다: 실패한 생성 디버깅, 오류 코드 해석, 무엇이 잘못됐는지 실제로 알려주는 오류 처리 구축 방법.
다음: How to Debug Failed AI API Generations
FAQ
Q: SDK 락인과 모델 락인의 차이는 무엇인가요?
SDK 락인은 코드가 특정 라이브러리를 임포트하고 있어 SDK를 바꾸면 코드 변경이 필요함을 의미합니다. 모델 락인은 모델 이름이 비즈니스 로직 전반에 흩어져 있는 상태를 뜻합니다. 대부분의 제공자가 이제 OpenAI SDK 형식을 지원하기 때문에 SDK 락인은 덜 위험합니다. 모델 락인은 찾고 고치기 더 어려워서 더 교묘합니다.
Q: CometAPI를 쓰면 OpenAI 락인을 CometAPI 락인으로 바꾸는 것 아닌가요?
부분적으로 그렇습니다. 직접 제공자 락인을 프록시 계층으로 바꾸는 것입니다. 장점: 하나의 키, 하나의 엔드포인트, 손쉬운 모델 전환. 위험: CometAPI가 장애면 모든 제공자가 함께 영향을 받습니다. 완화책은 이미 위 코드에 있습니다 — AI_BASE_URL은 환경 변수입니다. 필요하면 CometAPI를 우회해 제공자를 직접 호출하도록 구성만 바꾸면 됩니다. 코드 변경은 필요 없습니다.
Q: 이 패턴으로 Claude의 확장형 사고나 OpenAI의 reasoning_effort을 사용할 수 있나요?
가능합니다. call_model에 **kwargs로 넘기세요. 다만 해당 작업을 다른 모델로 라우팅하면 그 매개변수는 무시되거나 오류가 날 수 있습니다. 어떤 작업이 제공자 전용 기능을 사용하는지 문서화해 두세요 — 다음 개발자가 이유를 알 수 있게요.
Q: Claude의 temperature 상한(1.0)을 Claude와 GPT**?** 사이 라우팅 시 어떻게 처리하나요?
둘 다에서 안전하게 쓰려면 temperature를 1.0 이하로 유지하세요. GPT에서 창의적 작업에 더 높은 값을 써야 한다면, 그 작업은 MODEL_CONFIG에서 GPT로 명시적으로 라우팅하고 일반 라우터에 맡기지 마세요.
Q: 이미지/비디오 생성 API도 같은 방식으로 추상화해야 하나요?
원칙은 같습니다 — 중앙 구성, 정규화된 응답 래퍼, 비즈니스 로직에 제공자별 필드 금지. 이미지/비디오 API는 구조 차이가 더 큽니다(비동기/동기, 매개변수 집합 차이). 추상화 계층에 더 많은 작업이 필요합니다. 텍스트부터 시작하고 구조가 검증되면 패턴을 확장하세요.
Q: 모델 간 콘텍스트 윈도 차이는 어떻게 하나요?
라우팅 시 실제 위험입니다. GPT-5.5는 1M 토큰 콘텍스트 윈도, Claude 모델은 최대 200K, Gemini 3.5 Flash는 최대 1M을 지원합니다. 긴 문서 작업을 콘텍스트가 더 짧은 모델로 라우팅하면 입력이 조용히 잘릴 수 있습니다. 긴 입력이 있는 작업이라면 라우팅 전에 콘텍스트 길이를 확인하거나 — 그런 작업은 MODEL_CONFIG에서 항상 긴 콘텍스트 모델로 라우팅하도록 하세요.
