본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 17. 06:24

이메일 에이전트 구축 시 흔히 발생하는 실수와 해결 방법

요약

이메일 에이전트 구축 시 발생할 수 있는 무한 루프, 중복 응답, 동시성 문제 등 흔한 실수와 그 해결 방법을 다룹니다. 웹훅 필터링, 원자적 중복 제거, 분산 잠금 메커니즘의 중요성을 강조합니다.

핵심 포인트

  • 에이전트 자신의 이메일 주소를 필터링하여 무한 답장 루프 방지
  • 메시지 ID 기반의 원자적 체크 앤 셋을 통한 웹훅 중복 처리 방지
  • 동시 워커 간 충돌 방지를 위해 TTL이 포함된 스레드별 잠금(Locking) 적용
  • 웹훅 페이로드의 제한적인 정보로 인한 데이터 신뢰성 문제 주의

한 팀이 목요일에 그들의 첫 번째 이메일 에이전트 (email agent)를 출시합니다. 데모는 훌륭했고, 핸들러 (handler)는 배포되었으며, 웹훅 (webhook)도 등록되었습니다. 금요일 아침, 온콜 (on-call) 담당자가 깨어보니 에이전트가 밤새도록 자신의 답장에 열정적으로 답장을 보내고 있었고, 한 고객은 동일한 답변을 세 번이나 받았으며, Gmail의 스레드는 다섯 개의 별도 대화로 산산조각 나 있었습니다. 이 중 어느 것도 이례적인 실패가 아니었습니다. 이 모든 것은 Nylas Agent Accounts 쿡북 (cookbook)에 문서화되어 있는, 해결 방법이 알려진 잘 알려진 실수들입니다 (제품은 베타 버전이지만, 이러한 실수들은 시대를 초월합니다).

출시 전 제가 확인해 볼 9가지 항목은 다음과 같습니다.

1. 에이전트가 자기 자신에게 답장함

message.created 웹훅 (webhook)은 아웃바운드 (outbound) 메시지에도 발생합니다. 즉, 에이전트가 API를 통해 답장을 보낼 때, 해당 발신 메시지가 수신 메일과 동일한 이벤트를 트리거합니다. 이 확인 과정을 건너뛰면 당신은 영구 기관을 만든 셈입니다: 답장, 웹훅, 다시 답장.

해결 방법: 다른 로직이 실행되기 전, 핸들러 (handler)의 최상단에서 에이전트 자신의 주소를 필터링하세요.

const sender = msg.from?.[0]?.email;
if (sender === AGENT_EMAIL) return;

2. 웹훅 중복 제거 (Webhook deduplication) 부재

전송은 '최소 한 번 (at-least-once)' 보장됩니다. 만약 엔드포인트 (endpoint)가 충분히 빠르게 200을 반환하지 못하거나 네트워크에 문제가 생기면, 동일한 message.created 알림이 다시 도착하며, 단순한 핸들러 (handler)는 두 번 답장을 보내게 됩니다. 중복 제거 레시피 (dedup recipe)에서는 이를 중복 발생의 가장 흔한 원인으로 지목합니다.

해결 방법: 처리하기 전에 메시지 ID에 대해 원자적 체크 앤 셋 (atomic check-and-set)을 수행하세요. Postgres에서는 INSERT ... ON CONFLICT DO NOTHING, Redis에서는 SET id 1 NX EX 86400을 사용합니다. 테이블이 무한정 커지지 않으면서도 늦게 도착하는 재전송을 잡아낼 수 있도록 레코드에 24~48시간의 TTL (Time To Live)을 부여하세요.

3. 잠금 (Locking) 없는 중복 제거

두 개의 동시 워커 (concurrent workers, 예: Lambda 인스턴스, ECS 태스크)가 동일한 밀리초 내에 체크 앤 셋 (check-and-set)을 통과하여 둘 다 답장을 생성할 수 있습니다. 중복 제거 (Dedup)는 동일한 이벤트가 두 번 전달되는 것은 잡아내지만, 동일한 이벤트가 _동시에 처리되는 것_은 잡아낼 수 없습니다.

