Mutex Group을 사용한 VIP 등급 할인 중첩 해소
등급 할인과 시즌 캠페인이 충돌할 때 정확히 하나의 할인만 적용되도록 보장하는 패턴.
문제
리테일 팀은 할인 두 축을 동시에 운영합니다. 하나는 등급 할인입니다. PLATINUM·GOLD·SILVER로 나뉘고, 반복 구매 고객에게 상시 정률로 붙습니다. 다른 하나는 시즌 캠페인입니다. 여름 세일이나 런칭 위크처럼 기간을 정해 전 고객에게 일시 적용합니다. 둘 다 각자 정당한 근거로 만들어졌고, 따로 놓고 보면 아무 문제가 없습니다. 충돌은 한 주문에 둘이 같이 걸리는 순간에만 생깁니다.
캠페인 기간의 PLATINUM 고객이 그 순간입니다. 이 고객은 양쪽 모두에 해당합니다. 대부분의 할인 코드에서 두 할인은 누적 합계에 독립적으로 더해지는 항목이라, 고객은 둘 다 받습니다. 12% 등급 할인에 15% 캠페인 할인이 얹히면 27%입니다. 재무는 캠페인 요율만 가정해 예산을 짰으므로 추가 12%는 몇 주 뒤 수익 누락으로 드러나고, 원인 규명은 거기서 또 한참 걸립니다.
지켜졌어야 할 제약은 한 문장입니다. "할인은 최대 하나만 적용됩니다." 이 문장이 코드 어디에도 적혀 있지 않습니다. 두 할인 블록에 걸친 제약이고, 누적 합계 방식에는 그걸 둘 자리가 없기 때문입니다.
이 패턴이 노리는 것은 둘입니다. 한 주문에 할인이 정확히 하나만 적용되도록 보장하는 것, 그리고 몇 달 뒤에 누가 물어도 어느 할인이 왜 적용됐는지 답할 수 있게 하는 것입니다.
단순한 접근
첫 버전의 구조는 단순합니다. 등급 할인을 계산하고, 캠페인 할인을 계산하고, 합계를 차감합니다. 두 할인이 절대 겹치지 않는 동안에는 이 구조가 그대로 버팁니다.
public BigDecimal applyDiscount(Order order, Customer customer) {
BigDecimal subtotal = order.getSubtotal();
BigDecimal discount = BigDecimal.ZERO;
// 로열티 등급. 회계팀 소유
if (customer.getTier() == Tier.PLATINUM) {
discount = subtotal.multiply(new BigDecimal("0.12"));
} else if (customer.getTier() == Tier.GOLD) {
discount = subtotal.multiply(new BigDecimal("0.08"));
} else if (customer.getTier() == Tier.SILVER) {
discount = subtotal.multiply(new BigDecimal("0.05"));
}
// 시즌 캠페인. 이후 그로스팀이 추가
if (campaignService.isActive("SUMMER_SALE")) {
discount = discount.add(subtotal.multiply(new BigDecimal("0.15")));
}
if (campaignService.isActive("NEW_USER_WEEK")) {
discount = discount.add(subtotal.multiply(new BigDecimal("0.10")));
}
// 여름 세일 중 PLATINUM 고객은 이제 27%를 받습니다.
// 누구도 그렇게 결정하지 않았습니다. 두 독립 블록의 합일 뿐입니다.
return subtotal.subtract(discount);
}
주석이 소유권을 짚어 줍니다. 등급 블록은 회계팀 것이고, 캠페인 블록은 이후 그로스팀이 붙였습니다. 서로 다른 사람이 몇 달 간격으로 쓴 코드입니다. 부주의한 코드는 아닙니다. 각 블록은 실제 결정에 대응해 작성됐고, 출시되던 날에는 각각 옳았습니다.
결함은 블록 사이에 있습니다. 어느 블록도 다른 블록이 있다는 걸 모릅니다. 이 사실이 세 곳에서 드러납니다.
- "중첩 금지"를 적을 자리가 없습니다. 리뷰어가 봐야 하는 것은 코드 한 줄이 아니라 한 줄의 부재입니다. 두 블록 중 하나를 건드리는 리팩터링은 테스트를 하나도 깨뜨리지 않고 27%를 만듭니다.
- 우선순위가 암묵적입니다. 캠페인 요율이 등급 요율에 더해지는 대신 대체해야 한다고 비즈니스가 결정하면, 메서드 전체를 다시 읽고 순서를 재편하는 작업이 됩니다. 결정이 검토 가능한 데이터가 아니라 제어 흐름에 묻혀 있습니다.
- 기록이 없습니다. 6개월 뒤 재무가 "어떤 주문이 왜 27%를 받았습니까"라고 물으면, 답은
git blame과 그날 어느 캠페인이 켜져 있었는지에 대한 추측입니다.
패턴 정의
고칠 자리는 구조입니다. 각 할인을 독립 규칙으로 모델링하고, 경쟁하는 할인을 전부 단일 실행(Single, mutexMode: EXCLUSIVE) 모드의 상호 배타 그룹(Mutex Group) 하나에 넣고, 규칙 우선순위가 승자를 정하게 합니다.
LexQ는 이걸 세 조각으로 나눠 표현합니다.
- fact: 장바구니 한 건에서 엔진이 읽어 가는 값입니다.
loyaltyTier,purchaseSubtotalUsd,activeCampaign셋입니다. - 규칙: 할인마다 하나씩 둡니다. 조건과,
purchaseSubtotalUsd에서 일정 비율을 빼는MUTATE_FACT(값 변경) 액션으로 이루어집니다. - 상호 배타 그룹(Mutex Group): 할인 목록을 승자가 하나뿐인 경쟁으로 바꾸는 필드입니다.
설정은 규칙마다 같습니다. 모든 할인 규칙이 mutexGroup 키로 best-discount를 공유하고, mutexMode는 EXCLUSIVE입니다. 단일 실행 모드라 승리한 규칙의 액션만 실행됩니다. mutexStrategy는 값이 HIGHEST_PRIORITY 하나뿐이라 고를 전략이 없습니다. 한 주문에 걸린 할인 가운데 priority 숫자가 가장 작은 할인이 적용됩니다.
priority는 여기서 오해가 자주 생깁니다. 규칙을 만들 때 지정하는 값이 아닙니다. 버전 안에서 1..N으로 자동 배정되는 순서이고, 바꾸는 수단은 reorder 하나뿐입니다. 콘솔에서는 드래그입니다. 그리고 priority는 mutexGroup과 독립입니다. 그룹 내부 순번이 아니라 버전 전체에 걸친 단일 순번입니다.
캠페인 규칙을 목록 맨 위에 두려면 먼저 만들거나 위로 끌어 올립니다. 그러면 가장 작은 priority를 갖습니다. 캠페인 기간에는 캠페인 규칙과 등급 규칙이 모두 매칭되지만, 맨 위에 있는 캠페인 규칙이 이깁니다.
{
"name": "Campaign: Summer Sale 15%",
"condition": {
"type": "GROUP",
"operator": "AND",
"children": [
{
"type": "SINGLE",
"field": "activeCampaign",
"operator": "EQUALS",
"value": "SUMMER_SALE",
"valueType": "STRING"
}
]
},
"actions": [
{
"type": "MUTATE_FACT",
"parameters": {
"operand": 15,
"method": "PERCENTAGE",
"targetVar": "purchaseSubtotalUsd",
"operator": "SUB",
"rounding": { "mode": "HALF_UP", "scale": 2 }
}
}
],
"mutexGroup": "best-discount",
"mutexMode": "EXCLUSIVE",
"mutexStrategy": "HIGHEST_PRIORITY",
"mutexLimit": 1,
"isEnabled": true
}
{
"name": "Tier: PLATINUM 12%",
"condition": {
"type": "GROUP",
"operator": "AND",
"children": [
{
"type": "SINGLE",
"field": "loyaltyTier",
"operator": "EQUALS",
"value": "PLATINUM",
"valueType": "STRING"
}
]
},
"actions": [
{
"type": "MUTATE_FACT",
"parameters": {
"operand": 12,
"method": "PERCENTAGE",
"targetVar": "purchaseSubtotalUsd",
"operator": "SUB",
"rounding": { "mode": "HALF_UP", "scale": 2 }
}
}
],
"mutexGroup": "best-discount",
"mutexMode": "EXCLUSIVE",
"mutexStrategy": "HIGHEST_PRIORITY",
"mutexLimit": 1,
"isEnabled": true
}
두 규칙 모두 생성할 때 priority를 보내지 않습니다. 엔진이 생성 순서대로 버전 전역 순번을 배정하고, 이후 reorder로 바꿉니다. mutexLimit은 단일 실행 모드에서 항상 1이라 생략해도 됩니다. 승자는 하나입니다.
바뀐 것은 제약이 놓인 자리입니다. "최대 하나만 적용됩니다"가 이제 mutexMode 필드에 적혀 있습니다. 더 이상 두 if 블록 사이의 틈이 아닙니다.
GOLD 규칙까지 화면에 보입니다. SILVER를 비롯한 나머지 등급도 같은 형태로 이어 붙입니다.
변경 영향 시뮬레이션 전략
규칙을 콘솔로 옮기면 새 위험이 하나 생깁니다. 콘솔에서 고친 규칙이 PR 한 번 없이 수 초 만에 운영 주문에 반영됩니다. 그래서 운영 코드가 받던 규율을 같이 옮겨 와야 합니다. 실제 결과를 놓고 하는 리뷰 말입니다.
그 메커니즘이 변경 영향 시뮬레이션입니다. 대상 버전을 운영에 넣기 전에 과거 주문 데이터에 실행해 봅니다. 이 비교 자체를 정면으로 다루는 패턴은 배포 전 규칙 변경 검증입니다.
설정에는 버전 둘이 들어갑니다. 비교 기준 버전은 할인이 여전히 겹치는 쪽입니다. 지금 운영 트래픽을 받는 버전이거나, 아직 아무것도 내보내지 않았다면 상호 배타 그룹을 넣기 전의 초안입니다. 아래에서는 v1이 그쪽이고, 대상 버전은 best-discount 상호 배타 그룹이 적용된 v2입니다. 데이터셋은 과거 캠페인 기간을 적어도 한 번 포함하는 이력 실행 데이터로 잡습니다. 중첩 케이스를 실행에 반드시 넣기 위해서입니다.
운영 트래픽이 아직 없다면 대표 주문 데이터셋을 올려 같은 비교를 합니다. 등급과 캠페인 on/off 조합을 골고루 담되, 중첩 케이스를 빼놓지 않습니다.
lexq analytics simulation start --json '{
"policyVersionId": "<candidate-version-id>",
"dataset": {
"type": "HISTORICAL",
"source": "EXECUTION_LOGS",
"from": "2026-04-01",
"to": "2026-04-30"
},
"options": {
"baselinePolicyVersionId": "<baseline-version-id>",
"includeRuleStats": true,
"maxRecords": 10000,
"metricConfig": {
"targetVariable": "purchaseSubtotalUsd__delta",
"aggregationType": "SUM"
}
}
}'
위 화면의 실행은 운영 이력 대신 대표 주문 500건을 올려 돌린 쪽입니다. 그중 324건이 할인 규칙에 걸려 purchaseSubtotalUsd__delta를 남기고, 합계는 그 324건에만 닿습니다. 화면의 Measured가 그 숫자입니다. 나머지 176건은 할인을 하나도 받지 않습니다.
출시 판정은 두 조건으로 합니다. 첫째, 어떤 주문도 자신에게 적용 가능한 단일 최대 할인보다 큰 할인폭을 보이면 안 됩니다. purchaseSubtotalUsd__delta의 절댓값으로 봅니다. 하나라도 있으면 중첩이 남아 있습니다. 둘째, 기간 전체의 실현 할인율이 캠페인 예산 가정 대비 팀이 정한 허용 범위 안에 들어와야 합니다. 계획한 캠페인 요율의 ±2% 이내처럼, 숫자는 팀이 미리 정해 둡니다. 이 허용 범위는 화면의 Change 수치와 다릅니다. 화면의 백분율은 비교 기준 버전에서 대상 버전으로 집계 할인이 옮겨 간 폭이고, 중첩을 걷어내는 변경이라면 크게 나오는 것이 정상입니다.
시뮬레이션은 과거 데이터를 읽기만 합니다. 아무것도 쓰지 않으므로 부작용이 없습니다.
의사결정 트레이스 출력
모든 실행은 트레이스를 반환합니다. 여름 세일 기간에 PLATINUM 고객이 $600 장바구니를 결제하는 상황을 예로 듭니다. 어느 규칙이 이겼고 어느 규칙이 막혔는지가 여기 남습니다.
{
"result": "SUCCESS",
"data": {
"inputFacts": { ... },
"mutatedFacts": {
"purchaseSubtotalUsd": 510
},
"generatedVariables": {
"purchaseSubtotalUsd__delta": -90
},
"executionTraces": [ ... ],
"decisionTraces": [
{
"ruleName": "Campaign: Summer Sale 15%",
"status": "SELECTED",
"reasonCode": "FINAL_WINNER",
"reasonDetail": null
},
{
"ruleName": "Tier: PLATINUM 12%",
"status": "BLOCKED",
"reasonCode": "MUTEX_PRIORITY_LOST",
"reasonDetail": "Winner=[Campaign: Summer Sale 15%], Strategy=HIGHEST_PRIORITY"
},
{
"ruleName": "Tier: GOLD 8%",
"status": "NO_MATCH",
"reasonCode": "CONDITION_MISMATCH",
"reasonDetail": null
}
]
}
}
mutatedFacts가 최종 구매 소계인 510을 담습니다. generatedVariables의 purchaseSubtotalUsd__delta는 -90입니다. 부호 있는 할인액이고, 600의 15%와 정확히 같습니다.
decisionTraces를 읽을 때는 두 필드를 구분합니다. status는 결과의 분류이고, reasonCode는 그 분류 안의 구체적 사유입니다. PLATINUM 규칙이 걸어간 경로가 이 구분을 보여 줍니다. 조건은 매칭됐지만 상호 배타 경쟁에서 졌으므로 BLOCKED / MUTEX_PRIORITY_LOST입니다. 조건식을 어떻게 평가했고 어디서 매칭됐는지는 드라이런 화면의 실행 트레이스 표에 함께 나옵니다. 6개월 뒤 감사가 물어도 트레이스가 디버거 없이 15%를 설명합니다.
비교 기준 버전을 같은 입력으로 돌리면 차이가 그대로 드러납니다. 상호 배타 그룹이 없으니 Campaign과 PLATINUM이 둘 다 발동합니다. Campaign이 15%를 빼고, PLATINUM이 남은 금액의 12%를 다시 뺍니다. 할인폭은 -151.2, 최종 금액은 448.8이고 약 25.2% 할인입니다. 대상 버전이 없앤 것이 이 중첩입니다.
엣지 케이스
이 패턴은 흔한 중첩을 해소합니다. 언저리 경우들은 상호 배타 그룹이 어디까지 개입하고 어디서 손을 떼는지를 알아야 답이 나옵니다.
- 승자를 바꾸고 싶을 때.
priority는 버전 안에서 값 중복을 강제로 배제하므로 동률이 나오지 않습니다. 매칭된 멤버 가운데 승자는 항상 정확히 하나입니다. 다른 규칙을 이기게 하려면 reorder로 순서를 올립니다. - 그룹 안에서 아무것도 매칭되지 않을 때. 등급도 없고 활성 캠페인도 없는 고객이라면 할인 규칙이 하나도 매칭되지 않고, 상호 배타 그룹은 작동하지 않습니다. mutex가 개입하는 범위는 이미 매칭된 규칙들 사이의 중재까지입니다. 할인이 붙지 않고, 에러도 나지 않습니다.
- 출력 값이 가장 큰 규칙을 고르고 싶을 때. 엔진에 그런 전략은 없습니다. 순서는 저작자가 정하고 값에서 추론하지 않습니다. 등급이 결과 순으로 정렬된다면 그 순서를
priority에 담으면 됩니다. 20% 등급을 15% 등급보다 위에 둡니다. "가장 큰" 기준이 실행 시점 입력에 실제로 달려 있다면, 그 분기를 조건이 명시된 별도 규칙으로 쪼갭니다. 같은 구조를 결과가 여러 갈래인 결정에 쓴 예는 신용 신청 판정입니다. - 일부러 겹치게 하고 싶을 때. 모든 할인을 한 그룹에 묶을 이유는 없습니다. 최고 할인을 적용한 뒤 로열티 적립금 $5는 항상 추가로 빼기로 했다면, 적립금 규칙을
best-discount그룹에 넣지 않습니다. 상호 배타 그룹은 같은 그룹 안의 규칙끼리만 경쟁시키므로, 그룹 밖 규칙은 영향 없이 그대로 발동합니다. 그래서 "하나만 적용"과 "항상 같이 적용"이 한 버전에 공존합니다. 앞은 그룹 안, 뒤는 그룹 밖입니다. - 경쟁 범위가 다를 때. 정확히 하나가 아니라 한 그룹에서 최대 N개를 허용하고 싶다면, 예컨대 상위 두 개 할인까지 살리고 싶다면, 그룹을 상위 N개(Top N,
mutexMode:MAX_N) 모드로 두고 개수를mutexLimit으로 지정합니다. 같은 규칙 레벨 메커니즘이고, 한도에서 밀린 규칙은BLOCKED/MUTEX_LIMIT_REACHED로 기록됩니다. 경쟁이 한 버전 안의 규칙을 넘어 정책 그룹 사이로 올라가면 그건 실행 그룹(Execution Group)의 일입니다. 같은activationGroup을 공유하는 정책 그룹끼리activationMode·activationStrategy·executionLimit을 공유해 경쟁하고, 밀린 그룹의 규칙은GROUP_PRIORITY_LOST또는GROUP_LIMIT_REACHED로 남습니다. 이 패턴의 범위 밖입니다.
운영 배포
배포 조건은 시뮬레이션 판정과 같습니다. 중첩이 남은 주문이 0건이고, 집계 할인이 캠페인 예산 가정에 대해 정한 허용 범위 안에 있어야 합니다. 배포하면 규칙 스냅샷이 해시로 봉인되어 무결성이 검증되고, 누가 언제 어떤 버전을 올렸는지가 배포 기록에 남습니다.
노출은 한 번에 올리지 않습니다. 위 화면은 배포 기록 한 건이고, 운영에서는 그 앞에 A/B 테스트 단계를 둡니다. 대상 버전과 비교 기준 버전 사이에 트래픽 비율을 5%, 25%, 50%로 단계마다 올립니다. 각 단계에서 운영 의사결정 트레이스를 살펴 이상이 없을 때만 다음 비율로 갑니다. 수치가 유지되면 배포로 나머지 트래픽을 넘깁니다.
되돌릴 신호는 둘입니다.
- 할인폭이 단일 할인 상한을 넘을 때. 어떤 주문의 실제 할인폭(
purchaseSubtotalUsd__delta)이 그 주문이 받을 수 있는 가장 큰 단일 할인보다 크게 나오면, 시뮬레이션이 놓친 중첩이 새어 나온 것입니다. - 캠페인 규칙 선택 비율이 예측을 벗어날 때. 애플리케이션이 보내는 fact가 규칙이 기대하는 형태와 어긋난 것입니다.
롤백하면 정책 그룹이 이전 버전으로 돌아가고, 그 롤백 동작 자체도 배포 이력에 기록됩니다. 되돌린 것까지 감사 기록입니다.
전체 트래픽으로 넘긴 뒤에는 규칙별 통계를 봅니다. 각 할인 규칙이 얼마나 자주 승자가 되는지가 여기 나옵니다. 이 수치가 우선순위를 다시 잡는 근거가 되고, 한 번도 발동하지 않는 규칙을 정리하는 근거도 됩니다.
할인 규칙이 쌓여 장바구니 하나를 판정하는 시간이 길어지면, 어느 규칙이 그 시간을 썼는지부터 확인합니다. 규칙별 소요 시간을 읽는 절차는 느려진 규칙 집어내기에 있습니다.
LexQ가 어떻게 동작하는지 playground에서 직접 확인해보세요.