우리가 SDK 사용을 중단한 이유

@alvinsng
영어2일 전 · 2026년 7월 14일
435K
674
52
55
1.0K

TL;DR

Alvin Sng이 디버깅을 개선하고, 관측 가능성을 통합하며, 불필요한 의존성을 제거하기 위해 팀이 벤더 SDK를 커스텀 HTTP 클라이언트로 교체한 이유를 설명합니다.

우리는 더 이상 Stripe, WorkOS, Slack 클라이언트 SDK를 사용하지 않고 있으며, 나머지도 마이그레이션하는 중입니다. 대신, HttpBaseClient라는 작은 래퍼 클래스를 통해 REST API를 직접 호출합니다.

역설적으로 들리겠죠. SDK는 시간을 절약해 주기 위해 만들어진 건데요. 하지만 AI가 더 많은 코드를 네이티브로 실행할 수 있도록 포팅하고 있다는 얘기를 들어보셨을 겁니다. SDK에도 같은 아이디어가 적용됩니다. 왜일까요?

  • SDK는 HTTP 응답 헤더와 원시 응답 본문 같은 원시 세부 정보를 숨깁니다. 이러한 정보는 문제를 디버깅하거나 업스트림 팀에 조사 세부 사항을 보내는 에이전트에게 중요합니다.
  • SDK는 적절한 응답을 기대하지만, 실제로는 방화벽, 로드 밸런서, 게이트웨이에서 잘못된 응답이 반환됩니다. 이로 인해 우리가 필요한 디버깅 세부 정보가 가려집니다.
  • SDK는 재시도, 오류 처리, 관찰 가능성을 위한 중앙 집중식 진입점의 강제 기능을 우회하여 에이전트가 자신의 패턴을 재작성할 수 있게 합니다.
  • SDK는 일반적으로 OpenAPI에서 자동 생성된 무거운 블로트(bloat)를 포함하며, 우리는 극히 일부의 엔드포인트만 사용합니다.

SDK가 왜 시작되었는지 생각해보세요. SDK는 회사가 채택을 쉽게 만들기 위해 탄생했습니다: 공유 라이브러리를 제공하고, 고객의 시간을 절약하며, 반복적인 통합 작업을 숨기기 위해서입니다. 하지만 AI가 그 비용 곡선을 바꿨습니다. 이제 SDK와 통합하는 것은 HTTP API를 직접 호출하는 것만큼이나 많은 작업이 필요한 경우가 많으며, 정확히 우리가 사용하는 엔드포인트에 맞춰진 REST 클라이언트를 작성하는 것이 더 저렴하면서도 더 나은 통합 관찰 가능성을 제공합니다.

끝없는 두더지 잡기 게임

Alvin Sng - inline image

참 아름다운 'JSON' 응답이군요

이것이 우리 Sentry 오류 대시보드가 예전에 보이던 모습입니다. 일부 코드 경로에서 소수의 요청이 실패하는 것을 발견하면, 그 경로만 몽키 패치하여 사용자를 위해 SDK 문제를 처리하곤 했습니다. 매일 다른 코드 경로에서 예상치 못한 오류가 발생했고, 우리는 그 경로도 패치했으며, 이 게임은 끝나지 않았습니다.

SDK는 일반적인 프로덕션 실패 모드에서 취약합니다: 서버가 무언가 잘못되었다고 말하지만, 응답이 SDK가 예상한 JSON 형태가 아닌 경우입니다.

과부하된 Nginx 게이트웨이가 예상치 못한 HTML을 반환합니다. Cloudflare가 요청을 차단합니다. 방화벽이 속도 제한 페이지를 표시합니다. 벤더 API는 일반적으로 JSON이지만, 그 앞에 있는 것은 항상 벤더 API가 아닙니다.

이런 일이 발생하면 많은 SDK가 응답을 파싱하려고 시도하다가 일반적인 파싱 오류로 실패하고 유용한 부분을 버립니다. 원시 본문은 사라지고, 상태 텍스트는 묻혀버립니다. HTTP 헤더는 종종 사용 가능한 방식으로 반환되지 않습니다. 벤더 지원팀이 HTTP 헤더에서 요청 ID를 요청하면, SDK가 이를 반환하지 않기 때문에 우리는 난처해집니다.

에이전트가 SDK의 직접성을 남용합니다

우리의 클라이언트 SDK 호출은 무법지대였습니다. Stripe는 하나의 패턴을, WorkOS는 또 다른 패턴을, Slack은 자체적인 특성을 가졌습니다. 다른 통합은 raw fetch, SDK 호출, 일회성 재시도 로직, 또는 재시도 로직이 전혀 없는 경우도 있었습니다.

SDK는 잘못된 것을 쉽게 보이게 만들었습니다. 에이전트는 항상 벤더 클라이언트에 직접 접근하여 어떤 라우트에서든 \stripe.customers.create(...)\를 호출할 수 있었습니다. 생산적으로 느껴지지만, 인증, 재시도, 메트릭, 로그, 오류 변환이 있어야 할 공유 장소를 우회합니다. 우리 코드베이스에는 다음과 같은 클로저 래퍼가 흩어져 있었습니다:

typescript
1const response = await catchRateLimitError(() =>
2 stripe.customers.retrieve(stripeCustomerId)
3);

한 군데라도 놓쳐서 SDK 호출을 래핑하는 것을 잊어버리면, 곤란한 상황이 발생했습니다. Stripe 속도 제한과 WorkOS 속도 제한은 우리 제품에 동일한 의미입니다: 업스트림이 속도를 늦추라고 요청하는 것입니다. 그러나 타입 수준에서는 완전히 다른 객체였습니다. 일부 코드는 SDK 특정 예외를 잡았고, 일부는 일반 Error를 잡았으며, 일부는 아무것도 잡지 않았습니다. 이는 Sentry 두더지 잡기 게임으로 변했습니다: 한 호출 지점에서 429를 수정하고, 다른 곳에서 동일한 유형의 실패를 기다리는 식이었습니다.

SDK는 비대합니다

NPM 패키지 openai 및 anthropic-ai/sdk는 Stainless에 의해 OpenAPI 스펙에서 자동 생성됩니다. Stripe도 Stripe의 OpenAPI 스펙에서 생성됩니다. 이것이 수십 개의 프로그래밍 언어에서 대규모 API를 위한 공개 SDK의 유지보수를 확장하는 방법입니다.

하지만 훌륭한 공개 SDK는 모든 사람을 만족시켜야 합니다. 우리 백엔드는 모든 사람의 SDK가 필요하지 않습니다. 우리가 실제로 사용하는 8개의 Stripe 엔드포인트, WorkOS 사용자 및 조직 엔드포인트, Slack 메서드만 있으면 됩니다. Stripe는 6.5MB, workos-inc/node는 6.9MB, slack/web-api는 7.7MB, linear/sdk는 34MB입니다. 극단적인 예로 googleapis는 198MB에 달합니다.

생성된 SDK는 전체 플랫폼을 함께 가져옵니다: 수백 개의 메서드, 오버로드, 페이지네이션 헬퍼, 재시도 동작, 환경 감지, 호환성 심(shim), 그리고 어딘가에서 누군가가 여전히 의존하고 있기 때문에 사라질 수 없는 오래된 표면들까지. 우리 백엔드 내부에서는 이러한 일반성이 대개 블로트(bloat)입니다. 더 나쁜 것은, 그것이 우리와 유선(wire) 사이에 위치한다는 점입니다.

우리는 HTTP API가 API 계약임을 잊습니다

사람들은 때때로 HTTP API를 마치 저수준 구현 세부 사항이고 SDK가 실제 안정적인 인터페이스인 것처럼 이야기합니다. 공개 REST API는 계약입니다. 벤더는 함부로 이를 깨뜨릴 수 없습니다. SDK 작성자도 이를 알고 있습니다. 모든 고객에게 업그레이드를 강제할 수 없기 때문입니다. 많은 고객이 몇 년 된 SDK 버전을 프로덕션에서 계속 실행하고 있으며, 이는 오래된 유선 계약이 계속 작동해야 함을 의미합니다.

자체 HttpBaseClient

HttpBaseClient는 클라이언트 SDK를 대체하는 우리의 솔루션입니다. Provider 하위 클래스는 벤더별 부분(기본 URL, 인증 헤더, 콘텐츠 유형, 오류 매핑, 실제 사용하는 엔드포인트에 대한 좁은 메서드)을 제공합니다. HttpBaseClient는 나머지(직렬화, 파싱, 전송 오류, 구조화된 로그, 메트릭, 상태 매핑, 지속 시간 추적)를 담당합니다. 이를 통해 관찰 가능성이 통합되어 모든 벤더가 일관된 표준을 따릅니다. 다음은 그 구조를 간략히 나타낸 것입니다:

typescript
1abstract class HttpBaseClient<TEndpoint extends string> {
2 protected abstract readonly baseUrl: string;
3
4 protected constructor(private readonly dependency: string) {}
5
6 protected abstract buildAuthHeaders(): Promise<Record<string, string>>;
7
8 protected async request<TBody, TResponse>(config: {
9 method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
10 path: string;
11 endpoint: TEndpoint;
12 body?: TBody;
13 }): Promise<TResponse> {
14 const url = `${this.baseUrl}${config.path}`;
15 const headers = await this.buildAuthHeaders();
16 const labels = {
17 dependency: this.dependency,
18 endpoint: config.endpoint,
19 method: config.method,
20 };
21 const start = performance.now();
22
23 logInfo('upstream request starting', labels);
24
25 try {
26 const response = await callWithMetrics(
27 () =>
28 fetch(url, {
29 method: config.method,
30 headers,
31 body: config.body === undefined ? undefined : JSON.stringify(config.body),
32 }),
33 this.dependency,
34 labels
35 );
36
37 const body = await parseBody(response);
38 if (!response.ok) throw this.mapHttpError(response, body);
39
40 logInfo('upstream request succeeded', {
41 ...labels,
42 statusCode: response.status,
43 durationMs: performance.now() - start,
44 });
45
46 return body as TResponse;
47 } catch (cause) {
48 logWarn('upstream request failed', {
49 ...labels,
50 durationMs: performance.now() - start,
51 cause,
52 });
53 throw cause;
54 }
55 }
56}
57
58// 그러면 Stripe 래퍼가 작고 명확해집니다:
59enum StripeEndpoint {
60 CustomersCreate = 'stripe/customers/create',
61}
62
63class StripeHttpClient extends HttpBaseClient<StripeEndpoint> {
64 protected readonly baseUrl = 'https://api.stripe.com';
65
66 constructor(private readonly apiKey: string) {
67 super(ClientVendor.Stripe);
68 }
69
70 createCustomer(body: { email: string; name: string }) {
71 return this.request<typeof body, Stripe.Customer>({
72 method: 'POST',
73 path: '/v1/customers',
74 endpoint: StripeEndpoint.CustomersCreate,
75 body,
76 });
77 }
78
79 // 더 많은 엔드포인트는 여기에 추가합니다.
80}

