본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 27. 06:20

내 봇이 계속 중복 게시물을 올렸던 이유, 진짜 버그는 GPT-5가 아니었다

요약

AI 에이전트가 중복 메시지를 게시하는 문제는 모델의 성능 저하가 아닌, 분산 시스템의 타임아웃과 안전하지 않은 재시도(unsafe retries)로 인해 발생합니다. 이를 해결하기 위해서는 작업의 멱등성(idempotency)을 보장하는 설계가 필수적입니다.

핵심 포인트

  • 중복 게시물은 LLM의 문제가 아닌 분산 시스템의 타임아웃 버그임
  • 타임아웃 발생 후 작업이 완료되면 재시도로 인해 부작용이 중복됨
  • 외부 채널로 향하는 부작용(side effects)은 반드시 멱등성을 가져야 함
  • Idempotency-Key와 같은 패턴을 사용하여 재시도 시 중복을 방지해야 함

에이전트의 하트비트(heartbeat)는 정상적으로 보이는데 Telegram이나 Discord 봇이 계속해서 중복 게시물을 올린다면, 일반적인 원인은 GPT-5나 Claude의 실패가 아닙니다.

그것은 대개 지루한 분산 시스템(distributed-systems) 버그입니다:

  • 요청이 30초에서 타임아웃(timeout) 발생
  • 작업은 실제로 51.7초에 성공
  • 재시도(retry)가 실행됨
  • 동일한 부작용(side effect)이 두 번 발생

저는 r/openclaw 스레드를 읽던 중 누군가가 이 실패 모드를 한 문장으로 설명한 것을 보고 이 패턴을 발견했습니다:

타임아웃이 발생할 때마다, 원래 메시지가 50초 후에 전송되었고, AND 재시도도 전송되어 결국 중복 메시지가 생깁니다.

이 문장은 "내 AI 봇이 불안정하다"라고 말하는 버그의 상당 부분을 설명해 줍니다.

모델의 불안정성 때문이 아닙니다. 프롬프트(prompt)의 이상함 때문도 아닙니다. GPT-5가 변덕을 부려서도 아닙니다.

단순히 안전하지 않은 재시도(unsafe retries) 때문입니다.

버그의 형태

전형적인 흐름은 다음과 같습니다:

  1. 에이전트가 GPT-5, Claude Opus, 또는 Qwen을 호출합니다.
  2. 추론(Inference)이 예상보다 오래 걸립니다.
  3. 워크플로우(workflow)가 결과를 Telegram 또는 Discord로 보냅니다.
  4. 클라이언트가 응답을 받기 전에 타임아웃(timeout)이 발생합니다.
  5. 전송은 어쨌든 실제로 성공합니다.
  6. 재시도가 동일한 메시지를 다시 게시합니다.

해당 OpenClaw 스레드에서 숫자가 결정적인 힌트였습니다:

  • gateway timeout after 30000ms
  • message.action 51702ms

이는 호출자가 30초에 포기했지만, 작업(action)은 51.702초에 완료된 것으로 보인다는 의미입니다.

따라서 재시도는 미친 짓이 아니었습니다. 시스템이 시키는 대로 정확히 수행하고 있었을 뿐입니다.

문제는 작업이 멱등성(idempotent)을 가질 때만 재시도가 안전하다는 것입니다.

규칙: 재시도는 괜찮지만, 부작용(side effects)이 위험한 부분이다

연산을 재시도하는 것은 대개 좋습니다.

중복 제거(dedup) 없이 외부로 향하는 부작용(outbound side effects)을 재시도하는 것이 바로 중복된 Telegram 메시지, 중복된 Discord 게시물, 중복된 이메일, 중복된 티켓, 그리고 결과적으로 중복된 고객의 고통을 유발하는 방식입니다.

이것이 제가 더 많은 에이전트 빌더들이 구분 지었으면 하는 차이점입니다:

패턴실제로 일어나는 일
모델 호출 재시도 (Retry model call)추가적인 추론 (inference)을 감수할 수 있다면 보통 안전함
...

