---
title: "Provider API를 제품 로직에서 분리하는 법"
slug: "08-api-boundary"
canonicalUrl: "https://moonshotnotes.com/posts/08-api-boundary/"
sourceUrl: "https://moonshotnotes.com/posts/08-api-boundary/"
markdownUrl: "https://moonshotnotes.com/agent/posts/08-api-boundary.md"
language: "ko"
category: "AI Agent"
updatedAt: "2026-05-26"
agentTokenEstimate: 1896
---

# Provider API를 제품 로직에서 분리하는 법

Claude Code CLI 분석을 바탕으로 model provider API 요청, streaming 응답, tool schema, usage/cost 처리를 runtime boundary로 분리하는 방법을 설명합니다.

## Agent metadata

- Source: https://moonshotnotes.com/posts/08-api-boundary/
- Markdown: https://moonshotnotes.com/agent/posts/08-api-boundary.md
- Language: ko
- Category: AI Agent
- Tags: Claude Code, AI Agent, Runtime, CLI
- Updated: 2026-05-26
- Estimated tokens: 1896

## 핵심 요약

- Provider API boundary는 단순 SDK wrapper가 아니다.
- 내부 message, tool schema, system context, model options를 provider payload로 번역한다.
- streaming response는 내부 runtime event로 변환되어야 한다.
- provider SDK 타입이 session shell, tool runtime, ledger 전체로 새면 유지보수가 어려워진다.

이번 글에서는 model provider API boundary를 보겠습니다.

agent runtime은 당연히 모델 API에 의존합니다. 하지만 제품 전체가 provider SDK의 타입과 event 이름에 끌려가면 안 됩니다. provider를 바꾸거나 fallback을 추가하거나 streaming 처리 방식을 바꿀 때 앱 전체가 흔들리기 때문입니다.

Claude Code류의 agent를 runtime 관점으로 보면 모델 호출 경계는 단순 wrapper가 아닙니다. 내부 message와 provider payload 사이를 번역하고, tool schema를 고르고, streaming response를 runtime event로 바꾸고, usage와 cost를 추적하는 계층입니다.

## 1. Provider SDK 타입이 앱 전체로 새면 생기는 문제

처음에는 provider SDK 타입을 그대로 쓰는 것이 편합니다. 하지만 agent가 커지면 문제가 됩니다.

- session shell이 provider event 이름을 알아야 한다.
- ledger가 provider usage 형식을 직접 읽어야 한다.
- tool runtime이 provider tool schema 형식에 묶인다.
- fallback model을 추가하려면 모든 계층을 수정해야 한다.
- 테스트에서 provider stream을 mocking하기 어렵다.

따라서 내부 runtime은 자체 event vocabulary를 가져야 합니다.

```text
# 읽는 법: 아래 항목은 동작 흐름을 빠르게 확인하기 위한 요약 예시입니다.
provider stream event
→ provider adapter
→ runtime event
→ shell / ledger / accounting / tool loop
```

## 2. API boundary의 책임

| 책임 | 설명 |
|---|---|
| message encoding | 내부 message를 provider payload로 변환 |
| system context assembly | system prompt, policy hint, workspace context 조립 |
| tool schema selection | 현재 turn에서 노출할 도구 schema 선택 |
| option mapping | model, token limit, output format, budget hint 변환 |
| stream translation | provider event를 runtime event로 변환 |
| usage accounting | token/cache/cost 정보를 내부 형식으로 누적 |
| error handling | retry, fallback, non-streaming path 추상화 |

이 계층이 있으면 model provider가 바뀌어도 agent runtime의 다른 계층은 그대로 유지될 가능성이 높습니다.

## 3. Message encoding과 tool schema selection

모든 도구 schema를 항상 모델에게 보내는 것은 비용 면에서 좋지 않습니다. 반대로 필요한 도구가 누락되면 모델이 적절한 요청을 만들 수 없습니다.

따라서 API boundary는 현재 turn에 필요한 도구를 고르는 정책을 가져야 합니다.

