우리가 SDK 사용을 중단한 이유
TMT우리는 Stripe, WorkOS, Slack 클라이언트 SDK 사용을 중단했고, 나머지 SDK도 걷어내는 작업을 진행 중입니다. 대신 HttpBaseClient라고 부르는 작은 래퍼 클래스를 통해 각 서비스의 REST API를 직접 호출합니다.
거꾸로 가는 이야기처럼 들릴 겁니다. SDK는 원래 시간을 아껴 주는 도구니까요. 하지만 AI가 점점 더 많은 코드를 네이티브로 돌아가도록 포팅하고 있다는 이야기를 들어 보셨을 겁니다. 같은 논리가 SDK에도 적용됩니다. 왜일까요?
- SDK는 HTTP 응답 헤더나 원시 응답 본문 같은 저수준 세부 정보를 감춥니다. 문제를 디버깅하거나 상세 정보를 업스트림 팀에 넘겨 조사를 요청하는 에이전트에게는 이런 정보가 결정적입니다.
- SDK는 정상적인 응답이 온다고 가정하지만, 실제로는 방화벽, 로드 밸런서, 게이트웨이에서 비정상 응답이 돌아옵니다. 그 과정에서 우리에게 필요한 디버깅 정보가 가려집니다.
- SDK는 재시도, 오류 처리, 관측성을 한곳에 모으도록 강제하는 중앙 진입점을 우회하게 만들어, 에이전트가 저마다의 패턴을 새로 만들어 쓰게 됩니다.
- 대개 OpenAPI 명세에서 자동 생성되다 보니 덩치가 크고 군더더기가 많은데, 정작 우리가 쓰는 엔드포인트는 극히 일부입니다.
SDK가 왜 생겨났는지 생각해 보세요. SDK는 기업들이 자사 서비스 도입을 쉽게 느끼게 하고 싶어서 태어났습니다. 공용 라이브러리를 배포해 고객의 시간을 아껴 주고, 반복적인 연동 작업을 감춰 주는 것이죠. 하지만 AI가 그 비용 곡선을 바꿔 놓았습니다. 이제는 SDK로 연동하는 일이 HTTP API를 직접 호출하는 것만큼 손이 가는 경우가 많고, 우리가 실제로 쓰는 엔드포인트만 겨냥한 맞춤형 REST 클라이언트를 만드는 비용은 저렴해진 데다, 그렇게 하면 더 나은 통합 관측성까지 얻을 수 있습니다.
끝나지 않는 두더지 잡기
이렇게나 사랑스러운 "JSON" 응답들
예전 우리 Sentry 오류 대시보드가 이런 모습이었습니다. 어떤 코드 경로에서 일부 요청이 실패하는 것을 발견하면, 사용자를 위해 그 경로만 몽키패치해서 SDK의 실수를 수습하곤 했습니다. 다음 날이면 또 다른 코드 경로가 예상치 못한 오류를 일으켰고, 그것도 패치했습니다. 이 게임은 끝나지 않았습니다.
SDK는 프로덕션에서 흔한 장애 유형에 취약합니다. 서버는 뭔가 잘못됐다고 말하는데, 응답이 SDK가 기대하는 JSON 형태가 아닌 경우입니다.
과부하가 걸린 Nginx 게이트웨이는 예상 밖의 HTML을 돌려줍니다. Cloudflare가 요청을 차단하기도 하고, 방화벽이 레이트 리밋 페이지를 띄우기도 합니다. 벤더 API 자체는 보통 JSON을 주지만, 그 앞단에 서 있는 것이 항상 벤더 API인 것은 아닙니다.
이런 일이 생기면 많은 SDK가 응답을 파싱하려다 실패하면서 뭉뚱그려진 파싱 오류를 던지고, 유용한 정보를 내다 버립니다. 원시 본문은 사라지고, 상태 텍스트는 묻히고, HTTP 헤더는 쓸 만한 형태로 반환되지 않는 경우가 많습니다. 벤더 지원팀이 HTTP 헤더에 들어 있는 요청 ID를 알려 달라고 해도, SDK가 그 값을 돌려주지 않으니 방법이 없습니다.
에이전트는 SDK의 직접성을 남용한다
우리의 클라이언트 SDK 호출은 무법지대였습니다. Stripe는 이런 패턴, WorkOS는 저런 패턴, Slack은 또 나름의 별난 구석이 있었습니다. 다른 연동 코드에는 생짜 fetch 호출, SDK 호출, 일회성 재시도 로직이 섞여 있거나 아예 재시도 로직이 없기도 했습니다.
SDK는 잘못된 방식을 쉬워 보이게 만들었습니다. 에이전트는 언제든 벤더 클라이언트로 직행해서 아무 라우트에서나 stripe.customers.create(...)를 호출할 수 있었습니다. 생산적인 것처럼 느껴지지만, 인증, 재시도, 메트릭, 로그, 오류 변환이 모여 있어야 할 공유 지점을 우회하는 행동입니다. 우리 코드베이스에는 이런 클로저 래퍼가 여기저기 널려 있었습니다:
const response = await catchRateLimitError(() =>
stripe.customers.retrieve(stripeCustomerId)
);한 군데라도 놓쳐서 SDK 호출을 래핑하지 않으면 그날은 고생길이었습니다. Stripe의 레이트 리밋과 WorkOS의 레이트 리밋은 우리 제품 입장에서는 같은 의미입니다. 업스트림이 속도를 늦춰 달라고 요청하는 것이죠. 하지만 타입 수준에서는 완전히 다른 객체였습니다. 어떤 코드는 SDK 전용 예외를 잡았고, 어떤 코드는 범용 Error를 잡았고, 어떤 코드는 아무것도 잡지 않았습니다. 그 결과가 Sentry 두더지 잡기였습니다. 한 호출 지점에서 429 하나를 고쳐 놓으면, 같은 부류의 장애가 다른 곳에서 또 터지기를 기다리는 식이었습니다.
SDK는 비대하다
NPM 패키지 openai와 anthropic-ai/sdk는 Stainless가 OpenAPI 명세로부터 자동 생성합니다. Stripe 역시 Stripe의 OpenAPI 명세에서 생성됩니다. 대규모 API의 공개 SDK를 수십 개 프로그래밍 언어에 걸쳐 유지보수하려면 이렇게 할 수밖에 없습니다.
하지만 훌륭한 공개 SDK는 모두를 만족시켜야 합니다. 우리 백엔드에 필요한 것은 모두를 위한 SDK가 아닙니다. 우리가 쓰는 Stripe 엔드포인트 8개, WorkOS의 사용자·조직 엔드포인트, 그리고 실제로 호출하는 Slack 메서드가 필요할 뿐입니다. Stripe는 6.5MB, workos-inc/node는 6.9MB, slack/web-api는 7.7MB, linear/sdk는 34MB입니다. 극단적인 사례인 googleapis는 198MB에 달합니다.
자동 생성된 SDK는 플랫폼 전체를 통째로 끌고 옵니다. 수백 개의 메서드, 오버로드, 페이지네이션 헬퍼, 재시도 동작, 환경 감지, 호환성 심(shim), 그리고 어딘가의 누군가가 여전히 의존하고 있어서 없앨 수 없는 낡은 인터페이스까지. 우리 백엔드 안에서 이런 범용성은 대부분 군더더기입니다. 더 나쁜 점은, 그것이 우리와 실제 네트워크 통신 사이를 가로막고 앉아 있다는 것입니다.
우리는 HTTP API가 API 계약이라는 사실을 잊는다
사람들은 가끔 HTTP API를 저수준 구현 세부 사항처럼, SDK를 진짜 안정적인 인터페이스처럼 이야기합니다. 공개 REST API가 곧 계약입니다. 벤더는 이를 함부로 깨뜨릴 수 없습니다. SDK 개발자들도 이 사실을 압니다. 모든 고객에게 업그레이드를 강제할 수 없기 때문입니다. 많은 고객이 몇 년 묵은 SDK 버전을 프로덕션에서 계속 돌리고 있고, 그 말은 예전 통신 계약이 어차피 계속 지켜져야 한다는 뜻입니다.
우리가 만든 HttpBaseClient
HttpBaseClient는 클라이언트 SDK를 대체하는 우리의 구현체입니다. 벤더별 서브클래스가 벤더 고유의 요소를 제공합니다. 기본 URL, 인증 헤더, 콘텐츠 타입, 오류 매핑, 그리고 실제로 사용하는 엔드포인트만을 위한 좁은 메서드들입니다. 나머지는 HttpBaseClient가 책임집니다. 직렬화, 파싱, 전송 오류, 구조화 로그, 메트릭, 상태 매핑, 소요 시간 추적까지. 이렇게 관측성이 하나로 통합되어 모든 벤더 호출이 일관된 표준을 따르게 됩니다. 단순화한 뼈대는 다음과 같습니다:
abstract class HttpBaseClient<TEndpoint extends string> {
protected abstract readonly baseUrl: string;
protected constructor(private readonly dependency: string) {}
protected abstract buildAuthHeaders(): Promise<Record<string, string>>;
protected async request<TBody, TResponse>(config: {
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
path: string;
endpoint: TEndpoint;
body?: TBody;
}): Promise<TResponse> {
const url = `${this.baseUrl}${config.path}`;
const headers = await this.buildAuthHeaders();
const labels = {
dependency: this.dependency,
endpoint: config.endpoint,
method: config.method,
};
const start = performance.now();
logInfo('upstream request starting', labels);
try {
const response = await callWithMetrics(
() =>
fetch(url, {
method: config.method,
headers,
body: config.body === undefined ? undefined : JSON.stringify(config.body),
}),
this.dependency,
labels
);
const body = await parseBody(response);
if (!response.ok) throw this.mapHttpError(response, body);
logInfo('upstream request succeeded', {
...labels,
statusCode: response.status,
durationMs: performance.now() - start,
});
return body as TResponse;
} catch (cause) {
logWarn('upstream request failed', {
...labels,
durationMs: performance.now() - start,
cause,
});
throw cause;
}
}
}
// 이렇게 하면 Stripe 래퍼는 작고 명시적인 코드가 됩니다:
enum StripeEndpoint {
CustomersCreate = 'stripe/customers/create',
}
class StripeHttpClient extends HttpBaseClient<StripeEndpoint> {
protected readonly baseUrl = 'https://api.stripe.com';
constructor(private readonly apiKey: string) {
super(ClientVendor.Stripe);
}
createCustomer(body: { email: string; name: string }) {
return this.request<typeof body, Stripe.Customer>({
method: 'POST',
path: '/v1/customers',
endpoint: StripeEndpoint.CustomersCreate,
body,
});
}
// 추가 엔드포인트는 여기에 들어갑니다.
}바로 이것이 핵심입니다. 이 클래스는 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를 올바르게 호출하는 법과 적절한 패턴을 재사용하는 법을 에이전트에게 가르치고, 모든 런타임 호출을 벤더 패키지에 밀어 넣지 않도록 하는 스킬 말입니다.
