본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 23. 15:01

코드베이스 전체에 LLM SDK/API 호출을 흩뿌리는 것을 멈추세요. 문제를 해결해 준 '2개 파일 규칙'을 소개합니다

요약

LLM SDK 업데이트나 모델 교체 시 발생하는 코드 수정 부담을 줄이기 위한 '2개 파일 규칙' 아키텍처를 제안합니다. 헥사고날 아키텍처를 적용하여 SDK 임포트를 어댑터와 레지스트리 파일로 제한함으로써 비즈니스 로직을 보호합니다.

핵심 포인트

  • SDK 직접 호출을 지양하고 인터페이스를 통한 추상화 필요
  • 2개 파일 규칙: 어댑터와 프로바이더 레지스트리만 SDK 임포트 허용
  • 헥사고날 아키텍처(포트와 어댑터)를 LLM 인프라에 적용
  • 모델 변경 및 SDK 업데이트 시 비즈니스 로직 영향 최소화

저는 LLM SDK를 업그레이드하면서 평범한 버전 업데이트를 예상했습니다. 하지만 결과적으로 15개 이상의 파일을 수정해야 했고, 4개의 프로바이더 (Provider)에 걸친 중대한 변경 사항 (Breaking changes)을 수정해야 했으며, 남은 하루 동안 실수로 놓친 것이 없는지 걱정하며 보내야 했습니다. 이런 일이 발생한 것은 두 번째였습니다. 저는 세 번째도 일어날 것임을 알고 있었습니다.

만약 당신이 프로덕션 환경의 LLM 시스템을 배포해 본 적이 있다면, 아마 이 냄새를 맡아본 적이 있을 것입니다. SDK의 마이너 버전 업데이트로 인해 maxTokensmaxOutputTokens로 이름이 바뀌었고, 이제 컴파일 타임 (Compile time)이 아닌 런타임 (Runtime)에서 15개의 파일이 깨지게 됩니다. 분류 작업 하나를 Claude에서 더 저렴한 모델로 전환하려면 비즈니스 로직 (Business logic) 내의 임포트 경로 (Import paths)와 타입 시그니처 (Type signatures)를 수정해야 합니다. 당신은 classifyEmail, scoreLead, triageTicket, categorizeRequest를 작성했을 것이고, 이들은 모두 프롬프트 문자열만 다를 뿐 동일한 함수입니다.

이것은 SDK의 문제가 아닙니다. 아키텍처 (Architecture)의 문제입니다. 제가 이를 어떻게 해결했는지, 그리고 그 과정에서 탄생한 오픈 소스 라이브러리에 대해 설명하겠습니다.

제가 만든 '2개 파일 규칙'은 다음과 같습니다: 전체 코드베이스에서 오직 두 개의 파일만이 LLM SDK를 임포트(Import)할 수 있도록 허용하는 것입니다. 하나는 제 인터페이스를 SDK 호출로 변환하는 어댑터 (Adapter)이고, 다른 하나는 설정 (Config)으로부터 클라이언트 (Client)를 생성하는 프로바이더 레지스트리 (Provider registry)입니다. 그 외의 모든 것은 타입이 지정된 인터페이스 (Typed interface)와 통신하며, 어떤 프로바이더, 모델, 또는 SDK가 작동 중인지 알지 못합니다.

이것은 그저 Alistair Cockburn이 정의한 헥사고날 아키텍처 (Hexagonal architecture, 포트와 어댑터)를 LLM에 적용한 것뿐입니다. 당신은 이미 데이터베이스나 메시지 큐 (Message queues)에 대해 이 방식을 사용하고 있습니다. 아무도 비즈니스 로직 곳곳에 생 SQL (Raw SQL)을 흩뿌려 놓지 않습니다. LLM 프로바이더들도 같은 범주에 속합니다. 이들은 애플리케이션 로직 (Application logic)이 아니라 인프라 (Infrastructure)입니다.

의존성 흐름 (Dependency flow)은 다음과 같이 변화합니다:

기존:
애플리케이션 코드
├─ 직접적인 SDK 호출
├─ 직접적인 SDK 호출
└─ SDK 타입을 유출하는 모델 라우터 (Model router)