| 도구 종류 | 노출 전략 |
|---|---|
| 기본 read-only 도구 | 대부분의 turn에서 노출 가능 |
| write 도구 | 권한 모드와 사용자 요청에 따라 제한 |
| 고비용 외부 도구 | 명시적 요청 또는 command에서만 노출 |
| plugin 도구 | availability와 policy 통과 후 노출 |

tool schema selection은 tool runtime과 연결되지만, provider payload에 넣는 최종 조립은 API boundary에서 하는 편이 깔끔합니다.

## 4. Stream event translation

provider마다 stream event 구조가 다를 수 있습니다. 내부 runtime은 다음 정도의 공통 event만 알면 됩니다.

| 내부 event | 의미 |
|---|---|
| `assistant_started` | assistant response 시작 |
| `assistant_text_delta` | 부분 텍스트 |
| `tool_request_ready` | 실행 가능한 tool request 생성 |
| `usage_updated` | 사용량 갱신 |
| `assistant_finished` | assistant response 종료 |
| `provider_error` | provider 오류 |

이렇게 하면 session shell은 “부분 텍스트를 표시한다”만 알면 되고, provider가 어떤 raw event를 줬는지는 몰라도 됩니다.

## 5. Usage와 cost accounting

streaming API에서는 usage 정보가 마지막에만 오지 않을 수 있습니다. 중간 event로 갱신될 수도 있고, provider마다 cache token이나 reasoning token 같은 세부 항목을 다르게 표현할 수 있습니다.

따라서 usage는 final response에서 한 번 읽는 것이 아니라 stream 중간에 계속 흡수하는 구조가 좋습니다.

> **주의:**
> 장기 실행 agent에서 비용 추적을 마지막에만 계산하면 중간 취소, retry, fallback, compaction 상황에서 오차가 커질 수 있습니다. budget enforcement가 필요하다면 stream 중간 usage update를 처리해야 합니다.

## 6. 개념 코드로 보는 provider adapter

아래 코드는 설명용으로 새로 작성한 코드입니다.

```python
# 읽는 법: 실제 구현 복제가 아니라 runtime 경계를 설명하는 개념 코드입니다.
class ProviderAdapter:
    # 객체가 이후 단계에서 참조할 runtime 의존성과 상태 저장소를 초기화합니다.
    def __init__(self, client, encoder, pricing):
        self.client = client
        self.encoder = encoder
        self.pricing = pricing

    # runtime message와 tool context를 provider가 이해하는 요청 payload로 인코딩합니다.
    def build_request(self, messages, tool_context):
        return {
            "messages": self.encoder.encode_messages(messages),
            "tools": self.encoder.encode_tools(select_tools(tool_context)),
            "system": self.encoder.encode_system(tool_context.system_context),
            "options": self.encoder.encode_options(tool_context.model_options),
        }

    # provider stream을 순회하면서 raw event를 runtime event로 변환해 내보냅니다.
    async def stream(self, request):
        async for raw in self.client.open_stream(request):
            event = self.translate(raw)
            if event is not None:
                yield event

    # provider별 event 종류를 제품 내부에서 쓰는 표준 RuntimeEvent로 매핑합니다.
    def translate(self, raw):
        if raw.kind == "text_piece":
            return RuntimeEvent("assistant_text_delta", text=raw.value)

        if raw.kind == "tool_call_done":
            return RuntimeEvent("tool_request_ready", tool_request=decode_tool_request(raw))

        if raw.kind == "usage":
            normalized = normalize_usage(raw.usage)
            return RuntimeEvent("usage_updated", usage=normalized)

        if raw.kind == "done":
            return RuntimeEvent("assistant_finished", reason=raw.reason)
```

여기서 provider adapter는 내부 runtime event만 밖으로 내보냅니다. 이 contract가 안정적이면 provider 교체가 쉬워집니다.

## 7. AI 활용 개발자 관점

AI 도구 사용자는 provider boundary를 직접 보지는 못합니다. 하지만 다음 현상으로 품질을 느낄 수 있습니다.

- 모델이 바뀌어도 UI와 도구 실행 흐름이 안정적인가?
- stream 중간에 비용 정보가 업데이트되는가?
- 도구 schema가 너무 많아 모델이 엉뚱한 도구를 고르지 않는가?
- provider 오류가 발생했을 때 사용자에게 이해 가능한 메시지가 나오는가?
- fallback이 있더라도 transcript와 비용 기록이 일관적인가?