많은 AI 신뢰성 버그들은 사실 LLM의 탈을 쓴 분산 시스템 (distributed systems) 버그일 뿐입니다.

멱등성 (Idempotency)의 실제 의미

가장 깔끔한 설명은 여전히 Stripe에서 제공합니다.

당신은 Idempotency-Key와 함께 POST 요청을 보냅니다. Stripe는 해당 키에 대한 첫 번째 결과를 저장하고, 재시도 시 동일한 상태 코드 (status code)와 본문 (body)을 반환합니다.

이는 클라이언트가 첫 번째 요청이 성공했는지 더 이상 추측할 필요가 없음을 의미합니다.

예시:

curl https://api.stripe.com/v1/customers \
  -u sk_test_...: \
  -H "Idempotency-Key: KG5LxwFBepaKHyUD" \
...

이 패턴은 에이전트의 부수 효과 (side effects)에도 적용되어야 정상입니다.

만약 Telegram Bot API, Discord webhooks, Slack, 이메일 또는 기타 외부 채널로 메시지를 보내고 있다면, 모든 외부 발신 동작은 작업 식별자 (operation identity)를 가져야 합니다.

만약 API가 네이티브 멱등성 (native idempotency)을 지원하지 않는다면, 자체적인 중복 제거 원장 (dedup ledger)을 구축하십시오.

에이전트 프레임워크가 상황을 악화시키는 이유

그들이 도움을 주려고 노력하기 때문입니다.

Temporal은 기본적으로 Activity를 재시도 (retries)합니다. 이는 좋은 설계입니다. 하지만 만약 당신의 Activity에 "이 메시지를 Discord에 게시하기"가 포함되어 있고 그 작업이 멱등적이지 않다면, 재시도는 기꺼이 중복을 만들어낼 것입니다.

n8n도 더 친숙한 UI를 가졌을 뿐 동일한 함정을 가지고 있습니다.

당신은 다음 기능들을 켤 수 있습니다:

  • Retry On Fail (실패 시 재시도)
  • Wait Between Tries (시도 간 대기)
  • 에러 워크플로우 (error workflows)
  • 디버깅을 위한 execution.retryOf

모두 유용한 기능들입니다.

하지만 그 중 어떤 것도 Telegram 전송 자체를 안전하게 만들어주지는 않습니다.

재시도 기능은 중복 제거 (dedup) 기능이 아닙니다.

Discord에서의 실제 실패 사례

Discord의 속도 제한 (rate limits)은 이 상황을 더욱 복잡하게 만듭니다.

그들의 제한은 동적이며, 문서는 다음과 같은 헤더를 읽으라고 안내합니다:

  • X-RateLimit-Limit
  • X-RateLimit-Remaining
  • X-RateLimit-Reset
  • X-RateLimit-Reset-After
  • X-RateLimit-Bucket

이제 이것을 느린 LLM 호출과 결합해 보십시오.

컨텍스트 윈도우 (context window)가 비대해져서 GPT-5가 응답하는 데 40초가 걸린다고 가정해 봅시다. 당신의 봇이 마침내 Discord로 메시지를 보냅니다. 이때 Discord가 속도 제한 (rate limit) 응답을 보내거나 클라이언트가 타임아웃 (timeout)을 발생시킵니다. 그런데 당신의 코드는 이 모든 상황을 동일하게 취급합니다:

  • 타임아웃 (timeout)
  • 429 (Too Many Requests)
  • 알 수 없는 전송 상태 (unknown delivery state)

그러고 나서 즉시 재시도 (retry)를 수행합니다.

그 결과 다음과 같은 티켓(문의)들이 접수되는 것입니다:

  • “Discord가 무작위로 메시지를 중복해서 올립니다”
  • “OpenAI가 불안정한 게 분명합니다”
  • “모델이 느려지면 내 봇이 메시지를 두 번 게시합니다”

아니요. 당신의 시스템이 연산 재시도 (compute retries)와 부작용 재시도 (side-effect retries)를 분리하는 데 실패한 것입니다.

