이메일 에이전트로부터 배운 멱등성 (Idempotency) 교훈
요약
이메일 에이전트 사례를 통해 이벤트 기반 시스템에서 멱등성(Idempotency)의 중요성을 설명합니다. 최소 한 번 전달(at-least-once delivery) 보장 환경에서 중복 메시지를 처리하기 위한 원자적 체크 앤 셋(atomic check-and-set) 전략을 제안합니다.
핵심 포인트
- 최소 한 번 전달(at-least-once)은 웹훅 시스템의 정직한 계약임
- 중복 메시지 처리를 위해 메시지 ID 기반의 원자적 체크 앤 셋 필요
- Postgres의 ON CONFLICT나 Redis의 NX 옵션을 활용한 경합 조건 방지
- 메시지 ID에 TTL을 부여하여 저장소 효율성 유지
- 처리를 시작하기 전 빠른 응답(200 OK)을 통해 재시도 방지
한 고객이 오전 9:14에 지원 에이전트(support agent)에게 이메일을 보냅니다. 9:15에 고객은 도움이 되는 답변을 받습니다. 9:16에 고객은 토씨 하나 틀리지 않고 똑같은 답변을 다시 받습니다. 아무것도 충돌하지 않았습니다. 예외(exception)도 발생하지 않았습니다. 당신의 에이전트는 단지 지시받은 일을 정확히 두 번 수행했을 뿐입니다.
저는 이메일 에이전트가 제가 지난 몇 년간 본 것 중 멱등성 (Idempotency)을 가르쳐주는 가장 좋은 스승이라고 생각합니다. 왜냐하면 실패 모드(failure mode)가 매우 직관적이기 때문입니다. 중복된 데이터베이스 행(database row)은 눈에 보이지 않습니다. 하지만 중복된 이메일은 사람의 편지함에 도착하여 당신의 제품이 고장 난 것처럼 보이게 만듭니다. Nylas Agent Accounts (현재 베타 버전)에서 답변 루프(reply loop)를 구축하면서, 저는 이메일뿐만 아니라 모든 이벤트 기반 시스템 (event-driven system)에 적용되는 교훈들을 내면화하게 되었습니다.
교훈 1: 최소 한 번 전달 (at-least-once)은 정직한 계약이다
본능적으로 플랫폼을 탓하게 됩니다: "왜 웹훅 (webhook)을 똑같이 두 번 받았지?" 하지만 최소 한 번 전달 (at-least-once delivery)은 웹훅 시스템이 제공할 수 있는 유일하고 정직한 보장입니다. 중복 답변 문서 (duplicate-reply docs)에 따르면, 엔드포인트 (endpoint)가 200을 충분히 빠르게 반환하지 않거나, 일시적인 네트워크 오류 (transient network blip)로 응답이 사라지면 message.created 알림이 다시 전달됩니다. 그 대안인 정확히 한 번 전달 (exactly-once)은 플랫폼이 불확실할 때마다 이벤트를 조용히 누락시킨다는 것을 의미하며, 누락된 이벤트는 반복된 이벤트보다 더 나쁩니다.
따라서 중복은 보고해야 할 버그가 아닙니다. 그것은 설계 단계에서 고려해야 할 계약입니다. 해결책은 메시지 ID (message ID)를 키로 사용하는 원자적 체크 앤 셋 (atomic check-and-set)입니다:
const alreadyProcessed = await db.processedMessages.setIfAbsent(messageId, {
receivedAt: Date.now(),
});
...
저장소(storage)보다 원자성 (atomicity)이 더 중요합니다. Postgres에서는 INSERT ... ON CONFLICT DO NOTHING이 그 역할을 하며, Redis에서는 SET messageId 1 NX EX 86400이 그 역할을 합니다. '읽고 나서 쓰기 (read-then-write)' 시퀀스는 당신이 해결하려는 경합 조건 (race condition)을 다시 불러옵니다. 그리고 레코드에 TTL (Time To Live)을 부여하세요. 24시간에서 48시간 정도면 테이블이 영원히 커지지 않으면서도 재전송 (redelivery) 상황을 커버할 수 있습니다. 그 기간이 지난 후 동일한 메시지 ID에 대해 웹훅 (webhook)이 들어온다면, 그것은 재전송이 아니라 거의 확실하게 당신의 시스템 자체에 있는 버그이며, 당신은 이를 반드시 드러내야 (surface) 합니다.
조금 더 조용한 결론도 있습니다: 행동하기 전에 승인(acknowledge)하세요. 문서의 예시 핸들러는 첫 번째 줄에서 res.status(200).end()를 호출한 다음 처리를 시작합니다. 엔드포인트가 응답하기 전에 LLM 호출에 소비하는 매 초는, 플랫폼이 전송 실패를 결정하고 재시도 (retry)를 큐에 넣을 수 있는 시간입니다. 재전송을 완전히 없앨 수는 없지만, 스스로 재전송을 만들어내는 일은 멈출 수 있습니다.
레슨 2: 중복 제거 (dedup)와 잠금 (locking)은 서로 다른 문제를 해결한다
여기가 대부분의 사람들이 놓치는 부분입니다. 중복 제거 (Deduplication)는 동일한 이벤트가 두 번 전달되는 것을 잡아냅니다. 하지만 동일한 이벤트가 동시에 두 번 처리되는 것에 대해서는 아무런 조치도 취하지 못합니다. 만약 핸들러가 Lambda나 여러 워커 프로세스 (worker processes)에서 실행된다면, 두 인스턴스가 동일한 밀리초(millisecond) 범위 내에서 '확인 후 설정 (check-and-set)' 과정을 통과해 버릴 수 있습니다.
문서에서는 워커가 충돌하여 종료되더라도 자동으로 해제될 수 있도록 30초 TTL을 가진 스레드별 잠금 (per-thread lock)을 권장합니다. 그리고 잠금 내부에서는 실제 데이터 (ground truth)를 바탕으로 이중 확인 (double-check)을 수행합니다: 스레드를 가져와서 latestDraftOrMessage를 확인하고, 만약 from 주소가 에이전트 자신의 주소라면 중단합니다. 웹훅이 도착한 시점과 당신의 잠금을 획득한 시점 사이에, 다른 워커가 이미 모든 작업을 완료했을 수도 있습니다. 스레드 그 자체만이 이에 대해 거짓말을 할 수 없는 유일한 기록입니다.
중복 제거, 그다음 잠금, 그다음 상태 검증으로 이어지는 이 계층적 구조는 일반화될 수 있습니다. 멱등성 (Idempotency)은 단일 메커니즘이 아닙니다. 그것은 이전 단계가 잡아내지 못한 것을 각각 잡아내는, 저렴한 확인 절차들의 스택 (stack)입니다.
레슨 3: 최고의 조정 (coordination)은 조정이 없는 것이다
가장 까다로운 중복은 인프라에서 발생하는 것이 아닙니다. 그것은 동일한 수신함(inbox)을 지켜보는 두 명의 행위자, 즉 두 명의 에이전트 혹은 에이전트와 인간이 모두 동일한 메시지에 답장이 필요하다고 결정할 때 발생합니다. 이것은 중복 제거(dedup)로 해결할 수 있는 문제가 아닙니다. 이것은 중복 이벤트가 아니라 조정(coordination)의 문제입니다.
가장 깔끔한 해결책은 아키텍처적인 것입니다: 하나의 에이전트, 하나의 수신함. 에이전트 계정(Agent Accounts)을 사용하면 각 에이전트가 자신만의 주소와 자신만의 웹훅(webhook) 스트림을 갖게 되므로(예: sales-agent@, support-agent@, scheduling@가 각각 자신의 grant_id로 필터링) 이를 거의 비용 없이 구현할 수 있습니다. 겹치는 부분이 없다는 것은 해결해야 할 충돌이 없음을 의미합니다. 인간에게 가시성이 필요한 경우, 두 번째 작성자가 되는 대신 읽기 전용 IMAP 액세스를 제공하면 됩니다.
이것은 축소판으로 보는 분산 시스템(distributed-systems)의 교훈입니다: 파티셔닝(partitioning)은 감당할 수 있는 한 락킹(locking)보다 항상 우월합니다.
레슨 4: 당신의 로직이 다음 버그라고 가정하라
세 가지 레이어를 모두 갖추더라도 여전히 답장 폭풍(reply storm)을 만들어낼 수 있습니다. 아웃바운드(Outbound) 전송 또한 message.created를 발생시킵니다. 만약 핸들러(handler)가 에이전트 자신의 메시지를 건너뛰는 것을 잊는다면, 에이전트는 자기 자신에게 답장을 하게 되고, 이는 또 다른 웹훅을 트리거하여 영원히 반복됩니다. 첫 번째 방어책은 모든 핸들러 상단에 두 줄의 코드를 추가하는 것입니다:
// 모든 핸들러의 첫 번째 체크 — 에이전트 자신의 메시지는 건너뜁니다.
const sender = msg.from?.[0]?.email;
if (sender === AGENT_EMAIL) return;
두 번째 방어책은 스레드당 전송 예산(per-thread send budget)입니다: 5분 이내에 3회 이상의 전송이 발생하면 무언가 잘못된 것이므로, 전송을 중단하고 인간에게 에스컬레이션(escalate)해야 합니다.
이것은 멱등성(idempotency)의 과소평가된 사촌 격인 기능입니다. 즉, 당신의 정확한 코드가 대량의 트래픽 상황에서 잘못된 동작을 할 때를 위한 서킷 브레이커(circuit breaker)입니다. 중복 제거(dedup)가 플랫폼으로부터 당신을 보호한다면, 속도 제한(rate limit)은 당신 자신으로부터 당신을 보호합니다.
레슨 5: 처리하기에 가장 저렴한 이벤트는 절대 발생하지 않는 이벤트이다
이 모든 것 아래에는 한 단계의 레이어가 더 존재합니다. 에이전트 계정(Agent Accounts)은 웹훅 핸들러(webhook handler)가 메시지를 확인하기도 전에 수신 메일을 분류하는 서버 측 규칙(rules)을 지원합니다. 예를 들어, 자동 알림은 에이전트가 답장하지 않는 폴더로 라우팅하거나, SMTP 레이어에서 스팸을 차단하거나, 응답이 필요 없는 메일을 아카이브할 수 있습니다.
curl --request POST \
--url "https://api.us.nylas.com/v3/rules" \
--header "Authorization: Bearer <NYLAS_API_KEY>"
...
그러면 당신의 핸들러는 메시지가 어떤 폴더에 도착했는지 확인하고, 에이전트가 건드려서는 안 되는 폴더는 건너뜁니다. 선언적(declaratively)으로 필터링해낸 모든 메시지는 당신의 멱등성(idempotency) 스택이 정확성을 유지할 필요가 없는 메시지가 됩니다. 입력 공간(input space)을 줄이는 것은 아무도 블로그 포스트를 쓰지 않는 멱등성 전략입니다. 왜냐하면 그것이 엔지니어링(engineering)이라기보다는 설정(configuration)처럼 보이기 때문입니다.
진지하게 고려할 만한 반론
"이메일을 보내는 데 치고는 장치가 너무 많다." 맞는 말입니다. 만약 당신의 에이전트가 단일 스레드(single-threaded) 프로세스에서 하루에 10개의 메시지만 처리한다면, 중복 제거(dedup) 테이블만으로도 충분할 것이며, 락(lock)을 거는 것은 시기상조일 수 있습니다. 문서 자체에서도 인위적인 동시성 부하 테스트(synthetic concurrent load testing)만이 레이스 컨디션(race condition)을 드러낼 수 있는 유일한 방법이라고 명시하고 있는데, 이는 단일 스레드 배포 환경에서는 해당 문제가 발생하지 않을 것임을 암시합니다.
하지만 비용의 비대칭성(cost asymmetry)이 결정의 근거가 되어야 합니다. 전체 스택은 기껏해야 40줄 정도의 코드입니다. 고객에게 답장을 두 번 보내는 것은 되돌릴 수 없는 신뢰 문제(trust incident)를 야기합니다. 저는 차라리 40줄의 코드를 더 가져가겠습니다.
훔쳐올 만한 가치가 있는 습관 하나를 더 공유하자면, 모든 스킵(skip)을 로그로 남기라는 것입니다. 메시지가 중복되었거나 다른 워커(worker)가 락(lock)을 보유하고 있어 드롭(drop)된 경우, 이를 기록하십시오. 침묵하는 멱등성은 정확하긴 하지만 디버깅(debugging)이 불가능합니다.
만약 답장 루프(reply loop)를 구축하고 있다면, 방지 레시피(prevention recipe)를 처음부터 끝까지 읽은 다음, 5개의 동시 연결(concurrent connections)에서 핸들러(handler)로 동일한 웹훅(webhook) 페이로드(payload)를 발송하는 부하 테스트(load test)를 작성하십시오. 정확히 단 하나의 답장만 나간다면, 배포할 자격을 얻은 것입니다. 당신이 배포했던 최악의 중복 동작(duplicate-action) 버그는 무엇이었나요? 그리고 어떤 계층(layer)에서 이를 잡아낼 수 있었을까요?
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기