LLM 프로바이더 20개를 하나의 인터페이스로 — 어댑터 패턴 실전
LLM을 쓰는 제품을 만들면 반드시 두 번째 프로바이더를 붙이게 됩니다. 가격 때문이든, 특정 모델이 더 잘해서든, 한 곳이 장애 나서든요. 그리고 그 순간 알게 됩니다. "메시지를 보내고 답을 받는다"는 같은데 그걸 표현하는 방식이 전부 다르다는 걸요.
시스템 프롬프트를 최상위 필드로 받는 곳이 있고 메시지 배열 안에 넣는 곳이 있습니다. 툴 호출 결과를 담는 형태가 다릅니다. 스트리밍 이벤트 이름이 다릅니다. 어떤 곳은 스트림 종료를 명시적으로 알려주고 어떤 곳은 안 알려줍니다. 이 차이가 애플리케이션 코드에 새어 들어가면, 프로바이더를 추가할 때마다 if provider == "..."가 늘어납니다.
이 글은 그 차이를 어댑터 계층에 가두는 방법입니다.
1. 공통 인터페이스는 잔인하게 작게
가장 흔한 실수는 인터페이스를 크게 만드는 것입니다. "이 프로바이더는 X를 지원하니까 메서드를 넣자"를 반복하면, 20개 구현체 중 3개만 의미 있게 구현하고 나머지 17개는 return nil을 쓰는 인터페이스가 됩니다.
모든 구현체가 진짜로 할 수 있는 것만 넣으세요. 실제로는 네 개면 됩니다.
type Provider interface {
// 메시지를 보내고 응답을 받는다
Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error)
// 스트리밍 — 청크를 콜백으로 흘리고, 끝나면 완성된 응답을 반환한다
ChatStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk)) (*ChatResponse, error)
DefaultModel() string
Name() string
}
ChatStream이 콜백으로 흘리면서 최종 응답도 반환하는 게 포인트입니다. 호출부는 실시간 출력을 위해 청크가 필요하고, 동시에 툴 호출·사용량 같은 완성된 결과도 필요합니다. 두 가지를 한 메서드로 주면 호출부에서 청크를 재조립하는 코드가 사라집니다.
2. 능력 차이는 선택적 인터페이스로
그럼 확장 기능은 어떻게 하냐. Go에서는 선택적 인터페이스가 정답입니다. 별도 인터페이스로 정의하고, 필요한 곳에서 타입 어서션으로 확인합니다.
// 확장 사고(reasoning)를 지원하는 프로바이더만 구현
type ThinkingCapable interface {
SupportsThinking() bool
}
// 세션 단위 외부 상태(장기 실행 프로세스 등)를 가진 프로바이더만 구현
type SessionCloser interface {
CloseSession(ctx context.Context, sessionKey string) error
}
// 호출부: 지원하면 쓰고, 아니면 조용히 넘어간다
if tc, ok := provider.(ThinkingCapable); ok && tc.SupportsThinking() {
req.Options[OptThinkingLevel] = level
}
이 패턴의 이점은 새 능력을 추가해도 기존 구현체를 하나도 안 건드린다는 것입니다. 인터페이스에 메서드를 추가하면 20개 파일을 고쳐야 하지만, 새 선택적 인터페이스는 지원하는 한 곳만 구현하면 끝입니다.
3. 정적 능력은 구조체로 선언하라
런타임 분기가 필요한 능력은 메서드보다 선언적 구조체가 낫습니다. "스트리밍 되나?"를 알려고 매번 타입 어서션을 열 번 하는 것보다, 한 번 물어보고 값으로 들고 있는 게 낫습니다.
type ProviderCapabilities struct {
Streaming bool // ChatStream을 지원하는가
ToolCalling bool // 툴 정의를 받는가
StreamWithTools bool // 툴 호출 중에도 스트리밍이 되는가
Thinking bool // 확장 사고를 지원하는가
Vision bool // 이미지 입력을 받는가
CacheControl bool // 블록 단위 캐시 지시자를 지원하는가
MaxContextWindow int // 기본 모델의 컨텍스트 윈도우
TokenizerID string // 토큰 계산에 쓸 토크나이저 식별자
}
type CapabilitiesAware interface {
Capabilities() ProviderCapabilities
}
StreamWithTools가 Streaming과 따로 있는 게 실전의 흔적입니다. 스트리밍은 되지만 툴 호출이 섞이면 청크가 깨지는 구현이 실제로 존재합니다. 이런 조합 제약은 불리언 하나로는 표현이 안 되니 별도 필드로 둡니다.
4. 직렬화와 전송을 분리하라
각 프로바이더 구현 안에서 다시 한 겹을 나눕니다. "내부 형식 ↔ 프로바이더 형식" 변환과 "HTTP를 실제로 쏘는" 부분은 다른 관심사입니다.
// 내부 형식과 프로바이더별 형식 사이의 변환만 담당한다.
// 각 Provider 구현체 안에 조립해 쓴다 (Provider를 대체하지 않는다).
type ProviderAdapter interface {
// 내부 요청 → 프로바이더 와이어 포맷(바이트 + 헤더)
ToRequest(req ChatRequest) ([]byte, http.Header, error)
// 프로바이더 응답 바이트 → 내부 응답
FromResponse(data []byte) (*ChatResponse, error)
// SSE 청크 하나 → 내부 청크. 무시할 청크면 nil을 반환한다.
FromStreamChunk(data []byte) (*StreamChunk, error)
Capabilities() ProviderCapabilities
Name() string
}
이렇게 나누면 변환 로직을 HTTP 없이 테스트할 수 있습니다. 프로바이더가 실제로 보낸 응답 바이트를 파일로 떠 두고, FromResponse에 넣어 파싱이 맞는지 검증하면 됩니다. 네트워크도, 목 서버도 필요 없습니다. 새 프로바이더를 붙일 때 대부분의 시간은 이 변환 코드에서 쓰이므로, 여기 테스트가 쉬운 게 큰 차이를 만듭니다.
FromStreamChunk가 nil을 반환할 수 있다는 것도 중요합니다. SSE 스트림에는 실제 내용이 없는 킵얼라이브, 메타데이터, 시작·종료 신호가 섞여 있습니다. 이걸 "빈 청크"로 위로 올리면 호출부가 매번 걸러야 합니다. 어댑터에서 nil로 삼키는 게 맞습니다.
5. SSE 차이 흡수하기 — 실제로 무엇이 다른가
스트리밍이 프로바이더 간 차이가 가장 큰 지점입니다. 둘 다 SSE(Server-Sent Events)를 쓰지만 세부가 다릅니다.
- OpenAI 계열:
data:줄만 씁니다. 스트림 끝에data: [DONE]이라는 명시적 종료 신호를 보냅니다. - Anthropic 계열:
event:줄로 이벤트 타입을 먼저 알려줍니다(message_start,content_block_delta등).[DONE]같은 종료 마커는 없고 연결 종료로 끝납니다.
여기서 흔히 저지르는 실수가 프로바이더마다 SSE 파서를 새로 짜는 것입니다. 그럴 필요 없습니다. 스캐너 하나가 두 형식을 다 처리할 수 있습니다.
// SSE 스트림을 줄 단위로 읽어 데이터 페이로드를 뽑는다.
// event: 타입과 data: 페이로드를 모두 추적해 여러 프로바이더가 공유한다.
func (s *SSEScanner) Next() bool {
for s.scanner.Scan() {
line := s.scanner.Text()
// 이벤트 타입 추적 (Anthropic 계열이 사용)
if after, ok := strings.CutPrefix(line, "event:"); ok {
s.eventType = strings.TrimSpace(after)
continue
}
// 데이터 페이로드 추출
var payload string
if after, ok := strings.CutPrefix(line, "data: "); ok {
payload = after
} else if after, ok := strings.CutPrefix(line, "data:"); ok {
payload = after
} else {
continue // 빈 줄, 주석, 기타 필드는 건너뛴다
}
// [DONE]은 OpenAI 계열의 종료 마커
if payload == "[DONE]" {
return false
}
s.data = payload
return true
}
s.err = s.scanner.Err()
return false
}
공유 스캐너가 줄 단위 프로토콜만 처리하고, 페이로드의 의미 해석은 각 어댑터의 FromStreamChunk가 맡습니다. 관심사가 깔끔하게 갈립니다.
실전 함정 하나: 스캐너 버퍼 크기를 반드시 키우세요. Go의 bufio.Scanner는 기본 최대 줄 길이가 64KB입니다. 이미지가 담긴 응답이나 긴 툴 호출 인자는 이걸 넘겨 스트림이 조용히 끊깁니다. 원인을 찾기 아주 힘든 버그입니다.
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, initBufSize), maxBufSize) // 기본 64KB로는 부족하다
6. 레지스트리 — 이름으로 찾고, 없으면 폴백
구현체가 여럿이면 조회 지점이 필요합니다. 여기에 계층적 폴백을 넣으면 멀티테넌트 환경에서 유용합니다. 테넌트가 자기 키로 등록한 프로바이더가 있으면 그걸 쓰고, 없으면 공용 설정으로 떨어집니다.
type Registry struct {
providers map[string]Provider // "테넌트/이름" 키
mu sync.RWMutex
}
func (r *Registry) GetForTenant(tenantID uuid.UUID, name string) (Provider, error) {
r.mu.RLock()
defer r.mu.RUnlock()
// 1) 테넌트 전용 등록을 먼저 찾는다
if tenantID != MasterTenantID {
if p, ok := r.providers[key(tenantID, name)]; ok {
return p, nil
}
}
// 2) 없으면 공용(기본) 설정으로 폴백
if p, ok := r.providers[key(MasterTenantID, name)]; ok {
return p, nil
}
return nil, fmt.Errorf("provider not found: %s", name)
}
등록 교체 시 이전 인스턴스를 닫아 주는 것도 잊지 마세요. HTTP 클라이언트, 커넥션 풀, 장기 실행 프로세스를 들고 있는 구현체가 있으면 조용히 샙니다.
func (r *Registry) Register(tenantID uuid.UUID, p Provider) {
r.mu.Lock()
defer r.mu.Unlock()
k := key(tenantID, p.Name())
if old, ok := r.providers[k]; ok {
if c, ok := old.(io.Closer); ok {
c.Close() // 교체 전 정리 — 리소스 누수 방지
}
}
r.providers[k] = p
}
새 프로바이더를 붙일 때 실제로 겪는 것들
- 툴 스키마 방언 — 같은 JSON Schema라도 지원 범위가 다릅니다.
oneOf를 거부하거나, 최상위가 반드시object여야 하거나, 특정 키워드에서 400을 뱉습니다. 스키마 정규화 계층을 따로 두는 게 결국 필요해집니다. - 툴 호출 ID 규칙 — 여러 번 반복하는 에이전트 루프에서 ID가 중복되면 400을 뱉는 구현이 있습니다. 반복 인덱스를 섞어 유일화하세요.
- 사고(reasoning) 블록 되돌려주기 — 확장 사고를 쓰는 모델은 다음 요청에 사고 블록을 서명과 함께 그대로 돌려줘야 하는 경우가 있습니다. 이 원본을 보존할 자리를 응답 구조체에 미리 만들어 두세요.
- "호환 API"를 표방하는 프록시 — 모르는 필드를 받으면 400을 뱉는 경우가 흔합니다. 선택적 필드는 네이티브 엔드포인트에만 넣으세요.
정리
- 공통 인터페이스는 모두가 진짜 할 수 있는 것만 — 네 개면 충분하다
- 확장 기능은 선택적 인터페이스로 — 새 능력이 기존 구현체를 건드리지 않는다
- 런타임 분기용 능력은 선언적 구조체로 노출
- 변환과 전송을 분리하면 HTTP 없이 파싱을 테스트할 수 있다
- SSE는 줄 단위 파싱을 공유하고 의미 해석만 어댑터별로 — 버퍼 크기는 반드시 키울 것
- 레지스트리는 계층적 폴백 + 교체 시 정리
어댑터 패턴의 목적은 우아함이 아니라 변경 격리입니다. 프로바이더 하나가 API를 바꿨을 때 고칠 파일이 하나인가, 스무 개인가 — 실제로 그 차이만 남습니다.
댓글
댓글 쓰기