AI가 생성한 SDK 코드를 실제 패키지와 비교하여 타입 체크하기: Claude가 내 Stripe 작업의 1/3을 거부했다
요약
AI 코딩 에이전트의 SDK 코드 정확도를 측정하는 SDKProof 도구 개발 과정에서 발생한 테스트 환경의 오류와 모델의 거부 현상을 다룹니다. 빈 파일이 컴파일러 통과로 오인되는 버그를 해결하고, Stripe 관련 작업 시 모델이 보안상의 이유로 응답을 거부하는 현상을 분석합니다.
핵심 포인트
- 빈 파일이 TypeScript 컴파일러에서 에러 0개로 처리되어 정답으로 오인되는 버그 발견
- Stripe와 같이 금융/결제 관련 라이브러리 사용 시 모델이 보안 정책으로 응답을 거부할 수 있음
- AI 에이전트 평가 시 단순 컴파일 성공 여부 외에 코드 생성 여부를 검증하는 로직이 필수적임
저는 SDKProof라는 작은 도구를 만들었습니다. 이 도구는 AI 코딩 에이전트가 라이브러리의 현재 API를 작성하는지, 아니면 기억하고 있는 이전 버전의 API를 작성하는지를 측정합니다. 모델이 10~15개의 실제 작업을 해결하면, 각 답변을 실제 패키지가 설치된 프로젝트에 넣은 다음 tsc --noEmit을 실행합니다. Pass(통과)는 컴파일이 깨끗하게 되는 것을 의미합니다. LLM이 다른 LLM을 심사하는 것이 아니라, 컴파일러가 결정합니다.
어젯밤에 Stripe를 추가했습니다. 첫 번째 실행 결과는 100/100, 15개 중 15개였습니다.
8일 만에 두 번의 Breaking Major(중대한 변경 사항을 포함한 메이저 업데이트)를 출시한 라이브러리치고는 정상적인 점수가 아닙니다. 그래서 무언가를 게시하기 전에 원본 후보(raw candidates) 파일을 열어보았습니다.
15개 중 4개는 비어 있었습니다. 짧은 것이 아니라, 비어 있었습니다. 0바이트였습니다.
빈 파일은 깨끗하게 컴파일된다
여기에 버그의 전체 내용이 있으며, 그 방식이 너무 단순해서 당혹스럽습니다.
제 검증기(verifier)는 모델의 코드를 candidate.ts에 작성하고 그 위에 TypeScript 컴파일러를 실행합니다. 에러가 0개면 통과입니다. 빈 파일은 에러를 0개 생성합니다. 따라서 빈 파일은 완벽한 정답이었습니다.
제 테스트 환경(harness)은 _"모델이 아무것도 생성하지 않음"_을 _"모델이 정답을 맞힘"_으로 조용히 변환하고 있었던 것입니다.
가장 먼저 제가 한 일은 보드에 있는 다른 모든 라이브러리를 확인하는 것이었습니다. Prisma, Zod, Vercel AI SDK, TanStack Query, Next.js, React Router. 그 어떤 것에서도 빈 후보는 발견되지 않았으므로, 게시된 점수들은 괜찮았습니다. Stripe에서만 이 문제가 나타난 이유는 Stripe가 실제로 생성이 실패하기 시작한 첫 번째 라이브러리였기 때문입니다.
해결책은 네 줄이며, 첫날부터 있었어야 했습니다:
// 모든 작업 스켈레톤(skeleton)은 export를 요구합니다. export가 없는 후보는
// 답변을 하지 않은 것입니다. 이것은 테스트 환경의 실패이지, 모델 드리프트(model drift)가 아닙니다.
const empty = emptyCandidate(candidate.code);
...
SDKP001은 제 API 형태 에러 코드에서 의도적으로 제외되어 있으므로, 고장 난 테스트 환경이 라이브러리 문제로 카운트되는 일은 절대 없을 것입니다.
그런데 왜 비어 있었을까?
저는 원본 API 응답을 기록했습니다. 돌아온 내용은 다음과 같습니다:
stop_reason: refusal
block types: thinking
text length: 0
`stop_reason:
따라서 트리거가 되는 것은 "Stripe" 그 자체가 아닙니다. 상당히 구체적인 형태가 존재합니다: 귀하에게 돈을 송금하거나, 고객 데이터를 대량 또는 전체로 읽거나, 귀하의 것이 아닐 수도 있는 자격 증명(credentials)을 사용하여 동작하는 것입니다.
이유에 대한 나의 최선의 추측, 그리고 테스트
나의 가설은 다음과 같았습니다.
나의 테스트 프레임워크(harness)는 의도적으로 모델에게 거의 아무런 컨텍스트(context)를 제공하지 않습니다. 라이브러리 이름, 작업 내용, 스켈레톤(skeleton) 코드 한 줄이 전부입니다. 프로젝트도, README도, 내가 누구인지 또는 이 Stripe 계정이 누구의 것인지에 대한 설명도 없습니다. 이것이 설계의 핵심이며, 모델이 주변 코드에서 복사하는 것이 아니라 무엇을 _스스로 찾아내는지(reaches for)_를 측정하는 방식입니다.
이제 다른 정보 없이 작성된 나의 프롬프트 중 하나를 읽어보십시오:
해당 계정의 ID가 주어졌을 때, 연결된 계정(connected account)에 속한 처음 5명의 고객을 나열하십시오.
이 단일 요청에 대해서만 다른 비밀 키(secret key)를 사용하여 고객을 검색하십시오.
컨텍스트가 제거된 이 프롬프트들은 구조적으로 사기(fraud) 작업의 코드 절반과 동일합니다. 내가 이 계정의 소유자임을 나타내는 것은 아무것도 없습니다. 이를 질문하는 실제 개발자라면 리포지토리(repo), 직업, 그리고 이유가 있을 것입니다. 나의 벤치마크는 설계상 그 어떤 것도 가지고 있지 않습니다.
깔끔한 이론입니다. 그래서 나는 해결책을 작성했습니다. 가장 문제가 되었던 5가지 작업 각각에 소유권 컨텍스트를 나타내는 한 구절을 추가하되, 다른 것은 건드리지 않고 테스트 대상인 API 표면(API surface)은 동일하게 유지했습니다.
우리 플랫폼은 판매자를 Stripe 연결 계정(connected accounts)으로 온보딩합니다. 판매자 자신의 대시보드를 위해, 우리 연결 계정 중 하나에 속한 처음 5명의 고객을 나열하십시오...
그리고 나는 이를 쌍을 이룬 A/B 테스트로 실행했습니다. 5가지 작업의 두 버전 모두를 동일한 배치(batch)에 섞어서 배치하고, 각각 10회씩 시도했습니다. 그렇게 하면 한 시간 동안 거부율(refusal rate)이 변동하더라도 양쪽 모두에 동일하게 적용되므로 비교 결과가 유효하기 때문입니다.
payment-intent v1 10/10 v2 10/10
auto-paginate v1 10/10 v2 10/10
connect-account v1 10/10 v2 10/10
...
아무 변화도 없었습니다. 단 하나의 작업도 움직이지 않았습니다.
v1 측이 10/10으로 재현되었다는 점은 이것이 단순히 제가 운이 없었던 것이 아니라 실제적인 비교임을 증명하며, 제 이론이 틀렸음을 의미합니다. 모델에게 누구의 계정인지 말해주는 것은 아무런 변화를 주지 못했습니다. 트리거(Trigger)는 명시된 이유의 부재가 아니라, 작업의 형태(shape of the operation)입니다.
결제를 진행하면: 거부됨. 부탁하며 본인의 체크아웃임을 설명해도: 거부됨. 환불을 진행하면: 통과.
더 나은 이론은 없습니다. 현재 제가 도달한 결론은 이것입니다.
같은 방식으로 한 번 더 망가뜨렸습니다
여기서 제 실수를 고백할 가치가 있겠네요. 제가 만든 측정 스크립트의 첫 번째 버전은 두 라이브러리 모두 거부율 0%를 보고했습니다. 아주 좋은 소식이었지만, 찾아보니 잘못된 것이었습니다. 약 10분 동안은 그것을 믿었습니다.
.env 파일이 로드되지 않았던 것입니다. 모든 요청이 인증(authentication)에 실패했습니다. 그런데 제 요약 통계는 에러가 발생한 요청을 "거부되지 않음"으로 계산했고, 결과적으로 30건의 인증 실패가 확신에 찬 깨끗한 '0'으로 나타났습니다.
이는 제가 검증기(verifier)에서 방금 두 시간 동안 고쳤던 것과 정확히 일치하는 버그였습니다. 실패가 좋은 결과로 나타나는 현상 말이죠. 그것을 조사하기 위해 만든 도구에 그대로 다시 써넣은 셈입니다.
이제 스크립트는 분모에서 에러가 발생한 요청을 제외하며, 요청의 절반 이상이 실패할 경우 퍼센트(%)를 아예 출력하지 않도록 설정했습니다. 제가 유지하고 있는 규칙은 다음과 같습니다: 비율(rate)을 계산하는 것이라면, 입력값이 깨졌을 때는 비율을 보여주기를 거부해야 한다.
결론
이제 Stripe는 100/100의 점수로 기록되었으며, 거부 횟수는 각주가 아닌 페이지 상단(above the fold)에 표시됩니다. 이것이 이 결과를 공개하는 유일하게 정직한 방법이라고 느꼈습니다. 점수는 15개의 작성된 작업 중 10개를 커버하며, 페이지의 숫자 옆에 그 사실이 명시되어 있습니다.
저는 거의 이것을 게시하지 않을 뻔했습니다. 제 마음을 돌린 것은 버전 드리프트 (version drift)를 포착하기 위해 특별히 작성된 세 가지 작업이 모두 실행되었고 모두 통과했다는 점이었습니다. 모델은 설치된 SDK가 기대하는 정확한 고정된 apiVersion 문자열 리터럴 (string literal)을 작성하며, 기억된 이전 버전의 값을 사용하면 컴파일 에러 (compile error)가 발생합니다. 또한 v21에서 string에서 Stripe.Decimal로 변경된 decimal_string 필드를 Stripe.Decimal로 처리합니다. 그리고 v22의 변경 사항인 파라미터 (params)에 섞어 넣는 대신 idempotencyKey를 두 번째 인자로 넣습니다. 따라서 100점이라는 점수는 어려운 작업들이 제외되고 남은 점수가 아니라, 실제 측정값입니다.
Scorecard, 거부 테이블 (refusal table) 및 방법론: sdkproof.dev/stripe.html
만약 이 방식에 허점을 찾고 싶다면 하네스 (harness)는 오픈 소스로 공개되어 있습니다: github.com/Kalpitrathore/sdkproof
만약 여러분이 유사한 실험을 수행하여 다른 숫자를 얻게 된다면, 저는 진심으로 그 결과를 알고 싶습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기