변경 후:
애플리케이션 코드
↓ llmClassify(), llmDraft(), llmScore() ... 기능 (Capabilities)
↓ LLM 포트 (LLM Port, TypeScript 인터페이스, SDK 임포트 없음)
↓ 어댑터 + 프로바이더 레지스트리 (SDK를 건드리는 유일한 2개의 파일)
↓ OpenAI / Anthropic / Gemini / Ollama / Vercel AI SDK

호출자는 자신이 원하는 것(taskType: "triage")을 말합니다. 인프라가 그것을 어떻게 수행할지 결정합니다. 모델 이름 파라미터는 필요 없습니다.

제공자(provider) 파라미터도 없습니다. 정책은 설정(config)으로 위임됩니다. 그 증거는: 문제를 일으키지 않았던 SDK 업그레이드 사례입니다. 진짜 시험대는 파괴적 변경 사항(breaking changes)이 포함된 주요 SDK 버전 점프(maxTokens에서 maxOutputTokens로, CoreMessage에서 ModelMessage로 등)가 발생했을 때였습니다. 당시 마이그레이션 커밋의 모습은 다음과 같았습니다: 2개의 파일이 변경되었고(어댑터와 에이전트 런타임), 1개의 사소한 수정이 있었습니다. 18개의 모든 활동(activity) 파일은 변경되지 않았습니다. 10개의 모든 에이전트(agent) 파일도 변경되지 않았습니다. 최종 마이그레이션은 추가된 코드보다 더 많은 코드를 삭제했습니다: 192개 삽입, 688개 삭제. 31개 파일 중 28개는 변경되지 않았는데, 그 파일들은 SDK의 존재를 알지 못하기 때문입니다. 만약 핵심 의존성(core dependency) 업그레이드가 여러분의 비즈니스 로직(business logic)을 건드린다면, 여러분의 경계(boundaries) 설정이 잘못된 것입니다.

저를 놀라게 했던 부분은 이렇습니다: SDK를 격리하기 위해 이 작업을 시작했을 때, 어디에서나 동일한 7가지 작업이 나타났습니다. 그러다 더 큰 문제를 발견했습니다. 저는 21개의 서로 다른 곳에서 LLM을 호출하고 있었던 것이 아니었습니다. 저는 약간의 변형을 가해 동일한 7가지 인지 작업(cognitive operations)을 재구현하고 있었던 것입니다:

능력 (Capability)입력값 (What you give it)반환값 (What you get back)
콘텐츠 분류 (Classify content)루브릭 (rubric)열거형(enum) 중 하나의 레이블 + 추론 (reasoning)
콘텐츠 점수 산정 (Score content)루브릭 + 축 (axes)축당 수치 등급 (numeric ratings per axis)
초안 작성 (Draft)페르소나 + 상황 (persona + situation)선택된 톤의 더 긴 텍스트 (longer text in a chosen tone)
요약 (Summarize)긴 콘텐츠 + 길이 목표 (long content + length target)핵심 사항이 유지된 더 짧은 콘텐츠 (shorter content, key points kept)
비정형 텍스트 추출 (Extract unstructured text)스키마 (schema)타입이 지정된 구조화된 객체 (a typed structured object)
계획 수립 (Plan)목표 + 제약 조건 (goal + constraints)단계별 순서 목록 (an ordered list of steps)
증거 분석 (Analyze evidence)질문 + 주의 사항이 포함된 권장 사항 (question + recommendation with caveats)

5개의 활동은 5개의 서로 다른 프롬프트(prompt) 구조로 분류되었습니다. 9개의 초안 메시지는 9개의 서로 다른 톤 주입(tone injections)으로 작성되었습니다. 작업은 동일하지만, 공유된 구현(shared implementation)이 없었습니다. 하나의 분류 프롬프트를 개선했을 때, 저는 다른 4곳을 업데이트해야 한다는 사실을 기억해야 했습니다. 저는 보통 잊어버리곤 했습니다. 여러분은 47개의 프롬프트를 작성하고 있는 것이 아닙니다. 약간씩 다른 재료를 사용하여 7개의 프롬프트를 47번 작성하고 있는 것입니다. 그래서 저는 그것들을 능력 팩토리(capability factories)로 추출했습니다.