이것이 핵심입니다. 이 클래스는 Stripe 전체를 모델링하려 하지 않습니다. 모든 벤더 호출이 가져야 할 HTTP 동작을 모델링합니다. Stripe는 여전히 폼 인코딩을 사용하고, WorkOS는 베어러 인증과 JSON 본문을 사용하며, Slack은 여전히 HTTP 200에서 ok: false라는 특이한 동작을 유지합니다. 하지만 나머지 백엔드에서는 하나의 일관된 형태만 보입니다.

여기에서 우리 HttpBaseClient의 더 긴 버전을 확인할 수 있습니다. 예제 코드로 읽기 쉽게 일반화하고 수정했습니다.

SDK를 여전히 사용하는 경우

현재 우리의 접근 방식은 하이브리드입니다: 런타임에는 자체 HTTP 클라이언트를 사용하지만, 타입이 여전히 시간을 절약해 주는 곳에서는 SDK를 유지합니다. StripeHttpClient는 Stripe.Customer를 반환할 수 있고, SlackHttpClient는 slack/web-api 인수 타입을 빌려올 수 있으며, WorkOS 타입은 여전히 유선 응답을 설명할 수 있습니다.

시간이 지나면서 이것도 단계적으로 없앨 것으로 예상합니다. AI가 우리에게 필요한 정확한 요청 및 응답 타입을 생성하고 유지하는 데 더 좋아짐에 따라, 타입만을 위해 전체 SDK 패키지를 유지해야 할 이유는 약해집니다. 하지만 런타임 동작이 고통스러운 부분이므로, 그곳에서 마이그레이션이 시작됩니다.

SDK가 단순한 REST 래퍼가 아니라 제품 경계인 경우에는 여전히 SDK를 사용합니다. 관찰 가능성이 가장 명확한 예입니다. Sentry의 경우 SDK는 런타임 계측, 오류 캡처, 범위 전파, 릴리스 메타데이터, 그리고 우리가 직접 구현하고 싶지 않은 통합을 처리합니다. 이는 벤더 SDK를 일반 백엔드 HTTP 호출을 위한 얇은 클라이언트로 사용하는 것과는 다릅니다.

모든 API가 HTTP 위의 REST인 것은 아니며, 괜찮습니다. 데이터베이스 호출이 좋은 예입니다. 우리의 하위 수준 추상화는 BaseClient입니다: 각 클라이언트에 동일한 메트릭, 로깅, 오류 처리 계약을 제공하면서, 하위 클래스가 자체 전송에 대한 'fetch'의 의미를 재정의할 수 있도록 허용합니다.

앞으로의 방향

개발자들은 API 문서를 실제 통합 가이드로 취급하고 SDK를 참조 구현(인증, 페이로드, 페이지네이션, 재시도, 엣지 케이스에 대한 템플릿)으로 간주할 것입니다. 'SDK 출시'의 다음 버전은 에이전트에게 API를 올바르게 호출하고, 올바른 패턴을 재사용하며, 모든 런타임 호출을 벤더 패키지를 통해 푸시하지 않도록 가르치는 '에이전트 스킬 출시'가 될 수 있습니다.

원클릭 저장

YouMind로 바이럴 글을 AI 심층 읽기

소스를 저장하고, 핵심 질문을 던지고, 주장을 요약해 바이럴 글을 다시 활용할 수 있는 노트로 바꾸세요. 하나의 AI 워크스페이스에서 모두 할 수 있습니다.

YouMind 둘러보기
크리에이터를 위해

당신의 Markdown을 깔끔한 𝕏 글로

직접 쓴 장문을 올릴 때 이미지, 표, 코드 블록을 𝕏에 맞게 정리하는 일은 번거롭습니다. YouMind는 전체 Markdown 초안을 깔끔하고 바로 게시할 수 있는 𝕏 글로 바꿔 줍니다.

Markdown → 𝕏 사용해 보기

분석할 패턴 더 보기

최근 바이럴 아티클

더 많은 바이럴 아티클 보기