## 8. Agent 개발자 체크리스트

```text
# 읽는 법: 아래 항목은 동작 흐름을 빠르게 확인하기 위한 요약 예시입니다.
Provider API Boundary 체크리스트

[ ] provider SDK 타입이 session shell과 tool runtime으로 새지 않는다.
[ ] 내부 message를 provider payload로 바꾸는 단일 조립 지점이 있다.
[ ] tool schema selection 정책이 명시되어 있다.
[ ] stream event는 내부 RuntimeEvent로 변환된다.
[ ] usage/cost는 stream 중간에도 갱신된다.
[ ] retry/fallback/non-streaming path가 같은 event contract를 유지한다.
[ ] provider별 feature 차이는 adapter 내부에 격리된다.
```

## 실패 사례: provider SDK 타입이 제품 전체로 번진 경우

초기에는 provider SDK의 message 타입을 그대로 쓰는 것이 빠릅니다. `messages`, `tools`, `stream`, `usage`가 이미 있으니 내부 타입을 따로 만들 필요가 없어 보입니다. 하지만 두 번째 provider를 붙이거나, streaming과 non-streaming fallback을 같이 운영하거나, tool schema를 workspace별로 다르게 선택하기 시작하면 SDK 타입은 제품 로직 전체를 묶어버립니다.

예를 들어 UI가 provider의 chunk event 이름을 직접 알고 있으면 provider를 바꾸는 순간 UI도 바뀝니다. ledger가 provider별 usage 필드를 직접 저장하면 비용 리포트가 provider마다 다른 의미를 갖습니다. tool runtime이 provider tool schema를 직접 만들면 같은 도구도 provider별로 다른 validation 규칙을 갖게 됩니다. 결국 adapter가 아니라 제품 전체가 provider integration이 됩니다.

## 비교표: 얇은 wrapper와 진짜 boundary

| 구분 | 얇은 SDK wrapper | Provider API boundary |
|---|---|---|
| 입력 | 앱 message를 거의 그대로 전달 | 내부 message를 provider payload로 변환 |
| 출력 | provider event를 그대로 노출 | RuntimeEvent로 정규화 |
| tool schema | 호출 지점마다 조립 | adapter 진입 전 선택 후 변환 |
| 비용 | provider usage 필드 직접 사용 | 내부 UsageSnapshot으로 변환 |
| fallback | 별도 코드 경로 | 같은 event contract 유지 |

이 차이는 운영 중에 크게 드러납니다. provider timeout이 발생했을 때 boundary가 있으면 retry 정책, 사용자 메시지, ledger 기록을 같은 방식으로 처리할 수 있습니다. wrapper만 있으면 각 화면과 서비스 함수가 provider 오류를 제각각 해석합니다.

## 구현 예시: 내부 event contract

```ts
type RuntimeEvent =
  | { type: "assistant_text_delta"; turnId: string; text: string }
  | { type: "tool_request"; turnId: string; callId: string; name: string; input: unknown }
  | { type: "usage"; turnId: string; inputTokens: number; outputTokens: number; cachedInputTokens?: number }
  | { type: "provider_error"; turnId: string; retryable: boolean; message: string };
```

Provider adapter는 OpenAI, Anthropic, local model 같은 외부 형식을 이 contract로 바꿉니다. 나머지 제품은 provider별 chunk 이름을 모릅니다. 덕분에 UI는 streaming delta를 표시하고, ledger는 usage를 저장하고, tool runtime은 request를 처리하는 데 집중할 수 있습니다. provider 교체는 여전히 어렵지만 blast radius가 adapter 경계 안으로 줄어듭니다.

## 마무리

Provider API boundary는 보이지 않지만 agent 제품성을 크게 좌우합니다. 이 경계가 얇고 명확해야 provider 변경, fallback, 비용 추적, tool schema 최적화를 안정적으로 할 수 있습니다.

다음 글에서는 tool runtime을 보겠습니다. 모델이 도구를 요청했다고 바로 실행하면 안 됩니다. registry, schema validation, permission, orchestration이 필요합니다.
