본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 25. 23:12

하나의 버그가 LLM 에이전트 플릿 전체에 세 가지 실패를 연쇄적으로 일으켰습니다. 제가 추가한 네 가지 가드레일(Guardrails)을

요약

LLM 에이전트 플릿 운영 중 발생한 Playwright 인자 오류와 그로 인한 연쇄적인 시스템 실패 사례를 다룹니다. 단일 버그가 재시도 로직과 메시지 큐 처리 오류로 이어지는 과정을 분석하고, 이를 방지하기 위한 정적 분석 및 가드레일 구축의 중요성을 설명합니다.

핵심 포인트

  • 단일 버그가 재시도 로직과 결합해 계정 차단 등 연쇄 실패를 유발할 수 있음
  • 자율 시스템에서 '그냥 잘 작동하는 상태'는 가장 위험한 신호임
  • AST(추상 구문 트리)를 활용한 정적 분석으로 특정 버그 유형을 사전에 차단 가능
  • 에이전트 시스템에는 다층적인 가드레일 설계가 필수적임

저는 집에서 Mac mini를 사용하여 소규모 LLM 에이전트 플릿(fleet)을 운영하고 있습니다. 제가 개입하지 않아도 블로그 콘텐츠를 초안 작성, 검토 및 게시하는 약 44개의 예약된 작업(scheduled jobs)이 돌아가고 있습니다. 대부분의 작업은 Claude CLI를 호출하고, Playwright로 헤드리스 브라우저(headless browser)를 구동하며, 블로그에 게시합니다.

몇 달 동안은 아무 문제 없이 잘 작동했습니다. 그러던 어느 날 밤, 단 하나의 버그가 시스템의 서로 다른 세 부분을 동시에 무너뜨렸고, 저는 "그냥 잘 작동한다"는 상태가 자율 시스템(autonomous system)이 처할 수 있는 가장 위험한 상태라는 것을 깨달았습니다.

무엇이 고장 났는지, 그리고 같은 방식으로 다시 고장 나지 않도록 제가 추가한 네 가지 가드레일(guardrails)은 무엇인지 설명하겠습니다.

연쇄 반응 (The cascade)

5월 9일, 저의 게시 작업 중 하나가 실패했습니다. 그러더니 또 실패했습니다. 제가 확인했을 때는 이미 네 번이나 실패한 상태였고, 제 로그인 제공업체(login provider)는 계정을 일시적으로 차단했습니다.

근본 원인은 창피할 정도로 사소했습니다. 게시 스크립트는 페이지 컨텍스트(page context)에서 코드 스니펫을 실행하여 블로그 카테고리를 매칭했는데, 다음과 같았습니다:

page.evaluate(fn, arg1, arg2)   # 세 개의 인자(arguments)

Playwright의 evaluate()는 함수와 최대 한 개의 인자만 받습니다. 그 이상의 인자를 전달하면 런타임(runtime)에 오류가 발생합니다. 이것만 해결하면 되는 단 한 줄짜리 수정 사항이었습니다. 하지만 이것이 연쇄 반응을 일으켰습니다:

  1. Playwright 오용: 게시 단계가 매번 오류를 발생시켰습니다.
  2. 재시도 로직 (retry logic): 충실하게 다시 시도했습니다. 몇 분 사이에 네 번의 빠른 로그인 시도가 발생하자, 로그인 제공업체는 이를 공격으로 간주하여 계정에 속도 제한(rate-limited)을 걸었습니다.
  3. Telegram 봇: 에이전트에게 메시지를 전달하는 이 봇은 각 메시지를 처리한 후에 last_update_id를 저장했습니다. 에이전트가 메시지 처리 도중 충돌(crash)하면 오프셋(offset)이 커밋되지 않았고, 재시작 시 동일한 메시지를 다시 읽어 또 충돌했습니다. 무한 루프에 빠진 것입니다.

하나의 버그. 세 가지의 독립적인 실패 모드(failure modes). 그 중 어느 것에도 가드레일(guardrail)이 없었습니다.