팩토리(factory)는 불변하는 부분(스키마 (schema), 루브릭 (rubric), 모델 라우팅 (model routing), 관측성 훅 (observability hooks))을 가져와서, 변하는 부분(콘텐츠 (content))만을 인자로 받는 함수를 반환합니다:

import { createClassifier } from "@llm-ports/capabilities";
import { z } from "zod";

const IntentSchema = z.object({
  intent: z.enum(["question", "request", "complaint", "feedback", "other"]),
  urgency: z.enum(["low", "normal", "high"]),
  reasoning: z.string(),
});

export const classifyIntent = createClassifier({
  port: llm,
  // 제공자 불가지론적(provider-agnostic) 포트
  schema: IntentSchema,
  schemaName: "user-intent",
  rubric: `question: asking for information
request: wants something done
complaint: reports a problem
feedback: opinion only
other: anything else`,
});

그러면 모든 파일에 걸친 모든 호출 지점(call site)이 동일한 형태를 갖게 됩니다:

const result = await classifyIntent({ content: userMessage });
// { intent: "request", urgency: "high", reasoning: "..." } - 완벽하게 타입이 지정됨

루브릭 (rubric)을 한 번 개선하면 시스템 내의 모든 분류기 (classifier)가 함께 좋아집니다. 프롬프트 엔지니어링 (prompt engineering)은 더 이상 흩어져 있는 문자열이 아니라 재사용 가능한 시스템 자산이 됩니다.

llm-ports

저는 이 패턴을 실제 운영 시스템에서 추출하여 MIT 라이선스의 오픈 소스 TypeScript 라이브러리인 llm-ports로 출시했습니다.

60초 설정

.env 파일에 제공자(provider) 설정:

LLM_PROVIDER_FAST = anthropic|<model>|cost:50/day
LLM_PROVIDER_SMART = anthropic|<model>|cost:200/day
LLM_TASK_ROUTE_TRIAGE = fast,smart

포트를 한 번 생성:

import { createRegistryFromEnv } from "@llm-ports/core";
import { createAnthropicAdapter } from "@llm-ports/adapter-anthropic";

export const llm = createRegistryFromEnv({
  adapters: {
    anthropic: createAnthropicAdapter({
      apiKey: process.env.ANTHROPIC_API_KEY!,
    }),
  },
}).getPort();

SDK 임포트 없이 어디에서나 사용:

const result = await llm.generateText({
  taskType: "triage",
  prompt: "Classify this email..." // 이 이메일을 분류하세요...

" , }); 레지스트리(Registry)는 작업에 적합한 모델을 선택하고, 비용 제한을 강제하며, 예산 소진 시 제공자 체인(Provider chain)을 통해 폴백(Fallback)을 수행하고, 사용량, 비용 및 지연 시간(Latency)을 기록합니다.

**얻을 수 있는 기능:**
- OpenAI, Anthropic, Google Gemini, Ollama 및 Vercel AI SDK를 아우르는 멀티 제공자 라우팅 (Multi-provider routing).
- 제공자가 예산을 초과할 경우를 대비한 폴백 체인 (Fallback chains).
- 시간별, 일별, 월별 제한이 있는 USD 기반 비용 게이팅 (Cost gating).
- 예산 소진은 타입화된 예외(Typed exception)로 처리되며, 예상치 못한 청구서로 이어지지 않습니다.
- 7가지 기능 팩토리 (Capability factories): createClassifier, createScorer, createDrafter, createSummarizer, createExtractor, createPlanner, createAnalyzer.
- 구조화된 출력 (Structured output)을 위한 검증 복구 (Validation recovery). 모델이 잘못된 JSON이나 틀린 열거형(Enum)을 반환하면, 수정 프롬프트와 함께 자동으로 재시도합니다.
- 잘못된 출력이 하류(Downstream)로 유출되는 대신 기능 경계(Capability boundary)에서 차단됩니다.
- 도구 사용(Tool-use) 안전 프리미티브 (Safety primitives): 파괴적 마커 (Destructive markers), 확인이 필요한 작업 (Confirmation-required actions), 최대 출력 바이트 제한.
- 비용, 지연 시간, 품질 및 결과에 대한 관찰 가능성 훅 (Observability hooks).
- LangChain 또는 LlamaIndex에 대한 런타임 의존성 없음.
- 코어(Core) + 어댑터 하나 + 기능(Capabilities) 구성으로 설치 크기가 작으며, 전체 과정에 엄격한 TypeScript를 적용합니다.

**비교 분석:**
- **Vercel AI SDK**: 제공자 호출을 통합합니다.
- **llm-ports**: 그 위에 레지스트리, 폴백 체인, USD 비용 게이팅, 검증 복구 및 기능 팩토리를 추가합니다. 점진적으로 마이그레이션할 수 있는 어댑터가 제공됩니다.
- **LiteLLM**: Python 중심의 HTTP 프록시입니다.
- **llm-ports**: TypeScript 기반이며 프로세스 내부(In-process)에서 실행되어 추가적인 네트워크 홉(Network hop)이 없습니다.
- **Portkey**: 상용 호스팅 게이트웨이입니다.
- **llm-ports**: MIT 라이선스이며 호스팅 의존성이 없습니다.
- **LangLangChain.js**: 프레임워크입니다.
- **llm-ports**: 경량 아키텍처 및 제어 계층(Control layer)이며, 앱 전체를 그 안에서 구축하는 프레임워크가 아닙니다.

**사용 시점 (및 사용하지 말아야 할 시점):**
2개 이상의 제공자를 사용하거나(또는 나중에 교체할 가능성이 있는 경우), 호출 지점(Call sites)이 5개 이상인 경우, SDK 업그레이드로 인해 계속 어려움을 겪는 경우, 또는 비용 제어와 중앙 집중식 품질 추적이 필요한 경우에 사용하세요.

LLM 호출이 1~2개뿐이거나, 단순히 프로토타이핑 중이거나, 또는 내장된 메모리 및 RAG (Retrieval-Augmented Generation) 레이어를 갖춘 완전한 에이전트 프레임워크를 원하는 경우에는 이 방식을 건너뛰셔도 좋습니다. 솔직히 말씀드리면 llm-ports는 출시 전 단계이며, 현재 0.1.0-alpha.5 버전입니다. 핵심 아키텍처는 250개 이상의 오프라인 회귀 테스트 (regression tests)를 통해 안정적이지만, 일부 어댑터 (adapter) 및 에이전트 경로들은 여전히 강화 작업 중입니다 (Vercel 어댑터의 멀티턴 에이전트 및 런타임 에러 시 재시도 기능은 모두 v0.2에 포함될 예정입니다). 각 기능별 상태는 공개적으로 문서화되어 있으므로, 도입하기 전에 무엇이 견고한지 미리 확인할 수 있습니다. 

직접 시도해 보세요: 
npm install @llm-ports/core @llm-ports/adapter-anthropic @llm-ports/capabilities 
npm: https://www.npmjs.com/package/@llm-ports/core 
GitHub (이메일 분류 및 PDF 추출을 포함한 7개의 실행 가능한 예제): https://github.com/baabakk/llm-ports 
Docs: https://baabakk.github.io/llm-ports/ 

만약 capability-factory 패턴이 여러분이 구축하고 있는 방식과 일치한다면, GitHub Discussions를 통해 진심 어린 피드백을 받고 싶습니다. 7가지 목록에 없는 어떤 형태를 여러분은 재구현하고 계신가요? Capabilities에 아직 없는 어떤 조절 장치 (knobs)가 필요한가요? 

LLM은 더 이상 여러분이 관리해야 하는 의존성 (dependency)이 아닙니다. 그것은 여러분이 구성하는 인프라 (infrastructure)가 됩니다. 일단 그 전환을 이루고 나면, 다른 모든 것들이 더 단순해질 것입니다. 

다음의 두 가지 긴 글을 바탕으로 작성되었습니다: Ports and Adapters for AI 및 The 7 LLM Capabilities Every Production AI System Reimplements.

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0