Adapter 패턴

“인터페이스(Interface, 외부와 주고받는 약속된 신호 규격)가 서로 다른 두 코드를 그대로 이어붙이는 구조 패턴.”

Adapter 패턴(어댑터 패턴, 인터페이스 변환기)은 GoF 구조(Structural) 패턴 중 하나다. 이미 존재하지만 내가 원하는 모양이 아닌 클래스를, 수정하지 않고 내 시스템이 기대하는 인터페이스로 감싸서 쓰게 해준다. Wrapper(래퍼, 포장) 패턴이라는 별칭으로도 자주 불린다.

한눈에 보기

항목내용
분류구조(Structural) 패턴
목적호환되지 않는 인터페이스를 원하는 인터페이스로 변환
핵심 질문”이미 잘 동작하는 라이브러리인데, 시그니처가 안 맞아서 못 쓰고 있나?”
변형클래스 어댑터(상속 기반), 객체 어댑터(합성 기반)
트레이드오프래퍼 클래스 한 겹 더 생김. 대신 원본을 건드리지 않음

한마디 요약: “콘센트가 다르면 콘센트를 갈지 말고, 어댑터를 끼워라.”


일상 비유: 해외여행 전원 어댑터

한국 110/220V 기기를 들고 영국(BS1363 플러그) 호텔에 갔다고 하자. 드라이어의 콘센트 모양을 깎아서 바꿀 수는 없다. 대신 “한쪽은 한국 플러그, 반대쪽은 영국 플러그”인 어댑터 젠더(Adapter Gender)를 끼운다. 드라이어 내부는 하나도 안 바꾸고, 호텔 콘센트도 안 바꿨다. 가운데 껴 있는 어댑터가 모양 변환을 담당한다.

코드 세계도 똑같다. “라이브러리의 인터페이스”를 못 바꾸고 “내 애플리케이션의 인터페이스”도 이미 결정됐을 때, 가운데에 Adapter 클래스를 둬서 변환한다.


구조

역할설명
Target클라이언트가 기대하는 인터페이스 (우리 쪽 규격)
Adaptee가져다 쓰고 싶은 기존 클래스 (상대 쪽 규격)
AdapterTarget을 구현하면서 내부적으로 Adaptee를 호출해 변환
ClientTarget 인터페이스만 바라봄. Adaptee의 존재는 모름

단계 분해:

  1. 1단계: Client 코드는 Target 인터페이스에만 의존
  2. 2단계: 실제로 쓰고 싶은 외부 클래스(Adaptee)의 메서드 이름, 인자 구조는 다름
  3. 3단계: Adapter가 Target을 구현하고, 메서드 내부에서 Adaptee의 호출로 변환해 돌려줌

클래스 어댑터 vs 객체 어댑터

구분클래스 어댑터객체 어댑터
결합 방식Adaptee를 상속Adaptee를 필드로 가짐 (합성)
유연성런타임 교체 불가런타임 교체 가능
다중 상속언어 지원 필요(Python 가능, Java 불가)어디서나 가능
권장도낮음높음 (대부분 경우 합성 선호)

AI 에이전트 개발 예시 1: LLM Provider 통일

OpenAI, Anthropic, Cohere, 로컬 Ollama마다 SDK 호출 시그니처가 다 다르다. 애플리케이션 안에서 “LLM을 바꿔도 호출부는 똑같이 쓰고 싶다”면 Adapter가 정답이다.

상황: 시그니처가 제각각

# OpenAI
openai.ChatCompletion.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "hi"}],
)
 
# Anthropic
anthropic.messages.create(
    model="claude-opus-4-7",
    max_tokens=1024,
    messages=[{"role": "user", "content": "hi"}],
)
 
# Cohere
cohere.chat(
    model="command-r",
    message="hi",
    chat_history=[],
)

호출 함수명도, 인자 이름도, 응답 객체 구조도 제각각. Client 코드가 이걸 다 알면 LLM 교체가 지옥이 된다.

Target 정의

from abc import ABC, abstractmethod
from dataclasses import dataclass
 
@dataclass
class ChatMessage:
    role: str
    content: str
 
@dataclass
class ChatResponse:
    text: str
    input_tokens: int
    output_tokens: int
 
class ChatModel(ABC):
    @abstractmethod
    def chat(self, messages: list[ChatMessage]) -> ChatResponse: ...

Adapter 구현

class OpenAIAdapter(ChatModel):
    def __init__(self, client, model: str):
        self._client = client
        self._model = model
 
    def chat(self, messages):
        raw_messages = [{"role": m.role, "content": m.content} for m in messages]
        resp = self._client.chat.completions.create(
            model=self._model,
            messages=raw_messages,
        )
        return ChatResponse(
            text=resp.choices[0].message.content,
            input_tokens=resp.usage.prompt_tokens,
            output_tokens=resp.usage.completion_tokens,
        )
 
 
