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과 그날 어느 캠페인이 켜져 있었는지에 대한 추측입니다.
패턴 정의
해법은 구조적입니다. 각 할인을 독립된 룰로 모델링하고, 경쟁하는 모든 할인을 하나의 EXCLUSIVE mutex group에 넣은 뒤, 룰 priority가 승자를 결정하게 합니다.
LexQ 관점에서 이 시나리오는 세 개념에 대응됩니다.
- Fact: 엔진이 읽는 입력.
loyalty_tier,purchase_subtotal_usd,active_campaign. - Rule: 할인당 룰 하나. 각각 condition과,
purchase_subtotal_usd에서 일정 비율을 차감하는MUTATE_FACT액션을 가집니다. - Mutex Group: 할인 목록을 정확히 한 명의 승자가 있는 경쟁으로 바꾸는 필드.
모든 할인 룰은 같은 mutexGroup 키 best-discount를 지니며 mutexMode는 EXCLUSIVE입니다. 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": "active_campaign",
"operator": "EQUALS",
"value": "SUMMER_SALE",
"valueType": "STRING"
}
]
},
"actions": [
{
"type": "MUTATE_FACT",
"parameters": {
"rate": 15,
"method": "PERCENTAGE",
"refVar": "purchase_subtotal_usd",
"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": "loyalty_tier",
"operator": "EQUALS",
"value": "PLATINUM",
"valueType": "STRING"
}
]
},
"actions": [
{
"type": "MUTATE_FACT",
"parameters": {
"rate": 12,
"method": "PERCENTAGE",
"refVar": "purchase_subtotal_usd",
"operator": "SUB",
"rounding": { "mode": "HALF_UP", "scale": 2 }
}
}
],
"mutexGroup": "best-discount",
"mutexMode": "EXCLUSIVE",
"mutexStrategy": "HIGHEST_PRIORITY",
"mutexLimit": 1,
"isEnabled": true
}
두 룰을 만들 때 priority를 보내지 않는 점에 주목합니다. 엔진이 생성 순서대로 버전 전역 순번을 배정하고, 그 순서는 reorder로 언제든 바꿀 수 있습니다. EXCLUSIVE 모드에서 mutexLimit은 항상 1이며(승자 하나), 생략해도 무방합니다.
"최대 하나만 적용된다"는 제약은 이제 mutexMode라는 필드에 명시됩니다. 더 이상 두 if 블록 사이의 틈이 아닙니다. GOLD 룰은 위 화면에 이미 보이며, SILVER 등 나머지 등급도 같은 형태로 그 아래에 이어집니다.
변경 영향 시뮬레이션 전략
할인을 룰로 옮기면 새로운 리스크가 생깁니다. 콘솔에서 수정한 룰은 그 사이에 PR 하나 없이 수 초 안에 라이브 주문에 도달합니다. 프로덕션 코드가 받는 규율, 즉 실제 결과를 놓고 하는 리뷰를 이식해야 합니다. 그 메커니즘이 변경 영향 시뮬레이션입니다. candidate 버전을 라이브에 올리기 전에 과거 주문 데이터에 돌려 봅니다.
설정에는 두 버전을 씁니다. baseline은 할인이 여전히 중첩되는 현재 프로덕션 버전입니다. candidate는 best-discount mutex group이 적용된 버전입니다. 데이터셋은 최소 한 번의 과거 캠페인 기간을 포함하는 historical 실행 데이터로, 중첩 케이스가 실행에 반드시 들어가도록 합니다. 아직 프로덕션 트래픽이 없다면, 대표 주문(등급과 캠페인 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": "purchase_subtotal_usd__delta",
"aggregationType": "SUM"
}
}
}'
candidate를 출시할지는 두 조건이 결정합니다. 첫째, 어떤 주문도 자신에게 적용 가능한 단일 최대 할인보다 큰 할인폭(purchase_subtotal_usd__delta의 절댓값)을 보여서는 안 됩니다. 하나라도 그러면 중첩이 살아남은 것입니다. 둘째, 집계 할인이 팀이 캠페인 예산 가정 대비 설정한 허용 범위(예: 계획 캠페인 요율의 ±2% 이내) 안에 들어와야 합니다. 시뮬레이션 중에는 integration 호출이 mock 처리되므로 실행에 부작용이 없습니다.
Decision Trace 출력
모든 실행은 trace를 반환합니다. 여름 세일 기간 PLATINUM 고객의 $600 장바구니에 대해, decision trace는 어느 룰이 이겼고 어느 룰이 막혔는지 기록합니다.
{
"result": "SUCCESS",
"data": {
"traceId": "31bd3596-...",
"inputFacts": { ... },
"mutatedFacts": {
"purchase_subtotal_usd": 510.00
},
"generatedVariables": {
"purchase_subtotal_usd__delta": -90.00
},
"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는 최종 subtotal을 담습니다. generatedVariables는 purchase_subtotal_usd__delta(부호가 있는 할인액 -90.00, 정확히 600의 15%)를 운반합니다. decisionTraces에서 status는 결과의 분류이고 reasonCode는 그 분류 안의 구체적 사유입니다. PLATINUM 룰은 매칭됐지만 mutex 경쟁에서 졌고, 이는 BLOCKED / MUTEX_PRIORITY_LOST로 기록됩니다. 각 룰이 어떤 match expression으로 평가돼 매칭됐는지는 위 Dry Run 화면의 Execution Trace 표에 함께 나타납니다. 이것이 audit의 답입니다. 6개월 뒤, trace가 디버거 없이 15%를 설명합니다.
같은 주문을 baseline(v1)에 돌리면 결과가 갈립니다. mutex 그룹이 없어 Campaign과 PLATINUM이 둘 다 발동하고, Campaign이 15%를 깎은 뒤 PLATINUM이 남은 금액의 12%를 다시 깎아 최종 448.80 — 약 25.2% 할인이 됩니다. candidate가 없앤 것이 바로 이 누적입니다.
엣지 케이스
이 패턴은 흔한 중첩을 해소합니다. 인접한 몇 가지 경우는 의도적인 결정을 요구합니다.
- 승자를 바꾸고 싶을 때. 버전 안에서
priority는 유일성이 강제되어 동률이 생길 수 없습니다. 매칭된 멤버 중 승자는 항상 정확히 하나로 결정됩니다. 다른 룰을 이기게 하려면 reorder로 순서를 올립니다. - 그룹 안에서 매칭되는 룰이 없을 때. 등급도 없고 활성 캠페인도 없는 고객에게는 어떤 할인 룰도 매칭되지 않습니다. mutex group은 비활성입니다. 이미 매칭된 룰들 사이에서만 중재하기 때문입니다. 결과는 할인 없음이며 에러도 없습니다.
- 출력 값이 가장 큰 룰을 선택해야 할 때. 고정된 우선순위가 항상 목표는 아닙니다. 규칙이 "출력 값이 가장 큰 룰을 선택한다"이면 전략은
HIGHEST_PRIORITY가 아니라MAX_BENEFIT입니다. 그룹 구조는 동일하고mutexStrategy만 바뀝니다. 할인처럼 값을 줄이는 액션에서는 어느 출력 값이 "가장 큰" 것으로 평가되는지 미리 확인해야 합니다. - 일부러 중첩시키고 싶은 할인이 있을 때. 모든 할인을 한 그룹에 묶을 필요는 없습니다. 예를 들어 "최고 할인을 적용한 뒤, 로열티 적립금 $5는 항상 추가로 빼준다"면, 그 적립금 룰은
best-discount그룹에 넣지 않으면 됩니다. mutex는 같은 그룹 안의 룰끼리만 경쟁시키므로, 그룹 밖의 룰은 영향받지 않고 그대로 발동합니다. 결국 "하나만 적용"되는 할인(그룹 안)과 "항상 같이 적용"되는 할인(그룹 밖)을 한 버전에 함께 둘 수 있습니다. - 경쟁의 범위가 다를 때. 정확히 하나가 아니라 한 그룹 안에서 최대 N개까지 허용해야 한다면(예: 상위 두 개 할인),
mutexMode를MAX_N으로 두고mutexLimit으로 개수를 지정합니다. 같은 룰 레벨 메커니즘이며, 한도에서 밀린 룰은BLOCKED/MUTEX_LIMIT_REACHED로 기록됩니다. 제약이 한 그룹 안이 아니라 버전 전체의 룰 사이에 걸치면, 그것은 activation group이고GROUP_PRIORITY_LOST/GROUP_LIMIT_REACHED로 기록됩니다. 이 패턴의 범위 밖입니다.
프로덕션 배포
검증된 candidate는 Deploy로 프로덕션에 올립니다. 배포 시점의 룰 스냅샷은 해시로 봉인되어 무결성이 검증되고, 누가·언제·어떤 버전을 올렸는지가 배포 기록에 남습니다. 전량을 한 번에 옮기는 대신 점진적으로 노출하려면, candidate와 baseline 사이에 A/B 테스트를 켜고 트래픽 비율을 5% → 25% → 50% → 100%로 단계적으로 올립니다. 각 단계마다 라이브 decision trace를 살펴 이상이 없을 때만 다음 비율로 넘어갑니다.
다음 두 신호 중 하나라도 보이면 즉시 롤백합니다:
- 어떤 주문의 실제 할인폭(
purchase_subtotal_usd__delta)이 그 주문이 받을 수 있는 가장 큰 단일 할인보다 크게 나올 때 — 시뮬레이션이 놓친 중첩이 새어 나온 것입니다. - 캠페인 룰이 실제로 선택되는 비율이 시뮬레이션 예측과 크게 다를 때 — 애플리케이션이 보내는 fact가 룰이 기대하는 형태와 어긋난다는 뜻입니다.
롤백하면 policy group이 이전 버전으로 되돌아가고, 그 롤백 동작 자체도 배포 이력에 기록됩니다 — 되돌린 것까지 audit trail에 남는 셈입니다. 새 버전이 100% 트래픽에서 안정되면, per-rule 통계로 각 할인 룰이 얼마나 자주 "승자"가 되는지 볼 수 있습니다. 이 수치는 우선순위를 다시 조정하거나, 한 번도 발동하지 않는 룰을 정리할 근거가 됩니다.
LexQ가 어떻게 동작하는지 playground에서 직접 확인해보세요.