밤새 돌린 Elixir 폴링 루프가 돈을 쓰게 된 경우
요약
개발자가 구현한 폴링 루프가 의도치 않게 과도하게 실행되어 상당한 비용을 발생시킨 사례를 공유합니다. 특히, 코드 리뷰 과정에서 승인하고 병합했던 코드가 문제를 일으킨 원인이었으며, 에이전트의 충돌로 인해 프로세스가 멈추는 상황까지 발생했습니다.
핵심 포인트
- 폴링 루프 구현 시 과도한 비용 지출에 주의해야 합니다.
- 코드 리뷰만으로는 로직 오류나 비효율성을 잡아내기 어렵습니다.
- 에이전트 기반 시스템은 예상치 못한 충돌이나 반복 실행을 유발할 수 있습니다.
어느 날 아침 보드를 열었는데, 카드 하나가 이상했다. 고장 난 게 아니라 바빴다는 표시였다.
전날 저녁부터 활동 기록이 쌓여 있었고, 나는 점심 이후로 그것을 건드리지 않았다.
내 위장이 이유도 모른 채 특유의 느낌을 주었다. 그 밤에 나에게 얼마나 많은 비용이 들었는지 알아보기 위해 파헤치기 시작했다.
8시간 동안 243개의 에이전트 실행(agent runs). 약 3,100만 개의 입력 토큰(input tokens). $64.25가 지출되었으며, 이 중 $63.10은 순수한 낭비였다. 커밋(commits)은 하나도 생성되지 않았다.
그 숫자를 몇 번이나 읽었다. 아무것도 하지 않았는데 나에게서 빠져나간 63달러.
그리고 두 번째 부분이 나타났고, 이 부분은 더 심했다. 내가 이 코드를 작성한 것이 아니었다. Claude Code가 Camelot 내부에서, 내가 직접 만든 태스크 안에서 작성해 놓았다.
나는 diff를 읽었고, 승인했고(approved), 병합했다(merged). 나에게는 괜찮아 보였다.
루프는 내가 알아차렸거나 가드(guard)가 잡아냈기 때문에 멈춘 것이 아니었다. 결국 러너(runner)가 잘못된 형식의 출력(malformed output)으로 인해 충돌하면서 태스크를 에러 상태로 밀어 넣었기 때문에 멈췄다. 돌이켜보면, 심지어 이 충돌마저도 팀에서 가장 책임감 있는 구성원처럼 느껴졌다.
그래서 이것은 세 줄짜리 cron 트리거(cron trigger)의 이야기이자, 비싼 무언가를 그 뒤에 배치했을 때 무슨 일이 벌어지는지에 대한 이야기이며, 내가 완전히 잘못된 것들을 보고 있던 코드 리뷰의 이야기이다.
Ash와 Oban을 사용한 2분 간격 GitHub 폴러
Camelot AI에서는 태스크가 칸반 보드(kanban board)를 따라 여러 단계를 거칩니다.
에이전트가 풀 리퀘스트(pull request)를 열면, 태스크는 pr 단계에 놓이고 폴러(poller)가 작동합니다. 2분마다 GitHub를 확인하고 반응합니다. 병합됨(Merged) → 완료(done). CI 실패 → 에이전트를 다시 보내기(send the agent back in). 리뷰어가 변경 요청을 할 경우(Reviewer requested changes) → 에이전트를 다시 보내기(send the agent back in).
그 poller 자체도 Camelot 작업이었습니다. 제가 원하는 것을 설명했고, 에이전트가 가서 PR을 열었고, 저는 화요일 저녁에 그것을 읽었습니다. Ash와 Oban을 사용하면 트리거를 작성하는 것이 거의 아무것도 아니었는데, 돌아온 것은 이것이었습니다:
triggers do
trigger :check_pr_status do
action(:check_pr_status)
...
이것이 전부입니다. AshOban은 where 표현식을 가져와 2분마다 일치하는 행을 찾고, 각 행에 대해 액션을 실행합니다. 저는 이런 것을 수십 개 손으로 작성해 왔습니다. 지루하고 믿음직해서 제가 아주 좋아합니다.
이것이 바로 제가 이것을 매우 빨리 읽은 이유입니다. 저는 그 형태를 인식했고, 그것을 인식한 순간 저는 읽는 것을 멈추고 스크롤하기 시작했습니다.
문제는 이 코드가 지루하고 믿음직하다는 것입니다. 컨테이너를 시작하고 LLM 토큰을 구매하는 무언가 앞에서 말이죠.
실제로 무슨 일이 일어났나?
같은 diff의 더 아래쪽 — 제가 아직 주의 깊게 읽고 있던 부분을 훨씬 지나서 — 다음과 같은 정신으로 작성된 브랜치가 있었습니다:
cond do
# ...
has_review_state?(reviews, "CHANGES_REQUESTED") ->
...
reviews는 GitHub의 리뷰 엔드포인트가 반환하는 것입니다. 그리고 여기에는 우리 둘 다 충분히 신중하게 생각하지 않은 것이 있습니다 — 비록 그중 한 명만 그렇게 해야 했지만:
GitHub는 제출된 모든 리뷰를 영원히 보관합니다.
리뷰는 지우는 플래그가 아닙니다. 그것은 변경 불가능한(immutable) 역사적 기록입니다. 검토자가 한 번 변경을 요청했다면, 그 항목은 풀 리퀘스트의 수명 주기 동안 모든 후속 호출 목록에 남아 있습니다. 이를 무시하면 상태가 DISMISSED인 새로운 리뷰가 추가될 뿐이며 — 이전 것은 그대로 남아 있습니다.
따라서 has_review_state?(reviews, "CHANGES_REQUESTED")는
문장(sentence)을 도착하기 전까지 세 번이나 읽었습니다. 게다가 함수 이름도 정직합니다. has_review_state?는 자신이 하는 일을 정확히 말해줍니다. 저는 이것을 "이 PR에 작업이 필요한가?"라고 해석했습니다. 제가 그렇게 의미하도록 원했고, 그 아래 줄의 코드가 예상했던 대로 작동했기 때문입니다.
2분마다 트리거가 해당 행(row)과 일치했습니다. 2분마다 조건이 참이었습니다. 2분마다 Camelot은 몇 시간 전에 해결된 피드백을 수정하기 위해 에이전트(agent)를 파견했습니다.
그리고 그 에이전트는 인정할 만하게도 계속해서 찾았지만, 할 일이 없다는 것을 발견하고 깔끔하게 종료되었습니다. 무려 243번이나요. 솔직히 제가 리뷰에서 보인 것보다 더 큰 규율입니다.
레벨 기반(Level-triggered), 그리고 비용이 따르다
이것의 이름은 레벨 기반 확인(level-triggered check)입니다. 이 코드는 '상태(state)'("변경 사항이 요청됨")을 테스트하고 있었지, '사건(event)'("변경 사항이 요청되었고, 나는 아직 조치하지 않았음")을 테스트한 것이 아니었습니다.
레벨 기반 방식은 일반적으로 조정 루프(reconciliation loop)에서 올바른 기본값입니다. 이 방식이 루프를 자가 치유(self-healing)하게 만듭니다. 즉, 이벤트 하나를 놓쳐도 다음 패스에서는 여전히 상태를 보고 수정할 수 있습니다. 이 시리즈의 첫 번째 기사는 바로 그러한 스타일을 지지하는 근거가 됩니다.
차이점은 반대편의 액추에이터(actuator)에 있습니다.
일반적인 Ash 애플리케이션에서는 중복적으로 작동하여 발동되는 레벨 기반 확인은 2분마다 데이터베이스 쿼리를 소모할 뿐이며, 당신은 절대 알아채지 못할 것입니다. 하지만 여기서는 액추에이터가 컨테이너를 프로비저닝하고, 리포지토리(repository)를 복제하며, 약 13만 토큰 분량의 컨텍스트를 가진 언어 모델(language model)을 전달합니다.
일반 앱에서의 중복 폴링은 반올림 오차(rounding error)에 불과합니다. 하지만 여기에서의 중복 폴링은 청구서(invoice)입니다. 그 문장은 여전히 저에게 조금 어렵습니다.
더 나쁜 2차 효과가 있었습니다. 프롬프트에는 PR 대화 내용이 포함되어 있었습니다. 쓸모없는 실행마다 그 대화에 자체적인 노이즈를 추가했습니다. 그래서 비용은 밤새 꾸준히 상승하여, 초반 약 $0.12에서 끝날 무렵 $0.45까지 올랐습니다. 이 루프는 단순히 낭비했을 뿐만 아니라, 가속하고 있었습니다.
그리고 저는 이것이 벌어지고 있을 때 아무것도 알지 못했습니다. 아무도 지켜보고 있지 않았습니다. 아마 이런 종류의 실패에서 가장 외로운 부분이 바로 그것일 겁니다. 새벽 3시에, 그 자체의 조용한 속도로 일어나고, 아침 식사 시간에 청구서를 보내는 것이죠.
어떤 방어 장치도 도움이 되지 않은 이유
이것은 한계 없이 만들어진 것이 아니었습니다. 이를 막아야 할 세 가지 메커니즘이 있었지만, 루프는 그 모든 것을 무시하고 지나갔습니다.
신선도 확인(freshness check)은 해당 경로에서 사용되지 않는 코드였습니다. pr_comments_seen_at 타임스탬프는 피드백이 두 번 처리되는 것을 방지하기 위해 정확히 존재합니다. 하지만 이것은 댓글(comments) 브랜치에 의해서만 참조되었고, 이 댓글 브랜치는 동일한 cond 내에서 리뷰(review) 브랜치보다 아래에 위치했습니다. 리뷰 브랜치는 여기에 도달하지 못했습니다. 방어 장치는 존재했고, 정확했지만, 도달할 수 없는 것이었습니다.
브랜치가 카운터를 초기화하여 제한을 무력화시켰습니다. 호출 부분을 다시 보세요:
request_changes(task, comments, 0)
이 세 번째 인수는 자동 수정 시도 횟수입니다. 연속적인 자동 재전송에는 상한선(기본값 2)이 있어, 에이전트가 고칠 수 없는 PR은 사람에게 맡겨집니다. 하지만 이 상한선은 병합 충돌(merge-conflict) 및 CI 실패 수정 건만 계산하며, 이 브랜치는 매번 0을 전달하여 카운터를 초기화했습니다.
방어 장치가 우연히 무시된 것이 아니었습니다. 코드가 능동적으로 그것을 해제했고, 밤새 두 시간마다 그랬습니다. 그리고 그 0은 차이(diff)에, 눈에 띄게, 세 글자 너비로 놓여 있었습니다.
브랜치의 순서가 복구를 불가능하게 만들었습니다. CHANGES_REQUESTED가 APPROVED보다 먼저 확인되었습니다. 따라서 리뷰어가 돌아와 PR을 승인했더라도, 루프는 계속 발사될 것이었습니다. 더 이른 브랜치가 먼저 일치했고, 승인은 결코 도달하지 못했습니다.
저는 이 문제들이 특이한 실수들(exotic mistakes)이 아니라는 점을 분명히 하고 싶습니다. 각각은 검토 과정에서 문제가 없어 보이는 종류의 것입니다. 저는 그것을 확실히 알고 있습니다. 왜냐하면 그 모든 것이 제 화면에 있었고, 제가 아무 문제 없이 읽었기 때문입니다. 그 아침 약 30분 동안 저는 이것을 에이전트의 실패로 만들려고 노력했습니다. 그렇지 않습니다. 에이전트는 그 역할을 수행하는 대로 그럴듯한 코드를 작성했고, 제가 수십 번 직접 작성했던 패턴을 작성했습니다. 바로 그것 때문에 통과된 것입니다. 마치 제 코드처럼 보였기 때문입니다. 저는 승인 게이트(approval gate)이고, 그 게이트가 열린 것입니다.
해결책: 리뷰를 마지막 커밋과 비교하기
두 가지 변경 사항이 있으며, 중요한 것은 눈에 띄는 것(obvious one)은 아닙니다. 이번에는 제가 직접 손으로 작성했습니다. 그것이 교훈이라서가 아니라, 그 아침에 저의 확신이 거기에 있었기 때문입니다.
첫째, 무언가를 묻기 전에 리뷰 목록을 각 검토자의 현재 평결(current verdict)로 줄여야 합니다:
@review_states ~w(APPROVED CHANGES_REQUESTED DISMISSED)
def latest_reviews(reviews) do
...
검토자별로 그룹화하고, 각 검토자의 가장 최신 상태가 담긴 리뷰를 가져옵니다. COMMENTED와 PENDING은 평결이 없으며 절대로 다른 평결을 무효화하지 못합니다. 이제 변경 요청(changes requested)을 했었다가 나중에 승인한 검토자는, 만약 당신이 그 사람에게 물어본다면 그렇게 말할 것처럼 '승인됨'으로 읽힙니다.
둘째 — 그리고 이것이 실제로 루프를 멈추게 하는 부분입니다 — 댓글 경로(comment path)가 이미 사용했던 리뷰에도 동일한 신선도 가드(staleness guards)를 적용해야 합니다:
def changes_requested?(reviews, last_commit_date, seen_at) do
reviews
|> latest_reviews()
...
평결은 마지막 커밋보다 최신이어야 하며 — 수정 사항을 푸시하는 것이 이를 해결합니다 — 마지막 전송(dispatch) 이후로 보이지 않아야 합니다. 리뷰가 처리되었다고 간주할 수 있는 두 가지 독립적인 이유입니다.
실제 사건의 경우, 이것만으로도 한 번의 패스에 끝났을 것입니다. 해당 리뷰는 11:46:46Z에 제출되었습니다. 헤드 커밋(head commit)은 11:52:34Z였습니다. 루프가 시작되기 전에 이미 그 리뷰는 오래된 것이었습니다(stale). 타임스탬프 비교 한 번으로 모든 비극이 끝난 것입니다.
다른 버그: with 체인 내부의 선택적 호출
제가 그곳에 있을 때 같은 종류의 실패를 가진 두 번째 문제를 발견했습니다. 동일한 PR, 동일한 리뷰였고, 저도 잡을 수 있었지만 놓친 것이었습니다. 이것은 순수한 Elixir 관용구(idiom) 실수이기 때문에 별도의 단락으로 언급할 가치가 있으며, 매우 쉽게 승인되는 종류의 실수입니다.
폴러는 하나의 with 구문 안에 5개의 GitHub 호출을 연결합니다:
with {:ok, pr_data} <- Client.get_pull_request(...),
{:ok, reviews} <- Client.list_pull_request_reviews(...),
{:ok, comments} <- Client.list_pull_request_comments(...),
...
이것은 좋은 Elixir처럼 보입니다. 대부분 그렇습니다. 이것이 함정입니다. 너무 관용적(idiomatic)이라서 눈이 그냥 지나치게 됩니다. 하지만 마지막 호출에는 GitHub App 설치에서 Checks: Read 권한이 필요하며, 이 권한 없이는 GitHub가 403 에러를 반환합니다.
403 에러 하나만 발생해도 전체 with 구문이 로그 한 줄로 무너집니다. 머지 충돌 처리(Merge-conflict handling), 리뷰 처리(review handling), 댓글 처리(comment handling), 병합 감지(merged detection), 닫힘 감지(closed detection) 등 모든 것이 선택적인 보강 호출(enrichment call) 하나 때문에, pr 단계의 모든 작업에서 조용히 중단됩니다.
해결책은 이 한 번의 호출이 실패하는 것을 허용한다는 점을 명시적으로 만드는 것입니다:
@spec best_effort_check_runs({:ok, [map()]} | {:error, term()}) :: {:ok, [map()]}
def best_effort_check_runs({:ok, runs}), do: {:ok, runs}
...
'체크 없음(no checks)'으로 격하시키고 계속 진행합니다. CI 실패는 권한이 부여될 때까지 감지되지 않는데, 이는 짜증나지만 견딜 만합니다. 다른 모든 것은 작동하므로, 이것이 핵심입니다.
with 구문은 전체 체인에 대해 하나의 에러 채널을 제공합니다. 그것이 기능입니다. 하지만 체인의 한 링크가 실제로 필수적이지 않은 순간, 이 기능은 버그가 됩니다. 왜냐하면 else 절에는 보통 로깅과 '생각 멈추기'를 넣는 곳이기 때문에, 조용히 실패하기 때문입니다.
제가 작업 방식을 바꾼 점
저는 잘못된 것에 대해 에이전트의 diff를 검토하고 있었습니다. 저는 명명 규칙(naming), 구조(structure), 코드베이스에 적합한지 여부, 그리고 제가 그렇게 작성했을지 여부를 읽었습니다. 이 모든 것은 여기서는 괜찮았고, 이것이 바로 제가 병합(merged)한 이유입니다. 이제 저는 루프(loop)를 건드리는 어떤 diff에도 세 가지 질문을 던지려고 합니다: 무엇이 그것을 작동하게 하는가(what makes it fire), 무엇이 그것을 멈추게 하는가(what makes it stop), 그리고 하나를 통과하는 데 비용이 얼마인가(what does one pass cost). 만약 그 diff가 두 번째와 세 번째 질문에 답하지 못한다면, 아무리 좋아 보여도 준비되지 않은 것입니다.
레벨 트리거 기반 체크는 단순히 트리거 조건뿐만 아니라 종료 조건(termination condition)을 필요로 합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기