해결책은 "더 주의하자"가 아니었습니다. 연쇄 반응이 일어나기 전에 각기 다른 유형의 실패를 잡아낼 수 있는 네 가지 계층(layers)을 만드는 것이었습니다.

계층 1 — 정확한 버그 유형에 대한 정적 가드 (A static guard for the exact bug class)

Playwright 버그는 아무것도 실행하지 않고도 감지할 수 있었습니다. 그래서 저는 게시 인프라를 스캔하여 인수가 너무 많은 evaluate() 호출을 찾아내는 AST (Abstract Syntax Tree) 체크 로직을 작성했고, 해당 파일들에 변경이 있을 때마다 이를 실행하도록 했습니다.

def check_evaluate_arity(tree: ast.AST, filename: str) -> list[str]:
    errors = []
    for node in ast.walk(tree):
...

동일한 스크립트는 Python 내부에 포함된 파괴적인 쉘 문자열(pkill -9, rm -rf)도 찾아냅니다. 이러한 명령들은 subprocess.run 내부에 숨겨져 있는 것이 아니라, 별도의 가드 (Guard)가 확인할 수 있는 래퍼 스크립트 (Wrapper script)에 존재해야 하기 때문입니다.

교훈: 버그 유형을 정적으로 감지할 수 있다면, 런타임 (Runtime)에 이를 잡으려고 의존하지 마세요. 여러분의 특정 실패 사례를 알고 있는 린터 (Linter) 하나가 일반적인 테스트 스위트 (Test suite)보다 더 가치 있습니다.

계층 2 — 속도 제한 서킷 브레이커 (A rate-limit circuit breaker)

계정 차단이 발생한 이유는 제가 로그인을 얼마나 자주 시도하는지 추적하는 장치가 없었기 때문입니다. 그래서 저는 서킷 브레이커 (Circuit breaker)를 추가했습니다. 15분당 최대 2회의 로그인 시도만 허용하고, 그 이후에는 30분간의 냉각기 (Cooldown)를 갖도록 했습니다.

attempts = [t for t in history.get(blog, []) if now - t < WINDOW]  # 15분
if len(attempts) >= MAX_ATTEMPTS:                                   # 2
    cooldown_remaining = COOLDOWN - (now - min(attempts))           # 30분
...

핵심적인 설계 선택은 브레이커가 단순히 경고를 기록하는 것에 그치지 않고 동작 자체를 거부 (Refuses the action) 한다는 점입니다. 아무도 읽지 않는 경고는 가드레일 (Guardrail)이 아닙니다. 또한, 브레이커 자체에서 오류가 발생할 경우 오픈 상태로 실패 (Fails open) 하도록 설계했습니다. 즉, 브레이커 자체에서 예외가 발생하면 시도를 허용합니다. 고장 난 안전 점검 장치가 시스템 전체를 중단시켜서는 안 되기 때문입니다.

계층 3 — 멱등성 (Idempotency) 및 재시작 가드 (A restart guard)

무한 루프에는 두 가지 원인이 있었으므로, 두 가지 수정이 필요했습니다.

첫째, 작업을 수행한 후가 아니라 작업을 수행하기 에 오프셋 (Offset)을 커밋하십시오:

updates = get_updates(offset=last_update_id + 1)
if updates:
    last_update_id = max(u["update_id"] for u in updates)
...

이것은 단순히 멱등성 (Idempotency)에 관한 문제입니다. 이미 '확인된 (seen)' 메시지는 처리를 하다가 시스템이 충돌하더라도 절대 두 번 처리되어서는 안 됩니다. 작업 루프 (work loop)의 한 줄 위로 코드를 옮긴 것만으로도, 무한 루프가 단 하나의 메시지 누락으로 바뀌었습니다. 이는 훨씬 더 나은 실패 방식입니다.

두 번째는 재시작 가드레일 (restart guard)입니다. 만약 프로세스가 1분 내에 네 번 재시작된다면 무언가 잘못된 것이며, 더 빠르게 재시작한다고 해서 해결되지 않습니다. 따라서 백오프 (back off)를 수행합니다:

history = [t for t in history if now - t < 60]
history.append(now)
if len(history) >= 4:
...