class AnthropicAdapter(ChatModel):
    def __init__(self, client, model: str, max_tokens: int = 1024):
        self._client = client
        self._model = model
        self._max_tokens = max_tokens
 
    def chat(self, messages):
        raw_messages = [{"role": m.role, "content": m.content} for m in messages]
        resp = self._client.messages.create(
            model=self._model,
            max_tokens=self._max_tokens,
            messages=raw_messages,
        )
        return ChatResponse(
            text=resp.content[0].text,
            input_tokens=resp.usage.input_tokens,
            output_tokens=resp.usage.output_tokens,
        )

클라이언트 코드는 깨끗

def run_agent(llm: ChatModel, question: str) -> str:
    response = llm.chat([ChatMessage(role="user", content=question)])
    print(f"cost tokens: in={response.input_tokens}, out={response.output_tokens}")
    return response.text
 
# 전환이 자유로움
llm = OpenAIAdapter(openai_client, "gpt-4o")
# 또는
llm = AnthropicAdapter(anthropic_client, "claude-opus-4-7")
 
print(run_agent(llm, "Hello"))

run_agent 코드는 LLM 업체와 무관하다. 이것이 Adapter의 진가다.


AI 에이전트 개발 예시 2: Vector Store 통일

Pinecone, Weaviate, Chroma, FAISS도 같은 문제. 각자 upsert, insert, add, index_put 등 이름도 다르다.

class VectorStore(ABC):
    @abstractmethod
    def upsert(self, vectors: list[tuple[str, list[float], dict]]) -> None: ...
    @abstractmethod
    def search(self, query: list[float], k: int = 5) -> list[str]: ...
 
 
class PineconeAdapter(VectorStore):
    def __init__(self, index):
        self._index = index
 
    def upsert(self, vectors):
        self._index.upsert(vectors=[
            {"id": id_, "values": vec, "metadata": meta}
            for id_, vec, meta in vectors
        ])
 
    def search(self, query, k=5):
        res = self._index.query(vector=query, top_k=k)
        return [m["id"] for m in res["matches"]]
 
 
class ChromaAdapter(VectorStore):
    def __init__(self, collection):
        self._collection = collection
 
    def upsert(self, vectors):
        ids = [id_ for id_, _, _ in vectors]
        embs = [vec for _, vec, _ in vectors]
        metas = [meta for _, _, meta in vectors]
        self._collection.upsert(ids=ids, embeddings=embs, metadatas=metas)
 
    def search(self, query, k=5):
        res = self._collection.query(query_embeddings=[query], n_results=k)
        return res["ids"][0]

실제 라이브러리에서의 Adapter

라이브러리적용
LangChainChatOpenAI, ChatAnthropic 모두 BaseChatModel이라는 Target을 구현. 내부는 각 SDK Adaptee를 감쌈
LlamaIndexLLM, Embedding, VectorStore 인터페이스에 각 업체별 Adapter 구현체 배치
Python os.path운영체제별(POSIX/Windows) 경로 처리 차이를 추상화
Java Arrays.asList()배열(Adaptee)을 List 인터페이스(Target)로 변환

직접 확인해 보기

프로젝트에서 외부 SDK 호출이 여러 파일에 흩어져 있는지 확인한다.

grep -rn "openai\." src/ | wc -l
grep -rn "anthropic\." src/ | wc -l

두 SDK 호출이 비즈니스 로직 여기저기에 섞여 있다면 Adapter 도입 신호다. 이상적으로는 Adapter 파일 안에서만 외부 SDK 이름이 등장해야 한다.


왜 이렇게 하는가

Adapter의 설계 의도와 대안 대비 장점:

  • 기존 코드 무수정(Open/Closed): Adaptee도 Client도 건드리지 않음. SOLID의 OCP(개방 폐쇄 원칙)에 정확히 부합
  • 테스트 용이: Target 인터페이스에 대해 FakeAdapter를 만들면 네트워크 없이 테스트 가능
  • 의존성 역전(DIP) 달성 수단: Client가 추상(Target)에만 의존하게 하는 실천 방법
  • 관심사 격리: “호출 변환”이라는 저수준 작업이 Adapter 안에 갇혀 있음

트레이드오프:

  • 클래스 개수 증가. LLM 업체 N개면 Adapter N개
  • 모든 업체의 최소 공통 분모(LCD, Least Common Denominator)만 노출하게 되면 고급 기능을 잃을 수 있음. 이 경우 extra_params: dict를 Target에 받아 Adapter마다 해석하도록 하는 절충안을 쓴다

포함 관계

  • Adapter ⊂ DIP(의존성 역전)의 실천 수단: Client가 Target이라는 추상에 의존하고, 구체는 Adapter들이 구현
  • Adapter vs Facade: Adapter는 “모양 변환”이 목적, Facade는 “복잡도 숨김”이 목적. 겉보기가 비슷하지만 의도가 다름. Facade 패턴 참고
  • Adapter vs Decorator: Adapter는 인터페이스를 바꾸고, Decorator는 인터페이스를 유지한 채 책임을 추가

한마디 요약

“원본을 못 고치거나 고치기 싫으면, 사이에 어댑터를 끼워 인터페이스만 맞춰라.”

관련 문서: