본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 02. 21:47

N*M에서 N+M으로: 의존성 없는 LLM 프로바이더 레이어

요약

LLM 프로바이더의 프로토콜과 정체성을 분리하여 복잡도를 N×M에서 N+M으로 줄이는 아키텍처 설계 방식을 제안합니다. 기존의 서브클래스 기반 패턴이 가진 중복성과 확장성 문제를 해결하기 위해 의존성 없는 프로바이더 레이어 구축 사례를 다룹니다.

핵심 포인트

  • 프로토콜(코드)과 프로바이더(데이터)의 분리를 통한 복잡도 감소
  • 서브클래스 방식의 한계인 코드 중복 및 확장성 문제 지적
  • 에러 정규화, 서킷 브레이커 등 범용 인프라의 중요성
  • 동일 벤더 내 다중 프로토콜 지원을 위한 유연한 설계 필요

LLM API 프로토콜은 단 3개뿐이지만, 동일한 프로토콜을 실행하는 프로바이더는 무한합니다. 프로토콜과 정체성을 분리하십시오. 프로토콜은 코드이고, 프로바이더는 데이터입니다. 이렇게 하면 복잡도는 N×M에서 N+M으로 급감합니다. 300줄의 TypeScript. 의존성 제로.

문제는 "작동하지 않는다"가 아니라, "고장 났음을 알려주지 않는다"입니다.

지난 5월, 저는 unblind라는 Claude Code 스킬을 만들었습니다. 저는 DeepSeek을 일상적으로 사용하지만, 이미지를 볼 수 없습니다. 그래서 unblind는 이미지를 Mimo와 OpenAI의 비전 (vision) API로 전달합니다.

MVP(최소 기능 제품)에는 두 개의 프로바이더 (provider)가 있었습니다. 수십 줄의 if-else 문이었고, 잘 작동했습니다.

그러다 더 불안한 점을 발견했습니다. 만료된 API 키 — 경고 없음. 네트워크 일시 오류 — 재시도 없음. 권한 누락 — 조용히 건너뜀. 이 도구는 실패한 것이 아니었습니다. 사용자에게 알리지도 않고 조용히 작동을 멈춘 것이었습니다.

저는 Phase 0의 자가 치유 (self-healing), 서킷 브레이커 (circuit breakers), 지속적 캐싱 (persistent caching), 그리고 보안 샌드박스 (security sandbox)를 추가했습니다. 이제 unblind는 조용히 실패하지 않습니다.

하지만 다른 점을 또 발견했습니다. 서킷 브레이커는 당신이 비전 API를 호출하는지 번역 API를 호출하는지 상관하지 않습니다. 캐시는 응답이 이미지 설명인지 OCR 텍스트인지 상관하지 않습니다. 에러 정규화 (error normalization)는 상대측이 Mimo인지 OpenAI인지 상관하지 않습니다.

비전 스킬 안에 갇혀 있는 범용 프로바이더 인프라.

첫 번째 시도: 생태계를 따랐으나 한계에 부딪히다

생태계 내에서 가장 유사한 대규모 프로젝트는 19개의 프로바이더를 보유한 vision-support입니다. 패턴은 표준적입니다. 베이스 클래스 (base class) + 서브클래스 (subclasses), GoF 템플릿 메서드 (Template Method) 패턴입니다. 저는 v2.0을 위해 이 방식을 따랐습니다.