해결 방법 (Fix): 충돌(crash)이 발생한 워커(worker)가 스스로 잠금을 해제할 수 있도록 30초의 TTL (Time-To-Live)을 가진 스레드별 잠금 (per-thread lock)을 사용하세요. 그리고 잠금 내부에서 해당 스레드의 최신 메시지를 검사하여 에이전트가 이미 답장을 보냈다면 중단하는 이중 확인 (double-check) 로직을 추가해야 합니다. 중복 제거 (Dedup)와 잠금 (Locking)은 모두 필요합니다. 이 둘은 서로 다른 실패 모드 (failure modes)를 방지합니다.

4. 메시지 본문을 위해 웹훅 페이로드 (webhook payload)를 신뢰하는 문제

웹훅은 subject, from, snippet과 같은 요약 필드만 전달하며, 전체 본문 (full body)을 전달하지 않습니다. 더 심각한 것은, 본문이 약 1MB를 초과할 경우 이벤트 유형이 message.created.truncated로 변경되며 본문이 완전히 생략된다는 점입니다. 페이로드를 직접 파싱하는 에이전트는 테스트 환경에서는 작동하지만, 실제 이메일 환경에서는 실패합니다.

해결 방법 (Fix): 답장 처리 레시피 (reply-handling recipe)에서 하는 방식처럼, 페이로드에 포함된 ID를 사용하여 API로부터 항상 전체 메시지를 가져오고, 잘린 (truncated) 이벤트 유형을 명시적으로 처리하세요.

5. 스레드가 형성되지 않는 답장

"답장"을 새로운 메시지로 보내면 수신자의 클라이언트에는 인용된 문맥 (quoted context)이나 대화 그룹화 (conversation grouping)가 없는, 연결되지 않은 이메일로 도착합니다. 대화가 몇 차례 오가면 고객은 하나의 논의가 다섯 개의 파편으로 쪼개진 것을 찾아 헤매게 됩니다.

해결 방법 (Fix): 모든 답장에 reply_to_message_id를 전달하세요. 이렇게 하면 플랫폼이 In-Reply-ToReferences 헤더를 설정하여 Gmail, Outlook 및 에이전트 자신의 편지함에서 메시지가 올바르게 스레드 (thread)로 묶이게 됩니다. 들어오는 답장은 제목 (subject line)이 아니라 반드시 thread_id로 매칭하세요. 제목은 수정될 수 있으며, 서로 다른 두 개의 스레드가 동일한 제목을 공유할 수도 있습니다.

