실제로 예산을 차단하는 버그: AI 에이전트의 지출 가드레일(Spend Guardrail)이 감당할 수 없는 동시성(Concurrency) 버그
요약
AI 에이전트의 예산 제한을 관리하는 지출 가드레일(Spend Guardrail)에서 발생하는 동시성(Concurrency) 버그를 분석합니다. 단위 테스트에서는 통과하지만, 실제 운영 환경의 동시 요청 상황에서 데이터베이스 락(Lock) 문제로 인해 예산 한도를 초과하는 현상을 다룹니다.
핵심 포인트
- 모의 객체(Mock)를 사용한 단위 테스트는 동시성 문제를 발견하지 못함
- 집계 함수(Aggregate)에는 SELECT FOR UPDATE를 직접 사용할 수 없음
- 기존 행을 잠그는 방식은 데이터가 없는 초기 상태의 삽입 요청을 보호하지 못함
- 에이전트 결제 시스템 설계 시 실제 DB 환경에서의 동시성 제어가 필수적임
지갑을 가진 AI 에이전트는 돈을 쓰는 루프(Loop)입니다. 에이전트에게 "하루 $50, 호출당 최대 $5"와 같은 예산을 부여하면 어려운 부분은 다 해결되었다고 생각할 것입니다. 하지만 그렇지 않습니다. 진짜 어려운 부분은 에이전트가 한 번에 10개의 호출을 실행할 때 예산이 제대로 유지되도록 만드는 것입니다. 바로 이 지점에서 대부분의 지출 가드레일(Spend Guardrail)이 조용히 실패하며, 그 실패는 가장 최악의 형태입니다. 바로 작동하는 것처럼 보이기 때문입니다.
이것은 에이전트 결제(Agentic Payments)를 위한 오픈 소스 지출 가드레일인 Bridle을 형성한 버그에 관한 이야기이며, 왜 우리가 이제 동시성(Concurrency) 환경에서 실제 데이터베이스를 대상으로 실행되지 않은 단 한 줄의 돈 관련 코드도 신뢰하지 않는지에 대한 이야기입니다.
설정: "작동하는" 예산
순진한 설계는 명백하고 잘못되었습니다. 에이전트가 이동 창(Rolling window) 내에서 무엇을 소비했는지에 대한 원장(Ledger)을 유지합니다. 결제 전에 다음과 같이 확인합니다: spent + amount가 한도를 초과하는가? 초과하지 않는다면, 예약(Reservation)을 삽입하고 결제를 허용합니다.
check: SELECT sum(amount) FROM ledger WHERE agent = $1 AND window_active
decide: if sum + amount <= limit -> INSERT reservation; ALLOW
-> DENY
모의 저장소(Mocked store)를 사용하여 단위 테스트(Unit test)를 작성합니다. 에이전트가 $50 중 $45를 사용했을 때, $10를 쓰려고 하면 → 거부(Deny). $4를 쓰려고 하면 → 허용(Allow). 테스트 통과(Green). 배포합니다.
이제 에이전트는 — 에이전트는 버튼을 하나씩 클릭하는 인간이 아니기 때문에 — 각각 $4인 **20개의 동시 요청(Concurrent requests)**을 보냅니다. 그중 모든 요청이 SELECT를 실행합니다. 그중 모든 요청이 spent = $45를 읽습니다. 그중 모든 요청이 $45 + $4 = $49 <= $50를 계산하고 **허용(ALLOW)**이라고 말합니다. 20개의 결제가 나갑니다. 에이전트는 $50 한도에 대해 방금 $80를 썼고, 당신의 가드레일은 성공을 20번 보고했습니다.
단위 테스트는 여전히 통과(Green) 상태입니다. 그것들은 언제나 통과 상태일 것입니다. 왜냐하면 모의 객체(Mock)는 두 가지 일이 동시에 일어난다는 개념이 없기 때문입니다.
두 가지 함정
첫 번째 본능은 "그냥 잠금(Lock)을 추가하자"입니다. SELECT ... FOR UPDATE. 하지만 두 가지 함정이 기다리고 있으며, 우리는 두 가지 모두에 빠졌습니다.
함정 1: 집계 함수(Aggregate)에는 FOR UPDATE를 사용할 수 없습니다. SELECT sum(amount) ... FOR UPDATE는 유효하지 않습니다. Postgres는 집계 함수(Aggregate function)를 사용하는 경우 락(Locking)을 거부합니다. 그래서 사람들은 합산하려는 행(rows) 자체를 락으로 잠급니다. 이는 곧바로 다음 문제로 이어집니다.
함정 2: 행을 잠그는 것은 행이 없는 경우를 보호하지 못합니다. 위험한 시나리오는 원장(Ledger) 행이 아직 하나도 없는 새로운 에이전트나 기본 예산 상태의 에이전트가 등장할 때입니다. 잠글 대상이 아무것도 없습니다. 20개의 동시적인 "첫 결제"가 모두 빈 원장을 확인하고 모두 통과하게 되며, 에이전트에게 아무런 이력이 쌓이기도 전에 예산을 초과 할당(Overcommit)하게 됩니다. 행 락(Row lock)은 기존의 행을 보호할 뿐, 동시에 삽입될 예정인 행들에 대해서는 아무런 역할을 하지 못합니다.
이는 데모에서는 보이지 않지만(이미 예산 행이 있는 에이전트로 테스트하기 때문), 프로덕션 환경에서는 치명적인(모든 에이전트는 아무것도 없는 상태에서 시작하기 때문) 함정입니다.
해결책: 데이터가 아니라 결정을 잠그세요
우리가 실제로 필요로 하는 보장은 다음과 같습니다: 특정 (agent, currency)에 대해, 행이 존재하지 않더라도 확인 및 예약(Check-and-reserve) 과정이 직렬(Serially)로 실행되어야 한다. 이것은 데이터에 대한 락이 아니라, _논리적 엔티티(Logical entity)_에 대한 락입니다. Postgres에는 정확히 이에 적합한 도구가 있습니다:
SELECT pg_advisory_xact_lock(hashtext('bridle:' || $agent || ':' || $currency));
-- ...이제 SELECT sum, 결정, 그리고 INSERT를 수행합니다...
-- COMMIT / ROLLBACK 시 락이 자동으로 해제됩니다
에이전트를 키(Key)로 사용하는 어드바이저리 락(Advisory lock)은 행의 존재 여부와 관계없이 임계 구역(Critical section)을 직렬화합니다. 동일한 에이전트에 대한 두 개의 동시 예약은 차례를 기다리게 되며, 두 번째 예약은 첫 번째 예약의 삽입(Insert)을 확인하게 됩니다. 예산은 유지됩니다. 그리고 결정적으로, 락을 획득한 동일한 커넥션(Connection)이 쿼리를 실행해야 합니다. 한 커넥션에서 락을 잡고 다른 커넥션에서 SELECT를 수행하면 아무것도 직렬화되지 않습니다(이 미묘한 차이는 이를 프레임워크의 트랜잭션에 연결할 때 다시 한번 문제를 일으킵니다).
Bridle에서는 스토리지 인터페이스의 단일하고 타협 불가능한 프리미티브(Primitive) 뒤에 이 기능이 구현되어 있습니다:
withAgentLock(agentAddress, currency, fn): Promise<T>
핵심 로직은 withAgentLock 내부에서만 '확인 및 예약(check-and-reserve)' 단계로 진입합니다. 다른 진입 경로가 없기 때문에, 호출 지점에서 락(lock)을 보유하는 것을 잊어버릴 염려가 없습니다.
코드와 함께 반드시 전달되어야 하는 테스트
우리의 작업 방식을 바꾼 교훈은 다음과 같습니다: 버그가 유닛 테스트(unit test)에서는 보이지 않으므로, 수정 사항을 증명하는 테스트는 반드시 실제 Postgres를 대상으로 실행되어야 하며, 어댑터(adapter)와 함께 배포되어야 합니다.
Bridle의 동시성(concurrency) 테스트는 예산이 단 한 번의 예약만 허용하는 특정 에이전트를 대상으로, 실제 Postgres를 향해 20회 이상의 동시 예약을 시도합니다. 그리고 정확히 단 하나만 성공하며, 총 예약액이 예산과 일치하는지 확인합니다. 여기에는 _원래의 버그를 유발했던 '행 없음(no-row)' 기본값 케이스_도 포함됩니다. 이 테스트는 게이트(gated) 처리가 되어 있어, CI 환경에 데이터베이스가 없다면 테스트를 건너뛰는 것이 아니라 **실패(fail)**하도록 설계되었습니다. 파이프라인이 통과(green)되었다는 것은 보장(guarantee)이 실제로 검증되었다는 의미이지, 조용히 건너뛰었다는 의미가 아닙니다.
동시성을 "커버"하는 것처럼 보이는 모의(mocked) 유닛 테스트는 테스트가 없는 것보다 더 나쁩니다. 그것은 문제가 생겼을 때 반드시 빨간불(red)이 들어와야 하는 부분에 초록불(green)을 켜주는 것과 같습니다.
후속 이야기: 가드레일이 스스로를 망가뜨렸을 때
돈과 관련된 코드는 당신을 배신하는 새로운 방법을 찾아내기 때문에, 두 번째 이야기가 있습니다. 우리는 컴플라이언스 추적을 위해 모든 허용/거부 결정을 기록하는 훅(hook)인 감사 싱크(audit sink)를 추가했습니다. 최선 노력(Best-effort) 방식의 '발사 후 망각(fire-and-forget)' 구조였습니다. 문제는 이 이벤트를 발행(emit)하는 작업이 예약 삽입(reservation insert) 직전, 즉 잠금(locked) 트랜잭션
_내부_에서 발생했으며, try/catch로 감싸져 있지 않았다는 점입니다.
상황은 이렇습니다: 예산 범위 내에 있는 완벽하게 유효한 결제가 들어옵니다. 가드(guard)는 '허용(ALLOW)'을 결정합니다. 그리고 감사 싱크를 호출합니다. 이때 호스트의 싱크에서 (예를 들어 일시적인 로깅 오류로 인해) 예외가 발생합니다. 이 예외가 트랜잭션을 탈출하면 Postgres는 롤백(rollback)을 수행하고, 결과적으로 예산 범위 내에 있던 결제가 거부됩니다. 그저 "지켜보기"만 해야 했던 관측성(observability) 훅이, 정당한 결제를 죽일 수 있는 존재가 되어버린 것입니다.
코드 리뷰 과정에서 이 버그를 발견할 수 있었던 이유는, 우리가 이제 테스트가 실패(red)해서가 아니라 "이 의존성이 오작동한다면 무엇이 망가지는가?"라고 자문하며 자금 경로(money paths)를 적대적으로(adversarially) 읽기 때문입니다. 해결책은 지루할 정도로 간단합니다(sink를 격리하여, 최선형(best-effort) 훅이 절대 흐름을 끊지 못하게 함). 그리고 이제는 항상 예외를 던지는 sink를 포함한 테스트가 마련되어 있습니다. 하지만 핵심은 이 패턴에 있습니다. 지출 경로(spend path)에서는, 모든 부수 효과(side effect)는 무해함이 증명될 때까지 유죄로 간주됩니다.
우리가 현재 믿고 있는 것들
- 모의 객체(Mocks)는 동시성(concurrency)에 대해 거짓말을 합니다. 자금 관련 코드에서, 실제 병렬성(parallelism) 하에 실제 인프라를 타격하지 않는 테스트는 그저 장식에 불과합니다.
- 보증은 구현과 함께 이동합니다. Bridle의 스토리지 계약(storage contract)은 자체적인 동시성 테스트를 포함하며, 새로운 어댑터(adapter)는 반드시 이를 통과해야 합니다. 약속은 문서에 있는 것이 아니라 CI(지속적 통합)에 있습니다.
- 항상 실패 시 차단(Fail closed)하십시오. 정책이 없거나, 컨텍스트가 누락되거나, 훅이 오작동하더라도 정답은 거부(deny)여야 하며, 결코 조용히 허용(silent allow)해서는 안 됩니다. 실패 시 개방(fail open)되는 가드레일은 가드레일이 아닙니다.
이것이 바로 Bridle입니다. 실제로 검증 가능한 동시성 보증을 갖춘 실행 전 예산 예약(pre-execution budget reservation), 선언적 정책 엔진(declarative policy engine), 그리고 감사 추적(audit trail)을 제공합니다. 프레임워크에 구애받지 않고(framework-agnostic), 비수탁형(non-custodial)이며, x402에 대응할 준비가 되어 있습니다. Bridle은 사용자의 지갑이나 레일(rail) 위에서 작동하며 단 한 가지 작업만 수행합니다. 즉, 20개의 요청이 동일한 밀리초(millisecond)에 도착하더라도 에이전트의 지출 허용 여부를 정확하게 결정하는 것입니다.
npm install @igarzatech/bridle
- 코드 및 문서: https://github.com/IgarzaTech/bridle
- npm (provenance와 함께 게시됨): https://www.npmjs.com/package/@igarzatech/bridle
만약 돈을 쓰는 에이전트를 구축하고 계신다면, 동시성 문제를 어떻게 처리하고 계신지 정말 알고 싶습니다. 여기에 답글을 남겨주시거나 이슈(issue)를 생성해 주세요.
Bridle은 Apache-2.0 라이선스이며 공개적으로 구축되었습니다. 위에서 설명한 동시성 테스트, advisory-lock 어댑터, 그리고 감사 sink(audit-sink) 수정 사항은 모두 리포지토리(repo)에 포함되어 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기