class BaseProvider {
  async analyzeImage({ image, prompt, options }) {
    const { url, body, headers } = this._buildRequest(image, prompt, options);
...

프로바이더당 하나의 서브클래스를 할당했습니다. 저는 unblind를 3개에서 7개로 확장했습니다 — Groq, Together, Fireworks, 그리고 Ollama를 추가했습니다.

그때 Groq, Together, 그리고 Fireworks가 저를 곤혹스럽게 만들었습니다. 이들은 OpenAI와 정확히 동일한 Chat Completions API를 사용합니다. OpenAIProvider와의 유일한 차이점은 baseUrl과 모델 이름뿐입니다.

새로운 클래스를 세 개나 작성해야 한다고요? 거의 완전히 중복되는 내용입니다. OpenAIProvider를 재사용한다고요? 차이점들은 build 함수 안에 파묻혀 있습니다. 코드를 읽는 것만으로는 Groq와 OpenAI가 동일한 프로토콜 (Protocol)을 사용한다는 사실을 알 수 없습니다.

그리고 나중에, Mimo가 OpenAI와 Anthropic 프로토콜을 둘 다 지원한다는 사실을 발견했습니다. 동일한 벤더 (Vendor)인데 프로토콜 엔드포인트 (Endpoint)가 두 개인 셈입니다. 서브클래스 (Subclass) 접근 방식에서는 복잡도가 두 배로 늘어납니다.

잠시 멈춰서 생각하기

저는 서로 어울리지 않는 두 가지를 하나의 클래스에 쑤셔 넣었습니다.

개념정의변경 빈도되어야 하는 형태
프로토콜 (Protocol)"요청의 형식" — Anthropic Messages / OpenAI Chat / Google Gen AI매우 드묾 (3가지로 수렴하는 데 3년 소요)코드 (Code)
프로바이더 (Provider)"연결할 위치" — 어떤 벤더, 어떤 키, 어떤 baseUrl인지지속적으로 추가됨데이터 (Data)

이미 다섯 개의 프로바이더(OpenAI, Groq, Together, Fireworks, Ollama)가 단일 OpenAI Chat Completions 프로토콜을 사용하고 있습니다. Anthropic Messages는 현재 Mimo만 지원하지만, 내일이라도 어떤 프록시 게이트웨이 (Proxy gateway)가 GPT-4o를 Anthropic 형식으로 감쌀 수 있습니다. Google의 프로토콜은 현재 Gemini만 있지만, 이 상태가 계속되지는 않을 것입니다.

한 벤더 × 한 프로토콜 = 한 개의 레지스트리 (Registry) 행. 두 개의 프로토콜 = 두 개의 행. 다섯 명의 벤더 = 다섯 개의 행. 서브클래스 접근 방식에서는 N×M 클래스 폭발이 발생합니다. 프로토콜 접근 방식에서는 N개의 데이터 행 + M개의 프로토콜 객체 (Object)가 됩니다.

이 통찰은 30년의 데이터베이스 (Database) 경험에서 나왔습니다. MySQL과 PostgreSQL은 서로 다른 SQL 방언 (Dialect)이 필요하지만, 100개의 MySQL 인스턴스 (Instance)는 그저 서로 다른 연결 문자열 (Connection string)일 뿐입니다. Groq는 하나의 MySQL 인스턴스입니다. OpenAI는 또 다른 인스턴스이고요. 저는 모든 인스턴스마다 방언 (Dialect)을 작성하고 있었던 것입니다.

프로토콜은 코드입니다. 프로바이더는 데이터입니다.

// 프로토콜 (Protocol) — 코드. 프로젝트 전체에 3개의 프로토콜 객체가 있습니다.
const PROTOCOLS = {
  'openai-chat-completions': {
...
v2.0 템플릿 메서드 (Template Method)v3.0 프로토콜 주도 (Protocol-Driven)
복잡도N × M (N개의 서브클래스 × M개의 build 함수)N + M (N개의 데이터 행 + M개의 프로토콜 객체)
...

네 가지 핵심 설계 결정

1. 클래스가 아닌 순수 함수 (Pure functions) — 테스트 용이성을 위해

interface Protocol {
  readonly endpoint: (model: string) => string;
  readonly auth: (apiKey: string) => Record<string, string>;
...

extends도 없고, abstract도 없습니다. 프로토콜 (Protocol)은 그저 6개의 순수 함수 (Pure functions)를 가진 일반 객체일 뿐입니다.

// 의존성 없는 단위 테스트 (Unit test) — mock fetch나 Provider 인스턴스가 필요 없음
it('extractContent — OpenAI response', () => {
  assert.equal(
...

서브클래스 (Subclass) 방식으로는 이를 수행할 수 없습니다. 키(Key), URL, 타임아웃(Timeout)을 모두 갖춘 완전한 인스턴스가 필요하기 때문입니다. 프로토콜 접근 방식에서는 테스트의 80%가 의존성이 전혀 없습니다.

2. 서브클래스가 아닌 오버라이드 (Overrides) — 차이점은 데이터일 뿐

Groq은 max_tokens를 4096으로 제한합니다. OpenAI 프로토콜에는 그런 제한이 없습니다. 이 차이는 클래스를 만들 만큼 크지 않습니다.

{ name: 'groq', protocol: 'openai-chat-completions',
  overrides: {
    buildBody(proto, model, content, opts) {
...

Kubernetes의 전략적 병합 패치 (Strategic merge patch)에서 영감을 받았습니다. 기본값은 스펙 (Spec)에, 오버라이드 (Overrides)는 패치 (Patch)에 둡니다.

3. 세 가지 에러 형식 → 네 가지 카테고리

Anthropic은 { type: "error", error: { type: "invalid_request_error" } }를 반환합니다. OpenAI는 { error: { type: "..." } }를 반환합니다. Gemini는 { error: { code: 400, status: "INVALID_ARGUMENT" } }를 반환합니다.

완전히 호환되지 않습니다. 각 프로토콜은 단일 타입으로 정규화된 parseError(data, status)를 제공합니다.

type ParsedError =
  | { category: "auth" }       // 재시도 금지
  | { category: "rate_limit" }  // 백오프 (Back off)
...

상위 서킷 브레이커 (Circuit breaker)는 오직 category만 읽습니다. 반대편에 어떤 API가 있는지 알 필요가 전혀 없습니다.

4. TypeScript를 통한 런타임 에러의 컴파일 에러 전환

export type Input = TextInput | ImageInput | AudioInput | DocumentInput;

export interface ImageInput {
...

이전에는 mimeType이 선택적 문자열 (Optional string)이었습니다. 이를 잊어버리면 런타임 (Runtime)에 크래시가 발생했습니다. 이제는 type이 어떤 필드가 필수인지 결정합니다. 이를 잊어버리면 컴파일 (Compile) 단계에서 실패합니다.

N×M → N+M: 수치화

프로토콜 중심 (Protocol-driven) 방식이 "모델 전환"을 최적화하는 것은 아닙니다. v2.0에서 이미 코드 수정 없이 이를 구현했습니다. 이 방식이 제거하는 것은 프로바이더 (Provider)와 프로토콜 (Protocol) 사이의 결합 (Coupling)입니다:

작업v2.0v3.0
모델 전환필드 변경 (코드 수정 없음)필드 변경 (코드 수정 없음)
...

진정으로 제거된 것은 차원 간의 결합 (cross-dimensional coupling)입니다. 프로토콜의 진화는 프로바이더 정의에 영향을 주지 않습니다. 프로바이더의 추가는 프로토콜 코드에 영향을 주지 않습니다. 마침내 N과 M이 독립적으로 진화하게 되었습니다.

배포 (Shipping it)

프로바이더 레이어를 추출한 후, unblind의 코드는 16% 감소했습니다. 추출된 패키지인 zeshim은 어떤 에이전트 도구에서도 재사용할 수 있습니다:

npm install zeshim

전체 설계 문서: unblind/display/provider-optimization.md.

하지만 프로바이더 레이어를 추출하면서 zeshim의 네 가지 원칙을 정의해야만 했습니다. 당시에는 당연하게 느껴졌지만, 돌이켜보니 이 원칙들이 아키텍처를 안정적으로 유지해 주는 핵심이었습니다.

부록: 추출 후에야 이해하게 된 네 가지 원칙

1. 제로 의존성 (Zero dependencies)은 선호도가 아니라 카테고리의 정의이다

52개의 프레임워크와 코딩 에이전트를 조사했습니다. 제로 의존성 기반의 멀티 프로바이더 추상화 (multi-provider abstraction)를 가진 것은 단 하나도 없었습니다. 제로 의존성 자체가 하나의 카테고리입니다. 첫 번째 의존성을 추가하는 순간, LangChain과 zeshim의 차이는 "다른 아키텍처"에서 "더 적은 코드"로 격하됩니다. 그리고 더 적은 코드는 해자 (moat)가 될 수 없습니다.

그 대가로, 토큰 계산을 위한 tiktoken도, 검증을 위한 Zod도, HTTP 최적화를 위한 undici도 사용하지 않습니다. 모든 것은 사용자 공간 (user-space)으로 밀려납니다.

2. 프로토콜 인터페이스는 절대 8개 이상의 함수를 가질 수 없다

LangChain의 BaseChatModel은 3개의 메서드에서 시작해 15단계의 호출 스택 (call stack)으로 비대해졌습니다. Vercel AI SDK의 LanguageModelV1은 10개 이상의 메서드를 요구합니다. zeshim은 현재 6개를 가지고 있습니다. stream? (+1)을 추가하고, countTokens? (+1)를 예약하더라도, 최대 한도는 8개로 제한합니다.

9번째 기능이 필요하다면? 그것은 새로운 함수가 아니라, 새로운 프로토콜 패밀리 (Protocol family)입니다.

3. 코어 (Core) ≤ 500 LOC (Lines of Code)

300 LOC → 462 LOC → ... 상한선 없이 "코어는 비대해지지 않을 것이다"라고 말하는 것은 빈말에 불과합니다. 500 LOC라는 것은 누구나 30분 안에 전체 코드베이스를 읽을 수 있음을 의미합니다. 이를 초과한다면, 별도의 독립적인 패키지 (package)로 분리해야 합니다.

4. 스케줄러 (Scheduler)는 코어 (Core)에 존재하지 않는다

프로바이더 (Provider)의 상태는 status(): "healthy"|"degraded"|"down"으로 노출됩니다. 스케줄링 로직 — 지연 시간 추적 (latency tracking), 비용 누적 (cost accumulation), 동적 라우팅 (dynamic routing) — 은 프로토콜 (Protocol)을 임포트 (import)하지 않으며 API 내부 구조에 대해 전혀 알지 못하는 별도의 패키지에 존재합니다. 프로바이더는 스케줄러의 존재를 알지 못합니다. 스케줄러는 프로토콜의 세부 사항을 알지 못합니다.

LLM API 프로바이더 레이어는 클라이언트 (client)에 있어야 할까요, 아니면 서버 (server)에 있어야 할까요? 여러분의 의견을 듣고 싶습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0