개발 slecs

LLM provider 교체에 유연한 추상화 구조 도입

목차

이번에 LLM provider를 추상화하는 작업을 했다. Claude, Anthropic, 그리고 로컬 모델로 쉽게 전환할 수 있도록 기반을 다진 거다.

왜 추상화를 먼저 준비했나

프로젝트가 LLM에 의존할수록, 특정 provider에 종속되면 문제가 커진다. 초기 단계에는 한 가지 provider(보통 Claude 또는 Anthropic API)로 시작하는 게 맞다. 빠르게 검증할 수 있으니까. 하지만 규모가 커지면:

  • 비용 최적화: 작업마다 최저비용 provider 선택하고 싶어질 수 있음
  • 가용성 확보: 특정 provider 장애에 자동 폴백
  • 모델 다양성: 추론 작업엔 저가 모델, 복잡한 작업엔 고성능 모델 사용
  • vendor lock-in 해소: 새로운 provider가 더 좋은 조건을 내놨을 때 유연하게 대응

지금 당장 이 모든 기능이 필요하진 않겠지만, 한번 특정 provider에 코드가 깊숙이 박혀버리면 나중에 뜯어내는 비용이 비약적으로 올라간다. 그래서 아직 코드가 그리 많지 않을 때 이 구조를 먼저 깔아두는 게 현명한 선택이라 판단했다.

어떻게 추상화했나

변경 파일을 보면:

파일 역할 이번 변경의 의미
src/lib/llm.ts Provider 인터페이스 정의 새로 생성. 모든 provider가 따를 계약 정의
src/lib/chat.ts 채팅 기능 구현 LLM 호출을 추상화된 인터페이스로 변경
.env.example 설정 템플릿 provider 선택 및 API 키 설정 예시 추가

핵심은 interface-based abstraction이다. 대충 이런 구조:

// src/lib/llm.ts - 추상화 계층
interface LLMProvider {
  sendMessage(prompt: string): Promise<string>;
  // 필요시 다른 메서드들
}

class ClaudeProvider implements LLMProvider {
  async sendMessage(prompt: string): Promise<string> {
    // Claude API 호출
  }
}

class AnthropicProvider implements LLMProvider {
  async sendMessage(prompt: string): Promise<string> {
    // Anthropic API 호출
  }
}

class LocalProvider implements LLMProvider {
  async sendMessage(prompt: string): Promise<string> {
    // 로컬 모델 호출
  }
}

// Factory로 환경 변수에 따라 인스턴스 생성
export function createProvider(): LLMProvider {
  const type = process.env.LLM_PROVIDER;
  switch(type) {
    case 'claude': return new ClaudeProvider();
    case 'anthropic': return new AnthropicProvider();
    case 'local': return new LocalProvider();
    default: throw new Error(`Unknown provider: ${type}`);
  }
}

이렇게 하면 src/lib/chat.ts 같은 비즈니스 로직은 provider 구현 세부사항을 몰라도 된다. 추상화된 인터페이스만 알면 된다.

실무에서 배운 점들

일찍 준비할수록 비용이 싸다

언뜻 "지금은 한 가지 provider만 쓰는데 왜 이런 걸 하냐"는 생각이 들 수 있다. 나도 처음에는 그랬다. 하지만 경험상 이런 종류의 abstraction은 아주 early stage에서 걸어두는 게 비용-효과가 제일 좋다. 나중에 갑자기 "provider 전환해야 한다"고 요구사항이 들어올 때 "이미 깔아뒀습니다"라고 말할 수 있으니까.

인터페이스 설계가 장기 유지보수를 좌우한다

provider별로 미묘한 차이가 있다. response format, error handling, rate limiting, streaming 등. 인터페이스를 너무 특정 provider에 맞게 설계하면 다른 provider 추가할 때 기존 interface를 뜯어고쳐야 한다. 반대로 너무 generic하면 각 provider의 강점을 못 살린다. 이 balance를 맞추는 게 핵심이다. 이번에는 일단 basic interface부터 시작해서, 나중에 필요하면 provider-specific extension으로 확장하기로 했다.

테스트와 로컬 개발 경험이 개선된다

로컬 provider 추상화를 추가하면 개발 환경에서 테스트할 때 API 호출과 비용을 줄일 수 있다. 이건 부수 효과지만, 팀원들의 온보딩 속도나 CI/CD 속도에도 긍정적 영향을 미친다.

Configuration 문서화가 중요하다

.env.example 업데이트는 작지만 무시할 수 없다. 팀원이 repository를 clone했을 때 어떤 환경 변수를 설정해야 하는지 명확히 알아야 한다. "LLM_PROVIDER를 설정하세요"라는 한 줄이 미리 있으면, 나중의 삽질을 많이 막을 수 있다.

결론적으로, 작은 abstraction 하나가 프로젝트의 확장성과 유지보수 난이도에 꽤 큰 영향을 미친다는 걸 다시 한 번 체감했다. 끝.


🛒 이 글과 어울리는 추천 상품

*위 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.

댓글 0

첫 댓글 달아줘.