프로토타입은 하루면 뚝딱이다. Next.js 라우트 핸들러 하나에 Claude API 붙이고, Tool Calling 루프 짜고, 채팅 UI 연결하면 '에이전트'처럼 돌아가는 무언가가 금방 만들어진다. 그런데 그 순간부터가 진짜 시작이다. 실제 사용자가 붙고, 모델 버전이 올라가고, 과금 로직이 필요해지면 프로토타입에서 눈치채지 못했던 마찰 세 개가 동시에 터진다.
마찰 1: 에이전트 루프의 상태 설계 실수
dev.to에 올라온 AutoValue 사례는 이 문제를 정직하게 짚는다. Claude API는 무상태(stateless)다. 매 요청마다 전체 대화 히스토리를 다시 보내야 한다. 여기까지는 다들 안다. 그런데 실제로 에이전트 루프를 짜면서 놓치는 건 따로 있다.
첫 번째는 어시스턴트 응답을 저장할 때 텍스트만 뽑아서 넣는 실수다. response.content 배열에는 tool_use 블록과 thinking 블록이 섞여 있는데, 이걸 통째로 히스토리에 넣지 않고 텍스트만 추출해 저장하면 다음 턴에서 API가 400을 반환한다. tool_result가 참조하는 tool_use_id가 히스토리에 없어서다. 항상 response.content 전체를 history.push해야 한다.
두 번째는 병렬 툴 결과를 여러 개의 user 메시지로 나눠 보내는 실수다. 모델은 한 턴에 여러 툴을 동시에 요청할 수 있다. 각 결과를 별도 user 메시지로 보내면 role 교대 규칙이 깨지고, 더 나쁜 건 모델이 슬그머니 병렬화를 포기하기 시작한다는 것이다. 모든 tool_result는 하나의 user 메시지 배열에 담아야 한다.
세 번째가 가장 은밀한 함정이다. AutoValue가 6단계 플로우를 운영하면서 발견한 것인데, 1단계에서 수집한 데이터(차체 타입 같은 것)가 3단계 툴 호출의 인자로 신뢰성 있게 전달되지 않는다. 모델이 대화를 '기억'하는 것처럼 보여도, 구조화된 팩트를 재현하는 능력은 생각보다 훨씬 불안정하다. 모델은 대화를 이끌고, 코드가 팩트를 소유해야 한다. 단계 간에 전달해야 할 데이터는 서버 사이드 코드에서 직접 축적하고 툴 실행 시 주입해야 한다.
마찰 2: 모델 버전 올리는 순간 터지는 API 변경
프로토타입이 안정화되면 자연스럽게 더 나은 모델로 올리고 싶어진다. 그 순간 또 다른 마찰이 기다린다. dev.to의 또 다른 글은 budget_tokens 파라미터가 Opus 4.7 이상에서 완전히 사라진 경험을 다룬다. 입력 크기의 30%를 thinking 예산으로 주는 헬퍼를 정성껏 만들었는데, 모델 버전 하나 올렸다고 400 에러와 함께 그 코드 전체가 무의미해졌다.
대체 방식은 thinking: { type: 'adaptive' }와 output_config: { effort: 'high' } 조합이다. budget_tokens가 '얼마나 생각할 수 있는가'를 제한했다면, effort는 '얼마나 생각하고 행동할 것인가'를 통합적으로 제어한다. 같은 축이 아니라서 1:1 매핑은 없다. 실무적으로는 분류·라우팅엔 low, 일반 앱 트래픽엔 medium~high, 코딩·에이전트 루프엔 xhigh가 기준점이 된다.
흥미로운 역설도 있다. 멀티스텝 에이전트 작업에서 effort를 높이면 총 토큰이 오히려 줄어드는 경우가 생긴다. 초반에 더 잘 계획하니 루프 횟수가 줄어서다. '높은 effort = 무조건 비싸다'는 직관이 항상 맞지 않는다. 또 하나 놓치기 쉬운 것: 4.7 이상에서 thinking 블록은 스트리밍 되지만 텍스트가 기본적으로 비어 있다. UI에서 추론 과정을 보여주려면 thinking: { type: 'adaptive', display: 'summarized' }를 명시해야 한다. 이걸 모르면 스트리밍이 멈춘 것처럼 보인다.
마찰 3: 과금 로직의 엣지케이스는 해피패스에 없다
에이전트가 외부 API를 호출하거나 사용량 기반 과금을 도입하는 순간 세 번째 마찰이 등장한다. MCP 서버 개발 경험을 공유한 dev.to 글은 Stripe 공식 문서가 다루지 않는 세 가지 시나리오를 정면으로 다룬다.
재시도 중복 과금: 클라이언트가 네트워크 단절로 재시도하면 논리적으로 하나인 요청이 두 번 처리될 수 있다. SELECT-before-INSERT로 막으려 하면 동시 재시도 레이스 컨디션에 뚫린다. DB 유니크 제약으로 idempotencyKey를 걸어야 애플리케이션 레이어가 아닌 데이터베이스 레이어에서 막힌다.
스트리밍 중단 과금 기준: 3,000 토큰 스트리밍 중에 사용자가 탭을 닫으면 어떻게 할 것인가? 정답은 없다. 중요한 건 명시적으로 결정하고 문서화하는 것이다. 이 글의 저자는 completed/partial/failed 세 상태를 만들고, Postgres엔 모두 기록하되 Stripe엔 BILLABLE_STATUSES 설정으로 어떤 상태를 청구할지 코드 레벨에서 선언했다.
실패 유형 구분: 서버 예외로 실패한 호출과 사용자 입력이 잘못돼 400을 반환한 호출은 다르게 취급해야 할 수 있다. 후자는 실제 컴퓨팅이 발생했으니까. 이 역시 failed 상태를 Postgres엔 기록(감사 로그)하되 Stripe 청구 여부는 설정으로 분리하는 방식이 유연하다.
시사점: '빠른 프로토타이핑'의 기술 부채는 운영 첫날 청구된다
세 마찰의 공통점은 프로토타입 단계에서 보이지 않는다는 것이다. 로컬에서 혼자 테스트하면 재시도 없고, 모델 버전 안 바꾸고, 병렬 툴 호출도 잘 안 생긴다. 하지만 실제 사용자가 붙으면 세 가지 모두 '정상 운영 조건'이 된다.
프론트엔드 개발자 관점에서 이 흐름은 명확하다. 에이전트 UX의 품질은 대화 디자인보다 상태 소유권 설계에 달려 있다. 모델이 기억한다고 믿고 팩트 관리를 위임하면 반드시 깨진다. API 변경은 벤더가 '인시덴털한 파라미터'를 정리할 때 예고 없이 온다—추상화 레이어를 쌓기 전에 그 파라미터가 근본적인 개념인지 구현 세부인지 먼저 파악해야 한다. 과금 엣지케이스는 해피패스 문서엔 없으니 설계 단계에서 세 가지 상태(완료/부분/실패)와 청구 기준을 명시적으로 결정해야 한다.
빠른 프로토타이핑은 여전히 옳다. 단, 이 세 마찰을 미리 알고 시작하면 프로토타입과 프로덕션 사이의 거리가 훨씬 짧아진다.