LabHub
배우기 러닝패스 코스

CAPA — Argo 프로젝트 인증 어소시에이트 · 이벤트 전달과 중복 업무 효과 실험실 · 이론

주문은 하나인데 워크플로는 왜 두 번 실행됐을까

LabHub 에서 이어서 보기

한 줄 요약

이벤트를 받았다는 사실, 워크플로가 실행됐다는 사실, 주문이 장부에 반영됐다는 사실은 서로 다른 증거입니다. 세 가지를 같은 성공으로 취급하면 장애를 놓치고 중복 처리를 만들 수 있습니다.

왜 이게 필요했나

가상의 주문 접수 서버를 운영한다고 생각해 봅시다. 사용자는 주문 버튼을 한 번 눌렀지만 네트워크가 불안해 응답을 보지 못했습니다. 화면은 다시 시도하라고 안내하고, 사용자는 같은 주문을 한 번 더 보냅니다. 서버에는 두 HTTP 요청이 도착합니다. 두 요청을 각각 새로운 사건으로 처리하는 이벤트 시스템에서는 서로 다른 실행이 만들어질 수 있습니다. 버튼을 한 번 눌렀다는 사용자 경험이 서버의 한 번 실행을 보장하지는 않습니다.

반대 방향의 오해도 있습니다. 웹훅은 200으로 응답했는데 업무는 시작되지 않았습니다. 필터가 입력을 거절했을 수도 있고, Sensor에 Workflow를 만들 권한이 없을 수도 있습니다. 이 경우 클라이언트 응답만 보고 성공이라고 보고하면 다음 장애 조사자는 처음부터 잘못된 가정 위에서 출발합니다.

이번 실습은 실제 결제가 아니라 작은 주문 장부입니다. 잘못된 경로를 일부러 적용하고, 예상하지 않은 입력을 보내고, 제출 권한을 빠뜨려 봅니다. 이후 같은 주문을 두 번 보내 업무 반영 횟수를 비교합니다. 실패를 겪는 것이 목적이 아니라, 실패한 계층을 증거로 좁히는 방법을 배우는 것이 목적입니다.

어떻게 동작하나

준비 신호에도 경계가 있다

EventSource는 HTTP 요청을 받아 이벤트 버스로 보내고 Sensor는 이를 구독해 조건을 평가합니다. Deployment가 준비됐다는 것은 파드 준비 조건이지, 특정 이벤트의 구독까지 모두 끝났다는 뜻은 아닙니다. 탐침에서는 파드 준비 직후 보낸 정상 대조군까지 실행되지 않았습니다. 이 버전의 JetStream 연결 구현은 새 소비자가 구독하기 시작한 이후 메시지를 대상으로 합니다. 따라서 첫 이벤트를 보내기 전에 해당 Sensor 파드와 구독 상태를 함께 확인해야 합니다.

로그의 시제도 읽어야 합니다. Subscribing은 시도 중이라는 뜻이며 구독 호출보다 앞에 기록됩니다. 성공한 구독의 로그와 실제 소비자 대기 상태를 확인한 뒤 첫 업무 이벤트를 보냅니다. 단순히 몇 초를 더 자는 방법은 느린 환경에서 다시 실패할 수 있습니다. 이 확인은 실습의 출발점을 맞추는 절차이지 운영 환경의 모든 이벤트 유실을 막는 보장은 아닙니다.

필터는 입력 계약의 일부다

웹훅 이벤트의 HTTP 본문은 이벤트 데이터 안의 body에 들어갑니다. 본문에 kind가 있으면 데이터 필터 경로는 body.kind입니다. body.event.kind로 적었다면 HTTP 본문에 event라는 중첩 객체가 있어야 합니다. 경로가 없을 때의 거부를 보려면, 같은 Sensor가 받아들일 정상 대조군도 함께 성공시켜야 합니다. 아무 워크플로도 없다는 사실만으로 필터가 정확하다고 결론 내릴 수는 없습니다.

문자열 필터는 정규식으로 평가됩니다. order.created라는 패턴의 점은 문자 그대로의 점이 아니고 시작과 끝도 고정하지 않습니다. 실측에서는 pre-orderXcreated-tail도 통과했습니다. 정확한 종류 하나를 허용하려면 점을 문자로 취급하고 문자열 전체를 제한해야 합니다. 입력이 증가할수록 허용 집합을 넓히는 변경은 특히 신중히 확인합니다.

type: bool도 엄격한 JSON 스키마 검사와 같지 않습니다. 이 실습의 버전에서는 불리언 true뿐 아니라 문자열 true와 숫자 1도 통과했습니다. JSON 불리언만 허용해야 한다면 변환 결과가 아니라 원래 값의 타입까지 확인합니다. 실습에서는 Lua 필터로 이를 구별합니다. 이것은 특정 필드의 타입 계약이며 전체 요청 스키마 검증을 대신하지는 않습니다.

