API 실패, 크레딧 환불, 대사: 요청 한 건의 순과금액을 감사하는 방법
작업, 호출 시도, 전달 상태, 지갑 이벤트를 분리해 과금 전 거부, 빈 결과, 부분 크레딧 환불을 중복 집계 없이 처리하며, 정수 기반 오프라인 예시를 사용합니다.
"실패하면 과금하지 않는다"가 실제로 지킬 수 있는 제품 약속이 되려면 두 가지를 분명히 해야 합니다. 무엇이 실패인지, 그리고 순과금이 발생하지 않았음을 어떻게 증명하는지입니다. HTTP 상태 코드에만 의존하면 빈 결과와 부분 전달을 놓치고, 잔액 변화만 확인하면 동시 요청, 충전, 조정이 섞입니다. 신뢰할 수 있는 대사를 하려면 개별 요청과 그에 연결된 이벤트를 함께 추적해야 합니다.
이 글에서는 API 클라이언트가 대사 기록을 어떻게 구성하는지 다루고, 현재 EveryInfra 코드베이스를 바탕으로 모호한 필드를 설명합니다. 여기 나오는 오프라인 원장은 애플리케이션 설계 예시이며, 고객 청구 명세서나 회계상 수익 인식 규칙이 아닙니다. 구체적인 액션 과금 조건, 통화 단위, 최종 명세서는 해당 제품의 과금 규칙에서 확인해야 합니다.
먼저 네 가지 대상을 분리하기
- 비즈니스 작업: 사용자가 실제로 달성하려는 목표입니다. 승인된 상품 리뷰 묶음을 점검하는 일이 그 예입니다. 작업 하나에 API 호출이 여러 번 필요할 수 있습니다.
- 호출 시도: 실제로 보낸 요청 한 건으로, 자체 타임스탬프, 파라미터 버전, 모델 또는 액션을 저장하고 요청 ID가 있으면 함께 저장합니다.
- 전달 결과: 성공, 빈 결과, 부분 전달, 실패, 아직 알 수 없음 중 하나입니다. 이는 비즈니스 상태이며 지갑 상태가 아닙니다.
- 지갑 이벤트: 해당 시도와 관련된 과금, 환불, 기타 명시적 조정으로, 순변동을 설명하는 데 사용합니다.
이 네 대상을 하나의 성공 필드에 억지로 넣지 말고 서로 연결합니다. 요청이 목표의 일부만 완료했다면, 비즈니스는 전달된 내용을 보존하면서 미완료 부분의 과금을 계속 감사할 수 있습니다. 타임아웃은 전달과 정산을 모두 미확정 상태로 남길 수도 있으므로, 함부로 비용 0으로 기록해서는 안 됩니다.
비즈니스 작업 ID, 요청 ID, 비동기 job_id, 과금 참조도 서로 다른 개념입니다. 서비스가 실제로 제공하는 관계만 저장하고, 비슷한 문자열로 존재하지 않는 과금 ID를 만들어 내서는 안 됩니다. 작업을 조회할 때 생성되는 HTTP 요청 ID로 최초 작업 제출의 연결 식별자를 덮어써서는 절대 안 됩니다.
과금 전 거부와 과금 후 환불은 서로 다른 경로를 따른다
2026-09-04에 배포된 소스 기준선을 보면, EveryInfra 텍스트 요청의 신원, 입력, 모델, 기능 권한 확인은 모두 과금 전에 이루어집니다. 메시지가 없으면 인증 전에 입력 오류가 반환될 수 있으므로, 여기서는 모든 오류에 일률적인 우선순위를 선언하지 않습니다. 메시지가 유효하고 신원 확인을 통과한 경로에서는 알 수 없는 모델이 과금 전에 422 unknown_model을 반환합니다. 이러한 실패는 실행 거부에 해당하며, 먼저 과금한 뒤 환불하는 흐름이 생기지 않습니다. 알 수 없는 모델에 503을 반환한다는 이전 설명은 더 이상 사용해서는 안 됩니다.
실행 단계에 들어간 뒤 실패한 요청은 일부 과금을 환불해야 할 수 있습니다. 구현에 환불 분기가 있다고 해서 클라이언트가 그 완료를 관측했다는 뜻은 아닙니다. 응답이 유실되면 먼저 요청 식별자로 확인 가능한 기록을 조회합니다. 증거가 없다면 오류 텍스트만 보고 재무 기록을 수정하지 말고 "정산 감사 대기" 상태로 둡니다.
2026-09-02 아카이브에는 실패 후 환불에 관한 비식별화 샘플과 빈 결과 및 배치 부분 전달 샘플이 들어 있습니다. 이 샘플들은 이러한 시나리오에 별도의 검수가 필요함을 보여 줄 뿐, 현재 모든 기능과 실패 유형에 적용되는 보편적 보장은 아닙니다. 이번 검토는 소스 코드와 공개 문서만 다루며, 실제 과금이나 환불 또는 계정 작업은 수행하지 않습니다.
합산 방식을 정하기 전에 필드 정의부터 읽기
동기식 데이터 응답을 예로 들면, billing.charged는 응답의 과금 상태를, billing.credits는 응답이 보고한 과금 단위 수를 나타내며, billing.amount는 금액 의미가 필요한 표시에 사용합니다. 할당량 잔여 값은 계정 스냅샷이므로 요청 한 건의 비용으로 볼 수 없으며, 카탈로그 정가가 실제 요청 기록을 대신할 수도 없습니다.
부분 환불은 계산 오류가 가장 흔하게 발생하는 지점입니다. 현재 데이터 구현은 원래 요금에서 계산된 환불액을 뺀 나머지 요금을 billing.credits에 기록하고, refunded_credits는 따로 반환합니다. 따라서 billing.credits에서 refunded_credits를 다시 빼서는 안 됩니다. 그렇게 하면 같은 환불이 두 번 차감됩니다. 이 결론은 여기서 검증한 동기식 데이터 분기에만 해당하며, 모든 제품에 자동으로 적용되지 않습니다.
완전히 가상인 예시에서 원래 30단위를 과금하고 10단위를 환불하면 순과금은 20입니다. 최종 응답이 이미 credits를 20, refunded_credits를 10으로 보고했다면 20이 곧 해당 응답이 나타내는 순금액입니다. 원시 이벤트를 감사하려면 30에서 10을 빼야 하며, 20에서 다시 10을 빼서 10을 얻어서는 안 됩니다.
같은 요청을 폴링할 때 각 응답에 중복 상태가 포함될 수 있으므로 모든 응답의 크레딧을 합산하지 마십시오. 응답 스냅샷, 과금 이벤트, 정산 조정은 데이터베이스에 각각 따로 저장하고, 각각의 안정적인 식별자로 먼저 중복을 제거한 뒤 집계하십시오. 새 HTTP 로그 항목이 보인다고 해서 크레딧이 새로 소비된 것은 아닙니다.
빈 결과와 부분 전달은 액션별로 해석하기
검색 결과가 비어 있으면 해당 도구의 유효한 전달 내용이 다른 필드에 있을 수 있는지 확인하십시오. 현재 일반 검색 구현은 results와 answer를 함께 확인하므로, 빈 results만 보면 answer가 있는 응답을 완전히 빈 응답으로 잘못 판단할 수 있습니다. 클라이언트는 도구의 문서화된 응답을 따라야 하며, 정산 계층에서 콘텐츠 가치를 추측해서는 안 됩니다.
배치 데이터 요청은 대상 수와 실제 전달 결과를 대조해 감사해야 합니다. 현재 부분 환불 분기는 total_targets, billed_targets, refunded_credits를 제공하며, 비즈니스 결과에 partial과 missed가 포함될 수도 있습니다. 이 필드들은 차이를 설명하는 데 도움이 되지만 구체적인 값은 실제 응답에서 가져와야 하며, 댓글 수를 성공한 대상 수로 간주해서는 안 됩니다.
완료되지 않은 대상을 다시 처리하기 전에 전달된 객체와 원본 요청을 보존하고, 어떤 대상이 아직 알 수 없는 상태인지 명확히 하십시오. 배치 전체를 다시 보내면 기존 콘텐츠가 중복되거나 새로운 호출 시도가 발생할 수 있습니다. 클라이언트 측 결과 중복 제거와 서버 측 중복 과금 방지는 서로 다른 작업입니다.
비동기 접수는 최종 정산이 아닙니다
HTTP 202와 job_id는 작업이 처리 파이프라인에 들어갔다는 뜻일 뿐, 사용자가 결과를 얻었다는 뜻은 아닙니다. 원래 작업을 보관하고 최종 상태를 조회하십시오. 작업 조회가 성공했다는 것은 조회 자체가 완료되었다는 의미일 뿐이므로, 작업이 아직 실행 중인지 또는 최종 처리를 기다리는 중인지 확인해야 합니다. 연결이 끊겼다고 해서 취소가 성공한 것으로 자동 해석해서는 안 됩니다.
현재 데이터 작업 조회는 주로 작업 상태, 결과 또는 실패 정보를 반환하며, 매번 완전한 과금 스냅샷을 제공하지는 않습니다. 과금 필드가 없으면 권한이 있는 과금 화면에서 확인하십시오. 0으로 채우지 말고, 생성 시점과 완료 시점의 스냅샷을 별개의 신규 과금으로 취급하지 마십시오. 이 글은 모든 비동기 기능이 동일한 정산 시점을 사용한다고 주장하지 않습니다.
단건 비용은 부동소수점에서 파생된 소수가 아닌 정수로 감사하기
대사를 하려면 먼저 지갑, 측정 단위, 요청 범위를 고정해야 합니다. 여러 지갑, 서로 다른 통화, 표시용 환율에 걸친 금액을 그대로 합산하지 마십시오. 인터페이스가 정수 크레딧을 사용한다면 그 원래 단위로 감사하는 것을 우선하십시오. 경계 지점에서 안전한 정수인지 확인하고, 값이 JavaScript의 정확한 정수 범위를 넘으면 명시적인 정수 표현을 사용하십시오.
다음은 사용자 정의 이벤트 구조의 예시입니다. 각 항목은 확인된 동일한 요청 범위에 속하고, credits는 양의 정수 문자열을 사용하며, kind는 과금 또는 환불을 지정합니다. 이 예시는 실제 과금 레코드를 읽거나, 데이터베이스에 연결하거나, 잔액을 변경하지 않습니다. 이벤트 전체 집합과 과금 정보 열람 권한은 호출자가 사전에 확인해야 하는 전제 조건입니다.
function reconcileAttempt(entries) {
const seen = new Map();
let debited = 0n, refunded = 0n;
for (const e of entries) {
if (typeof e.id !== "string" || !e.id.trim()
|| !["debit", "refund"].includes(e.kind)
|| typeof e.credits !== "string" || !/^[1-9][0-9]*$/.test(e.credits)) {
throw new Error("invalid synthetic ledger entry");
}
const fingerprint = JSON.stringify([e.kind, e.credits]);
if (seen.has(e.id)) {
if (seen.get(e.id) !== fingerprint) throw new Error("conflicting entry");
continue; // duplicate snapshots of the same event are not counted again.
}
seen.set(e.id, fingerprint);
if (e.kind === "debit") debited += BigInt(e.credits);
else refunded += BigInt(e.credits);
}
if (refunded > debited) throw new Error("check scope or missing debit evidence");
return {
debited: debited.toString(), refunded: refunded.toString(),
netCharged: (debited - refunded).toString()
};
}
console.log(reconcileAttempt([
{id: "synthetic-debit", kind: "debit", credits: "30"},
{id: "synthetic-refund", kind: "refund", credits: "10"},
{id: "synthetic-refund", kind: "refund", credits: "10"}
]));결과는 과금 30, 환불 10, 순과금 20이며, 반복해서 나타나는 중복 환불 이벤트는 다시 계산되지 않습니다. 같은 이벤트 ID에 서로 충돌하는 내용이 매핑되면 이 예시는 조용히 덮어쓰지 않고 중단합니다. 환불 합계가 확보된 과금 합계보다 큰 경우에도 감사가 중단됩니다. 이는 범위나 시간 구간이 불완전해서일 수 있으므로, 서비스가 과다 환불했다고 자동으로 가정해서는 안 됩니다.
이것은 완전한 지갑 알고리즘이 아닙니다. 입금, 지급, 출금, 동결, 거래 취소 등 기타 조정은 이 예시에서 모델링하지 않으며, 실제 시스템에서는 각 이벤트 정의에 따라 처리해야 합니다. 빈 목록에서 계산된 0은 입력 이벤트가 없었다는 뜻일 뿐, 알 수 없는 요청에 과금이 발생하지 않았음을 증명하지 않습니다.
JavaScript의 Number는 임의 정밀도 정수가 아닙니다. 여기서 정수 문자열을 유지했다가 명시적으로 BigInt로 변환하는 이유는 RFC 8259의 JSON 숫자 상호운용 범위에 관한 설명에 있습니다. 이 방식은 계산상의 표현 오류를 줄일 뿐, 누락된 과금 이벤트를 보완하지는 못합니다.
시간 구간별로 감사할 때 기초 이벤트와 기간을 넘나드는 이벤트 보존하기
요청은 하루에 시작해 다음 날 끝날 수 있고, 환불이나 취소 이벤트가 원래 과금보다 늦게 기록될 수도 있습니다. 요청 생성 시각으로 필터링할 때와 지갑 이벤트 발생 시각으로 필터링할 때 얻는 집합은 서로 다릅니다. 보고서에는 어떤 시간 기준을 사용했는지 명시하고, 기초·기말 및 기간 간 연관 관계를 보존해야 합니다. 그래야 전날 과금 없이 환불만 보여서 생기는 이상 징후 오탐을 피할 수 있습니다.
잔액 항등식을 맞추려면 해당 지갑 구간 내의 모든 차감과 적립을 반영해야 하며, 여기서 다룬 두 가지 이벤트 유형만으로는 부족합니다. 현재 잔액과 특정 호출 이전 잔액의 차이에는 동시에 실행된 작업이나 기타 조정도 포함될 수 있습니다. 이 차이는 교차 확인에는 적합하지만, 단일 요청의 비용을 단독으로 증명하는 데는 적합하지 않습니다.
지갑 소비, 고객 현금 입금, 프로모션 크레딧 사용, 회계상 매출은 각각 정의가 다릅니다. 이 글은 요청과 지갑 증거를 대조하는 방법만 다루며, 수익 인식이나 세무에 관한 결론은 제시하지 않습니다. 외부에 공개하는 재무 지표는 관련 제품 및 재무 담당자가 정의를 명확히 한 뒤에만 발표해야 합니다.
Stripe의 멱등 요청 문서는 비교 기준이 됩니다. 안전하게 재시도하려면 서비스가 해당 멱등성 메커니즘을 명시적으로 지원해야 합니다. 이 문서가 EveryInfra가 같은 멱등성 키를 받는다는 것을 증명하지는 않습니다. 이 글의 예시는 서버 측에서 중복 과금을 차단하는 것이 아니라, 확보한 레코드를 이벤트 ID로 중복 제거합니다. 이 두 가지 중복 제거를 혼동해서는 안 됩니다.
불일치는 원장을 자동 수정하지 않고 검토 대기열로 보내기
- 연결할 수 있는 전달 결과가 없는 과금 레코드: 비동기 처리가 끝나지 않았는지, 결과가 저장되지 않았는지, 연결 관계가 유실되었는지 확인합니다.
- 과금 상태가 불분명한 실패 결과: 원본 요청과 후속 이벤트를 감사하되, 임의로 환불 또는 취소 처리된 것으로 표시하지 않습니다.
- 동일 이벤트의 내용 충돌: 원본 버전과 조회 시각을 저장하고, 별도 경고 없이 덮어쓰는 처리를 중단합니다.
- 반복 표시되는 환불 이벤트: 같은 이벤트를 여러 번 읽은 것인지 확인하고, 중복 계산하지 않습니다.
- 통화 단위나 구간 불일치: 차이를 평가하기 전에 비교 범위를 맞춥니다.
지원 요청을 제출할 때는 필요한 요청 ID, 타임스탬프, 기능, 상태, 비식별화된 과금 참조 정보만 제공하십시오. 전체 API 키, 고객이 보낸 본문 전체, 계정 전체 내보내기 파일은 보내지 마십시오. 불일치 분류와 원장 수정에는 별도의 권한이 필요하며, 자동 생성된 이상 징후 플래그가 거래 취소나 기타 회계 조정을 승인하는 것은 아닙니다.
감사 가능한 요청은 무엇을 전달했는지, 어떤 비용 기준이 적용되었는지, 환불이 순액에 반영되었는지, 어떤 이벤트가 아직 확인되지 않았는지 설명할 수 있어야 합니다. 이 관계를 명확히 해야 "실패하면 최종 과금이 없다"는 원칙이 구호에 그치지 않으며, 클라이언트 측에서 환불을 이중으로 차감해 새로운 과금 불일치가 생기는 것도 막을 수 있습니다.
참고 LF-14