실행 시점 봉인으로 구독 일할 계산 증명하기
플랜 변경 한 건마다 요금 처리 방식을 정하고, 몇 달 뒤 분쟁이 걸려도 어느 정책 버전에서 나온 청구인지 증명하는 패턴.
문제
구독 서비스는 고객이 결제 주기 중간에도 플랜을 바꿀 수 있게 합니다. 이때 요금 계산은 정해져 있습니다. 남은 날수만큼 이전 플랜 요금을 돌려주고, 같은 날수만큼 새 플랜 요금을 청구합니다. 이렇게 요금을 날수 기준으로 잘라 계산하는 것이 일할 계산(proration)이고, 그 차액이 일할 계산 금액입니다. 이 공식은 처음부터 정해져 있는 산수라서, 어느 회사를 가도 똑같고 재무 회의를 아무리 열어도 바뀌지 않습니다.
바뀌는 건 공식이 아니라 그 주변의 처리 방침입니다. 다운그레이드를 지금 적용할지, 갱신 시점에 적용할지. 1달러도 안 되는 금액을 굳이 청구할지. 갱신 이틀 전에 들어온 변경에 청구서를 따로 낼지, 다음 청구서에 합칠지. 전부 재무 부서가 정하는 결정이고, 재무는 이 기준을 분기가 바뀌면 다시 보고, 시장이 다르면 다르게 가져가고, 청구 문의가 쌓이면 또 바꿉니다.
차단 규칙으로 요금제 등급별 기능 권한 결정하기가 답한 질문은 이 플랜으로 무엇을 쓸 수 있는가였습니다. 이 패턴은 플랜을 바꾸면 얼마를 내야 하는가에 답합니다. 그런데 기능 권한 검사에는 없던 부담이 하나 더 있습니다. 이 결정은 몇 달 뒤에 다시 문제가 됩니다. 일할 계산 청구는 연간 정산이나 재무 감사, 지불 거절처럼 시간이 한참 지난 뒤에야 따져 묻게 되는 청구입니다. 질문이 들어올 때쯤이면 처리 기준은 이미 바뀌어 있고, 팀은 7월 정책이 돌아가는 상태에서 5월 청구서를 5월 정책으로 설명해야 합니다.
이 패턴이 풀려는 문제가 바로 이것입니다. 재무가 기준을 계속 바꾸는 상황에서 플랜 변경 하나하나의 처리 방식을 정하고, 그 뒤로 정책이 두 번 더 바뀌었더라도, 몇 달 뒤 분쟁이 걸린 청구 한 줄이 정확히 어느 정책에서 나왔는지 증명하는 것.
단순한 접근
처음에는 기준값을 계산식 바로 옆, 청구 서비스의 상수로 둡니다.
public class ProrationService {
// 처리 기준값. 재무 부서가 관리합니다.
private static final BigDecimal MIN_CHARGE_USD = new BigDecimal("1.00");
private static final int FOLD_WINDOW_DAYS = 3;
public ProrationResult onPlanChange(PlanChange change) {
// 일할 계산 공식. 이 부분은 바뀌지 않습니다.
BigDecimal charge = change.newDailyRate()
.subtract(change.oldDailyRate())
.multiply(BigDecimal.valueOf(change.daysRemaining()))
.setScale(2, RoundingMode.HALF_UP);
// 처리 방침. 분기 하나하나가 재무의 결정인데,
// 어느 것도 결정으로 기록되지 않습니다.
if (change.isDowngrade()) {
return ProrationResult.deferToRenewal();
}
if (change.daysRemaining() <= FOLD_WINDOW_DAYS) {
return ProrationResult.foldIntoRenewal(charge);
}
if (charge.compareTo(MIN_CHARGE_USD) < 0) {
return ProrationResult.waive();
}
return ProrationResult.chargeNow(charge);
}
}
이대로도 잘 돌아가고, 분기 하나하나는 재무가 실제로 내린 결정을 담고 있습니다. 문제는 분기 자체가 아니라, 분기를 타고 지나간 흔적이 아무 데도 남지 않는다는 데 있습니다. 세 군데에서 드러납니다.
- 정책의 이력이 배포 이력에 묻혀 있습니다. "5월 12일의 최소 청구 기준은 얼마였는가"에 답하려면 상수에
git blame을 걸고, 릴리스 일정을 뒤지고, 롤링 배포가 진행되던 오전 9시 41분에 어느 빌드가 실제로 요청을 받고 있었는지 짐작해야 합니다. 찾아볼 곳은 세 군데나 되는데, 그중 어느 것도 기록이라 부를 만한 게 아닙니다. - 청구서에는 금액만 남고 처리 방식은 남지 않습니다. 면제된 청구도, 갱신 청구서로 이월된 청구도, 유예된 다운그레이드도 겉보기에는 똑같습니다. 오늘 청구서에 그 줄이 없다는 것뿐입니다. 서로 다른 세 가지 정책이 결과만 보면 전혀 구분되지 않습니다. 어느 분기를 탔는지는 함수가 리턴하는 순간 사라지고, 어디에도 남지 않습니다.
- 정책을 바꾸면 과거에 대한 설명까지 소리 없이 바뀝니다. 최소 기준을 올리고 나면, 지원팀은 5월 청구 문의에 답하려고 지금 배포된 코드를 열어 봅니다. 그리고 지금 정책 기준의 답을 그대로 내놓습니다. 옛 정책이 어디까지였고 새 정책이 어디부터인지, 소스 코드 어디에도 표시가 없기 때문입니다.
패턴 정의
해결책은 계산과 정책을 나누는 데서 시작합니다. 일할 계산 공식은 청구 코드에 그대로 둡니다. 일할 계산은 수학이지 정책이 아니고, 바뀌지 않는 공식에 규칙 엔진이 끼어들 이유가 없습니다. 밖으로 꺼내는 건 처리 결정입니다. 재무가 계속 손대는 부분이고, 분쟁이 걸리는 것도 이 부분입니다. 청구 시스템이 일할 계산 금액을 계산해 넘기면, 엔진이 그 금액을 어떻게 처리할지 정하고 그 결정을 기록합니다.
LexQ에서는 이 구조가 세 가지 개념으로 나뉩니다.
- 팩트: 엔진이 읽는 입력값입니다.
change_type,days_remaining, 그리고 청구 시스템이 계산해 넘긴prorated_charge_usd. - 규칙: 처리 방식마다 규칙 하나씩. 조건과, 처리 방식과 사유를 적는
SET_FACT액션으로 이루어집니다. 처리 방식은 계산해서 나오는 숫자가 아니라 종류를 고르는 결정이라서, 액션은 숫자를 바꾸는 대신 문자열을 적습니다. - 상호 배타 그룹(Mutex Group): 변경 한 건이 정확히 하나의 처리만 받도록 보장하는 필드입니다.
이 패턴에서 상호 배타 그룹은 있어도 되고 없어도 되는 장치가 아닙니다. 차단 규칙으로 KYC 등급별 이체 한도 집행하기에서는 요청 하나에 걸릴 수 있는 규칙이 애초에 하나뿐이라, 규칙끼리 부딪칠 일이 없습니다. 여기서는 다릅니다. 갱신 이틀 전에 들어온, 일할 계산 금액이 0.80인 업그레이드 하나가 이월 조건에도 걸리고, 면제 조건에도 걸리고, 표준 청구 조건에도 걸립니다. 규칙 세 개가 동시에 걸리는데 저마다 다른 처리를 내놓으니, 그중 하나를 고르고 나머지 둘이 왜 밀렸는지까지 기록해 줄 장치가 필요합니다. 우선순위 상호 배타 그룹으로 신용 신청 판정하기가 쓴 단일 결정 지점 구조를 그대로 가져와, 리스크 대신 돈에 적용한 것입니다.
처리 규칙은 모두 같은 mutexGroup 키 proration-treatment를 답니다. mutexMode는 EXCLUSIVE, mutexStrategy는 HIGHEST_PRIORITY입니다. 그리고 규칙을 어떤 순서로 두느냐가 그 자체로 재무 정책입니다. 이 패턴에서는 이월 규칙을 면제 규칙보다 앞에 둡니다. 갱신 직전에 들어온 소액이라면 면제해서 버리는 게 아니라 갱신 청구서에 실어서 받겠다는 뜻입니다. 그리고 이 둘을 표준 청구보다 앞에 둡니다. priority는 규칙을 만들 때 입력하는 값이 아니라 버전 안의 목록 순서 그대로 매겨지는 값이라, 순서 변경으로만 바뀝니다. 어느 처리가 이기는지 알고 싶으면 콘솔 목록에서 규칙이 몇 번째에 있는지만 보면 됩니다.
서로 경쟁하는 두 규칙은 이월과 면제입니다.
{
"name": "Fold — within final 3 days",
"condition": {
"type": "GROUP",
"operator": "AND",
"children": [
{
"type": "SINGLE",
"field": "change_type",
"operator": "EQUALS",
"value": "UPGRADE",
"valueType": "STRING"
},
{
"type": "SINGLE",
"field": "days_remaining",
"operator": "LESS_THAN_OR_EQUAL",
"value": 3,
"valueType": "NUMBER"
}
]
},
"actions": [
{
"type": "SET_FACT",
"parameters": { "key": "proration_treatment", "value": "FOLD_INTO_RENEWAL" }
},
{
"type": "SET_FACT",
"parameters": { "key": "treatment_reason", "value": "Change within final 3 days folds into the renewal invoice" }
}
],
"mutexGroup": "proration-treatment",
"mutexMode": "EXCLUSIVE",
"mutexStrategy": "HIGHEST_PRIORITY",
"mutexLimit": 1,
"isEnabled": true
}
{
"name": "Waive — below 1.00 minimum",
"condition": {
"type": "GROUP",
"operator": "AND",
"children": [
{
"type": "SINGLE",
"field": "change_type",
"operator": "EQUALS",
"value": "UPGRADE",
"valueType": "STRING"
},
{
"type": "SINGLE",
"field": "prorated_charge_usd",
"operator": "LESS_THAN",
"value": 1.00,
"valueType": "NUMBER"
}
]
},
"actions": [
{
"type": "SET_FACT",
"parameters": { "key": "proration_treatment", "value": "WAIVE" }
},
{
"type": "SET_FACT",
"parameters": { "key": "treatment_reason", "value": "Prorated charge below the 1.00 minimum" }
}
],
"mutexGroup": "proration-treatment",
"mutexMode": "EXCLUSIVE",
"mutexStrategy": "HIGHEST_PRIORITY",
"mutexLimit": 1,
"isEnabled": true
}
나머지 두 규칙도 모양은 같습니다. 유예 규칙은 change_type이 DOWNGRADE일 때 걸려 DEFER_TO_RENEWAL과 사유 Downgrade takes effect at next renewal을 적고, 맨 앞에 둡니다. 다운그레이드는 다른 어느 규칙에도 걸리지 않으니 이 규칙 혼자 결정합니다. 표준 청구 규칙은 모든 UPGRADE에 걸려 CHARGE_NOW와 사유 Standard mid-cycle proration을 적고, 맨 뒤에 둡니다. 더 구체적인 규칙이 하나도 이기지 못했을 때 적용되는 처리입니다. 이 네 규칙이면 팩트가 갖춰진 변경은 어느 것이든 정확히 하나의 처리를 받습니다. 그리고 그 제약은 if 블록 네 개가 어쩌다 그렇게 적힌 순서가 아니라 mutexMode라는 필드에 적혀 있습니다.
이제 기준값은 이력을 가진 데이터입니다. 이월 구간, 최소 기준, 다운그레이드 처리 모두 초안 버전에서 값 하나이고, 배포되는 수정 하나하나가 배포 기록을 가진 버전이 됩니다. 이 패턴의 나머지 절반은 그 이력이 있어야 성립합니다.
변경 영향 시뮬레이션 전략
재무가 최소 청구 기준을 1.00에서 5.00으로 올리기로 합니다. 이 값이 실제 플랜 변경에 닿기 전에, 변경이 미칠 파장을 미리 잴 수 있습니다. 단독 청구가 없어지는 업그레이드가 얼마나 되는지, 그 건들이 대신 어느 처리로 넘어가는지입니다. 운영 중인 버전을 복제해 값 하나만 고치면 아직 트래픽을 받지 않는 대상 버전이 됩니다.
이건 금액이 아니라 건수를 묻는 질문이라, 분석 대상 팩트는 필요 없습니다. 그 항목은 비워 둬도 됩니다. 규칙별 통계 포함(includeRuleStats)만 켜면 충분합니다.
lexq analytics simulation start --json '{
"policyVersionId": "<candidate-version-id>",
"dataset": {
"type": "HISTORICAL",
"source": "EXECUTION_LOGS",
"from": "2026-06-01",
"to": "2026-06-30"
},
"options": {
"baselinePolicyVersionId": "<baseline-version-id>",
"includeRuleStats": true,
"maxRecords": 50000
}
}'
시뮬레이션은 6월의 실제 플랜 변경으로 대상 버전을 검증합니다. 배포할지 말지는 두 가지로 판단합니다. 첫째, 면제 규칙의 매칭률 변화(대상 버전 매칭률에서 비교 기준 버전 매칭률을 뺀 값)가 곧 앞으로 청구되지 않을 업그레이드의 규모이고, 이 값이 재무가 감당하기로 한 선 안에 들어와야 합니다. 둘째, 이번 수정이 건드리지 않은 규칙은 매칭률이 조금도 움직여선 안 됩니다. 기준값 하나만 옮겼다고 했으니, 규칙별 통계가 그 말이 사실인지 확인해 줍니다. 시뮬레이션은 외부 호출을 모킹으로 대체합니다. 과거 데이터를 읽기만 할 뿐 아무것도 쓰지 않습니다.
아직 운영 트래픽이 없다면 대표 데이터셋을 올려 같은 비교를 돌리면 됩니다. 최소 기준 바로 아래와 바로 위의 금액, 이월 구간 바로 안쪽과 바로 바깥쪽의 남은 일수처럼 두 기준값 언저리에 몰린 변경으로 데이터셋을 구성하면, 처리가 갈리는 경우가 빠짐없이 들어갑니다. 변경 영향 시뮬레이션이 답하는 건 앞을 내다보는 질문입니다. 이 변경이 무엇을 바꾸는가. 여기서부터는 뒤를 돌아보는 질문에 답합니다.
의사결정 트레이스 출력
처리 세 개가 한꺼번에 걸리는 경우를 드라이런으로 돌려 봅니다. 갱신 이틀 전에 들어온 업그레이드이고, 일할 계산 금액은 0.80입니다.
{
"result": "SUCCESS",
"data": {
"traceId": "c7a2e9f4-...",
"inputFacts": {
"change_type": "UPGRADE",
"days_remaining": 2,
"prorated_charge_usd": 0.80
},
"mutatedFacts": {},
"generatedVariables": {
"proration_treatment": "FOLD_INTO_RENEWAL",
"treatment_reason": "Change within final 3 days folds into the renewal invoice"
},
"executionTraces": [ ... ],
"decisionTraces": [
{
"ruleName": "Defer — downgrade to renewal",
"status": "NO_MATCH",
"reasonCode": "CONDITION_MISMATCH",
"reasonDetail": null
},
{
"ruleName": "Fold — within final 3 days",
"status": "SELECTED",
"reasonCode": "FINAL_WINNER",
"reasonDetail": null
},
{
"ruleName": "Waive — below 1.00 minimum",
"status": "BLOCKED",
"reasonCode": "MUTEX_PRIORITY_LOST",
"reasonDetail": "Winner=[Fold — within final 3 days], Strategy=HIGHEST_PRIORITY"
},
{
"ruleName": "Charge — standard proration",
"status": "BLOCKED",
"reasonCode": "MUTEX_PRIORITY_LOST",
"reasonDetail": "Winner=[Fold — within final 3 days], Strategy=HIGHEST_PRIORITY"
}
]
}
}
청구 시스템이 읽는 값은 generatedVariables에 담깁니다. 처리 방식과, 규칙이 적어 둔 사유 문자열입니다. mutatedFacts는 비어 있고 __delta도 없습니다. SET_FACT는 prorated_charge_usd를 바꾸는 게 아니라 새 팩트를 만들고, 처리 종류를 고르는 결정은 숫자를 건드리지 않으니까요. 0.80이 왜 이렇게 처리됐는지는 트레이스가 설명합니다. 면제 규칙은 분명히 걸렸습니다. 금액이 실제로 최소 기준 아래니까요. 다만 우선순위가 더 높은 이월 규칙에 밀렸고, 그 사실이 경쟁 탈락(BLOCKED) · 상호 배타 우선순위 탈락(MUTEX_PRIORITY_LOST)으로 남으며 어느 규칙이 이겼는지까지 적힙니다. "이 소액을 왜 면제하지 않았는가"의 답은 누군가의 기억이 아니라 기록에 있습니다. 마지막 며칠 구간에 들어와서, 갱신 청구서에 실려 나가는 처리를 받은 것입니다.
이번에는 나중에 분쟁이 걸리는 경우입니다. 열흘 남은 업그레이드에 일할 계산 금액이 3.20인 건입니다.
{
"result": "SUCCESS",
"data": {
"traceId": "e1b8d3a6-...",
"inputFacts": {
"change_type": "UPGRADE",
"days_remaining": 10,
"prorated_charge_usd": 3.20
},
"mutatedFacts": {},
"generatedVariables": {
"proration_treatment": "CHARGE_NOW",
"treatment_reason": "Standard mid-cycle proration"
},
"executionTraces": [ ... ],
"decisionTraces": [
{
"ruleName": "Defer — downgrade to renewal",
"status": "NO_MATCH",
"reasonCode": "CONDITION_MISMATCH",
"reasonDetail": null
},
{
"ruleName": "Fold — within final 3 days",
"status": "NO_MATCH",
"reasonCode": "CONDITION_MISMATCH",
"reasonDetail": null
},
{
"ruleName": "Waive — below 1.00 minimum",
"status": "NO_MATCH",
"reasonCode": "CONDITION_MISMATCH",
"reasonDetail": null
},
{
"ruleName": "Charge — standard proration",
"status": "SELECTED",
"reasonCode": "FINAL_WINNER",
"reasonDetail": null
}
]
}
}
이 실행이 5월 12일 운영에서 돌았고, 고객이 3.20을 청구받았다고 합시다. 그리고 위에서 시뮬레이션한 최소 기준 변경이 7월 초에 배포됩니다. 며칠 뒤 고객이 그 청구 줄을 문제 삼습니다. 지금 정책대로라면 3.20은 5.00 기준 아래라 면제됐을 텐데, 왜 청구됐느냐는 겁니다.
이 질문에 답하려고 코드와 로그를 뒤져 가며 그날을 재구성할 필요가 없습니다. 답은 이미 세 군데에 기록으로 남아 있습니다.
봉인된 실행 기록. 운영 실행은 한 건 한 건 전체 트레이스와 함께 감사 원장에 기록됩니다. 도착한 그대로의 입력 팩트, 기록된 처리, 경쟁 규칙 하나하나의 결과까지 담깁니다. 그리고 기록되는 순간, 그때 운영 중이던 배포가 도장처럼 찍힙니다. 이 도장은 나중에 계산해 내는 값이 아니라 그 순간의 기록이라, 이후에 새 버전을 배포해도 과거 실행의 출처는 바뀌지 않습니다. 5월 12일 실행은 엔진이 무엇을 봤고 무엇을 결정했고 어느 배포 버전이 결정했는지에 대한 답으로 그대로 남습니다.
의사결정 출처(Decision Provenance). 그 기록을 열면 책임 이력이 작성, 발행, 배포 세 단계로 읽힙니다. 그 버전을 누가 작성했고, 누가 발행했고, 누가 언제 배포했는지입니다. "5월 12일에 적용되던 최소 기준"은 git blame으로 추론하는 값이 아니라, 배포 도장이 가리키는 버전을 열어 그날 그대로의 규칙으로 확인하는 값입니다.
의사결정 재실행(Decision Replay). "지금 정책이라면 다르게 결정했을까"라는 질문에는 저장된 트레이스를 현재 버전에 다시 돌려 보는 것으로 답합니다. 비교 기준 쪽은 저장된 운영 트레이스 그 자체입니다. 다시 평가하지 않습니다. 질문이 "무슨 일이 있었는가"이지 "다시 하면 어떻게 되는가"가 아니니까요. 재실행되는 건 현재 버전 쪽뿐입니다. 비교 결과에는 그때의 CHARGE_NOW와 지금의 WAIVE가 나란히 놓이고, 그 차이의 이유는 두 버전 사이에서 바뀐 기준값 하나입니다. 분쟁은 지원팀이 자신 있게 내놓을 수 있는 한 문장으로 끝납니다. 그 청구는 5월 12일에 운영 중이던 정책에서 옳았고, 그 정책과 배포와 결정이 여기 그대로 있다는 문장입니다.
엣지 케이스
이 패턴의 핵심은 특정 기준값이 아니라, 처리를 한곳에서 정하고 그 기록을 봉인하는 데 있습니다. 그 언저리에서 몇 가지는 따로 정하고 넘어가야 합니다.
- 기준값에 딱 걸치는 변경.
LESS_THAN_OR_EQUAL은 3일째를 이월 구간 안에 넣습니다. "마지막 사흘"을 포함으로 읽은 겁니다.LESS_THAN은 정확히 1.00인 금액을 면제 밖에 둡니다. "최소"를 청구되는 가장 작은 금액으로 읽은 겁니다. 어느 쪽도 틀린 해석은 아니지만, 어느 쪽도 우연이어서는 안 됩니다. 한 번 정해서 연산자에 적어 두면, 과거 어느 결정이든 기록된 조건식이 그때 어느 해석을 쓰고 있었는지 보여 줍니다. - 빠진 팩트.
days_remaining이 없는 페이로드는 조용히 지나가지 않습니다. 엔진은 임의의 기본값으로 메우는 대신 빠진 팩트 이름을 담아 에러를 던지고, 호출하는 쪽은 에러로 끝난 실행을 아직 결정되지 않은 것으로 다뤄야 합니다. 조용한 청구도, 조용한 면제도 안 됩니다. 엔진이 돈 문제에서 추측을 거부했으니, 애플리케이션이 대신 추측해서도 안 됩니다. - 아무것도 찍히지 않는 줄.
WAIVE,FOLD_INTO_RENEWAL,DEFER_TO_RENEWAL은 셋 다 당장의 청구 줄을 만들지 않습니다. 처리 팩트가 없으면 서로 다른 세 정책이 결과만 보고는 전혀 구분되지 않는데, 그게 바로 단순한 접근의 두 번째 결함이었습니다. 처리와 사유를 결정마다 남기는 건, 아무것도 청구되지 않은 줄도 청구된 줄만큼 설명할 수 있어야 하기 때문입니다. - 업그레이드도 다운그레이드도 아닌 변경. 결제 주기 전환이나 좌석 수 변경은 이 버전이 아직 다루지 않는 변경 유형입니다. 모르는
change_type은 어느 규칙에도 걸리지 않습니다. 실행은 처리 팩트 없이 미매칭(NO_MATCH)으로 끝나고, 호출하는 쪽은proration_treatment가 없는 결과를 에러와 똑같이 아직 결정되지 않은 것으로 다뤄야 합니다. 지원 범위를 넓히는 일은 기존 분기에 덧대는 게 아니라, 초안 버전에서 값 하나와 그에 맞는 규칙을 더하는 일입니다. - 재실행 결과는 증거이지 재청구가 아닙니다. 5월의 결정을 7월 정책에 돌려 보면 무엇이 달라졌을지는 보이지만, 5월의 청구가 고쳐지는 건 아닙니다. 정책을 바꾼 뒤 고객에게 차액을 돌려주기로 한다면 그건 앞으로 실행하는 새 결정이고, 새 실행으로 따로 기록됩니다. 과거 기록은 봉인된 채 남습니다. 원장은 지난 일을 설명하는 것이지, 지난 일을 고치는 게 아닙니다.
운영 배포
두 조건을 통과한 대상 버전은 배포해서 운영에 올립니다. 배포 순간의 규칙 스냅샷은 해시로 봉인되어 무결성이 검증되고, 누가 언제 어떤 버전을 올렸는지가 기록으로 남습니다. 앞으로의 모든 실행에 찍힐 바로 그 도장입니다.
처리 변경은 단계적으로 나눠 배포하지 않고 전체 플랜 변경에 한 번에 적용합니다. 트래픽을 나누면 같은 주에 똑같은 변경을 한 두 고객이 서로 다른 처리를 받고, 원장에는 어느 그룹에 들어갔는지가 결정 근거로 그대로 남습니다. 기록으로는 남지만, 고객에게 내밀 수 있는 설명은 아닙니다. 두 처리 정책을 나란히 비교해야 한다면, 그건 변경 영향 시뮬레이션이 이미 하는 일입니다. 대상 버전과 비교 기준 버전을 같은 과거 데이터에 돌려 차이를 읽으면 되고, 운영 청구를 가를 이유가 없습니다. 배포해도 되는지는 나눠 뿌려 보고 가늠하는 게 아니라 시뮬레이션에서 이미 답이 나와 있어야 하고, 배포가 더해 주는 건 그 답이 운영에서도 맞는지 확인하는 일뿐입니다.
다음 두 신호 중 하나라도 보이면 곧바로 롤백합니다.
- 바꾼 규칙의 운영 매칭률이 시뮬레이션 매칭률에서 벗어납니다. 운영에 들어오는 플랜 변경의 분포가 시뮬레이션에 쓴 과거 구간과 달라졌다는 뜻이고, 승인받았던 파장 범위는 더는 들어맞지 않습니다.
- 시뮬레이션이 변동 없다고 본 처리에서 변화가 나타납니다. 운영에 들어오는 팩트가 대상 버전을 잴 때 쓴 데이터셋과 다르다는 뜻입니다.
롤백하면 정책 그룹이 이전 버전으로 돌아가고, 롤백했다는 사실까지 배포 이력에 남습니다. 대상 버전이 전체 트래픽을 받기 시작하면 상시 답도 갖춰집니다. "6월에 면제된 업그레이드가 몇 건인가"는 규칙별 통계 조회로, "5월 12일에 적용되던 최소 기준"은 버전 이력과 배포 기록으로, 그리고 "이 청구서의 바로 이 줄"은 봉인된 실행 기록 그 자체로 답합니다. 이 패턴이 존재하는 이유가 바로 그 마지막 답을 지키는 데 있습니다.