여러 조건을 묶을 때 OR는 하나만 만족해도 통과하고 AND는 모두 만족해야 통과합니다. 종류가 틀려도 enabled가 true라는 이유로 실행돼서는 안 되는 업무라면 둘 사이의 연산자를 검토해야 합니다. 존재하지 않는 필드의 오류도 한 조건의 거부일 뿐, OR로 묶인 다른 조건의 승인을 무효화한다고 가정하지 않습니다.

누가 만들고 누가 실행하는가

Sensor가 Workflow를 생성하는 신원과 Workflow 안의 작업이 쓰는 신원은 나눕니다. 이번 Kubernetes 리소스 생성 트리거는 제출 계정에 Workflow create만 허용합니다. 업무 계정은 필요한 작업 결과 기록 권한을 가집니다. 권한 오류를 없애려고 두 계정 모두에 관리자 역할을 연결하면, 어떤 권한이 실제로 필요한지 배우지 못하고 피해 범위도 넓어집니다.

거부된 이벤트의 HTTP 응답은 200일 수 있습니다. 그래서 Sensor의 실제 forbidden 메시지, 요청의 추적 ID, 생성된 Workflow 유무를 함께 봅니다. 최소 권한으로 수정한 뒤에는 새 이벤트로 복구를 확인합니다. 이미 실패한 과거 이벤트가 자동 재처리된다고 말하려면 그 과거 ID를 따로 관측해야 합니다.

도착 ID, 실행 ID, 업무 키

같은 주문의 두 HTTP 요청이 서로 다른 도착 ID를 받는 것은 이상하지 않습니다. 두 Workflow UID가 다르다는 사실도 두 번의 주문을 뜻하지 않습니다. 업무 키는 재시도하더라도 같은 주문을 가리켜야 합니다. 이번 장부는 주문 ID를 업무 키로 쓰고, 이미 반영한 키인지 확인하는 일과 금액을 반영하는 일을 한 데이터베이스 트랜잭션으로 묶습니다.

단순 구현에서는 두 실행이 모두 금액을 더합니다. 개선한 구현에서는 두 실행 모두 정상 종료할 수 있지만 한쪽만 장부를 변경하고 다른 쪽은 중복으로 처리합니다. 같은 키인데 금액이 다르다면 정상 재시도로 숨기지 않고 충돌로 거부합니다. 성공 응답을 읽지 못했더라도 커밋한 기록이 남아 있으면 다음 요청이 업무 효과를 늘리지 않을 수 있습니다.

현장에서 만나는 모습

업무 대시보드에 성공 수를 하나만 두면 어떤 성공인지 알기 어렵습니다. HTTP 접수 수, 승인된 이벤트 수, Workflow 완료 수, 업무 반영 수를 나눠 생각해 보세요. 중복 요청이 있었던 시간에는 이 숫자들이 다르면서도 시스템은 의도대로 동작할 수 있습니다. 반대로 모두 같은 숫자여도 잘못된 입력이 전부 승인됐다면 업무는 틀렸습니다.

이번 단일 VM 장부는 고가용성 데이터베이스나 실제 결제 시스템이 아닙니다. 같은 SQLite 트랜잭션 밖에서 이메일을 보내거나 결제를 호출하면 그 외부 효과까지 원자적으로 묶이지 않습니다. 그런 요구에는 외부 서비스의 멱등 키, outbox 등 별도의 전달 계약을 검토해야 합니다. 여기서 확인한 것은 이 실습의 로컬 장부 효과이며 세션 종료 뒤 보존이나 모든 외부 부작용의 한 번 실행을 보장하지 않습니다.

다음 실습에서 할 것

필터 경로·정규식·타입·논리 연산을 고장 내고 정상 대조군과 비교합니다. 실제 권한 거부를 최소 Role로 고친 다음, 중복 요청에서 두 실행과 한 업무 효과를 각각 확인합니다. 마지막에는 어떤 계층을 관측했는지와 아직 증명하지 않은 것을 구분해 짧은 장애 보고서를 남깁니다.

공식 참고: [Data filter](https://argoproj.github.io/argo-events/sensors/filters/data/), [Script filter](https://argoproj.github.io/argo-events/sensors/filters/script/), [서비스 계정](https://argoproj.github.io/argo-events/service-accounts/), [고정 버전 구독 구현](https://github.com/argoproj/argo-events/blob/v1.9.11/pkg/eventbus/jetstream/sensor/trigger_conn.go), [SQLite 트랜잭션](https://sqlite.org/lang_transaction.html).