AI API와의 사투를 멈추고 깔끔한 통합 레이어를 구축한 방법
요약
다양한 AI API(OpenAI, Claude, 로컬 모델 등)를 사용할 때 발생하는 코드 복잡성과 유지보수 문제를 해결하기 위해 가벼운 어댑터 패턴을 도입하는 방법을 설명합니다. LangChain과 같은 무거운 프레임워크 대신, 핵심 기능에 집중한 얇은 인터페이스를 구축하여 확장성을 확보하는 과정을 다룹니다.
핵심 포인트
- 다양한 AI 제공업체 통합 시 발생하는 스파게티 코드 문제 해결
- LangChain 등 무거운 프레임워크의 과도한 추상화 문제 지적
- 어댑터 패턴을 통한 메시지 전송, 응답 처리, 에러 관리의 표준화
- 설정과 로직을 분리하여 유지보수 가능한 AI 통합 레이어 구축
제가 한계에 부딪혔던 그날을 기억합니다.
사용자가 제출한 콘텐츠를 요약하고, 후속 질문을 던진 뒤, 구조화된 보고서를 생성해야 하는 기능을 만들고 있었습니다. 세 가지 서로 다른 AI 작업이 필요했고, 저는 이미 세 개의 서로 다른 제공업체를 사용하고 있었습니다. 요약을 위해서는 OpenAI를, 추론을 위해서는 Claude를, 민감한 데이터를 위해서는 로컬 모델을 사용했죠. 제 코드베이스는 API 키, 속도 제한 (rate-limit) 재시도, 그리고 일관성 없는 에러 처리(error handling)가 뒤엉킨 스파게티 접시처럼 보였습니다.
새로운 AI 기반 기능을 추가하고 싶을 때마다, 저는 똑같은 HTTP 클라이언트 설정을 복사해서 붙여넣어야 했고, 서로 다른 응답 형식을 파싱해야 했으며, try-except 블록이 모든 것을 잡아내기만을 기도해야 했습니다. 그것은 취약했습니다. 추했습니다. 저는 더 나은 방법이 분명히 있을 것이라는 점을 알고 있었습니다.
시도했지만 실패했던 것들
통합된 제3자 SDK (third-party SDK)
처음에는 이렇게 생각했습니다. “그냥 여러 제공업체를 지원하는 라이브러리 중 하나를 쓰자.” LangChain을 시도해 보았지만, 단순히 API를 호출하기 위해 새로운 프레임워크를 배우고 있는 듯한 기분이 들었습니다. 추상화(abstraction)가 너무 두꺼워서 400 에러를 디버깅하려면 내부 코드의 5개 레이어를 통과해야 했습니다. 게다가 저에게는 생각의 사슬 (chain-of-thought)이나 에이전트 (agents)가 필요하지 않았습니다. 저는 그저 HTTP 호출을 하고 싶었을 뿐입니다.
일회성 헬퍼 함수 (helper function)
그다음에는 단일 call_ai(prompt, provider) 함수를 작성했습니다. 일주일 동안은 잘 작동했습니다. 그러다 스트리밍 (streaming)이 필요해졌습니다. 그다음에는 시스템 메시지 (system messages)를 전달해야 했습니다. 그러다 각 제공업체마다 컨텍스트 윈도우 (context windows)를 처리하는 방식이 제각각이라는 것을 깨달았습니다. 함수는 곳곳에 if provider == 'openai'가 박힌 200줄짜리 괴물이 되었습니다. 지속 가능하지 않았습니다.
YAML을 이용한 설정 기반 접근 방식
YAML 파일에 모델을 선언하고 리플렉션 (reflection)을 사용하여 클라이언트를 인스턴스화하는 방식을 시도했습니다. 영리한 방법이었을까요? 아마도요. 하지만 유지보수가 가능했을까요? 두 명 규모의 팀에게는 아니었습니다. 너무 많은 것을 추상화하여 실제로 네트워크상에서 어떤 일이 일어나고 있는지 이해하기 어렵게 만들었습니다.
결국 성공한 방법: 가벼운 어댑터 패턴 (adapter pattern)
저는 한 걸음 물러나 질문했습니다. “AI 모델로부터 내가 항상 필요로 하는 단 한 가지는 무엇인가?”
- 메시지 전송 (텍tx 또는 구조화된 데이터)
- 응답 받기 (텍스트, JSON 또는 스트림)
- 에러 처리 (속도 제한 (rate limits), 인증 (auth), 타임아웃 (timeouts))
- 사용량 로깅 (토큰 (tokens), 비용 (cost))
그게 전부입니다. 그 외의 모든 것(모델 이름, 최대 토큰 (max tokens), 온도 (temperature))은 설정 (configuration)의 영역입니다.
그래서 저는 얇은 어댑터 인터페이스 (adapter interface)를 구축했습니다. 다음은 Python으로 작성된 핵심 코드입니다 (동일한 패턴이 JavaScript나 Go에서도 작동합니다):
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, AsyncIterator, Optional
...
그 다음 OpenAI 어댑터를 구현했습니다:
import openai
from openai import AsyncOpenAI
...
Claude의 경우, Anthropic의 SDK를 사용하는 유사한 어댑터를 사용했습니다. 로컬 모델 (Ollama와 같은)의 경우, 간단한 HTTP 요청 어댑터를 사용했습니다.
이제 AI가 필요한 애플리케이션의 어떤 부분이라도 그 뒤에 어떤 제공자 (provider)가 있는지 알 필요 없이, 단지 BaseLLMAdapter에만 의존하게 됩니다. 저는 시작 시점에 이를 설정합니다:
# main.py (발췌)
from adapters import OpenAIAdapter, ClaudeAdapter, LocalAdapter
...
배운 점과 트레이드오프 (trade-offs)
이 패턴은 90%의 사용 사례에서 훌륭하게 작동합니다. 하지만 만능 해결책 (silver bullet)은 아닙니다.
- 도구 호출 (Tool calling)? 포함되지 않았습니다. 만약 애플리케이션이 함수 호출 (function calling)이나 도구를 집중적으로 사용한다면, 각 제공자마다 스키마 (schema)가 다릅니다. 어댑터를 확장하거나, 특정 제공자 전용 인터페이스가 필요할 수 있습니다.
- 비전/멀티모달 (Vision/multimodal)?
messages형식이 제각각입니다 (OpenAI는 타입이 포함된 콘텐츠 배열을 사용하고, Anthropic은 다른 구조를 사용합니다). 제 어댑터는 텍스트 전용을 가정하므로,image_bytes를 위한 선택적 키워드 인자 (optional kwargs)를 추가해야 했습니다. - 스트리밍 균일성 (Streaming uniformity): OpenAI는 델타 콘텐츠 (delta content)를 스트리밍하고, Anthropic은 전체 블록 (whole blocks)을 스트리밍합니다. 어댑터의
AsyncIterator[str]가 이 차이를 숨겨주지만, 블록 수준의 메타데이터 (metadata)를 잃게 됩니다.
다음에 다시 한다면 무엇을 다르게 할까요? 저는 첫날부터 이 어댑터 패턴을 사용할 것입니다. 또한 CI에서 각 제공자를 대상으로 실행되는 통합 테스트 (integration tests)를 작성할 것입니다 (제한된 API 키를 사용하여). 그리고 어댑터 인터페이스에 버전을 매길 것입니다. 제공자가 5개 정도 되면 시그니처 (signature)를 변경하는 것은 매우 고통스러운 일이기 때문입니다.
만약 여러분의 AI 통합 과정이 저의 경험과 비슷하다면, 아마 여러분도 리팩터링 (refactor) 한 번이면 정신줄을 놓을 것 같은 아슬아슬한 상태일 것입니다. 여러분은 AI 호출을 깔끔하게 유지하기 위해 결국 어떤 패턴을 사용하셨나요? 여러분의 이야기를 듣고 싶습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기