await nylas.messages.send({
  identifier: AGENT_GRANT_ID,
  requestBody: {
...

6. 모든 메시지에 즉각적으로 답장하는 문제

사람은 수정 사항을 보냅니다. 수신자가 답장을 보낸 후 실수를 발견하고 15초 후에 후속 메시지를 보냈는데, 에이전트가 이미 첫 번째 메시지에 답장을 해버렸다면 에이전트는 두 번째 메시지에도 답장을 하게 되고 대화가 갈라지게 (fork) 됩니다.

해결 방법 (Fix): 활성 스레드 (active threads)에서 응답하기 전에 30~60초의 쿨다운 (cooldown) 시간을 두고, 연속해서 들어오는 수신 메시지들을 하나의 신중한 답장으로 배치 (batching) 처리하세요.

7. 아웃바운드 서킷 브레이커 (outbound circuit breaker) 부재

중복 제거 (dedup), 잠금 (locking), 자체 필터링 (self-filtering)을 적용하더라도, 로직 버그로 인해 답장 폭풍 (reply storm)이 발생할 수 있으며, 자율적인 발신자는 기계적인 속도로 실패를 확산시킵니다. 이는 중복 제거 레시피를 적용할 때 반드시 함께 배포해야 하는 안전망입니다.

해결 방법: 스레드당 발신 예산 (per-thread send budget)을 설정하세요. 만약 에이전트가 하나의 스레드에서 5분 이내에 3개 이상의 메시지를 보냈다면, 발신을 중단하고 사람에게 에스컬레이션 (escalate) 하세요. 속도 제한 (rate limit)이 트리거되는 것은 단순한 알림 (page) 수준이지만, 통제 불능의 에이전트는 사과 여행 (apology tour)을 떠나게 만듭니다.

8. 스팸이 에이전트를 깨우게 방치하는 것

스팸, 반송 (bounce-backs), 부재중 자동 응답 (out-of-office auto-replies)은 모두 message.created 이벤트를 발생시킵니다. 이 모든 메시지가 LLM에 도달한다면, 쓰레기를 처리하기 위해 추론 비용 (inference costs)을 지불하게 되며, 에이전트가 그 쓰레기에 _답장_을 할 위험도 있습니다.

해결 방법: 규칙 (rules)을 사용하여 애플리케이션 하단에서 필터링을 수행하세요. block 규칙은 알려진 악성 발신자를 SMTP 레벨에서 거부하므로 코드가 메시지를 아예 접하지 않게 합니다. assign_to_folder는 자동 알림을 받은 편지함(inbox) 외의 곳으로 라우팅하여, 핸들러 (handler)가 에이전트가 답장해서는 안 되는 폴더를 건너뛸 수 있게 합니다. 규칙은 우선순위 순서 (0–1000, 낮은 숫자가 먼저)로 실행되므로, 광범위한 contains 규칙보다 구체적인 일치 규칙을 먼저 배치하세요. 처음 일치하는 block 규칙이 최종 결정됩니다.

9. 차단된 발신을 재시도 가능한 오류로 취급하는 것

워크스페이스에 아웃바운드 규칙 (outbound rules)이 있는 경우, block 규칙에 일치하는 발신은 403을 반환합니다. 이 규칙은 제공업체가 개입하기 전에 거부한 것이므로, 어떤 재시도 (retry)를 해도 전달되지 않습니다. 일반적인 재시도 로직을 가진 에이전트는 해당 발신을 영원히 반복 시도하며 네트워크가 불안정하다고 보고할 것입니다.

해결 방법: 발신 시 발생하는 403을 최종 오류 (terminal error)로 취급하세요. 이를 로그에 기록한 다음, GET /v3/grants/{grant_id}/rule-evaluations를 쿼리하여 정확히 어떤 규칙이 일치했고 어떤 데이터가 평가되었는지 확인하세요. 해당 엔드포인트는 "왜 이 메시지가 발송되지 않았는가?"에 대한 가장 빠른 해답을 제공합니다.

에러 핸들러(error handler)에 인코딩할 가치가 있는 미묘한 차이가 하나 있습니다. 규칙 평가는 '실패 시 차단(fails closed)' 방식으로 작동합니다. 즉, 일시적인 인프라 문제(예: in_list 매칭 중 리스트 조회 실패)로 인해 block 규칙을 평가할 수 없는 경우, 발송은 어쨌든 차단됩니다. 하지만 이때 응답은 403이 아닌 503으로 반환되며, 감사 기록(audit record)에는 blocked_by_evaluation_error: true가 포함됩니다. 따라서 규칙은 간단합니다: 503은 재시도하고, 403은 절대 재시도하지 마세요. 이 둘을 혼동하는 것이 에이전트가 전달 가능한 메일을 포기하거나, 전달 불가능한 메일을 계속해서 몰아붙이게 만드는 원인입니다.

9가지 사례 전체를 관통하는 패턴은 다음과 같습니다: 이메일 에이전트는 LLM 프롬프트(prompt)가 아니라, '최소 한 번 전달(at-least-once)' 인프라와 자율적 행동(autonomous action) 사이의 경계에서 실패합니다. 해결책은 지루합니다. 필터, 잠금(lock), 제한(cap), 규칙 같은 것들이며, 바로 그것이 핵심입니다. 언어 모델과 실제 사람의 편지함 사이에 서 있어야 하는 것은 바로 이런 지루한 것들입니다.

이를 출시 전 체크리스트로 만드세요: 9가지 항목이며, 부하 테스트(load test) 시에는 동시 연결에서 중복 웹훅(webhook)을 발송하여 특히 2번과 3번 항목을 실행해 보아야 합니다. 이 중 어떤 것이 운영 환경(production)에서 당신을 괴롭혔나요? 그리고 그 해결책이 이 목록에 있었나요?

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0