크래시 루프 (Crash-loops)는 작은 실패가 2일간의 서비스 중단으로 이어지는 방식입니다. 이 가드레일은 "무한 재시작"을 "재시작, 인지, 대기"로 바꿔줍니다.

레이어 4 — 빠르게 실패하고, 절대 재시도하지 마라 (Fail fast, never retry)

가장 직관에 어긋나는 교훈은, 이 시스템의 경우 재시도 (retrying)가 위험한 경로라는 점입니다. 계정이 차단된 원인이 바로 재시도였습니다. 그래서 이제 발행 (publish) 단계는 첫 번째 실패 시 즉시 중단(abort)하고, 작업 내용을 보존하며, 저에게 알림을 보냅니다.

def notify_publish_failure(blog, html_path, reason):
    """한 번의 실패 = 중단. 재시도 없음 — 반복된 시도가 차단을 유발함."""
    send_alert(
...

자동 재시도는 실패가 독립적이고 일시적 (transient)이라고 가정합니다. 속도 제한 (rate-limited)이 걸려 있는 제3자 서비스와 통신하는 시스템에서, 실패는 서로 상관관계 (correlated)가 있습니다. 즉, 두 번째 시도는 실패할 확률이 더 높을 뿐만 아니라 해를 끼칠 확률도 더 높습니다. 초안은 보존되며, 사람이 결정하고, 어차피 일일 작업 (daily job)은 다시 돌아올 것입니다.

보너스 — 시스템이 스스로 패턴을 찾도록 하기

일주일에 한 번, 작업 (job)이 지난 7일간의 에러 로그와 실행 중인 장애 테이블 (incident table)을 LLM에 전달하고 한 가지 질문을 던집니다: "어떤 실패가 최소 두 번 이상 반복되었는가?" 단발성 일시적 오류나 "예상된" 실패는 필터링됩니다. 반복되는 패턴만이 제가 검토할 수 있도록 초안 파일에 기록됩니다.

이 시스템은 장애 로그 (incident log)를 직접 수정하지 않으며, 오직 제안만 합니다. 저는 검토 게이트 (review gate)를 유지합니다. 자동화된 시스템이 자신의 사후 분석 (postmortems)을 직접 다시 쓰는 것은, 바로 다음 장애를 유발할 법한 지나치게 영리한 아이디어이기 때문입니다.

가드레일에는 끝이 없다

이 모든 것을 배포하고 일주일 후, 완전히 다른 이유로 플릿 (fleet)이 다시 고장 났습니다. Homebrew Python 업그레이드가 인터프리터 (interpreter)의 시그니처 (signature)를 변경했고, macOS가 파일 액세스 권한을 취소하면서, 수십 개의 작업이 오해를 불러일으키는 ModuleNotFoundError와 함께 실패하기 시작했습니다. 그리고 제가 _그 문제_를 수정하는 동안, 실수로 동일한 봇의 복사본 세 개를 실행하게 되었고, 이들은 동일한 메시지 큐 (message queue)를 차지하기 위해 서로 싸우기 시작했습니다.

네 가지 계층 중 그 어떤 것도 이를 잡아내지 못했는데, 이는 새로운 유형의 실패였기 때문입니다. 이것이 진짜 교훈입니다. 가드레일 (guardrails)을 만드는 목적은 "완료" 상태에 도달하기 위함이 아닙니다. 각 사고가 단 한 번만 발생할 수 있도록 만들기 위해 작성하는 것이며, 그 후에 다음 사고를 수집하러 가는 것입니다.

저는 풀타임 백엔드 (backend) 개발자로 일하면서 콘텐츠를 게시하기 위해 이 에이전트 (agents)들을 운영합니다. 전체 사고 기록과 가드레일 코드는 제 노트에 보관되어 있으며, 여기에는 일반화할 수 있다고 생각하는 부분만을 담았습니다. 비슷한 시스템을 운영 중이시라면 기꺼이 노트를 공유하겠습니다.

_전체 인포그래픽과 시각적 구조가 포함된 원문: https://jessinvestment.com/one-bug-cascaded-into-three-failures-across-my-llm-agent-fleet-here-are-the-four-guardrails-i-added/

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0