실질적인 해결책

해당 Reddit 토론에서 제가 본 가장 좋은 해결책은 가장 화려하지 않은 방법이었습니다:

타임아웃 발생 시 계속해서 중복 게시를 하는 Discord 봇을 만들었습니다. 투박한 중복 제거 키 (dedup key)를 추가하기 전까지 로그는 아무런 쓸모가 없었습니다... 제 타임아웃은 긴 컨텍스트 때문에 LLM이 40초 이상 걸려서 발생한 것이었습니다. 그래서 저는 90초의 게이트웨이 타임아웃 (gateway timeout)을 설정하고, 진행 중인 상태 (inflight state)를 명시적으로 처리했습니다.

이것이 바로 실행 지침 (playbook)입니다.

제가 매번 사용하는 패턴

  1. 전송 전 작업 ID (operation ID) 생성
  2. 진행 중인 상태 (inflight state) 저장
  3. 현실에 맞는 타임아웃 예산 (timeout budget) 사용
  4. 재시도 시, 먼저 원장 (ledger) 확인
  5. 429를 모호한 타임아웃과 별도로 처리
  6. 제공자 (provider) 응답 상세 정보 기록

괜찮은 작업 ID (operation ID)는 다음과 같은 형태를 띱니다:

conversation_id + turn_id + channel + message_hash

괜찮은 상태 모델 (state model)은 다음과 같습니다:

pending
sent
failed_unknown
...

최소한의 Node.js 예제: Discord 전송 시 중복 제거 (dedup)

다음은 Node.js로 작성된 간소화된 예제입니다.

import crypto from "node:crypto";
import fetch from "node-fetch";

...

이 예제는 의도적으로 단순하게 작성되었지만, 중요한 동작 원리는 포함되어 있습니다:

  • 전송 전에 작업 ID (operation ID) 생성
  • 전송 상태 (send state) 기록
  • 명시적인 타임아웃 (timeout) 설정
  • 모호한 타임아웃을 확정된 실패로 취급하지 않음
  • 재시도 시 다시 게시하기 전에 원장 (ledger)을 참조할 수 있음

운영 환경 (production)에서 이 원장 (ledger)은 Redis, Postgres, DynamoDB 또는 당신이 이미 신뢰하고 있는 지속 가능한 저장소 (durable store)에 있어야 합니다.

더 나은 재시도 결정 트리 (retry decision tree)

이것이 제가 모든 봇 코드베이스에 적용하고 싶은 결정 트리 (decision tree)입니다:

모델 호출이 실패했는가?
  -> 적절하다면 연산 (compute) 재시도

...

이 한 가지 차이점이 수많은 혼란을 정리해 줍니다.

n8n에서 이를 구현하는 방법

만약 제가 내일 n8n에서 이 문제를 해결한다면, 우선 세 가지를 수행할 것입니다:

1. 타임아웃 (timeout) 예산을 알려진 긴 문맥 추론 (long-context inference) 시간보다 높게 설정합니다.
2. 모든 외부 메시지 전송 액션 (outbound message action)에 대해 중복 제거 키 (dedup key)를 생성합니다.
3. execution.retryOf와 자체 작업 ID (operation ID)를 사용하여 재시도 계보 (retry lineage)를 기록합니다.

실용적인 n8n 패턴:

  • Code 노드를 사용하여 operationId를 생성합니다.
  • Telegram 또는 Discord 노드 실행 전에 Redis/Postgres를 확인합니다.
  • 이미 전송되었다면, 워크플로우를 단락 (short-circuit) 시킵니다.
  • 전송되지 않았다면, pending 상태로 표시합니다.
  • 메시지를 전송합니다.
  • 제공자 (provider) 응답 상세 정보와 함께 sent로 표시합니다.
  • 타임아웃 또는 모호한 에러 발생 시, failed_unknown으로 표시합니다.

이것은 초록색 하트비트 (heartbeat)를 멍하니 바라보며 Claude를 탓하는 것보다 훨씬 더 유용합니다.

Temporal에서 이를 구현하는 방법

