행사 주간에는 “좋은 아키텍처”보다 “지금 어떤 룰이 살아 있는가”가 앞섭니다. 할인 조건 하나가 어긋나면 매출과 고객센터가 동시에 흔들리기 때문입니다. 이 글은 그 한 주를 행사 전, 행사 중, 행사 후 세 막으로 나눠 정리한 기록입니다.
가운데 막에는 사건이 하나 있습니다. 장바구니 합계가 간헐적으로 어긋났고, 우리가 처음 세운 원인 가설은 틀렸습니다. 틀린 가설을 어떻게 버렸는지가 이 글에서 제일 쓸모 있는 부분이라고 생각합니다.
1막 · 행사 전, 무엇을 먼저 잠갔나
행사 시작 나흘 전에 팀이 한 일은 코드를 고치는 게 아니라 규칙을 문장으로 옮기는 일이었습니다. 프로모션 담당자는 “쿠폰은 중복 안 되고, 등급 할인은 되고, 카드사 즉시할인은 마지막에 붙습니다”라고 말했는데, 코드에는 그 순서가 어디에도 적혀 있지 않았습니다. 순서가 적혀 있지 않다는 사실 자체가 첫 번째 위험 신호였습니다.
그래서 행사 전 준비는 세 가지로 줄였습니다. 첫째, 할인 종류마다 적용 순서를 숫자로 못박아 문서에 적었습니다. 둘째, 운영팀이 보는 관리자 화면에 “지금 이 시각에 살아 있는 프로모션 목록”을 그대로 노출했습니다. 어떤 룰이 켜져 있는지 개발자에게 물어봐야 알 수 있는 상태로 행사에 들어가면, 장애가 났을 때 확인에만 20분이 날아갑니다. 셋째, 프로모션 엔진 전체를 한 번에 끌 수 있는 스위치를 만들고, 껐을 때 정가로 계산되는지 스테이징에서 확인했습니다.
- 할인 적용 순서를 숫자로 적고, 그 숫자를 코드와 문서 양쪽에 둡니다.
- 관리자 화면에서 현재 활성 프로모션을 시간 단위로 볼 수 있게 합니다.
- 프로모션 엔진을 통째로 끄는 스위치를 미리 만들고, 끈 상태를 실제로 테스트합니다.
- 주문과 환불 상태가 뒤틀렸을 때의 복구 경로를 행사 전에 한 번 걸어 봅니다.
2막 · 행사 중, 합계가 어긋난 날
사건은 행사 둘째 날 오후에 시작됐습니다. 고객센터 대시보드에서 “장바구니 금액이 결제창에서 달라진다”는 유형의 문의가 시간당 두세 건에서 열아홉 건으로 올라왔습니다. 문의 내용은 제각각이었지만 공통점이 있었습니다. 새로고침을 하면 금액이 바뀐다는 것이었습니다. 어떤 고객은 1,200원이 싸졌고, 어떤 고객은 3,000원이 비싸졌습니다.
먼저 확인한 것은 실제 결제 금액이었습니다. 승인 내역과 주문 테이블을 대조해 보니, 결제된 금액과 주문에 기록된 합계는 서로 일치했습니다. 즉 돈이 새고 있는 상황은 아니었고, 같은 장바구니가 계산될 때마다 다른 답을 낸다는 문제였습니다. 이 구분이 대응의 크기를 정합니다. 정산이 틀어졌다면 결제를 멈춰야 하지만, 계산이 흔들리는 것이라면 행사를 돌리면서 고칠 수 있습니다.
재현을 위해 문의가 들어온 장바구니 구성을 그대로 만들어 같은 요청을 스무 번 반복해 보냈습니다. 결과를 그대로 옮기면 이렇습니다.
동일 장바구니 20회 반복 호출 결과 (예시)
$ php bin/replay.php --cart=CART-4471 --times=20
count total applied_rules
11 43,800 ["GRADE-5","COUPON-A","CARD-INSTANT"]
6 42,600 ["COUPON-A","GRADE-5","CARD-INSTANT"]
3 43,100 ["GRADE-5","CARD-INSTANT","COUPON-A"]
diff: max 1,200 KRW / 동일 입력, 동일 프로모션 세트
여기서 두 가지가 분명해졌습니다. 적용된 할인 규칙의 집합은 항상 같았습니다. 달라진 것은 순서뿐이었습니다. 그리고 순서가 달라질 때마다 합계가 달라졌습니다. 정률 할인과 정액 할인이 섞여 있으면 곱하는 순서에 따라 결과가 달라지는데, 우리 규칙에는 두 종류가 모두 들어 있었습니다.
2막 계속 · 캐시를 의심했고, 틀렸습니다
그런데 우리가 처음 세운 가설은 순서가 아니었습니다. “캐시 TTL이 길어서 만료된 옛 프로모션이 남아 있다”였습니다. 행사 기간에 프로모션이 매시간 바뀌고 있었고, 규칙 목록을 10분 TTL로 캐싱하고 있었으니 그럴듯했습니다. 새로고침마다 금액이 달라지는 것도 “어떤 서버는 옛 캐시를, 어떤 서버는 새 캐시를 들고 있다”로 설명이 됐습니다.
검증은 단순했습니다. TTL을 10분에서 30초로 내리고 20분간 지켜봤습니다. 가설이 맞다면 오차 폭이 줄거나 사라져야 했습니다. 결과는 그대로였습니다. 같은 장바구니에서 여전히 1,200원 차이가 났고, 문의량도 줄지 않았습니다. 대신 캐시 미스가 늘면서 프로모션 조회 쿼리가 초당 40건에서 620건으로 뛰었고, DB 응답 시간이 눈에 띄게 나빠졌습니다. 5분 만에 TTL을 되돌렸습니다.
결정적인 증거는 반복 호출 결과였습니다. 캐시 문제였다면 규칙 집합이 요청마다 달랐어야 합니다. 그런데 집합은 세 번의 응답 모두 동일했고, 순서만 달랐습니다. 이 한 줄로 캐시 가설은 기각됐습니다. 부하만 늘리고 20분을 쓴 셈이지만, 덕분에 다음 질문이 좁아졌습니다. “왜 같은 규칙이 매번 다른 순서로 도는가.”
이 사례에 관하여여기 나오는 팀과 사건은 여러 커머스 프로젝트에서 흔히 반복되는 상황을 하나로 묶어 만든 예시입니다. 수치와 로그는 이해를 돕기 위한 것이며 실제 서비스의 측정값이 아닙니다.
답은 코드에 있었습니다. 프로모션 목록을 만드는 쪽에서 조건별로 규칙을 모아 연관 배열에 담고 있었는데, 중간에 캐시에서 꺼낸 항목과 DB에서 새로 읽은 항목을 array_merge로 합치는 경로가 있었습니다. 게다가 정렬 기준이 ORDER BY 없이 비결정적으로 돌아오는 쿼리에 의존하고 있었습니다. 즉 순서를 아무도 정하지 않았고, 우리는 그동안 우연히 맞아떨어진 순서를 정답이라고 믿고 있었습니다. 행사 주간에 규칙 개수가 늘어나면서 그 우연이 깨진 것입니다.
2막 마무리 · 무엇을 고쳤나
수정은 두 부분이었습니다. 규칙마다 명시적 우선순위를 부여해 결정적으로 정렬하는 것, 그리고 합계 계산을 부수효과 없는 순수 함수로 떼어 내 테스트를 붙이는 것이었습니다. 계산기가 DB도 캐시도 세션도 건드리지 않게 되면, 같은 입력에 같은 출력이 나오는지 확인하는 일이 아주 싸집니다.
src/Pricing/CartCalculator.php — 결정적 정렬과 순수 계산
<?php
declare(strict_types=1);
final class DiscountRule
{
public function __construct(
public readonly string $code,
public readonly int $priority, // 낮을수록 먼저 적용
public readonly string $type, // 'rate' | 'amount'
public readonly int $value,
) {}
}
final class CartCalculator
{
/**
* @param list<DiscountRule> $rules
* @return array{total:int, applied:list<string>}
*/
public static function calculate(int $subtotal, array $rules): array
{
// 우선순위 -> 코드 순으로 정렬해 동률에서도 순서를 고정합니다.
usort($rules, static fn (DiscountRule $a, DiscountRule $b): int
=> [$a->priority, $a->code] <=> [$b->priority, $b->code]);
$total = $subtotal;
$applied = [];
foreach ($rules as $rule) {
$discount = $rule->type === 'rate'
? intdiv($total * $rule->value, 100)
: min($rule->value, $total);
if ($discount <= 0) {
continue;
}
$total -= $discount;
$applied[] = $rule->code;
}
return ['total' => max(0, $total), 'applied' => $applied];
}
}
테스트는 세 종류를 붙였습니다. 알려진 장바구니에 대한 기대 합계, 규칙 배열을 무작위로 섞어 넣어도 결과가 같은지 확인하는 셔플 테스트, 그리고 정률과 정액이 섞였을 때의 경계값입니다. 셔플 테스트가 이번 사건을 그대로 잡아 주는 테스트인데, 사건이 나기 전에는 아무도 그런 테스트를 떠올리지 못했습니다. 이런 성격의 테스트를 붙이는 방법은 PHP 테스트 가이드에 정리해 두었습니다.
배포는 행사 중이었기 때문에 두 단계로 나눴습니다. 먼저 계산기만 교체해 새 코드와 옛 코드의 결과를 함께 계산하고 차이가 나면 로그만 남기는 그림자 모드로 30분 돌렸습니다. 차이가 0으로 수렴한 것을 확인한 뒤에 새 계산기를 실제 값으로 승격했습니다. 프로모션 엔진 전체 차단 스위치는 끝까지 쓰지 않았지만, 그 스위치가 있다는 사실이 “일단 배포해 보자”는 판단을 가능하게 했습니다.
2막 곁가지 · 고객센터를 먼저 무장시켰습니다
기술 대응과 별개로, 사건이 확인된 직후에 고객센터에 한 문단짜리 안내를 보냈습니다. 무엇이 잘못됐는지, 결제된 금액은 안전한지, 차액이 발생한 주문은 어떻게 처리할지 세 가지만 적었습니다. 정확히는 “일부 장바구니에서 할인 적용 순서가 달라져 화면 합계가 흔들렸고, 실제 승인 금액과 주문 기록은 일치합니다. 차액이 확인된 주문은 우리가 목록으로 뽑아 자동 보정합니다”였습니다.
이 문단이 있고 없고의 차이는 큽니다. 상담사가 “확인 중입니다”만 반복하면 같은 고객이 두세 번 다시 문의하고, 문의량이 실제 장애 규모보다 부풀어 보입니다. 사건 대응에서 문의량 그래프가 왜곡되면 판단도 같이 왜곡됩니다.
“행사 중에는 새 구조가 아니라, 누구나 즉시 읽을 수 있는 규칙이 이깁니다.”
3막 · 행사 후, 남긴 것
행사가 끝난 뒤 회고에서 우리가 붙잡은 문장은 “캐시를 의심하지 말자”가 아니었습니다. 캐시는 충분히 의심할 만했습니다. 문제는 검증 순서였습니다. 우리는 가설을 세운 뒤 곧바로 운영 설정을 바꾸는 방식으로 검증했고, 그 대가로 DB 부하를 받았습니다. 반면 결정적인 증거를 준 것은 설정을 하나도 건드리지 않은 요청 재생 비교였습니다. 관측으로 확인할 수 있는 가설을 조작으로 확인하면, 실패했을 때 비용을 두 번 냅니다.
전이 가능한 원칙으로 정리하면 이렇습니다. 간헐적으로 값이 달라지는 버그를 만나면, 입력이 달라진 것인지 처리 순서가 달라진 것인지부터 가릅니다. 집합은 같은데 결과가 다르면 그건 거의 항상 순서 문제이고, 순서 문제의 뿌리는 대개 “아무도 순서를 정하지 않았다”입니다. 언어가 배열 순회 순서를 보장해 주더라도, 그 배열이 만들어지는 경로가 여러 개라면 순서는 보장되지 않습니다. 그래서 정렬은 취향이 아니라 계약입니다.
그리고 금액처럼 되돌리기 어려운 계산은 부수효과에서 떼어 놓는 편이 좋습니다. 계산기가 순수 함수이면 재현이 쉽고, 재현이 쉬우면 사건이 났을 때 논쟁이 짧아집니다. 관련해서 실패 상황을 다루는 방식은 PHP 에러 처리 가이드에, 캐시와 쿼리 부하를 다룰 때 참고한 내용은 성능 가이드에 정리돼 있습니다.
- 같은 입력에서 결과가 흔들리면, 값보다 순서를 먼저 의심합니다.
- 가설 검증은 운영 설정을 바꾸기 전에 관측으로 시도합니다. 요청 재생 비교가 가장 쌉니다.
- 정렬 기준은 동률까지 정의합니다. 우선순위가 같을 때의 2차 정렬 키가 없으면 순서는 여전히 비결정적입니다.
- 금액 계산은 저장소 접근에서 분리해 순수 함수로 두고, 배열을 섞어도 결과가 같은지 테스트합니다.
- 행사 기능은 전부 끌 수 있는 스위치와 함께 배포합니다. 쓰지 않더라도 판단 속도를 바꿔 줍니다.
- 고객센터에는 “확인 중” 대신 세 문장짜리 사실을 먼저 보냅니다.