If-Else로 짜여진 가격 로직은 언젠가 무너집니다. 그 전에 해야 할 일
If-else 가격 로직을 리팩토링한다고 근본 문제가 풀리진 않습니다. 구조적인 답은 더 우아한 클래스 계층이 아니라, 룰을 데이터로 분리하고 변경을 시뮬레이션하는 것입니다.
처음엔 30줄이었던 함수가 지금 가격 정책 전체를 굴리고 있습니다
대개 Notion 페이지에 그려진 스케치 한 장에서 시작합니다. 단일 가격, 재구매 고객 10% 할인, 5만원 이상 무료 배송. PricingService.calculate()의 첫 버전은 30줄 정도였습니다. PM은 만족했고, 테스트는 초록색이었고, 아무도 이 코드를 걱정하지 않았습니다.
18개월 후, 같은 파일은 이렇게 변해 있습니다.
public BigDecimal calculate(Order order, Customer customer) {
BigDecimal subtotal = order.getSubtotal();
BigDecimal discount = BigDecimal.ZERO;
if (customer.getTier() == Tier.PLATINUM) {
if (order.getItemCount() >= 3) {
discount = subtotal.multiply(new BigDecimal("0.15"));
} else if (subtotal.compareTo(new BigDecimal("200000")) >= 0) {
discount = subtotal.multiply(new BigDecimal("0.12"));
} else {
discount = subtotal.multiply(new BigDecimal("0.10"));
}
} else if (customer.getTier() == Tier.GOLD) {
if (order.containsCategory("ELECTRONICS")
&& !order.getCreatedAt().isBefore(Instant.parse("2026-04-01T00:00:00Z"))
&& !order.getCreatedAt().isAfter(Instant.parse("2026-04-30T23:59:59Z"))) {
discount = subtotal.multiply(new BigDecimal("0.08"));
} else if (customer.getLifetimeValue().compareTo(new BigDecimal("5000000")) >= 0) {
discount = subtotal.multiply(new BigDecimal("0.10"));
}
}
if (order.getPromoCode() != null) {
if (order.getPromoCode().equals("WELCOME20") && customer.isFirstOrder()) {
BigDecimal extra = subtotal.multiply(new BigDecimal("0.20"));
if (extra.compareTo(discount) > 0) {
discount = extra; // 등급 할인과 중복 적용 불가
}
} else if (order.getPromoCode().equals("VIP15") && customer.getTier() == Tier.PLATINUM) {
discount = discount.add(subtotal.multiply(new BigDecimal("0.15")));
// 등급 할인과 중복 적용
}
}
// ... 그리고 200줄 더
}
나쁜 코드는 아닙니다. 원래 작성한 엔지니어는 자기 일을 정확히 알고 있었습니다. 모든 분기는 실제 비즈니스 결정에 대응해서 추가된 것이고, 모든 조건은 추가된 그 시점에는 다 말이 됐습니다.
문제는 다른 데 있습니다. 지금 이 파일은 서로 겹치는 가격 정책 요소 13개를 동시에 다루고 있고, 누구도 아직 도달 가능한지 확신하지 못하는 분기 6개를 안고 있으며, 특정 주문 9건만 커버하는 테스트 파일 하나로 버티고 있습니다. 이 파일을 수정하는 모든 행위가 작은 두려움이 되어 있습니다.
가격 로직은 다른 어떤 비즈니스 코드보다 빨리 썩습니다. 이유는 단순합니다. 가격은 누락된 조건이 즉시 매출 항목으로 드러나는 유일한 종류의 로직이기 때문입니다. 자격 검증 버그는 QA에서 잡힙니다. 가격 버그는 월요일 아침 CFO 이메일에서 잡힙니다.
리팩토링으로는 이 문제가 풀리지 않습니다
이런 코드에 대한 표준 처방은 리팩토링입니다. Strategy 패턴을 적용하세요. 각 할인을 DiscountRule 클래스로 분리하세요. 리스트에 정렬해서 순회하세요.
코드가 깔끔해집니다. 순환 복잡도 점수가 떨어집니다. 리드 개발자가 한숨을 돌립니다.
6개월 뒤, 같은 문제가 추가 레이어를 입은 채로 그대로 돌아옵니다.
이유는 이렇습니다. 리팩토링은 가독성을 해결합니다. 그러나 실제로 돈을 잃게 하는 세 가지를 하나도 해결하지 못합니다.
룰 변경이 여전히 배포를 거칩니다. 마케팅이 무료 배송 기준을 5만원에서 7만원으로 2주만 올려보고 싶다고 합니다. Strategy 패턴이 있어도 이건 여전히 PR 하나, 코드 리뷰, 스테이징 배포, 프로덕션 배포, 그리고 롤백 플랜입니다. 변경의 단위가 작아진 게 아니라, 단지 더 작은 파일 안으로 옮겨간 것뿐입니다.
변경의 영향 범위는 여전히 프로덕션 전까지 보이지 않습니다. 리팩토링은 단일 룰이 무엇을 하는지 알려줍니다. 하지만 4번 룰의 임계값을 바꿨을 때 11번 룰이 트래픽의 3%에서 조용히 이기기 시작하는 일은 알려주지 않습니다. 가격 시스템은 룰의 상호작용에서 무너지지만, 기존 코드 구조는 그 영향을 관찰 가능하게 만들지 못합니다.
감사 답변은 여전히 "git blame 확인해볼게요"입니다. 고객센터 상담사가 "이 고객은 왜 23% 할인을 받았나요?"라고 물을 때, 리팩토링된 코드베이스의 답은 여전히 "디버거에서 Strategy 체인을 따라가봐야겠어요"입니다. 사람이 질문하는 그 순간에 결정 그 자체가 답을 하지 않습니다.
리팩토링은 진짜 작업이고, 의미 없는 일은 아닙니다. 하지만 if-else 붕괴는 단순 구현 문제가 아니라, 더 깊은 모델링 오류의 증상입니다 — 가격 룰을 코드로 취급하지만, 실제로는 행동을 가진 데이터라는 것입니다.
첫 번째 단계: 룰을 데이터로 분리합니다
구조적인 답은 더 좋은 클래스 계층이 아닙니다. 룰을 코드베이스 바깥으로 완전히 꺼내는 것입니다.
데이터로 표현된 가격 룰은 네 부분으로 구성됩니다.
- 조건 (예:
loyalty_tier == 'PLATINUM' && purchase_subtotal_krw >= 500000) - 액션 (예:
purchase_subtotal_krw에서 12% 차감) - 우선순위 (여러 룰이 매치될 때 어느 것이 이기는지)
- 버전 (이 룰이 언제 효력을 갖게 됐는지, 누가 추가했는지, 왜)
룰이 데이터가 되면, 애플리케이션 코드는 함수 하나로 줄어듭니다. 입력 사실(facts)을 구성해서 룰 엔진을 호출하고, 결과를 적용합니다. 애플리케이션은 어떤 고객이 어떤 할인을 받는지 더 이상 알지 않습니다. 결정을 위임하는 방법만 압니다.
public BigDecimal calculate(Order order, Customer customer) {
Map<String, Object> facts = Map.of(
"purchase_subtotal_krw", order.getSubtotal(),
"loyalty_tier", customer.getTier().name(),
"cart_category_tags", order.getCategoryTags(),
"customer_ltv_krw", customer.getLifetimeValue(),
"loyalty_credit_krw", customer.getLoyaltyCredit(),
"is_first_purchase", customer.isFirstOrder(),
"applied_promo_code", order.getPromoCode(),
// tenant 필수 시스템 fact
"user_id", customer.getId(),
"payment_amount", order.getPaymentAmount()
);
PolicyResult result = lexq.execute("kr-pricing-v1", facts);
return (BigDecimal) result.mutatedFacts().get("purchase_subtotal_krw");
}
200줄짜리 calculate()가 10줄이 됩니다. 나머지는 모두 편집 가능한 어딘가에 있습니다. 콘솔, CLI, API. PM과 운영팀이 IntelliJ를 열지 않고도 현재 활성화된 룰셋을 읽을 수 있습니다.
이게 구조적 전환의 첫 단계입니다. 필요하지만, 이것만으로는 부족합니다.
두 번째 단계: 배포 전에 검증합니다
룰을 코드 밖으로 꺼내면 새로운 리스크가 생깁니다. 200줄짜리 if-else 블록은 적어도 컴파일 에러와 CI 파이프라인이 명백한 사고를 잡아줬습니다. 콘솔에서 오후 3시에 편집한 룰은 몇 초 안에 라이브가 되고 실제 주문에 영향을 주기 시작합니다.
운영 환경에 영향을 주는 변경을 직관에 의존해서 검증할 수는 없습니다.
프로덕션 코드가 가진 규율 — 버전 관리, 코드 리뷰, 스테이징 환경, 통합 테스트 — 이 룰에도 그대로 옮겨와야 합니다. 옮겨오지 못하는 것은 합성 데이터 기반 테스트입니다. 가격 룰은 서로 너무 얽혀 있어서 합성 데이터로는 의미 있는 검증이 안 됩니다. 의미 있는 유일한 테스트는 이것입니다. 지난 30일의 실제 주문에 새 룰을 돌려보고, 무엇이 어떻게 달라졌는지 정확히 알려주는 것.
이게 Impact Simulation이 하는 일입니다. 시뮬레이션 API는 후보 룰 버전을 받아서, 과거 트래픽의 한 구간을 가리키고, 구조화된 diff를 돌려줍니다. 단일 주문 — Platinum 고객의 60만원 장바구니, 여덟 개의 룰이 평가되어 세 개가 채택되는 케이스 — 의 LexQ 응답은 다음과 같습니다.
{
"result": "SUCCESS",
"data": {
"inputFacts": {
"purchase_subtotal_krw": 600000,
"is_first_purchase": false,
"loyalty_tier": "PLATINUM",
"payment_amount": 600000,
"user_id": "demo_user",
"applied_promo_code": "",
"loyalty_credit_krw": 0,
"cart_category_tags": "electronics,accessories",
"customer_ltv_krw": 8200000
},
"mutatedFacts": {
"loyalty_credit_krw": 25000,
"purchase_subtotal_krw": 503000
},
"generatedVariables": {
"loyalty_credit_krw__delta": 25000,
"purchase_subtotal_krw__delta": -97000
},
"executionTraces": [
{
"ruleName": "프로모: WELCOME20",
"matched": false,
"matchExpression": "applied_promo_code == 'WELCOME20'",
"inputFacts": {
"purchase_subtotal_krw": 600000,
"loyalty_tier": "PLATINUM",
"cart_category_tags": "electronics,accessories",
"customer_ltv_krw": 8200000,
"loyalty_credit_krw": 0,
"applied_promo_code": "",
"is_first_purchase": false,
"user_id": "demo_user",
"payment_amount": 600000
},
"generatedActions": [
{
"type": "MUTATE_FACT",
"parameters": {
"rate": 20,
"method": "PERCENTAGE",
"refVar": "purchase_subtotal_krw",
"operator": "SUB",
"rounding": { "mode": "HALF_UP", "scale": 0 }
}
}
]
},
{
"ruleName": "프로모: VIP15",
"matched": false,
"matchExpression": "applied_promo_code == 'VIP15'",
"inputFacts": {
"purchase_subtotal_krw": 600000,
"loyalty_tier": "PLATINUM",
"cart_category_tags": "electronics,accessories",
"customer_ltv_krw": 8200000,
"loyalty_credit_krw": 0,
"applied_promo_code": "",
"is_first_purchase": false,
"user_id": "demo_user",
"payment_amount": 600000
},
"generatedActions": [
{
"type": "MUTATE_FACT",
"parameters": {
"rate": 15,
"method": "PERCENTAGE",
"refVar": "purchase_subtotal_krw",
"operator": "SUB",
"rounding": { "mode": "HALF_UP", "scale": 0 }
}
}
]
},
{
"ruleName": "등급: PLATINUM 12%",
"matched": true,
"matchExpression": "loyalty_tier == 'PLATINUM'",
"inputFacts": {
"purchase_subtotal_krw": 600000,
"applied_promo_code": "",
"cart_category_tags": "electronics,accessories",
"user_id": "demo_user",
"loyalty_tier": "PLATINUM",
"customer_ltv_krw": 8200000,
"payment_amount": 600000,
"loyalty_credit_krw": 0,
"is_first_purchase": false
},
"generatedActions": [
{
"type": "MUTATE_FACT",
"parameters": {
"rate": 12,
"method": "PERCENTAGE",
"refVar": "purchase_subtotal_krw",
"operator": "SUB",
"rounding": { "mode": "HALF_UP", "scale": 0 }
}
}
]
},
{
"ruleName": "등급: GOLD 8%",
"matched": false,
"matchExpression": "loyalty_tier == 'GOLD'",
"inputFacts": {
"purchase_subtotal_krw": 600000,
"loyalty_tier": "PLATINUM",
"cart_category_tags": "electronics,accessories",
"customer_ltv_krw": 8200000,
"loyalty_credit_krw": 0,
"applied_promo_code": "",
"is_first_purchase": false,
"user_id": "demo_user",
"payment_amount": 600000
},
"generatedActions": [
{
"type": "MUTATE_FACT",
"parameters": {
"rate": 8,
"method": "PERCENTAGE",
"refVar": "purchase_subtotal_krw",
"operator": "SUB",
"rounding": { "mode": "HALF_UP", "scale": 0 }
}
}
]
},
{
"ruleName": "등급: SILVER 5%",
"matched": false,
"matchExpression": "loyalty_tier == 'SILVER'",
"inputFacts": {
"purchase_subtotal_krw": 600000,
"loyalty_tier": "PLATINUM",
"cart_category_tags": "electronics,accessories",
"customer_ltv_krw": 8200000,
"loyalty_credit_krw": 0,
"applied_promo_code": "",
"is_first_purchase": false,
"user_id": "demo_user",
"payment_amount": 600000
},
"generatedActions": [
{
"type": "MUTATE_FACT",
"parameters": {
"rate": 5,
"method": "PERCENTAGE",
"refVar": "purchase_subtotal_krw",
"operator": "SUB",
"rounding": { "mode": "HALF_UP", "scale": 0 }
}
}
]
},
{
"ruleName": "첫 구매 보너스",
"matched": false,
"matchExpression": "is_first_purchase == true",
"inputFacts": {
"purchase_subtotal_krw": 600000,
"loyalty_tier": "PLATINUM",
"cart_category_tags": "electronics,accessories",
"customer_ltv_krw": 8200000,
"loyalty_credit_krw": 0,
"applied_promo_code": "",
"is_first_purchase": false,
"user_id": "demo_user",
"payment_amount": 600000
},
"generatedActions": [
{
"type": "MUTATE_FACT",
"parameters": {
"value": 10000,
"method": "AMOUNT",
"refVar": "purchase_subtotal_krw",
"operator": "SUB",
"rounding": { "mode": "HALF_UP", "scale": 0 }
}
}
]
},
{
"ruleName": "전자제품 ≥ 50만원",
"matched": true,
"matchExpression": "(cart_category_tags contains 'electronics') && (purchase_subtotal_krw >= 500000)",
"inputFacts": {
"purchase_subtotal_krw": 600000,
"applied_promo_code": "",
"cart_category_tags": "electronics,accessories",
"user_id": "demo_user",
"loyalty_tier": "PLATINUM",
"customer_ltv_krw": 8200000,
"payment_amount": 600000,
"loyalty_credit_krw": 0,
"is_first_purchase": false
},
"generatedActions": [
{
"type": "MUTATE_FACT",
"parameters": {
"value": 25000,
"method": "AMOUNT",
"refVar": "purchase_subtotal_krw",
"operator": "SUB",
"rounding": { "mode": "HALF_UP", "scale": 0 }
}
}
]
},
{
"ruleName": "고LTV 고객 보상",
"matched": true,
"matchExpression": "customer_ltv_krw > 5000000",
"inputFacts": {
"purchase_subtotal_krw": 600000,
"applied_promo_code": "",
"cart_category_tags": "electronics,accessories",
"user_id": "demo_user",
"loyalty_tier": "PLATINUM",
"customer_ltv_krw": 8200000,
"payment_amount": 600000,
"loyalty_credit_krw": 0,
"is_first_purchase": false
},
"generatedActions": [
{
"type": "INCREMENT_FACT",
"parameters": {
"value": 25000,
"method": "AMOUNT",
"rounding": { "mode": "HALF_UP", "scale": 0 },
"targetVar": "loyalty_credit_krw"
}
}
]
}
],
"decisionTraces": [
{
"ruleName": "프로모: WELCOME20",
"status": "NO_MATCH",
"reasonCode": "CONDITION_MISMATCH",
"reasonDetail": null
},
{
"ruleName": "프로모: VIP15",
"status": "NO_MATCH",
"reasonCode": "CONDITION_MISMATCH",
"reasonDetail": null
},
{
"ruleName": "등급: PLATINUM 12%",
"status": "SELECTED",
"reasonCode": "FINAL_WINNER",
"reasonDetail": null
},
{
"ruleName": "등급: GOLD 8%",
"status": "NO_MATCH",
"reasonCode": "CONDITION_MISMATCH",
"reasonDetail": null
},
{
"ruleName": "등급: SILVER 5%",
"status": "NO_MATCH",
"reasonCode": "CONDITION_MISMATCH",
"reasonDetail": null
},
{
"ruleName": "첫 구매 보너스",
"status": "NO_MATCH",
"reasonCode": "CONDITION_MISMATCH",
"reasonDetail": null
},
{
"ruleName": "전자제품 ≥ 50만원",
"status": "SELECTED",
"reasonCode": "FINAL_WINNER",
"reasonDetail": null
},
{
"ruleName": "고LTV 고객 보상",
"status": "SELECTED",
"reasonCode": "FINAL_WINNER",
"reasonDetail": null
}
]
}
}
다섯 개의 상태 레이어가 의도적으로 노출됩니다. inputFacts는 들어온 값입니다. mutatedFacts는 룰이 변경한 값입니다. generatedVariables는 delta를 담고 있습니다 — *"이 룰이 이 주문에서 얼마의 비용을 만들었는가"*에 대한 답입니다. 같은 __delta 집계를 지난달 트래픽 전체에 돌리면, 같은 질문이 포트폴리오 스케일로 답해집니다. 이 룰 변경이 지난달에 4,200만원을 잃게 했을까? 배포 전에 이 숫자가 나옵니다. 사후가 아니라.
executionTraces는 모든 룰의 평가 흔적을 담습니다 — 어떤 매치 표현식이 어떤 fact에 대해 평가됐고, 매치 여부가 어떻게 됐고, 어떤 액션이 생성됐을지. 매치되지 않은 다섯 개의 룰도 trace에 남아 있는데, 시뮬레이션의 핵심이 정확히 그 부분입니다. 거의 적용될 뻔한 룰까지 보이지 않으면 시뮬레이션이 아닙니다. decisionTraces는 그 한 단계 위 — 어떤 룰이 최종 결정에 채택됐고, 왜 선택됐는지 보여줍니다. 디버거에서 Strategy 체인을 따라갈 필요가 없습니다. 결정이 스스로를 설명합니다.
이게 "룰을 데이터로"의 빠진 절반입니다. 시뮬레이션이 없다면, 외부화된 룰 시스템은 if-else 문제를 코드베이스 밖으로 이동시킨 것에 불과합니다. 변경 속도는 빨라지지만, 운영 리스크 자체는 줄어들지 않습니다.
여기서 중요한 건 룰 엔진 자체가 아닙니다. 중요한 건 변경을 프로덕션에 반영하기 전에, 실제 트래픽 기준으로 검증할 수 있느냐입니다.
이런 경우엔 필요 없습니다
구조적 답은 공짜가 아닙니다. 네트워크 호출, 외부 시스템, 학습 곡선이 따라옵니다. 이런 비용을 정당화하지 못하는 가격 모델도 있습니다.
다음의 경우엔 하드코딩으로 충분합니다.
- 가격이 1년에 한 번 바꾸는 단일 숫자일 때 (
월 9만원). - 변경이 균일할 때 — "전체 5% 인상" — 고객 세그먼트, 시간 윈도우, 조건 중첩이 일절 없을 때. SQL
UPDATE한 줄이 당신의 리팩토링입니다. - 룰 로직이 순수하게 기술적이고 외부 사양으로 고정돼 있을 때 (예: 정부가 공시한 세율표 기반 계산. 이건 오히려 콘솔에서 누군가 편집하면 안 됩니다).
룰을 데이터로 분리해야 하는 신호는 if-else 블록의 줄 수가 아닙니다. 가격 변경의 빈도와 다양성, 그리고 그중 몇 개를 배포하기 두려워하느냐입니다.
내일 무엇을 해야 하나요
PricingService 전체를 한 번에 마이그레이션할 필요는 없습니다. 가장 두려운 부분을 하나 고르세요 — 보통은 프로모 코드 분기 아니면 등급 중첩 섹션입니다. 그 부분만 룰 엔진으로 옮기세요. shadow mode로 지난달 주문에 돌려보세요. 프로덕션 코드가 실제로 내린 결정과 비교하세요. 차이를 메우세요. 그리고 cut over 하세요.
if-else 블록이 그날 사라지진 않습니다. 하지만 처음으로, 다음 가격 변경이 배포와 기도를 동시에 요구하지 않게 됩니다.
LexQ가 놓이는 자리
LexQ는 정확히 이런 종류의 변경을 위한 Impact Simulation이 내장된 의사결정 운영 플랫폼입니다. 실제 production 트래픽으로 룰 변경을 Test하고, full trace로 모든 결정을 Understand하며, 자신 있게 Deploy합니다.
→ LexQ 플레이그라운드에서 바로 실행해보기 — 가입 없이
관련 글