Temporal에서는 LLM 호출과 외부 부작용 (outbound side effects)을 분리하겠습니다.

  • 추론 (inference)은 재시도가 포함된 액티비티 (Activity)에 배치합니다.
  • 메시지 전달은 다른 액티비티에 배치합니다.
  • 전달 액티비티를 멱등적 (idempotent)으로 만듭니다.
  • 작업 ID (operation ID)를 액티비티 입력의 일부로 사용합니다.
  • 전송 결과를 어딘가 지속 가능한 저장소 (durable store)에 보관합니다.

실수는 "생성 + 전송"을 하나의 재시도되는 액티비티에 몰아넣고, 재시도가 알아서 잘 작동하기를 바라는 것입니다.

그렇게 되지 않습니다.

때로는 모델이 정말로 느린 경우도 있습니다

공정하게 말하자면, 때로는 모델 자체가 문제의 원인일 수 있습니다.

OpenAI, Anthropic, 로컬 Qwen, 로컬 Llama 등 당신이 무엇을 사용하든—긴 문맥 (long context), 부하 (load), 메모리 압박 (memory pressure) 또는 제공자 스로틀링 (provider throttling) 상황에서는 어떤 모델이든 느려질 수 있습니다.

멱등성 (Idempotency)이 추론 속도를 빠르게 만들어주지는 않습니다.

하지만 멱등성이 해주는 역할은, 느린 추론이 중복된 부작용 (side effects)으로 이어지는 것을 막아준다는 점입니다.

에이전트 (agents)를 대규모로 실행할 때는 이 점이 훨씬 더 중요합니다.

만약 토큰당 과금 (per-token billing) 대신 예측 가능한 정액제 (flat-rate) AI 액세스 설정을 사용하고 있다면, 여러분은 보통 에이전트 (agents)가 실행되고, 재시도 (retry)하며, 더 큰 워크로드 (workloads)를 처리하도록 허용하는 데 더 기꺼이 나설 것입니다. 이는 처리량 (throughput) 측면에서 매우 좋습니다. 하지만 이는 또한 더 나은 재시도 위생 (retry hygiene)이 필요함을 의미하는데, 공격적인 자동화는 잘못된 부작용 (side-effect) 처리를 빠르게 증폭시키기 때문입니다.

이것이 제가 Standard Compute가 하고 있는 일을 좋아하는 이유 중 하나입니다. 이는 팀들이 자동화를 과소 설계하게 만드는 토큰당 과금에 대한 불안감 (per-token paranoia)을 제거해주지만, 동시에 엔지니어링 트레이드오프 (engineering tradeoff)를 더 명확하게 만듭니다. 컴퓨팅 (compute) 비용이 저렴하고 예측 가능해지면, 워크플로 정확성 (workflow correctness)이 병목 현상 (bottleneck)이 됩니다.

그리고 워크플로 정확성은 동일한 메시지를 두 번 게시하지 않는 것에서부터 시작됩니다.

버그를 실제로 해결하는 지루하지만 중요한 교훈

만약 여러분의 봇이 Telegram이나 Discord와 통신한다면, 모든 외부 메시지를 하나의 결제 (payment)처럼 취급하십시오:

  • 메시지에 정체성 (identity)을 부여할 것
  • 재시도 (retries)가 발생할 것이라고 가정할 것
  • 전달 상태 (delivery state)를 저장할 것
  • 확인된 실패 (confirmed failure)와 알 수 없는 결과 (unknown outcome)를 구분할 것
  • "응답을 받지 못했다"와 "동작이 일어나지 않았다"를 절대 혼동하지 말 것

제가 목격하는 대부분의 지저분한 "AI 신뢰성 (AI reliability)" 버그들은 여전히 오래된 분산 시스템 (distributed-systems) 버그들입니다.

솔직히 말해서, 이는 좋은 소식입니다.

왜냐하면 그것들은 오늘 당장이라도 고칠 수 있기 때문입니다.

봇의 중복 게시를 막기 위해 GPT-6가 필요한 것은 아닙니다.

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0