Fifteen years of the same click: what the agent era keeps rediscovering about distributed systems
같은 깨달음을 15년째 반복합니다 — 에이전트 시대가 다시 발견하는 분산 시스템의 원칙
웹훅 중복 처리, reconciliation, graph engineering을 다루며 에이전트 시대의 새로운 문제처럼 보이는 현상들이 사실 오래된 분산 시스템 문제의 반복임을 설명합니다. 데이터베이스 제약, 트랜잭션 경계, 독립 검증, 명시적인 워크플로 제어 흐름이 여전히 핵심입니다.
- 주제
AI 요약
저자는 최근 며칠 동안 idempotent webhook, dead-letter queue를 포함한 reconciliation cron, graph engineering에 관해 서로 다른 대화를 나누면서도 결국 이미 알고 있던 분산 시스템의 메커니즘에 도달했다고 말합니다. 문제는 오래된 해법을 알고 있다는 사실 자체가 아니라, 새로운 이름과 에이전트라는 맥락 때문에 문제를 완전히 새로운 것으로 받아들여 하루를 생각한 뒤에야 익숙한 원칙을 떠올린다는 데 있습니다. 저자가 말하는 ‘클릭’은 새로운 기술을 이해하는 순간이 아니라, 새롭게 포장된 문제를 기존의 실패 모델과 연결하는 순간입니다.
■ 재시도 가능한 웹훅과 멱등성
웹훅이 재시도될 때 중복 결제가 발생한다면, 테스트로 중복 효과를 발견할 수는 있지만 테스트 요청 자체가 멱등성 보장을 만들어주지는 않습니다. 순차적으로 같은 이벤트를 두 번 보내면 두 번째 요청이 첫 번째 요청의 기록을 발견할 수 있지만, 실제 경쟁 조건은 두 요청이 동시에 도착할 때 발생합니다. 두 요청 모두 ‘아직 처리되지 않았다’고 읽고 진행하면 각 코드가 작성된 대로 실행되더라도 결과는 두 번 기록됩니다.
저자는 이벤트 식별자에 대해 데이터베이스의 non-null unique constraint를 두고, 필요하다면 provider와 account 범위까지 포함해야 한다고 설명합니다. 중복 판정용 레코드를 먼저 삽입하고, 삽입에 성공한 요청만 로컬 비즈니스 작업을 수행하게 해야 합니다. 이 삽입과 비즈니스 변경 사항은 같은 트랜잭션에서 커밋해야 합니다. 처리 기록만 먼저 커밋한 뒤 작업 전에 장애가 발생하면 다음 재시도가 영원히 버려질 수 있고, 반대로 작업은 수행했지만 처리 기록을 보호하지 않으면 중복 효과가 다시 발생하기 때문입니다.
여기서 애플리케이션 수준의 ‘조회 후 기록’은 경쟁 조건을 해결하지 못합니다. 동시에 실행된 두 요청이 같은 조회 결과를 보고 모두 쓰기를 진행할 수 있기 때문입니다. 경쟁하는 삽입을 조정하는 책임은 데이터베이스에 있어야 하며, 저자는 PostgreSQL의 uniqueness check 설명을 근거로 충돌 검사가 삽입 과정 안에서 이뤄져야 한다고 봅니다.
외부 결제나 이메일 발송은 또 다른 시스템 경계를 넘습니다. 따라서 전송 의도는 transactional outbox를 사용해 로컬 데이터베이스 트랜잭션 안에 함께 기록하고, 별도 worker가 이를 전달하게 합니다. 다만 worker 자체는 같은 메시지를 두 번 전달할 수 있습니다. 수신 provider가 멱등성 계약을 제공한다면 안정적인 operation key를 사용하고, provider가 해당 키를 보관하는 retention window도 고려해야 합니다. 로컬 데이터베이스의 unique row만으로는 외부 서비스 내부의 중복을 막을 수 없습니다. 또한 Stripe처럼 이벤트 전달 순서를 보장하지 않는 provider의 현재 상태를 projection할 때는 오래된 알림을 그대로 적용하기보다 provider를 다시 읽는 방식을 선호한다고 설명합니다. 동시 refresh에는 직렬화나 로컬 기록 전 version check도 필요합니다.
저자가 제안하는 테스트는 단순한 재요청 테스트보다 구체적입니다. 동일 이벤트를 동시에 전달한 뒤 실제 효과를 확인해야 합니다. 하나의 레코드와 하나의 이메일만 생성되어야 한다면 각각의 개수를 세고, 커밋 경계 부근에서 처리를 중단한 뒤 다시 재시도해야 합니다. 이는 동시성에 의한 중복과 커밋 전후 장애라는 서로 다른 실패 모드를 검증합니다.
■ 독립적인 reconciliation은 성공 보고와 다릅니다
저자는 다른 대화에서, 웹훅이 전달 완료를 보고했지만 downstream consumer가 schema mismatch 때문에 메시지를 조용히 버린 사례를 소개합니다. 전달 로그만 보면 정상처럼 보였지만, inbound 수와 저장된 데이터 수를 비교하는 reconciliation 단계에서야 손실이 드러났습니다. 기존 파이프라인을 전면 재작성하지 않고 이 문제를 다루기 위해 저자는 독립적인 두 번째 reader를 제안합니다.
이 reader는 자체 스케줄로 동작하는 read-only job이며, system of record에서 예상되는 데이터를 읽고 실제로 저장된 결과와 비교합니다. 기존 파이프라인 내부에서 read-back을 수행하면 같은 캐시, 같은 자격 증명, 같은 성공 정의를 공유하므로 원래 실행이 가진 맹점을 그대로 반복할 수 있습니다. 독립적인 reconciler는 내구성 있는 원천에서 기대 레코드를 산출하고, 의도적으로 선택한 별도 경로를 통해 저장 결과를 읽습니다. 따라서 결과는 또 하나의 ‘실행 완료’ 보고서가 아니라 discrepancy report가 됩니다.
이는 분산 트랜잭션을 포기한 뒤 microservices가 요구하는 reconciliation cron과 같은 역할을 합니다. 한쪽만 진행되고 다른 쪽은 진행되지 않았을 때, 어느 구성 요소의 성공적인 요청만으로 전체 합의를 추론할 수 없기 때문입니다. 가장 단순한 검사는 개수 비교지만, 개수가 같아도 한 레코드가 누락되고 다른 레코드가 중복되면 이를 발견하지 못합니다. 식별자만 일치시켜도 객체 내부가 비어 있는 문제는 놓칠 수 있습니다. 저자는 실제 테스트 앱에서 제목과 slug는 있지만 문단이 하나도 없는 프랑스어 초안이 성공한 6월 번역 작업 뒤에 남았던 사례를 듭니다. 따라서 개수 검증 위에 객체 유형별 content invariant가 필요하며, 해당 사례에서는 최소 하나의 문단이 조건이 됩니다.
schema rejection은 아무 흔적 없이 사라지는 대신 복구 가능한 레코드로 dead-letter queue에 들어가야 합니다. 고유 메시지로 구성된 고정 배치의 처리가 끝나 각 메시지에 하나의 결과가 생겼다면, 회계식은 다음과 같이 단순해집니다.
inbound = stored + dead-lettered
처리가 진행 중이라면 pending도 식에 포함해야 합니다. 전달 시도 횟수와 고유 레코드 수를 서로 다른 기준으로 세면 이 식은 의미를 잃습니다. 또한 dead-lettered 메시지는 저장만 해서는 복구되지 않으므로 담당자와 recovery path가 필요합니다. Reconciler는 문제를 고치는 장치가 아니라, 파이프라인이 놓친 불일치를 드러내는 backstop입니다.
■ Graph engineering은 에이전트의 제어 흐름을 드러냅니다
graph engineering 논의에서 저자가 주목한 변화는 그래프가 무엇을 표현하는가입니다. 지식 사이의 관계만 보던 관점에서 벗어나면, 어떤 작업이 시작될 수 있는지, 무엇에 의존하는지, 한 branch가 실패했을 때 무엇이 일어나는지를 실행 구조로 볼 수 있습니다. 이 의미에서 graph engineering은 에이전트 workflow의 control flow를 명시적이고 inspectable하게 만듭니다.
노드는 단계이고, edge와 condition은 허용된 전이를 표현합니다. 여기에는 병렬 작업이 어떻게 다시 합쳐지는지도 포함됩니다. 모델을 호출하는 노드의 응답이 가변적이어도 주변 소프트웨어는 이 구조를 바탕으로 상태 계약과 실패 처리를 적용할 수 있습니다. 저자가 먼저 검증하고 싶은 사례는 fan-out 이후 한 노드가 조용히 실패하는 경우입니다. 두 검사를 병렬로 시작한 review workflow에서 한 검사는 돌아오고 다른 검사는 timeout된다면, join이 무엇을 근거로 진행해야 하는지 명확해야 합니다. 누락된 결과를 노출할지, 해당 branch만 재시도할지, review를 incomplete로 표시할지 정하지 않은 다이어그램은 정상 경로만 그린 것이며 실제 workflow를 정의한 것이 아닙니다.
모델은 다음 단계를 선택하는 데 도움을 줄 수 있지만, 그 선택도 execution contract 안에 기록되어야 합니다. 그렇지 않으면 다이어그램은 보통 일어나는 일을 설명할 뿐이고, 실제 제어 흐름은 대화 속에 흩어집니다. 저자는 서비스 오케스트레이션에서 익숙했던 문제의 상자 안에 모델이 들어왔다고 해서 화살표가 허용하는 상태 전이와 실패 처리를 덜 중요하게 볼 이유는 없다고 말합니다.
■ 에이전트의 설명과 시스템의 증거를 분리해야 합니다
세 논의를 관통하는 문제는 각 참여자가 전체 결과의 일부 증거만 가지고 있다는 점입니다. worker는 이벤트를 받았다는 사실만 알고, sender는 요청이 승인됐다는 사실만 알며, coordinator는 한 branch의 결과만 받을 수 있습니다. 누구도 전체 작업의 결과를 보장하지 않습니다. 에이전트는 여기에 특히 설득력 있는 참여자로 추가됩니다. 실패한 구성 요소의 오류 메시지보다 더 그럴듯한 문단으로 성공 이유를 설명할 수 있기 때문에, 실제 증거보다 그 설명을 높게 평가하기 쉽습니다.
그러나 트랜잭션은 중복 쓰기를 막을 수 있어도 기록할 내용이 올바른지 판단하지는 못합니다. 그래프는 의사결정을 inspectable하게 만들 수 있어도 그 결정이 정답임을 보장하지는 않습니다. 오래된 메커니즘은 일부 실패를 가둘 수 있지만, 모델의 판단을 검증하는 일은 여전히 별도의 작업입니다. 저자는 프랑스 코미디 시리즈 ‘Kaamelott’의 Perceval이 모르는 단어가 나오면 “C’est pas faux”, 즉 대략 “틀렸다고 할 수는 없네요”라고 말하는 장면을 빗대어, 새로운 이름을 이해하기 전에 이미 알고 있던 메커니즘을 알아보는 자신의 모습을 설명합니다. 15년의 시스템 아키텍처 경험은 생각하는 시간을 없애주지는 않았지만, 그 생각이 끝난 뒤 도달할 유용한 장소를 알려줬으며, 저자는 그 ‘클릭’이 조금 더 일찍 오기를 바란다고 말합니다.
원문: dev.to / 번역·요약: Trawling