Pat Helland의 '외부 대 내부 데이터' 개념을 배우고 명확한 경계 설정, 멱등성 호출 설계, 네트워크 실패 시 상태 조정 방법을 알아보세요.
"외부 vs 내부"를 쉬운 말로 설명하면\n\n앱을 만들 때 요청이 차곡차곡, 정확한 순서로 도착한다고 상상하기 쉽습니다. 현실의 네트워크는 그렇지 않습니다. 사용자가 화면이 멈춰서 "결제"를 두 번 누를 수 있습니다. 버튼을 누른 직후 모바일 연결이 끊길 수 있습니다. 웹후크가 지연되거나 두 번 도착하거나 아예 오지 않을 수 있습니다.\n\nPat Helland의 외부 대 내부 데이터(data on the outside vs inside) 개념은 그런 혼란을 생각하기 위한 깔끔한 방법입니다.\n\n### "외부"는 어떤 모습인가\n\n"외부"는 시스템이 통제하지 못하는 모든 것입니다. 다른 사람과 시스템과 대화하는 곳이며 전달이 불확실한 곳입니다: 브라우저와 모바일 앱에서 오는 HTTP 요청, 큐의 메시지, 서드파티 웹후크(결제, 이메일, 배송), 클라이언트·프록시·백그라운드 작업이 트리거하는 재시도 등입니다.\n\n외부에서는 메시지가 지연되거나 중복되거나 순서가 어긋날 수 있다고 가정하세요. "보통은 신뢰할 만하다" 하더라도 언젠가는 그렇지 않을 때를 대비해 설계해야 합니다.\n\n### "내부"는 무엇을 뜻하는가\n\n"내부"는 당신의 시스템이 신뢰할 수 있게 만드는 것입니다. 그건 당신이 저장하는 내구성 있는 상태, 적용하는 규칙, 그리고 나중에 증명할 수 있는 사실들입니다:
\n- 데이터베이스 레코드와 그 변경 이력
가장 중요한 곳부터 멱등성을 추가하세요: 주문 생성, 결제 캡처, 환불 발행. PostgreSQL의 고유 제약으로 멱등성 키를 저장하면 중복이 안전하게 거부됩니다.
조정을 정상 기능으로 취급하세요. "너무 오래 보류된" 항목을 검색하고 외부 시스템을 다시 확인해 로컬 상태를 수리하는 작업을 예약하세요.
안전하게 반복하세요. 전환과 재시도 규칙을 조정하고 동일한 요청을 일부러 다시 보내고 동일한 이벤트를 다시 처리해 테스트하세요.\n\nKoder.ai(koder.ai) 같은 채팅 기반 플랫폼 위에서 빠르게 구축하더라도 이러한 규칙을 생성되는 서비스에 초기에 반영할 가치가 있습니다: 속도는 자동화에서 오지만 신뢰성은 명확한 경계, 멱등성 핸들러, 조정에서 옵니다.
자주 묻는 질문
“외부 대 내부 데이터”는 간단히 말해 무엇을 뜻하나요?
"외부"는 당신이 제어하지 못하는 모든 것입니다: 브라우저, 모바일 네트워크, 큐, 서드파티 웹후크, 재시도, 타임아웃. 메시지는 지연되거나, 중복되거나, 손실되거나, 순서가 뒤바뀔 수 있다고 가정하세요.
"내부"는 당신이 제어하는 것들입니다: 저장된 상태, 규칙, 그리고 나중에 증명할 수 있는 사실들(보통 데이터베이스에 있음).
들어오는 요청이나 웹후크가 정확히 한 번만 발생한다고 왜 신뢰할 수 없나요?
네트워크가 항상 진실을 말해주지 않기 때문입니다.
클라이언트가 타임아웃을 경험했다고 해서 서버가 요청을 처리하지 않았다는 의미는 아닙니다. 웹후크가 두 번 도착했다고 해서 제공자가 그 동작을 두 번 했다는 뜻도 아닙니다. 각 메시지를 "새로운 진실"로 취급하면 중복 주문, 이중 청구, 또는 멈춘 워크플로가 발생합니다.
일반적인 앱에서 “경계”는 어디에 그어야 하나요?
명확한 경계는 신뢰할 수 없는 메시지가 내구성 있는 사실이 되는 지점입니다.
일반적인 경계 예:
데이터베이스에 커밋하는 API 엔드포인트
이벤트를 상태 변경으로 기록하는 큐 소비자
제공자가 발생했다고 주장한 것을 기록하는 웹후크 핸들러
데이터가 경계를 넘어오면 내부에서 불변 조건(예: "주문은 한 번만 결제될 수 있다")을 강제합니다.
사용자가 "결제"를 재시도할 때 이중 청구를 막으려면 어떻게 해야 하나요?
멱등성을 사용하세요. 기본 원칙은: 같은 의도는 여러 번 전송되어도 같은 결과를 내야 한다는 것입니다.
실무 패턴:
클라이언트가 작업마다 고유한 멱등성 키를 전송함
서버는 키와 최종 응답을 내구성 있게 저장함
중복이 오면 첫 번째 요청과 같은 리소스 ID/상태를 반환함
멱등성 기록은 어디에 저장해야 하나요, 그리고 얼마나 오래 보관해야 하나요?
메모리에만 두지 마세요. 보호 기록은 재시작 후에도 남아 있어야 하므로 경계 내부의 영구 저장소(예: PostgreSQL)에 보관하세요.
보존 기간에 대한 규칙:
낮은 위험의 작업: 몇 분~몇 시간
비용이 큰 작업(결제, 환불, 배송, 이메일): 며칠 이상
현실적인 재시도와 지연된 콜백을 커버할 만큼 오래 보관하세요.
‘우리가 확실하지 않다’ 버그를 피하려면 어떤 상태들을 추가해야 하나요?
불확실성을 인정하는 상태를 사용하세요.
단순하고 실용적인 집합 예:
pending_* (의도를 수락했지만 결과를 모름)
succeeded / failed (최종 결과를 기록함)
needs_review (불일치가 감지되어 사람이나 특별한 작업이 필요함)
이렇게 하면 타임아웃 시 추측하지 않도록 하고 조정 작업이 착지할 명확한 장소를 제공합니다.
왜 분산 트랜잭션이 앱 워크플로의 함정인가요?
네트워크를 통해 여러 시스템에 원자적으로 커밋하는 것은 거의 불가능하기 때문에 함정이 됩니다.
예: "주문 저장 → 카드 결제 → 재고 예약"을 동기적으로 수행하면 어떤 단계가 타임아웃될 때 성공 여부를 알 수 없습니다. 재시도하면 중복이 발생할 수 있고, 재시도하지 않으면 작업이 미완성으로 남습니다.
부분적 성공을 설계하세요: 먼저 의도를 영구 저장하고, 외부 작업을 수행한 뒤 결과를 기록합니다.
아웃박스/인박스 패턴이란 무엇이며 언제 사용해야 하나요?
아웃박스/인박스 패턴은 네트워크가 완벽하지 않더라도 시스템 간 메시지를 신뢰성 있게 만듭니다.
Outbox: 상태 변경과 동일한 데이터베이스 트랜잭션으로 보낼 메시지 행을 작성합니다.
워커가 아웃박스를 읽어 메시지를 전송합니다.
Inbox(수신 측): 처리된 메시지 ID를 저장해서 재전달이 중복 부작용을 만들지 않도록 합니다.
조정(리컨실리에이션)이란 무엇이며 간단한 구현 방법은 무엇인가요?
조정(리컨실리에이션)은 당신의 기록과 외부 시스템이 불일치할 때 복구하는 방법입니다.
좋은 기본값:
보류 상태가 오래된 항목을 다시 확인하는 예약 작업
비교 단계(우리 상태 vs 제공자 상태)
수리 작업: 재시도, 취소, 환불, 또는 needs_review로 표시
결제, 이행, 구독 등 웹후크가 관련된 항목에는 필수적입니다.
Koder.ai 같은 플랫폼으로 빠르게 빌드해도 이런 것이 여전히 중요한가요?
중요합니다. 빠르게 만들더라도 네트워크 실패는 사라지지 않습니다.
Koder.ai로 서비스를 생성하는 경우에도 다음 기본을 초기에 포함하세요:
명확한 경계(언제 의도가 내구화되는지)
"생성/캡처/환불" 스타일 작업에 대한 멱등성 핸들러
외부 참조와 함께 저장된 상관 ID
보류 레코드를 다시 확인하는 조정 작업
그러면 재시도와 중복 콜백이 비용이 큰 문제가 아니라 지루한 일상이 됩니다.
비즈니스 규칙(예: "주문은 한 번만 결제될 수 있다")
상태에 대한 진실의 출처(대기중, 결제됨, 취소됨 등)\n\n내부에서는 불변 조건을 지켜야 합니다. "주문 당 한 번의 결제"를 약속했다면, 그 약속은 내부에서 강제되어야 합니다. 외부는 신뢰할 수 없기 때문입니다.\n\n마음가짐은 간단합니다: 완벽한 전달이나 완벽한 타이밍을 가정하지 마세요. 모든 외부 상호작용을 반복될 수 있는 신뢰할 수 없는 제안으로 취급하고, 내부가 안전하게 반응하도록 만드세요.\n\n이 원칙은 작은 팀과 단순한 앱에도 중요합니다. 네트워크 문제로 중복 청구나 멈춘 주문이 발생하면 이론이 아니라 환불, 지원 티켓, 신뢰 상실로 현실화됩니다.\n\n구체적 예: 사용자가 "주문하기"를 누르고 앱이 요청을 보냈는데 연결이 끊깁니다. 사용자가 다시 시도합니다. 내부에 "이게 같은 시도다"를 인식할 방법이 없으면 주문이 두 개 만들어지거나 재고가 두 번 예약되거나 확인 이메일이 두 통 전송될 수 있습니다.\n\n## Pat Helland의 핵심 교훈\n\nHelland의 요지는 명확합니다: 외부 세계는 불확실하지만 시스템의 내부는 일관성을 유지해야 합니다. 네트워크는 패킷을 잃고, 휴대폰은 신호를 잃고, 시계는 어긋나며, 사용자는 새로고침을 누릅니다. 당신의 앱은 그런 것을 통제할 수 없습니다. 통제할 수 있는 것은 데이터가 명확한 경계를 넘었을 때 무엇을 진실로 받아들일지입니다.\n\n### 일상적 한 순간에서의 시간과 불확실성\n\n와이파이가 좋지 않은 건물을 걸어가며 휴대폰으로 커피를 주문하는 사람을 상상해보세요. 그들이 "결제"를 누릅니다. 스피너가 돌다가 네트워크가 끊깁니다. 그들은 다시 누릅니다.\n\n첫 번째 요청이 서버에 도달했지만 응답이 돌아오지 않았을 수도 있고, 두 요청 모두 도달하지 않았을 수도 있습니다. 사용자 입장에서는 두 경우가 동일하게 보입니다.\n\n이것이 바로 시간과 불확실성입니다: 무슨 일이 일어났는지 아직 모를 수 있고, 나중에 알게 될 수도 있습니다. 시스템은 기다리는 동안에도 합리적으로 동작해야 합니다.\n\n### 재시도, 중복, 그리고 재정렬\n\n외부가 신뢰할 수 없다는 것을 받아들이면 몇 가지 "이상한" 동작이 정상으로 보입니다:
\n- 재시도는 중복을 만든다(두 번의 "결제" 요청).
메시지는 순서가 바뀌어 도착할 수 있다(예: "취소"가 "결제"보다 먼저 도착).
요청은 처리되었지만 클라이언트는 응답을 보지 못할 수 있다.\n\n외부 데이터는 주장이지 사실이 아닙니다. "내가 결제했다"는 신뢰할 수 없는 채널로 전송된 진술에 불과합니다. 그것이 사실이 되려면 경계를 넘어 시스템 내부에 내구성 있고 일관된 방식으로 기록되어야 합니다.\n\n이것은 세 가지 실무적 습관으로 이어집니다: 명확한 경계를 정의하고, 재시도를 멱등성으로 안전하게 만들고, 현실이 맞지 않을 때 조정을 계획하세요.\n\n## 명확한 경계: 시스템이 소유하는 것과 소유하지 않는 것\n\n"외부 vs 내부" 아이디어는 실용적인 질문에서 시작합니다: 당신 시스템의 진실은 어디서 시작하고 끝나는가?\n\n경계 내부에서는 데이터를 통제하므로 강한 보장을 할 수 있습니다. 경계 밖에서는 최선의 노력을 하고 메시지가 손실·중복·지연·순서가 바뀔 수 있다고 가정합니다.\n\n실제 앱에서 그 경계는 보통 다음과 같은 지점에 나타납니다:
\n- 데이터베이스에 레코드를 쓰는 API 엔드포인트
이벤트를 저장된 변경으로 바꾸는 큐 소비자
제공자가 일어난 일을 기록하는 콜백 핸들러
자신의 상태를 커밋한 후 다른 시스템에 알림을 보내는 송신자\n\n그 선을 그은 뒤에는 내부에서 양보할 수 없는 불변 조건들을 결정하세요. 예:
\n- 주문 ID는 데이터베이스에서 유일하다.
잔액은 음수가 될 수 없다.
상태는 앞으로만 이동한다 (created -> paid -> shipped).
수락한 외부 요청마다 저장된 감사 로그가 있다.\n\n경계에는 또한 "우리가 지금 어디에 있는가"에 대한 명확한 언어가 필요합니다. 많은 실패는 "우리가 당신의 요청을 들었다"와 "우리가 끝냈다" 사이의 간극에 존재합니다. 도움이 되는 패턴은 세 가지 의미를 분리하는 것입니다:
\n- Received: 메시지가 엣지(경계)에 도착함(아직 저장되지 않았을 수 있음)
Accepted: 저장되었고 나중에 작업을 안전하게 재시도할 수 있음
Processed: 의도한 작업이 완료되어 결과가 기록됨\n\n팀이 이를 건너뛰면 부하가 걸리거나 부분 장애 시에만 발생하는 버그로 이어집니다. 한 시스템은 "paid"를 돈이 실제로 수금된 의미로 쓰고, 다른 시스템은 결제 시도가 시작되었다는 의미로 쓰면 중복, 멈춘 주문, 누구도 재현할 수 없는 지원 티켓이 생깁니다.\n\n## 멱등성: 재시도를 안전하게 만들기\n\n멱등성이란 같은 요청이 두 번 전송되더라도 시스템이 이를 하나의 요청처럼 처리하고 같은 결과를 반환한다는 뜻입니다.\n\n재시도는 정상입니다. 타임아웃이 발생합니다. 클라이언트는 자신을 반복합니다. 외부가 반복할 수 있다면 내부는 이를 안정된 상태 변경으로 바꿔야 합니다.\n\n간단한 예: 모바일 앱이 "$20 결제"를 전송하고 연결이 끊겼습니다. 앱이 재시도합니다. 멱등성이 없으면 고객은 두 번 청구될 수 있습니다. 멱등성이 있으면 두 번째 요청은 첫 번째 결제 결과를 반환합니다.\n\n### 멱등성 구현의 일반적 방법\n\n대부분 팀은 다음 패턴 중 하나(때로는 혼합)를 사용합니다:
\n- Idempotency key(멱등성 키): 클라이언트가 의도된 작업마다 고유한 키를 보냄(예: Idempotency-Key: ...). 서버는 키와 최종 응답을 기록함.
중복 제거 테이블: (client_id, key) 또는 (order_id, operation)으로 키된 행을 저장하고 두 번째 부작용을 거부함.
자연 키: 이미 유일한 비즈니스 식별자를 사용해 "결제 생성" 같은 작업이 한 번만 존재하도록 함.\n\n중복이 도착했을 때 최선의 동작은 보통 "409 충돌"이나 일반 오류를 반환하는 것이 아닙니다. 처음에 반환한 것과 동일한 결과(같은 리소스 ID와 상태)를 반환하는 것입니다. 그것이 클라이언트와 백그라운드 작업의 재시도를 안전하게 만듭니다.\n\n### 기록을 어디에 보관할까(얼마 동안)\n\n멱등성 기록은 경계 내부의 내구성 있는 저장소에 있어야 하며, 메모리에 두어서는 안 됩니다. API가 재시작되어 잊어버리면 안전성 보장이 사라집니다.\n\n현실적인 재시도와 지연된 전달을 커버할 만큼 레코드를 오래 보관하세요. 창은 비즈니스 리스크에 따라 달라집니다: 낮은 위험 생성은 몇 분~몇 시간, 결제/이메일/배송처럼 중복 비용이 큰 경우는 며칠, 파트너가 장기간 재시도할 수 있다면 더 길게 보관합니다.\n\n## "분산 트랜잭션" 함정을 피하는 방법\n\n분산 트랜잭션은 위안처럼 들리지만 실제로는 사용 불가, 느리거나 너무 취약해서 의존하기 어렵습니다. 네트워크 홉이 포함되면 모든 것이 함께 커밋된다고 가정할 수 없습니다.\n\n일반적인 함정은 모든 단계가 지금 당장 성공해야만 작동하는 워크플로를 구축하는 것입니다: 주문 저장, 카드 결제, 재고 예약, 확인 전송. 3단계가 타임아웃되면 실패인지 성공인지 알 수 없습니다. 재시도하면 이중 청구나 이중 예약이 발생할까요?\n\n이를 피하는 두 가지 실용적 접근법:
\n- Outbox/inbox: 상태 변경과 동일한 트랜잭션으로 내구성 있는 의도를(아웃박스 행) 작성한 다음 워커가 메시지를 전송하게 합니다. 수신 측에서는 메시지 ID로 키된 인박스를 유지해 같은 메시지가 다시 와도 안전하게 처리됩니다.
사가(Saga) 스타일의 단계와 보상: 워크플로를 독립적으로 완료되는 작은 단계로 나눕니다. 나중 단계가 실패하면 역사 전체를 롤백하려 하기보다 보상(예: 재고 해제 또는 미결제 주문 취소)을 실행합니다.\n\n워크플로마다 한 스타일을 선택하고 지키세요. "때로는 아웃박스를 사용하고 때로는 동기 성공을 가정"하면 테스트하기 어려운 엣지 케이스가 생깁니다.\n\n간단한 규칙: 경계를 가로질러 원자적으로 커밋할 수 없다면 재시도, 중복, 지연을 설계하세요.\n\n## 조정(리컨실리에이션): 실제 시스템이 불일치에서 회복하는 방법\n\n조정은 기본 진실을 인정하는 것입니다: 네트워크를 통해 다른 시스템과 통신할 때는 때때로 무슨 일이 있었는지 서로 다르게 판단하게 됩니다. 요청이 타임아웃되고 콜백이 늦게 도착하며 사람들이 작업을 재시도합니다. 조정은 불일치를 감지하고 시간이 지나며 고치는 방식입니다.\n\n외부 시스템을 독립적인 진실의 출처로 취급하세요. 앱은 자체 내부 기록을 유지하지만 파트너·제공자·사용자가 실제로 무엇을 했는지와 비교할 방법이 필요합니다.\n\n### 일반적인 조정 메커니즘\n\n대부분 팀은 몇 가지 평범한 도구를 사용합니다(평범함은 좋습니다): 보류 작업을 재시도하고 외부 상태를 재확인하는 워커, 불일치 탐지를 위한 정기 스캔, 그리고 지원팀이 재시도·취소·검토 완료로 표시할 수 있는 작은 수리 액션.\n\n### 무엇을 비교하고 무엇을 기록할까\n\n조정은 비교할 항목을 알아야만 작동합니다: 내부 원장 vs 제공자 원장(결제), 주문 상태 vs 배송 상태(이행), 구독 상태 vs 청구 상태 등.\n\n상태를 수리 가능하게 만드세요. 바로 "created"에서 "completed"로 점프하지 말고, pending, on hold, needs review 같은 보류 상태를 사용하세요. 이렇게 하면 "확실하지 않다"고 말할 수 있고 조정 작업이 착지할 장소를 제공합니다.\n\n중요 변경에 대한 작은 감사 흔적을 캡처하세요:
\n- 요청을 보낸 시점과 마지막 응답을 들은 시점
내부 레코드를 외부 이벤트/참조에 연결하는 상관 ID
마지막으로 알려진 외부 상태(출처 포함)
수동 오버라이드의 이유 필드(누가, 무엇을, 왜)\n\n예: 앱에서 배송 라벨을 요청했는데 네트워크가 끊기면 내부에는 "라벨 없음" 상태가 남을 수 있지만 운송업체는 실제로 라벨을 생성했을 수 있습니다. 조정 워커가 상관 ID로 검색해 라벨이 존재함을 발견하고 주문을 진행하거나(세부가 일치하지 않으면) 검토 대상으로 표시할 수 있습니다.\n\n## 단계별: 네트워크 실패를 견디는 워크플로 설계\n\n네트워크가 실패할 것이라고 가정하면 목표가 바뀝니다. 모든 단계를 한 번에 성공시키려 하지 마세요. 대신 각 단계를 반복해도 안전하고 수리하기 쉬운 상태로 만드세요.\n\n### 실용적인 워크플로\n\n1) 한 문장으로 경계 설명을 작성하세요. 시스템이 소유하는 것(진실의 출처), 미러하는 것, 단지 다른 곳에 요청만 하는 것을 명확히 하세요.\n\n2) 행복 경로 이전에 실패 모드를 나열하세요. 최소 항목: 타임아웃(작동했는지 모름), 중복 요청, 부분 성공(한 단계는 되었지만 다음 단계는 아님), 순서가 어긋난 이벤트.\n\n3) 각 입력에 대한 멱등성 전략을 선택하세요. 동기 API의 경우 보통 멱등성 키와 저장된 결과입니다. 메시지/이벤트의 경우 고유 메시지 ID와 "이걸 처리했나?" 레코드입니다.\n\n4) 의도를 영구 저장한 다음 행동하세요. 먼저 "PaymentAttempt: pending" 또는 "ShipmentRequest: queued" 같은 것을 저장하고 외부 호출을 수행한 뒤 결과를 저장하세요. 재시도들이 같은 의도를 가리키도록 안정적인 참조 ID를 반환하세요.\n\n5) 조정 및 수리 경로를 만들고 가시화하세요. 조정은 "너무 오래 보류된" 레코드를 스캔해 외부 시스템을 재확인하는 작업일 수 있습니다. 수리 경로는 "재시도", "취소", "해결로 표시" 같은 안전한 관리자 액션과 감사 메모를 포함할 수 있습니다. 몇 가지 기본 관찰 지표도 추가하세요: 상관 ID, 명확한 상태 필드, 몇 가지 카운트(보류, 재시도, 실패).\n\n예: 체크아웃이 결제 제공자 호출 직후 타임아웃되면 추측하지 마세요. 시도를 저장하고 시도 ID를 반환한 뒤 사용자가 동일한 멱등성 키로 다시 시도하게 하세요. 이후 조정이 제공자가 결제했는지 확인하고 중복 청구 없이 시도를 업데이트합니다.\n\n## 예시 시나리오: 재시도와 지연된 콜백이 있는 주문 흐름\n\n고객이 "주문하기"를 누릅니다. 서비스는 제공자에게 결제 요청을 보냈지만 네트워크가 불안정합니다. 제공자에게는 그들의 진실이 있고 당신 데이터베이스에는 당신의 진실이 있습니다. 설계하지 않으면 둘은 어긋납니다.\n\n### 외부에서 무슨 일이 일어나는가(당신이 통제하지 못하는 이벤트)\n\n당신 관점에서 외부는 늦거나 반복되거나 누락될 수 있는 메시지 스트림입니다:
\n- "주문 제출"이 API에 도달함
당신의 결제 요청이 제공자에게 전송됨
제공자가 "승인(authorized)"이라는 웹후크를 보냄
제공자가 웹후크를 재시도하여 같은 콜백을 다시 보냄
클라이언트가 타임아웃되어 "주문하기"를 재시도함\n\n이 단계들 중 어느 것도 "한 번만" 발생한다고 보장하지 않습니다. 보장되는 것은 "어쩌면"뿐입니다.\n\n### 내부에 무엇을 유지하는가(당신이 통제하는 레코드)\n\n경계 내부에는 외부 이벤트를 내부 사실에 연결하는 데 필요한 최소한의 내구성 있는 사실을 저장하세요.\n\n고객이 처음 주문할 때 order 레코드를 pending_payment 같은 명확한 상태로 생성하세요. 또한 고유한 제공자 참조와 고객 행동에 묶인 idempotency_key가 있는 payment_attempt 레코드를 생성하세요.\n\n클라이언트가 타임아웃되어 재시도하면 API가 두 번째 주문을 생성해서는 안 됩니다. idempotency_key를 조회해 같은 order_id와 현재 상태를 반환해야 합니다. 이 한 선택이 네트워크 실패 시 중복을 막습니다.\n\n이제 웹후크가 두 번 도착하면 첫 번째 콜백이 payment_attempt를 authorized로 업데이트하고 주문을 paid로 옮깁니다. 두 번째 콜백은 같은 핸들러에 도달하지만 제공자 이벤트 ID를 저장했거나 현재 상태를 확인해 이미 처리했음을 감지하고 아무 작업도 하지 않습니다. 결과가 이미 참이므로 200 OK를 반환해도 됩니다.\n\n마지막으로 조정이 엉망인 경우를 처리합니다. 일정 시간이 지나도 주문이 pending_payment이면 백그라운드 잡이 저장된 참조로 제공자에게 조회합니다. 제공자가 "authorized"라고 말하면 웹후크를 놓쳤더라도 당신의 레코드를 업데이트합니다. 제공자가 "failed"라고 말하는데 당신이 이미 결제된 것으로 표시했다면 검토 대상으로 플래그를 세우거나 환불 같은 보상 조치를 트리거합니다.\n\n## 중복과 멈춘 상태를 유발하는 흔한 실수\n\n대부분의 중복 레코드와 "멈춘" 워크플로는 외부에서 무슨 일이 있었는지(요청이 도착했는지, 메시지가 수신되었는지)를 내부에서 안전하게 커밋한 것과 혼동해서 발생합니다.\n\n전형적인 실패: 클라이언트가 "주문하기"를 보내고 서버가 작업을 시작하는데 네트워크가 끊기고 클라이언트가 재시도합니다. 각 재시도를 완전히 새로운 진실로 처리하면 이중 청구, 중복 주문, 여러 통의 이메일이 발생합니다.\n\n주요 원인:
\n- 들어오는 요청을 너무 일찍 신뢰함: 데이터베이스 커밋 전에 이메일을 보내거나 "주문 생성"을 기록함
재시도가 새 행을 생성함: 모든 시도에서 새 주문 ID를 생성하고 재시도를 하나의 결과로 매핑하지 않음
"한 번 정확히 전달"을 가정함: 큐와 콜백은 이를 약속하지 않음(중복, 지연, 재정렬 가능)
안정적인 식별자가 없음: "이 의도를 전에 본 적이 있는가?"에 답할 수 없으면 중복을 막을 수 없음
성공/실패만 있고 중간 상태가 없음: 보류/대기 상태가 없으면 타임아웃이 미스터리가 됨\n\n모든 것을 더 나쁘게 만드는 한 가지 문제: 감사 기록이 없음. 필드를 덮어쓰고 최신 상태만 남기면 나중에 조정하는 데 필요한 증거를 잃습니다.\n\n하나의 좋은 체크는: "이 핸들러를 두 번 실행하면 같은 결과가 나오는가?"입니다. 답이 아니면 중복은 드문 엣지 케이스가 아니라 보장된 결과입니다.\n\n## 빠른 체크리스트와 실용적 다음 단계\n\n하나만 기억하세요: 메시지가 늦게 오거나 두 번 오거나 아예 오지 않더라도 앱은 올바르게 동작해야 합니다.\n\n중복 레코드, 누락된 업데이트, 멈춘 워크플로로 바뀌기 전에 약한 지점을 찾는 체크리스트:
\n- 진실의 출처가 명확하다: 각 워크플로에 대해 한 곳의 "진실"(보통 데이터베이스)을 가리킬 수 있다.
모든 쓰기는 안전하게 재시도 가능하다: 각 명령/API 호출에 멱등성 키(또는 자연 고유 키)가 있다.
안정적인 ID와 상관 ID가 끝에서 끝까지 존재한다: 로그, 테이블, 콜백에서 하나의 비즈니스 동작을 추적할 수 있다.
조정이 자동으로 실행된다: "우리가 믿는 것"과 "무슨 일이 일어났는지"를 정기적으로 비교하고 수리하거나 명확한 경보를 올린다.
롤백이 상태를 오염시키지 않는다: 상태 변경은 감사 가능하고 버전 간 호환된다.\n\n빠르게 답할 수 없는 항목이 있다면 경계가 흐릿하거나 상태 전이가 빠졌다는 신호입니다.\n\n실용적 다음 단계:
\n1. 먼저 경계와 상태를 스케치하세요. 워크플로당 작은 상태 집합을 정의하세요(예: Created, PaymentPending, Paid, FulfillmentPending, Completed, Failed).
외부 데이터 vs 내부 데이터 — Pat Helland의 앱 설계 교훈 | Koder.ai