프로모션·동적 가격 시스템 설계 — 수만 개 딜을 충돌 없이 운영하는 법
한 줄 요약: 프로모션·동적 가격 시스템의 핵심은 룰 엔진으로 할인 충돌을 우선순위 기반으로 해소하고, 이벤트 소싱으로 가격 이력을 불변 보존하며, CDC 파이프라인으로 검색·목록·상세 페이지 전체에 가격을 밀리초 내 동기화하는 것이다.
실제 문제: 가격 사고는 조용히 수십억을 날린다
국내 커머스 플랫폼들이 실제로 겪은 가격 관련 사고를 살펴보면 공통된 패턴이 보입니다.
쿠팡 타임딜 중복 할인 사고 (2022): 타임딜 30% 할인과 회원 등급 할인 15%가 동시에 적용되도록 설계됐지만, 내부적으로 두 프로모션이 같은 price 필드를 직접 덮어쓰는 구조였습니다. 타임딜 가격 65,000원에서 회원 할인이 원래 가격 100,000원 기준 15%인 15,000원을 추가로 빼 50,000원이 되는 대신, 이미 내려간 65,000원에서 다시 15%를 계산해 55,250원이 됐습니다. 마케팅팀이 의도한 수익 시나리오와 전혀 다른 결과였습니다.
네이버 쇼핑 가격 불일치 사고 (2023): 검색 결과에는 89,000원으로 표시된 상품이 상세 페이지에서 119,000원으로 보였습니다. 검색 인덱스는 6분마다 배치로 갱신됐고, 그 사이 판매자가 가격을 올린 것이었습니다. 사용자는 89,000원을 기대하고 클릭했지만 상세 페이지에서 다른 가격을 보고 이탈했습니다. 전환율이 해당 기간 23% 하락했습니다.
11번가 타임딜 조기 오픈 사고 (2021): 오후 2시 시작 예정인 타임딜이 오전 10시부터 노출됐습니다. 스케줄러가 KST/UTC 시간대 변환 버그로 정각보다 4시간 일찍 딜을 오픈한 것이었습니다. 재고가 미리 소진됐고, 2시에 이벤트를 기다리던 사용자들은 빈 페이지를 마주했습니다.
이 사고들이 가르쳐 주는 핵심 문제:
- 할인 충돌: 여러 프로모션이 같은 상품에 동시에 적용될 때 의도치 않은 결과 발생
- 가격 불일치: 검색·목록·상세·결제 각 화면이 서로 다른 가격을 보여주는 상황
- 스케줄 오류: 타임딜 시작/종료 시각 정밀도 부족으로 조기 오픈 또는 지연 종료
- 이력 소실: 가격 변경 이후 “언제 얼마였는지” 추적 불가로 CS와 회계 처리 실패
- 역마진 방어 없음: 프로모션 중복 적용으로 판매 원가보다 낮은 가격이 결제까지 도달
설계 의사결정 로드맵
결정 1: 가격 결정 방식 — 고정 vs 룰 기반 vs ML 동적
| 후보 | 장점 | 단점 | 언제 적합 |
|---|---|---|---|
| 고정 가격 | 구현 단순, 예측 가능 | 시장 변화 대응 불가, 경쟁사 대비 경직 | SKU 100개 이하 소규모 쇼핑몰 |
| 룰 기반 가격 | 마케팅팀 직접 편집, 배포 불필요 | 규칙 충돌 관리 필요, 복잡도 증가 | 프로모션이 잦은 커머스 |
| ML 동적 가격 | 수요·경쟁·재고 실시간 반영 | 모델 개발·운영 비용, 소비자 불신 위험 | 대규모 플랫폼, 가격 민감도 높은 카테고리 |
우리의 선택: 룰 기반 가격 + 경계 조건(최저가·최고가) 설정
- 룰 엔진은 마케팅팀이 배포 없이 즉시 프로모션을 만들 수 있고,
min_price·max_price경계로 역마진과 가격 폭등을 동시에 방어한다. ML 동적 가격은 소비자가 “새로고침마다 가격이 바뀐다”고 인식하면 신뢰를 잃는다. 국내 커머스에서는 룰 기반이 현실적 균형점이다.
결정 2: 프로모션 충돌 해소 — 우선순위 vs 최대할인 vs 스택
| 후보 | 장점 | 단점 | 언제 적합 |
|---|---|---|---|
| 우선순위 (1개만 적용) | 충돌 없음, 마진 보호 | 사용자에게 불친절, 쿠폰 활용률 저하 | 마진이 낮은 식품·생필품 |
| 최대할인 자동 선택 | 사용자에게 최선 보장 | 프로모션별 의도 무시, 설계 복잡 | 비교 쇼핑 중심 플랫폼 |
| 스택형 (복수 적용) | 사용자 만족, 쿠폰 소진 빠름 | 역마진 위험, 상한 관리 필수 | 객단가 높은 가전·패션 |
우리의 선택: 타입별 분리 스택 + 합산 상한
- 프로모션을 타입으로 분류(타임딜/멤버십/카테고리/쿠폰)하고, 같은 타입은 우선순위 1개만, 다른 타입은 스택을 허용한다.
max_total_discount_rate로 주문 금액의 특정 비율 이상 할인을 전체 차단해 역마진을 방어한다.
결정 3: 딜 스케줄링 — cron vs 이벤트 vs 큐
| 후보 | 장점 | 단점 | 언제 적합 |
|---|---|---|---|
| cron 배치 | 구현 단순 | 1분 오차 불가피, 타임딜 정각 불가 | 일 단위 할인 적용 |
| 이벤트 기반 (Kafka delay) | 실시간 정밀 | Kafka 딜레이 메시지 구현 복잡 | 밀리초 단위 정각 오픈 필요 |
| 지연 큐 (Redis Sorted Set) | 초 단위 정밀, 구현 현실적 | Redis 단일 소비자 병목 가능 | 타임딜·플래시세일 수백 개 동시 관리 |
우리의 선택: Redis Sorted Set 지연 큐 + DB 이벤트 로그
ZADD deals score=unix_timestamp member=dealId로 등록하고, 스케줄러가 100ms마다ZRANGEBYSCORE 0 now로 만료된 딜을 꺼내 처리한다. cron은 1분 단위가 최소 단위라 “오후 2시 00분 00초” 정각 오픈이 불가능하다. Kafka 지연 메시지는 브로커 설정이 복잡하고 지연 정밀도가 수 초 수준이다.
결정 4: 가격 이력 관리 — SCD Type 2 vs 이벤트 로그
| 후보 | 장점 | 단점 | 언제 적합 |
|---|---|---|---|
| SCD Type 2 (유효기간 행 추가) | 시점 조회 SQL 단순 | 행 수 폭발, 현재 가격 조회 JOIN 필요 | 가격 변경이 드문 B2B |
| 이벤트 로그 (append-only) | 불변 보존, 감사 추적 완벽 | 과거 가격 재현에 이벤트 재생 필요 | 가격 변경이 잦은 커머스 |
| 스냅샷 + 이벤트 로그 혼용 | 조회 성능 + 감사 추적 동시 확보 | 구현 복잡도 증가 | 대규모 플랫폼 |
우리의 선택: 이벤트 로그 (append-only) + 일별 스냅샷
- 가격 변경은
price_change_events테이블에 INSERT만 하고 UPDATE는 절대 없다. 일 1회 스냅샷으로 “오늘 최저가” 같은 집계 쿼리 성능을 확보한다. 이벤트 로그는 “언제 누가 얼마로 바꿨는지” 완전한 감사 추적을 제공해 CS와 회계 처리에 결정적이다.
결정 5: 가격 표시 일관성 — 캐시 vs CDC vs 이벤트
| 후보 | 장점 | 단점 | 언제 적합 |
|---|---|---|---|
| 주기적 캐시 갱신 | 구현 단순 | 갱신 주기만큼 불일치 허용 | 가격 변경 빈도 낮은 서비스 |
| CDC (Change Data Capture) | DB 변경 즉시 전파, 코드 분리 | Debezium 등 CDC 인프라 필요 | 대규모 커머스, 실시간 일관성 필수 |
| 도메인 이벤트 발행 | 명시적 흐름, 코드 추적 쉬움 | 발행 누락 시 불일치 | 이벤트 기반 아키텍처 |
우리의 선택: CDC + 이벤트 혼용 (CDC를 안전망으로)
- 가격 변경 서비스가 도메인 이벤트를 Kafka에 발행하되, Debezium CDC가 DB 변경을 독립적으로 감지해 이중 보장한다. 이벤트 발행 누락이 있어도 CDC가 잡아낸다. “목록 가격 = 상세 가격 = 결제 가격”을 보장하지 못하면 소비자 보호법 위반이자 신뢰 손상이다.
요구사항 분석 및 규모 추정
기능 요구사항
- 가격 관리: 판매자/운영자가 상품 기본 가격과 프로모션 가격을 설정
- 프로모션 관리: 타임딜·멤버십 할인·카테고리 할인 등 다양한 프로모션 생성·수정·종료
- 가격 계산: 여러 프로모션이 동시 적용될 때 최종 가격을 충돌 없이 계산
- 딜 스케줄링: 정각 오픈·종료, 시간대 정확성 보장
- 가격 이력: 상품의 모든 가격 변경 이력 보존 및 시점 조회
- 가격 표시 일관성: 검색·목록·상세·장바구니·결제 전 화면에서 동일한 가격 표시
비기능 요구사항
- 정확성: 역마진 발생 건수 0건, 가격 계산 오차 0원
- 일관성: 가격 변경 후 전 채널 반영 < 500ms
- 지연: 가격 계산 p99 < 30ms, 딜 오픈 정각 오차 < 1초
- 확장성: 상품 5,000만 개, 동시 가격 조회 TPS 200,000
규모 추정
| 항목 | 수치 |
|---|---|
| 활성 상품 수 | 5,000만 개 |
| 일 가격 변경 건수 | 500만 건 |
| 동시 활성 프로모션 | 10만 개 |
| 가격 조회 TPS | 200,000 |
| 타임딜 동시 오픈 | 500개 |
| 가격 이력 누적 (연간) | 18억 건 |
고수준 아키텍처
비유: 프로모션·가격 시스템은 공항 운임 책정 시스템과 같습니다. 수백만 개의 좌석마다 얼리버드·성수기·회원 등급·프로모션 코드가 동시에 적용되고, 예약 확정 순간의 가격이 탑승까지 바뀌지 않아야 하며, 모든 가격 이력이 회계 감사에서 추적 가능해야 합니다.
graph LR
A[판매자/운영자] --> B[가격 관리 API]
B --> C[룰 엔진]
C --> D[가격 DB]
D --> E[CDC 파이프라인]
E --> F[검색/목록 캐시]
G[사용자] --> H[가격 조회 API]
H --> F
| 컴포넌트 | 역할 |
|---|---|
| 가격 관리 API | 기본 가격·프로모션 등록, 변경 이벤트 발행 |
| 룰 엔진 | 복수 프로모션 우선순위 평가, 최종 가격 계산, 역마진 방어 |
| 가격 DB | 기본 가격 + 이벤트 로그 영구 저장, CDC 소스 |
| CDC 파이프라인 | DB 변경 감지 → Kafka 발행 → 다운스트림 갱신 |
| 검색/목록 캐시 | Redis에 최종 계산 가격 캐싱, < 500ms 동기화 보장 |
| 타임딜 스케줄러 | Redis Sorted Set 기반 정각 오픈·종료 처리 |
핵심 컴포넌트 상세 설계
프로모션 룰 엔진 — 조건 평가에서 할인 계산까지
비유: 룰 엔진은 법원의 판사와 같습니다. 여러 법률(프로모션)이 동시에 적용될 때 우선순위·특별법 우선 원칙(타입별 분리)에 따라 하나씩 판결을 내리고, 최종 판결이 나오기 전까지 어느 규칙도 단독으로 가격을 바꾸지 못합니다.
가격을 직접 덮어쓰는 방식이 사고의 근원입니다. 올바른 설계는 모든 프로모션을 “할인 금액을 반환하는 후보”로 만들고, 최종 단계에서 충돌을 해소한 뒤 한 번만 가격을 계산하는 것입니다.
// 프로모션 타입 — 같은 타입 내에서는 1개만 적용
public enum PromotionType {
TIME_DEAL, // 타임딜 (가장 높은 우선순위)
MEMBERSHIP, // 회원 등급 할인
CATEGORY, // 카테고리 할인
SELLER_COUPON // 판매자 쿠폰 (가장 낮은 우선순위)
}
public record PromotionRule(
String promotionId,
PromotionType type,
int priority, // 숫자가 낮을수록 높은 우선순위
DiscountSpec discount,
long minPrice, // 이 가격 이하로 내려가면 적용 불가 (역마진 방어)
boolean stackable // 다른 타입과 중복 적용 가능 여부
) {}
룰 엔진의 핵심은 “타입별로 가장 유리한 1개를 선택한 뒤 스택을 허용하되, 합산 할인에 상한을 두는 것”입니다.
@Service
public class PriceRuleEngine {
private static final double MAX_DISCOUNT_RATE = 0.70; // 최대 70% 할인 상한
public PriceCalculationResult calculate(long basePrice, List<PromotionRule> candidates) {
// 1단계: 타입별로 가장 높은 우선순위(숫자 가장 작은) 프로모션 1개 선택
Map<PromotionType, PromotionRule> bestByType = candidates.stream()
.collect(Collectors.toMap(
PromotionRule::type,
p -> p,
(a, b) -> a.priority() < b.priority() ? a : b // 낮은 priority 번호가 우선
));
// 2단계: 스택 허용 타입만 합산 (stackable=false는 단독 적용)
List<PromotionRule> applicable = bestByType.values().stream()
.filter(PromotionRule::stackable)
.collect(Collectors.toList());
// 타임딜이 있으면 non-stackable 단독 적용
Optional<PromotionRule> timeDeal = bestByType.values().stream()
.filter(p -> p.type() == PromotionType.TIME_DEAL)
.findFirst();
if (timeDeal.isPresent() && !timeDeal.get().stackable()) {
long discounted = applyDiscount(basePrice, timeDeal.get().discount());
long finalPrice = Math.max(discounted, timeDeal.get().minPrice());
return new PriceCalculationResult(finalPrice, List.of(timeDeal.get()), basePrice - finalPrice);
}
// 3단계: 합산 할인 계산 — BigDecimal로 금전 오차 방지
long totalDiscount = applicable.stream()
.mapToLong(p -> computeDiscount(basePrice, p.discount()))
.sum();
// 4단계: 역마진 상한 적용
long maxAllowedDiscount = (long)(basePrice * MAX_DISCOUNT_RATE);
long safeDiscount = Math.min(totalDiscount, maxAllowedDiscount);
long finalPrice = basePrice - safeDiscount;
return new PriceCalculationResult(finalPrice, applicable, safeDiscount);
}
private long computeDiscount(long basePrice, DiscountSpec spec) {
return switch (spec.type()) {
case PERCENT -> {
// BigDecimal — double 나눗셈 오차 방지 (1원 차이가 수억 건에서 수십억 불일치)
BigDecimal rate = BigDecimal.valueOf(spec.value())
.divide(BigDecimal.valueOf(100));
yield BigDecimal.valueOf(basePrice).multiply(rate)
.setScale(0, RoundingMode.HALF_UP).longValue();
}
case FIXED -> spec.value();
};
}
private long applyDiscount(long basePrice, DiscountSpec spec) {
long discount = computeDiscount(basePrice, spec);
return basePrice - discount;
}
}
가격 계산은 호출될 때마다 같은 입력에 같은 결과를 반환해야 합니다. 외부 상태(현재 시각, 재고 수량)를 직접 조회하면 안 되고, 모든 컨텍스트를 파라미터로 받아야 합니다. 그래야 같은 주문 데이터로 언제든 가격을 재현할 수 있습니다.
가격 충돌 해소기 — 우선순위와 상한 처리
비유: 충돌 해소기는 에어컨·히터·환기 시스템이 동시에 켜진 빌딩의 중앙 제어반입니다. 각 시스템이 제각각 온도를 조절하려 하면 충돌이 납니다. 중앙 제어반이 “히터 우선, 에어컨은 대기” 식으로 명시적 우선순위를 가지고 최종 상태를 결정합니다.
충돌 해소 규칙은 코드가 아닌 설정으로 관리해야 마케팅팀이 배포 없이 수정할 수 있습니다.
// 충돌 해소 정책 — DB나 설정 파일에서 로드
public record ConflictPolicy(
Map<PromotionType, Integer> typePriority, // TIME_DEAL=1, MEMBERSHIP=2, CATEGORY=3, SELLER_COUPON=4
Map<PromotionType, Boolean> stackableTypes, // 타입별 스택 허용 여부
double globalMaxDiscountRate, // 전체 최대 할인율 (역마진 방어)
long absoluteMinPrice // 절대 최저가 (운영자 설정)
) {
public static ConflictPolicy defaultPolicy() {
return new ConflictPolicy(
Map.of(TIME_DEAL, 1, MEMBERSHIP, 2, CATEGORY, 3, SELLER_COUPON, 4),
Map.of(TIME_DEAL, false, MEMBERSHIP, true, CATEGORY, true, SELLER_COUPON, true),
0.70, // 70% 초과 할인 불가
1000 // 최소 1,000원 이하 판매 불가
);
}
}
충돌 해소 흐름을 명시적으로 구분하면 “왜 이 가격이 나왔는지” 추적이 가능합니다.
graph LR
A[프로모션 후보들] --> B[타입별 분류]
B --> C[우선순위 선택]
C --> D[스택 합산]
D --> E[상한 적용]
E --> F[최종 가격]
타임딜 스케줄러 — 정각 정밀도 보장
비유: 타임딜 스케줄러는 타이머가 내장된 금고와 같습니다. 금고는 정해진 시각 정확히에 잠금이 풀려야 하고(오픈), 시간이 되면 다시 잠겨야 합니다(종료). 1분씩 늦거나 빠르면 안 됩니다.
cron의 1분 최소 단위 문제는 Redis Sorted Set으로 해결합니다. 스케줄러 노드는 100ms마다 만료된 딜을 확인해 단일 자릿수 초 단위 오차로 딜을 처리합니다.
@Component
public class DealScheduler {
private static final String DEAL_OPEN_KEY = "deals:scheduled:open";
private static final String DEAL_CLOSE_KEY = "deals:scheduled:close";
// 딜 등록 — 오픈·종료 시각을 Unix timestamp score로 저장
public void scheduleDeal(Deal deal) {
long openScore = deal.getStartTime().toEpochSecond();
long closeScore = deal.getEndTime().toEpochSecond();
redisTemplate.opsForZSet().add(DEAL_OPEN_KEY, deal.getDealId(), openScore);
redisTemplate.opsForZSet().add(DEAL_CLOSE_KEY, deal.getDealId(), closeScore);
// DB에도 스케줄 기록 — Redis 장애 시 재적재 기준
dealRepository.saveSchedule(deal);
}
// 100ms마다 실행 — 오픈 처리
@Scheduled(fixedDelay = 100)
public void processOpenDeals() {
long now = Instant.now().getEpochSecond();
// score <= now 인 딜 ID를 최대 100개 꺼냄 (원자적 pop)
Set<ZSetOperations.TypedTuple<String>> due =
redisTemplate.opsForZSet().rangeByScoreWithScores(DEAL_OPEN_KEY, 0, now, 0, 100);
if (due == null || due.isEmpty()) return;
for (ZSetOperations.TypedTuple<String> entry : due) {
String dealId = entry.getValue();
try {
openDeal(dealId);
redisTemplate.opsForZSet().remove(DEAL_OPEN_KEY, dealId);
} catch (Exception e) {
log.error("딜 오픈 처리 실패: dealId={}", dealId, e);
// 실패한 딜은 큐에 남아 다음 주기에 재시도
}
}
}
private void openDeal(String dealId) {
Deal deal = dealRepository.findById(dealId).orElseThrow();
deal.activate();
dealRepository.save(deal);
// 가격 캐시 즉시 무효화 — CDC 파이프라인이 500ms 내 검색에 반영
priceCache.invalidate(deal.getProductIds());
eventPublisher.publish(new DealOpenedEvent(dealId, deal.getProductIds()));
}
}
시간대 문제는 항상 UTC 기준으로 저장하고, 화면 표시 시에만 KST로 변환합니다. 11번가 사고는 스케줄러 내부에서 LocalDateTime.now()(시스템 기본 시간대)를 UTC timestamp와 비교한 것이 원인이었습니다.
// 잘못된 방식 — 시스템 시간대에 따라 결과가 달라짐
if (LocalDateTime.now().isAfter(deal.getStartTime())) { ... }
// 올바른 방식 — 항상 UTC 기준
if (Instant.now().isAfter(deal.getStartTimeUtc())) { ... }
가격 이력 저장소 — 불변 기록과 시점 조회
비유: 가격 이력은 은행 거래 장부와 같습니다. 은행은 과거 입출금 내역을 절대 수정하지 않습니다. 잔액이 틀렸으면 정정 거래(역분개)를 새로 추가합니다. 과거를 지우면 감사 추적이 불가능해집니다.
모든 가격 변경은 새 행을 INSERT합니다. UPDATE/DELETE는 존재하지 않습니다. 이것이 이벤트 소싱의 핵심입니다.
// 가격 변경 이벤트 테이블 — append-only (UPDATE/DELETE 없음)
// CREATE TABLE price_change_events (
// event_id BIGINT AUTO_INCREMENT PRIMARY KEY,
// product_id BIGINT NOT NULL,
// change_type VARCHAR(20) NOT NULL, -- BASE_PRICE_SET, PROMOTION_START, PROMOTION_END 등
// before_price BIGINT, -- 변경 전 가격 (최초 등록 시 NULL)
// after_price BIGINT NOT NULL, -- 변경 후 가격
// reason VARCHAR(200), -- 변경 사유 (감사 추적)
// operator_id BIGINT, -- 변경한 사람 (판매자/운영자)
// occurred_at TIMESTAMP(3) NOT NULL, -- 밀리초 단위 정밀도
// INDEX idx_product_time (product_id, occurred_at)
// );
@Service
public class PriceHistoryService {
public void recordChange(PriceChangeEvent event) {
priceEventRepository.insert(event); // INSERT only — UPDATE 없음
// 일별 스냅샷 갱신 (집계 쿼리 성능용)
dailySnapshotCache.invalidate(event.getProductId(), LocalDate.now());
}
// 특정 시점의 가격 조회 — 이벤트 재생
public long getPriceAt(long productId, Instant pointInTime) {
return priceEventRepository
.findLatestBefore(productId, pointInTime) // occurred_at <= pointInTime ORDER BY occurred_at DESC LIMIT 1
.map(PriceChangeEvent::afterPrice)
.orElseThrow(() -> new PriceHistoryNotFoundException(productId, pointInTime));
}
// "이 상품 30일 최저가는 얼마였나" — CS·회계에서 필수
public long getMinPriceInRange(long productId, Instant from, Instant to) {
return priceEventRepository
.findAllBetween(productId, from, to)
.stream()
.mapToLong(PriceChangeEvent::afterPrice)
.min()
.orElse(0L);
}
}
가격 표시 동기화 — 검색·목록·상세 일관성
비유: 가격 동기화는 뉴스 채널 자막 업데이트와 같습니다. 메인 스튜디오(DB)에서 속보가 나오면 위성 중계차(검색 서버), 자막 송출(목록 API), 재방송(캐시)이 모두 같은 내용을 보여줘야 합니다. 어느 하나라도 다르면 시청자(사용자)가 혼란을 겪습니다.
Debezium CDC가 DB의 바이너리 로그를 읽어 Kafka에 발행하면, 각 다운스트림 소비자가 자신의 캐시를 갱신합니다. 애플리케이션 코드가 이벤트를 발행하는 것과 CDC가 DB를 직접 감지하는 것, 둘 다 구현해 이중 보장합니다.
graph LR
A[가격 DB] --> B[CDC Debezium]
B --> C[Kafka 토픽]
C --> D[검색 인덱스]
C --> E[목록 캐시]
C --> F[상세 캐시]
@KafkaListener(topics = "price.changes")
public class PriceSyncConsumer {
public void onPriceChanged(PriceChangedEvent event) {
long productId = event.getProductId();
long newPrice = event.getNewPrice();
// 검색 인덱스 갱신 (Elasticsearch)
searchIndexService.updatePrice(productId, newPrice);
// 목록 캐시 갱신 (Redis Hash)
String cacheKey = "price:product:" + productId;
redisTemplate.opsForHash().put(cacheKey, "final_price", String.valueOf(newPrice));
redisTemplate.expire(cacheKey, Duration.ofMinutes(30));
// 상세 페이지 캐시 무효화
detailPageCache.invalidate(productId);
log.info("가격 동기화 완료: productId={}, price={}, lag={}ms",
productId, newPrice,
Instant.now().toEpochMilli() - event.getOccurredAt().toEpochMilli());
}
}
결제 직전에는 캐시를 신뢰하지 않고 반드시 DB에서 최신 가격을 다시 조회합니다. “장바구니에 담을 때 가격”과 “결제 시 가격”이 다를 수 있고, 이 경우 사용자에게 명시적으로 알려야 합니다.
// 결제 직전 가격 검증 — 캐시 우회, DB 직접 조회
public PriceValidationResult validateCartPrice(Cart cart) {
List<PriceMismatch> mismatches = new ArrayList<>();
for (CartItem item : cart.getItems()) {
long cachedPrice = item.getPriceAtAddTime();
long currentPrice = priceRuleEngine.calculate(
item.getBasePrice(),
promotionService.getActivePromotions(item.getProductId()) // DB 직접 조회
).finalPrice();
if (cachedPrice != currentPrice) {
mismatches.add(new PriceMismatch(item.getProductId(), cachedPrice, currentPrice));
}
}
return mismatches.isEmpty()
? PriceValidationResult.valid()
: PriceValidationResult.mismatch(mismatches);
}
이 설계의 한계와 대안 — “이게 실패하면?”
비유: 잘 설계된 비행기도 매뉴얼 끝에는 반드시 비상 탈출 절차가 있습니다. 설계의 완성도는 “잘 됐을 때 얼마나 빠른가”가 아니라 “잘못됐을 때 얼마나 빨리 멈추는가”로 결정됩니다.
한계 1: 룰 엔진 버그로 역마진 발생 — floor price와 킬 스위치가 없으면 무방비
룰 엔진은 마케팅팀이 배포 없이 직접 수정하는 코드입니다. “배포 없이 수정 가능”의 이면은 “배포 검증 없이 버그가 즉시 프로덕션에 반영된다”입니다. discountRate를 0.20으로 입력해야 할 곳에 20.0을 입력하면 100,000원짜리 상품이 -1,900,000원이 됩니다. max_discount_rate 상한이 있어도, 상한 계산 자체가 버그 있는 룰 엔진을 통과하면 의미가 없습니다.
진짜 방어선은 룰 엔진 바깥에 있어야 합니다.
// 룰 엔진과 완전히 분리된 독립 하한 레이어 — 엔진 결과를 믿지 않음
public class PriceFloorGuard {
// 절대 하한: 모든 상품에 적용, 코드 변경 없이 설정 파일에서 수정 가능
private final long absoluteFloorPrice; // 예: 100원
private final double maxDiscountRateHardCap; // 예: 0.85 (85% 초과 할인 물리 차단)
public long enforce(long basePrice, long engineResult) {
long floorByRate = (long)(basePrice * (1.0 - maxDiscountRateHardCap));
long floorByAbs = absoluteFloorPrice;
long safeFloor = Math.max(floorByRate, floorByAbs);
if (engineResult < safeFloor) {
log.error("FLOOR_GUARD_TRIGGERED: base={}, engine={}, floor={}",
basePrice, engineResult, safeFloor);
alertService.sendP0("역마진 방어 발동 — 룰 엔진 결과 비정상");
return safeFloor; // 엔진 결과 무시, 하한 반환
}
return engineResult;
}
}
긴급 킬 스위치는 별도 Redis 플래그로 관리합니다. 운영자가 Redis CLI 한 줄로 전체 프로모션을 즉시 중단할 수 있어야 합니다.
// 킬 스위치 — Redis에서 실시간으로 읽음, 배포 불필요
public long calculate(long basePrice, List<PromotionRule> rules) {
if (redisTemplate.hasKey("promotion:kill_switch:all")) {
// 모든 프로모션 중단, 기본가 반환
log.warn("KILL_SWITCH_ACTIVE: promotion disabled for productId");
return basePrice;
}
return priceFloorGuard.enforce(basePrice, calculateInternal(basePrice, rules));
}
# 역마진 사고 발생 시 운영자가 즉시 실행
redis-cli SET promotion:kill_switch:all 1 EX 3600 # 1시간 전체 중단
redis-cli SET promotion:kill_switch:TIME_DEAL 1 # 타임딜만 선택 중단
한계 2: 가격 캐시와 DB 불일치 — CDC 지연이 “충분”한가?
CDC가 500ms 내 검색·목록 캐시를 갱신한다고 설계했습니다. 그런데 “결제 시 DB 재검증”이 정말 충분한가는 별도로 따져봐야 합니다.
CDC 지연 중 발생하는 상황:
T+0ms : DB 가격 변경 (49,000원 → 79,000원)
T+50ms : CDC Debezium이 binlog 읽음
T+200ms : Kafka 발행 완료
T+450ms : 검색 인덱스 갱신 (Consumer lag 포함)
T+0~450ms: 이 구간에 검색한 사용자는 49,000원을 봄
장바구니에 담는 행위는 T+0~450ms 구간에 발생할 수 있습니다. 결제 시 DB 재검증에서 가격 차이가 감지되면 사용자에게 “가격이 변경됐습니다. 현재 가격은 79,000원입니다”를 보여줍니다. 이게 트레이드오프입니다.
| 접근 | 장점 | 단점 | 언제 적합 |
|---|---|---|---|
| 결제 시 재검증 (현재 설계) | 구현 단순, 가격 정확 | 사용자 결제 흐름 중단 가능 | 가격 변경이 드문 서비스 |
| 장바구니 담기 시 가격 잠금 | 사용자 경험 일관성 | 잠금 중 가격 인상 손해, 구현 복잡 | 고가 상품 (가전·명품) |
| 검색-결제 가격 불일치 허용 + 보상 | 전환율 최대화 | 법적 리스크, 소비자 불신 | 권장하지 않음 |
결제 재검증이 자주 실패한다면 CDC 파이프라인의 Consumer lag을 먼저 점검하십시오. 재검증 실패율이 0.1%를 넘으면 캐시 TTL 단축이나 price_token 만료 시간 조정이 필요합니다.
한계 3: 타임딜 시작/종료 정밀도 — Redis TTL은 최대 1초 오차
Redis Sorted Set 스케줄러가 100ms마다 폴링한다고 설계했습니다. 이것은 이론적으로 100ms 오차를 목표로 하지만, 실제로는 다음 요인들이 오차를 키웁니다.
- Redis 클라이언트 RTT: 수십 ms
- JVM GC pause: 50~200ms (G1GC Full GC 시 최대 500ms)
- 스케줄러 스레드 스케줄링 지연: 수십 ms
- 100ms 폴링 자체의 최악 오차: 100ms
합산하면 최악 약 1초 오차가 현실적입니다. “오후 2시 00분 00초” 정각 오픈을 마케팅 자료에 넣었다면 사용자가 0시 1초에 페이지를 새로고침해도 딜이 열리지 않을 수 있습니다.
1초 이내 정밀도가 비즈니스 요구사항이라면 다음 대안을 검토하십시오.
대안 1 — 스케줄러 폴링 주기 단축 (10ms)
장점: 구현 변경 최소
단점: Redis 부하 10배 증가, CPU 소모
대안 2 — DB 스케줄러 (Quartz + 분산 락)
장점: 정밀도 신뢰도 높음, 장애 내성 강함
단점: 구현 복잡, Redis보다 느림
대안 3 — 클라이언트 사이드 카운트다운 + 정각 자동 새로고침
장점: 사용자 체감 정밀도 최대화
단점: 클라이언트 시간과 서버 시간 동기화 필요 (NTP 오차)
대부분의 커머스에서 1초 오차는 허용 가능합니다. “정각 정밀도”를 요구사항으로 정의할 때 비즈니스 팀과 합의가 먼저입니다.
한계 4: 프로모션 N개 중첩 — 조합 폭발과 그리디 알고리즘의 함정
현재 설계는 “타입별 1개 선택 후 스택”으로 조합 폭발을 억제합니다. 타입이 4개면 최대 4개 프로모션 조합으로 제한됩니다. 그런데 타입 내 “최고 우선순위 1개만 선택”은 그리디 알고리즘입니다. 그리디가 항상 최적해를 보장하지는 않습니다.
예시: 사용자가 가진 쿠폰들
- SELLER_COUPON_A: 10,000원 고정 할인
- SELLER_COUPON_B: 기본가 15% 할인
기본가 50,000원 상품:
- A 적용: 50,000 - 10,000 = 40,000원
- B 적용: 50,000 × 0.85 = 42,500원
→ 그리디(낮은 priority 번호 선택)가 B를 선택했다면 사용자는 더 비싸게 삼
"최대 할인 자동 선택" 정책이라면 그리디는 부정확합니다.
타입 내 프로모션이 100개 이상으로 늘어나면 “최적 조합 계산”은 배낭 문제(Knapsack)가 됩니다. 이 경우 선택지는 세 가지입니다.
접근 1 — 그리디 (현재 설계): O(N log N)
허용 조건: "우선순위 번호 = 비즈니스 의도"가 항상 성립할 때
실패 조건: 사용자에게 "최대 할인"을 약속했을 때
접근 2 — 완전 탐색: O(2^N)
허용 조건: N ≤ 20 이하
실패 조건: 동시 활성 프로모션이 수백 개일 때
접근 3 — 휴리스틱 pruning: O(N^2) ~ O(N^3)
방법: 금액 기준 상위 K개만 남긴 뒤 완전 탐색
허용 조건: K를 작게 유지 (K ≤ 10)
실패 조건: 소액 쿠폰 다수가 고액 단일 쿠폰보다 유리한 경우
현실에서는 “타입별 1개 + 합산 상한”이라는 그리디가 대부분의 커머스에서 충분합니다. 단, 정책을 문서화하십시오. “최대 할인이 보장되지 않을 수 있습니다”를 마케팅 문구에 명시하지 않으면 CS 분쟁이 됩니다.
한계 5: ML 동적 가격의 윤리적 문제 — 기술적으로 가능해도 비즈니스적으로 위험
Phase 4 설계에 “ML 기반 동적 가격 보조 레이어”를 포함했습니다. 이것은 강력한 도구이지만, 가장 조심해야 할 영역이기도 합니다.
법적 리스크: 국내 전자상거래법은 “동일 상품에 대한 합리적 이유 없는 가격 차별”을 금지합니다. ML 모델이 구매력이 낮은 사용자에게 높은 가격을 제시하는 패턴을 학습하면, 이것은 기술적 가격 차별입니다. 2019년 쿠팡 사례가 증거입니다.
소비자 불신: 한국 소비자는 “새로고침마다 가격이 바뀐다”거나 “친구와 가격이 다르다”는 경험에 매우 민감합니다. 신뢰 손상은 단기 매출 최적화 효과를 장기적으로 상쇄합니다.
허용 가능한 범위와 불가 범위:
| 허용 | 불가 |
|---|---|
| 시간대별 가격 (모든 사용자 동일) | 개인 구매력 추정 기반 가격 |
| 재고 수량 기반 가격 (상품 속성) | 사용자 디바이스 기반 가격 (iOS=고가) |
| 수요-공급 실시간 반영 | 이전 포기 기록 기반 가격 인상 |
| 회원 등급 할인 (명시적 공개) | A/B 테스트 기반 가격 분기 (비공개) |
권장: ML 가격은 상품 속성(재고·수요)에만 반응하고, 사용자 식별자를 입력으로 받지 않도록 모델 입력을 제한하십시오. 그리고 “현재 가격 = 기준가 × (1 + 동적 조정률)” 수식에서 조정률의 절대값을 ±10% 이내로 하드코딩으로 제한하십시오.
동시성과 락 — “같은 순간” 문제들
비유: 은행 창구 두 곳에서 동시에 같은 계좌에 입출금하면 잔액이 꼬입니다. 가격 시스템도 마찬가지로 “정확히 같은 순간”에 여러 작업이 같은 데이터를 건드리면 결과가 예측 불가능해집니다.
타임딜 시작 순간의 Cache Stampede
타임딜이 오픈되면 스케줄러가 캐시를 무효화하고, 수만 명의 대기 사용자가 동시에 새로고침합니다. 이 순간 캐시 미스가 폭발적으로 발생해 모든 요청이 DB로 직행하는 “Cache Stampede”가 발생합니다.
graph LR
A[타임딜 오픈] --> B[캐시 무효화]
B --> C[수만 건 동시 조회]
C --> D{캐시 미스?}
D -->|모두 미스| E[DB 폭격]
D -->|분산락 획득| F[단 1건만 DB 조회]
F --> G[캐시 갱신]
G --> H[나머지 캐시 히트]
분산 락 + Single Flight 패턴으로 방어합니다. 같은 상품 ID에 대해 캐시 재계산 요청이 동시에 몰려도 단 1개의 요청만 DB를 조회하고, 나머지는 그 결과를 기다려 공유합니다.
@Component
public class PriceCacheService {
// Single Flight: 동일 키에 대한 중복 DB 조회 제거
private final ConcurrentHashMap<Long, CompletableFuture<Long>> inflightRequests
= new ConcurrentHashMap<>();
public long getPrice(long productId) {
String cacheKey = "price:product:" + productId;
Long cached = (Long) redisTemplate.opsForValue().get(cacheKey);
if (cached != null) return cached;
// 동일 productId 요청이 이미 DB 조회 중이면 그 결과를 기다림
return inflightRequests.computeIfAbsent(productId, id -> {
CompletableFuture<Long> future = CompletableFuture.supplyAsync(() -> {
try {
// 분산 락 획득 (최대 500ms 대기)
String lockKey = "lock:price:" + id;
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(2));
if (Boolean.TRUE.equals(acquired)) {
long price = loadFromDb(id);
redisTemplate.opsForValue()
.set(cacheKey, price, Duration.ofMinutes(5));
redisTemplate.delete(lockKey);
return price;
} else {
// 락 획득 실패 → 잠시 후 캐시 재시도
Thread.sleep(50);
Long retried = (Long) redisTemplate.opsForValue().get(cacheKey);
return retried != null ? retried : loadFromDb(id);
}
} catch (Exception e) {
throw new RuntimeException(e);
}
});
// 요청 완료 후 inflight 맵에서 제거
future.whenComplete((v, ex) -> inflightRequests.remove(id));
return future;
}).join();
}
}
가격 업데이트 중 주문 진입 — Optimistic Lock vs 가격 토큰
판매자가 가격을 수정하는 순간 사용자가 장바구니에 담으면 어느 가격이 유효한가는 결제까지 일관성을 유지해야 합니다. Optimistic Lock과 가격 토큰(price_token) 방식의 차이를 이해해야 합니다.
Optimistic Lock 방식: 상품 테이블에 version 컬럼을 두고, 결제 시 WHERE product_id = ? AND version = ?로 검증합니다. 가격이 변경됐으면 version이 달라지므로 결제가 실패합니다.
// Optimistic Lock — version 불일치 시 결제 실패
@Entity
public class Product {
@Version
private long version; // 가격 변경마다 증가
private long price;
}
// 결제 처리
@Transactional
public void processPayment(long productId, long expectedVersion, long cartPrice) {
Product product = productRepository.findByIdWithLock(productId);
if (product.getVersion() != expectedVersion) {
throw new PriceChangedDuringCheckoutException(cartPrice, product.getPrice());
}
// 결제 진행
}
가격 토큰 방식: 장바구니 담기 시 서버가 price_token을 발급하고, 결제 시 이 토큰이 유효한지 검증합니다. 토큰에는 상품 ID, 가격, 만료 시각이 HMAC 서명으로 포함됩니다.
// 가격 토큰 발급 — 장바구니 담기 시
public PriceToken issuePriceToken(long productId, long price) {
long expiresAt = Instant.now().plusSeconds(1800).getEpochSecond(); // 30분 유효
String payload = productId + ":" + price + ":" + expiresAt;
String signature = hmac.sign(payload);
return new PriceToken(payload + "." + signature, expiresAt);
}
// 결제 시 검증
public void validatePriceToken(long productId, long cartPrice, PriceToken token) {
if (!hmac.verify(token)) throw new InvalidPriceTokenException();
if (token.isExpired()) throw new PriceTokenExpiredException();
long currentPrice = priceService.getCurrentPrice(productId);
if (cartPrice != currentPrice) {
throw new PriceChangedException(cartPrice, currentPrice);
}
}
| 방식 | 장점 | 단점 | 선택 기준 |
|---|---|---|---|
| Optimistic Lock | 구현 단순, DB 레벨 보장 | 동시 수정 많으면 충돌 빈번, 재시도 로직 필요 | 가격 변경이 드문 서비스 |
| 가격 토큰 | 만료 시간 제어 가능, 분산 환경에 적합 | 토큰 발급/검증 로직 추가, 만료된 세션 처리 필요 | 가격 변경이 잦은 타임딜 커머스 |
Redis 가격 캐시와 DB 업데이트의 원자성
가격을 변경할 때 DB를 먼저 업데이트하고 Redis 캐시를 지우는 순서가 중요합니다. 순서가 반대면 일관성이 깨집니다.
잘못된 순서 (Cache Aside 역순):
1. Redis 캐시 삭제
2. [공백 구간: 다른 요청이 DB 조회 → 구 가격을 캐시에 기록]
3. DB 업데이트
결과: 구 가격이 캐시에 남아 TTL 만료까지 유지
올바른 순서:
1. DB 업데이트 (트랜잭션 내)
2. 트랜잭션 커밋 후 Redis 캐시 삭제
결과: 캐시 삭제 후 요청은 DB에서 신 가격 조회
트랜잭션 커밋 후 이벤트를 발행하는 Spring의 @TransactionalEventListener를 활용하면 “DB 커밋 전 캐시 삭제” 실수를 방어할 수 있습니다.
@Transactional
public void updatePrice(long productId, long newPrice) {
productRepository.updatePrice(productId, newPrice);
priceHistoryRepository.insert(new PriceChangeEvent(productId, newPrice));
// 트랜잭션 커밋 후에만 캐시 무효화 — 커밋 전 삭제 방지
applicationEventPublisher.publishEvent(new PriceUpdatedDomainEvent(productId));
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onPriceUpdated(PriceUpdatedDomainEvent event) {
redisTemplate.delete("price:product:" + event.getProductId());
// CDC도 동시에 동작하므로 이중 보장
}
오버엔지니어링 경고 — 규모에 맞는 설계
비유: 동네 슈퍼마켓에 코스트코 물류 센터 시스템을 도입하면 운영비가 매출을 초과합니다. 시스템 설계는 현재 규모의 10배를 견디면 충분하고, 100배를 준비하는 것은 낭비입니다.
규모별 적정 설계
| 상품 수 | 적정 설계 | 과잉 설계 |
|---|---|---|
| 1,000개 이하 | if/else 하드코딩 + 단일 DB |
룰 엔진, CDC, Kafka — 전부 불필요 |
| 10만 개 | JSON DSL 룰 엔진 + Redis 캐시 | CDC는 아직 과함, Kafka도 선택적 |
| 100만 개 | 룰 엔진 + Kafka 이벤트 + Redis | CDC 도입 검토 시작 |
| 1,000만 개+ | CDC + 이벤트 이중 보장 + 파티셔닝 | ML 동적 가격은 조직 성숙도 필요 |
상품 1,000개 서비스라면 이 글의 설계를 그대로 구현하지 마십시오. 룰 엔진, CDC, Kafka, Debezium을 모두 운영하면 인프라 유지 비용이 개발 비용을 넘어섭니다. 아래가 현실적인 Phase 1입니다.
# Phase 1: 상품 1,000개, 프로모션 10개 — 이게 충분합니다
def calculate_price(base_price: int, promotions: list[dict]) -> int:
discount = 0
for promo in promotions:
if promo["type"] == "TIME_DEAL":
discount = max(discount, base_price * promo["rate"])
elif promo["type"] == "MEMBERSHIP":
discount += base_price * promo["rate"]
final = base_price - int(discount)
return max(final, 100) # 최소 100원
룰 엔진을 도입해야 할 신호는 다음과 같습니다.
- 마케팅팀이 “개발팀 배포를 기다리지 않고 프로모션을 수정하고 싶다”고 요청할 때
- 활성 프로모션이 20개를 초과할 때
- 프로모션 충돌 버그가 반복적으로 CS로 들어올 때
“동적 가격이 정말 필요한가?” — 대부분의 커머스는 고정 가격 + 쿠폰으로 충분
ML 동적 가격은 매력적으로 들리지만 실제 도입 비용을 계산하면 다릅니다.
ML 동적 가격 도입 비용:
- 데이터 파이프라인 구축: 3~6개월
- 모델 개발·검증: 2~4개월
- A/B 테스트 인프라: 1~2개월
- 법무 검토 (가격 차별 이슈): 1개월
- 운영 모니터링 시스템: 1개월
합계: 8~14개월, 인력 3~5명
고정 가격 + 쿠폰으로 달성 가능한 것:
- 세그먼트별 쿠폰 발급 → 사실상 개인화 가격
- 타임딜 → 시간대별 가격 변동
- 플래시세일 → 수요 기반 가격 조정
- 회원 등급 할인 → 충성도 기반 가격
결론: 연 매출 1,000억 이하 서비스라면 ML 동적 가격의 ROI는 음수일 가능성이 높습니다.
Kafka 파이프라인 심층 — 가격 이벤트가 전파되는 경로
비유: Kafka는 라디오 방송국입니다. 가격 DB가 새 소식(가격 변경)을 발행하면, 검색·목록·장바구니·광고 등 수십 개의 리스너가 동시에 같은 소식을 받아 각자의 화면을 업데이트합니다.
가격 변경 이벤트 팬아웃
가격이 변경되면 단순히 검색 인덱스만 업데이트하는 것이 아닙니다. 하나의 이벤트가 여러 다운스트림 시스템을 동시에 업데이트해야 합니다.
graph LR
A[가격 DB] --> B[CDC + 도메인 이벤트]
B --> C[price.changes 토픽]
C --> D[검색 인덱스]
C --> E[목록 캐시]
C --> F[장바구니 서비스]
C --> G[광고 입찰 시스템]
각 Consumer 그룹은 독립적으로 처리하므로 한 시스템의 Consumer lag이 다른 시스템에 영향을 주지 않습니다. 단, Consumer별 lag을 개별적으로 모니터링해야 합니다.
// 가격 변경 이벤트 — 모든 다운스트림이 소비
public record PriceChangedEvent(
long productId,
long oldPrice,
long newPrice,
String changeReason,
Instant occurredAt,
String traceId // 전파 추적용
) {}
// 각 Consumer 그룹이 동일 이벤트를 독립 소비
// search-index-updater → 검색 인덱스 갱신
// list-cache-updater → 목록 캐시 갱신
// cart-price-validator → 장바구니 내 해당 상품 가격 무효화
// ad-bid-adjuster → 광고 입찰가 재계산
Consumer Lag → 가격 불일치 지속 시간 계산
Consumer lag이 발생하면 각 시스템의 가격이 얼마나 오래 불일치 상태를 유지하는지 정량화할 수 있습니다.
가격 불일치 지속 시간 = CDC 감지 지연 + Kafka 전파 지연 + Consumer 처리 지연
예시:
CDC 감지: 50ms
Kafka 발행: 100ms
Consumer lag (검색 인덱스): 300ms
Elasticsearch 반영: 100ms
합계: 550ms
Consumer lag이 5초로 급증하면:
검색 인덱스 불일치 지속: 5,000ms + 기타 250ms ≈ 5.25초
이 5초 동안 가격 변경을 검색한 사용자 수 = 초당 검색 TPS × 5초
TPS 10,000이면 50,000건의 조회가 구 가격을 봄
Consumer lag을 SLO로 관리하는 것이 핵심입니다.
// Consumer lag 모니터링 — 임계값 초과 시 알림
@Scheduled(fixedDelay = 30_000) // 30초마다
public void monitorKafkaLag() {
Map<String, Long> lagByGroup = kafkaAdminClient.listConsumerGroupOffsets(
List.of("search-index-updater", "list-cache-updater", "cart-price-validator")
);
lagByGroup.forEach((group, lag) -> {
metrics.gauge("kafka.consumer.lag", lag, "group", group);
if (lag > 10_000) { // 10,000건 이상 밀리면 경고
alertService.sendWarning(
String.format("Consumer lag 초과: group=%s, lag=%d", group, lag)
);
}
if (lag > 100_000) { // 100,000건 이상이면 가격 불일치 SLO 위반
alertService.sendCritical(
String.format("Consumer lag 위험: group=%s, lag=%d — 가격 불일치 SLO 위반", group, lag)
);
}
});
}
Kafka Streams 실시간 가격 집계 vs 배치 ETL
“오늘 최저가”, “30일 평균가”, “경쟁사 대비 가격 지수” 같은 집계 지표를 어떻게 계산할지는 트레이드오프입니다.
| 방식 | 지연 | 구현 복잡도 | 비용 | 언제 적합 |
|---|---|---|---|---|
| 배치 ETL (야간 집계) | 최대 24시간 | 낮음 | 낮음 | “오늘 최저가” 정확도가 중요하지 않을 때 |
| 마이크로배치 (Spark Structured Streaming) | 1~5분 | 중간 | 중간 | 대부분의 가격 분석 대시보드 |
| Kafka Streams 실시간 | 수백 ms | 높음 | 높음 | 실시간 가격 이상 감지, 동적 가격 피드백 루프 |
Kafka Streams로 실시간 가격 집계 예시:
// 최근 1시간 상품별 평균 가격 — 실시간 이상 감지에 활용
StreamsBuilder builder = new StreamsBuilder();
KStream<Long, PriceChangedEvent> priceEvents =
builder.stream("price.changes", Consumed.with(Serdes.Long(), priceEventSerde));
// 1시간 Tumbling Window로 상품별 평균가 계산
priceEvents
.groupByKey()
.windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofHours(1)))
.aggregate(
() -> new PriceStats(0L, 0L),
(productId, event, stats) -> stats.update(event.newPrice()),
Materialized.as("price-stats-store")
)
.toStream()
.map((windowedKey, stats) -> KeyValue.pair(
windowedKey.key(),
new HourlyPriceAvg(windowedKey.key(), stats.average(), windowedKey.window().startTime())
))
.to("price.hourly-avg");
대부분의 커머스 분석 요구사항은 5분 마이크로배치로 충분합니다. Kafka Streams는 실시간 이상 감지가 필요한 Phase 3 이상에서 도입하십시오.
극한 시나리오 3개
극한 시나리오 1: 블랙프라이데이 — 500개 타임딜이 자정 0시에 동시 오픈
연말 블랙프라이데이 행사에서 마케팅팀이 500개 상품의 타임딜을 전부 “자정 00시 00분 00초”로 설정했습니다. 자정이 되자 Redis Sorted Set 스케줄러가 500개의 딜 오픈 작업을 동시에 처리해야 했습니다. 각 딜 오픈은 DB 업데이트 + 가격 캐시 무효화 + Kafka 이벤트 발행 + 검색 인덱스 갱신을 포함했고, 작업 하나당 평균 150ms가 소요됐습니다. 단일 스케줄러 노드에서 순차 처리하면 500건 × 150ms = 75초 지연이 발생해, 마지막 딜은 자정이 아닌 0시 1분 15초에 오픈됩니다.
동시에 대기하던 수십만 사용자가 자정에 일제히 페이지를 새로고침하면서 가격 조회 TPS가 평소의 20배인 400만 TPS로 폭증했습니다. Redis 캐시가 500개 상품에 대해 동시에 무효화된 상태(Cache Stampede)라 모든 요청이 DB로 직접 향했고, DB 커넥션 풀이 2초 만에 고갈됐습니다.
메커니즘과 근거: 단일 스케줄러 노드 + 순차 처리가 첫 번째 병목입니다. 500개 × 150ms = 75초 지연은 피할 수 없습니다. 두 번째 병목은 Cache Stampede입니다. 500개 상품의 캐시가 동시에 만료되면 DB에 500개의 동시 쿼리가 날아오고, 각 쿼리가 복수의 JOIN을 포함하면 DB 스레드가 즉시 소진됩니다.
대응 전략:
첫째, 스케줄러를 파티셔닝합니다. 딜 ID를 해시해 N개의 스케줄러 워커에 분산하면 처리 시간이 N분의 1로 줄어듭니다. 500개를 10개 워커에 분산하면 50개씩, 50 × 150ms = 7.5초로 단축됩니다.
// 파티셔닝된 딜 처리 — 각 워커는 자신의 파티션만 처리
@Scheduled(fixedDelay = 100)
public void processMyPartition() {
long now = Instant.now().getEpochSecond();
String partitionKey = "deals:scheduled:open:partition:" + workerPartition;
Set<String> due = redisTemplate.opsForZSet()
.rangeByScore(partitionKey, 0, now, 0, 50); // 최대 50개씩
if (due != null) {
due.parallelStream().forEach(this::openDeal); // 병렬 처리
}
}
둘째, Cache Stampede를 PER(Probabilistic Early Recomputation)으로 방어합니다. 캐시 만료 시각보다 일찍 확률적으로 갱신을 시작해 만료 순간 대량 요청이 DB에 도달하는 것을 막습니다.
public long getPrice(long productId) {
PriceCacheEntry entry = redisTemplate.opsForValue()
.get("price:product:" + productId);
if (entry != null) {
// PER: 남은 TTL이 짧을수록 조기 갱신 확률 증가
long remainingTtl = redisTemplate.getExpire("price:product:" + productId, TimeUnit.SECONDS);
double recomputeProb = Math.exp(-remainingTtl / 10.0); // TTL=0이면 확률 1.0
if (Math.random() < recomputeProb) {
// 비동기 갱신 — 현재 요청은 기존 캐시 값 반환
asyncPriceRefresher.refresh(productId);
}
return entry.finalPrice();
}
// 캐시 미스 시 DB 조회 + 갱신
return loadAndCache(productId);
}
셋째, 타임딜 오픈을 5초에 걸쳐 스태거링(staggering)합니다. 500개를 0~5초 사이 균등 분산하면 초당 100개씩만 오픈돼 가격 조회 피크가 5배 낮아집니다. 사용자에게는 “자정 오픈”으로 표시하지만 실제 처리는 수초에 걸쳐 분산됩니다.
graph LR
A[자정 딜 500개] --> B[파티션 분배]
B --> C[워커 10개 병렬]
C --> D[PER 캐시 갱신]
D --> E[정상 오픈]
결과: 처리 시간 75초 → 7.5초, DB 동시 쿼리 500개 → 분산 처리로 최대 50개, Cache Stampede 방지.
극한 시나리오 2: 룰 엔진 버그 — 역마진 가격이 24시간 방치
새벽 3시, 상품 카테고리 할인 룰을 수정하는 배포가 있었습니다. 신규 룰 파싱 코드에 버그가 있어 discountType=PERCENT인 경우 할인율이 백분율이 아닌 소수점으로 적용됐습니다. 20% 할인이 2,000% 할인으로 계산됐습니다. 100,000원짜리 상품이 -1,900,000원으로 계산됐지만 max_discount_rate 상한이 없어 최종 가격은 0원(Long의 최소값 오버플로우 방어 누락)으로 표시됐습니다. 야간이라 담당자가 없었고, 24시간 동안 수만 건이 0원으로 결제됐습니다.
메커니즘과 근거: 이 사고는 세 가지 방어가 모두 실패한 결과입니다. 첫째, 배포 전 룰 시뮬레이션이 없었습니다. 둘째, 런타임 역마진 감지 알림이 없었습니다. 셋째, 가격이 0원이 되는 극단적 결과에 대한 하드코딩 하한이 없었습니다. 평균 결제 금액이 갑자기 99% 하락하는 이상 지표가 있었지만 모니터링 알림 임계값이 “일 평균 대비 30% 하락”으로 설정돼 있어 1시간 단위 급락은 감지하지 못했습니다.
대응 전략:
첫째, 룰 저장 전 강제 시뮬레이션을 실행합니다. 새 룰을 DB에 저장하기 전에 샘플 가격 100개에 적용해 결과를 검증합니다. 계산된 가격이 기본 가격의 30% 미만이면 저장을 거부합니다.
@Transactional
public void savePromotionRule(PromotionRule rule) {
// 저장 전 강제 시뮬레이션
List<Long> samplePrices = productRepository.findSamplePrices(100);
for (long basePrice : samplePrices) {
PriceCalculationResult result = ruleEngine.calculate(basePrice, List.of(rule));
double discountRate = 1.0 - (double) result.finalPrice() / basePrice;
if (discountRate > 0.80) {
throw new InvalidRuleException(
String.format("룰 시뮬레이션 실패: 기본가 %d원에서 %.0f%% 할인 발생 (최대 80%% 초과)",
basePrice, discountRate * 100)
);
}
}
promotionRuleRepository.save(rule);
}
둘째, 가격 이상 감지 알림을 분 단위로 운영합니다. 직전 10분 평균 가격 대비 50% 이상 하락하면 즉시 PagerDuty 알림을 발송하고 해당 룰을 자동 정지합니다.
@Scheduled(fixedDelay = 60_000) // 1분마다
public void detectPriceAnomaly() {
Map<Long, Long> currentAvgPrices = priceMetricsService.getAveragePricesLast10Min();
Map<Long, Long> baselinePrices = priceMetricsService.getAveragePricesLast1Hour();
for (Map.Entry<Long, Long> entry : currentAvgPrices.entrySet()) {
long productId = entry.getKey();
long current = entry.getValue();
long baseline = baselinePrices.getOrDefault(productId, current);
if (baseline > 0 && (double) current / baseline < 0.50) {
alertService.sendCritical(
String.format("가격 이상 감지: productId=%d, 현재 평균=%d, 기준=%d", productId, current, baseline)
);
promotionService.suspectProduct(productId); // 해당 상품 프로모션 일시 정지
}
}
}
셋째, 하드코딩 절대 하한을 룰 엔진 계산 결과에 무조건 적용합니다. 룰 엔진에 버그가 있어도 이 하한을 뚫을 수 없습니다.
// 룰 엔진 결과와 무관하게 항상 적용되는 하한
private static final long ABSOLUTE_MIN_PRICE = 100L; // 최소 100원
private static final double HARD_MAX_DISCOUNT_RATE = 0.85; // 최대 85% 할인
public PriceCalculationResult calculate(long basePrice, List<PromotionRule> rules) {
PriceCalculationResult engineResult = calculateInternal(basePrice, rules);
long safeMinByRate = (long)(basePrice * (1.0 - HARD_MAX_DISCOUNT_RATE));
long finalPrice = Math.max(
engineResult.finalPrice(),
Math.max(safeMinByRate, ABSOLUTE_MIN_PRICE)
);
return new PriceCalculationResult(finalPrice, engineResult.appliedPromotions(),
basePrice - finalPrice);
}
graph LR
A[룰 저장 요청] --> B[시뮬레이션 검증]
B -->|통과| C[DB 저장]
B -->|실패| D[저장 거부]
C --> E[이상 감지 모니터링]
E -->|임계 초과| F[자동 정지 + 알림]
결과: 룰 버그가 있어도 시뮬레이션이 저장을 막고, 통과하더라도 런타임 이상 감지가 1분 내 정지하며, 하드코딩 하한이 0원 결제를 원천 차단합니다.
극한 시나리오 3: 가격 불일치 — 검색 49,000원, 결제 창 79,000원
대규모 판매 행사 기간 중 수천 명의 사용자가 CS에 “검색에서 49,000원이었는데 결제 창에서 갑자기 79,000원이 됐다”고 항의했습니다. 조사 결과, 판매자가 가격을 49,000원으로 내렸다가 30분 후 79,000원으로 올렸는데, 검색 인덱스가 6분 배치로 갱신되면서 79,000원 변경분은 아직 반영되지 않은 상태였습니다. 사용자들은 낮은 가격을 보고 클릭했지만 상세·결제에서는 높은 가격을 만났습니다.
이 상황은 단순한 기술 버그가 아닙니다. 전자상거래법에서 “표시된 가격으로 판매해야 한다”는 조항에 따르면 검색에서 보여준 49,000원이 구속력이 있을 수 있습니다. 가격 불일치는 법적 리스크로 이어집니다.
메커니즘과 근거: 검색 인덱스의 배치 갱신 주기(6분)와 가격 변경 즉시성(초 단위) 사이의 간격이 핵심입니다. DB에서 가격을 변경하면 CDC가 수백 밀리초 내 Kafka에 발행하지만, 검색 인덱스 갱신 워커가 배치 모드로 동작하면 최대 갱신 주기만큼 지연됩니다. 이 공백이 수천만 번의 클릭에서 불일치 경험을 만듭니다.
대응 전략:
첫째, 검색 인덱스를 CDC 기반 실시간 갱신으로 전환합니다. Kafka 토픽에서 가격 변경 이벤트를 소비해 Elasticsearch 문서를 즉시 업데이트합니다. 배치 6분 → CDC 500ms 이내로 단축됩니다.
@KafkaListener(topics = "price.changes", groupId = "search-index-updater")
public void updateSearchIndex(PriceChangedEvent event) {
UpdateRequest request = new UpdateRequest("products", String.valueOf(event.getProductId()))
.doc(Map.of(
"final_price", event.getNewPrice(),
"price_updated_at", event.getOccurredAt().toString(),
"discount_rate", event.getDiscountRate()
));
elasticsearchClient.update(request, ProductDocument.class);
// 처리 지연 메트릭 기록 (목표: < 500ms)
metrics.recordLag("search_price_sync",
Instant.now().toEpochMilli() - event.getOccurredAt().toEpochMilli());
}
둘째, 검색 결과 클릭 시 가격 유효성 검증을 추가합니다. 사용자가 검색 결과를 클릭해 상세 페이지로 이동할 때 표시했던 검색 가격과 현재 가격을 비교합니다. 다르면 변경 사실을 명시적으로 알립니다.
// 상세 페이지 진입 시 가격 검증
public ProductDetailResponse getProductDetail(long productId, Long searchDisplayedPrice) {
long currentPrice = priceService.getCurrentPrice(productId);
ProductDetailResponse response = buildDetailResponse(productId, currentPrice);
// 검색에서 표시한 가격과 다르면 명시적 알림
if (searchDisplayedPrice != null && !searchDisplayedPrice.equals(currentPrice)) {
response.setPriceChangedWarning(
new PriceChangedWarning(searchDisplayedPrice, currentPrice,
currentPrice < searchDisplayedPrice ? "가격이 인하됐습니다" : "가격이 변경됐습니다")
);
}
return response;
}
셋째, 가격 버전 토큰을 도입합니다. 검색 결과에 price_version 토큰을 포함하고, 장바구니 담기·결제 진행 시 이 토큰이 현재 유효한지 검증합니다. 토큰이 만료됐으면 최신 가격을 다시 보여주고 사용자가 동의해야 결제를 진행합니다.
// 가격 버전 토큰 — 가격 변경 시마다 새 토큰 발급
public String generatePriceToken(long productId, long price) {
String payload = productId + ":" + price + ":" + Instant.now().getEpochSecond();
return Base64.getEncoder().encodeToString(
hmac.sign(payload.getBytes()) // HMAC으로 위변조 방지
);
}
// 결제 진행 전 토큰 검증
public void validatePriceToken(long productId, long cartPrice, String token) {
long currentPrice = priceService.getCurrentPrice(productId);
if (!tokenValidator.verify(productId, cartPrice, token)) {
throw new PriceTokenExpiredException(productId, cartPrice, currentPrice);
}
if (cartPrice != currentPrice) {
throw new PriceChangedException(cartPrice, currentPrice);
}
}
graph LR
A[가격 DB 변경] --> B[CDC 500ms 내]
B --> C[검색 인덱스 갱신]
C --> D[검색 결과 표시]
D -->|클릭| E[가격 검증]
E --> F[결제 진행]
결과: 배치 6분 지연 → CDC 500ms 이내 동기화, 가격 불일치 시 사용자에게 명시적 고지, 법적 리스크 최소화.
실무 실수 Top 5
| # | 실수 | 결과 | 올바른 방법 |
|---|---|---|---|
| 1 | 가격 필드를 직접 덮어쓰는 프로모션 구현 | 복수 프로모션 간 충돌로 의도치 않은 할인 조합 | 각 프로모션은 “할인 금액 후보”만 반환, 최종 계산은 룰 엔진이 단일 책임 |
| 2 | 할인율 계산에 double/long 나눗셈 사용 | 20% 할인이 19.999…%로 계산, 수억 건 누적 시 수십억 원 불일치 | BigDecimal로 반올림 모드(HALF_UP) 명시 처리 |
| 3 | 스케줄러 내부에서 LocalDateTime.now() 사용 | 시스템 시간대에 따라 오픈 시각이 달라짐 (KST 서버에서 UTC 타임스탬프 비교) | 항상 Instant.now()와 UTC 기준 저장, 표시 시에만 변환 |
| 4 | 검색 인덱스를 배치로 갱신 | 가격 변경 후 수 분간 검색·상세 간 불일치, 법적 리스크 | CDC + Kafka 기반 실시간 갱신으로 500ms 이내 동기화 |
| 5 | 가격 변경 이력을 UPDATE로 덮어씀 | “언제 얼마였나” 추적 불가, CS·회계·감사 처리 실패 | append-only 이벤트 로그, UPDATE/DELETE 절대 없음 |
Phase 1→4 진화
Phase 1 — 상품 1만 개, 프로모션 10개 이하 (스타트업 초기)
월 비용: 약 20만 원
가격과 할인을 하드코딩으로 처리합니다. 프로모션이 10개 이하면 if/else로 관리할 수 있고 룰 엔진은 과투자입니다. 가격 이력은 updated_at 컬럼 하나로 “마지막 변경 시각”만 기록합니다. 타임딜은 cron으로 1분 단위로 오픈합니다.
구성: API 서버 1대 + PostgreSQL 1대
가격 관리: 단일 price 컬럼 + 프로모션 if/else 코드
이력: updated_at 단일 컬럼
스케줄링: cron (1분 단위)
동기화: 없음 (단일 DB 직접 조회)
Phase 2 — 상품 100만 개, 동시 프로모션 1,000개 (서비스 성장)
월 비용: 약 150만 원
마케팅팀이 직접 프로모션을 만들기 시작하면 룰 엔진이 필요합니다. JSON DSL로 룰을 DB에 저장하고 배포 없이 수정합니다. 가격 이력 테이블을 append-only로 분리합니다. Redis 캐시로 가격 조회 DB 부하를 줄입니다.
구성: API 서버 2대 + Redis 1대 + PostgreSQL 1대
가격 계산: JSON DSL 룰 엔진 (DB 저장, Caffeine L1 캐시 5분)
이력: price_change_events append-only 테이블
스케줄링: Redis Sorted Set 지연 큐 (100ms 폴링)
동기화: Kafka 도메인 이벤트 기반 (5초 이내)
Phase 3 — 상품 1,000만 개, 동시 프로모션 5만 개 (고성장)
월 비용: 약 800만 원
CDC 파이프라인으로 가격 불일치를 500ms 이내로 줄입니다. 가격 이력이 10억 건을 넘어서면 파티셔닝이 필요합니다. 타임딜 스케줄러를 파티셔닝해 자정 동시 오픈 병목을 해소합니다. 룰 엔진에 배포 전 시뮬레이션과 런타임 이상 감지를 추가합니다.
구성: API 서버 4대 + Redis Sentinel + PostgreSQL (Primary+Replica) + Kafka + Debezium
가격 동기화: CDC Debezium → Kafka → 검색/목록/상세 실시간 갱신 (< 500ms)
이력: price_change_events (월별 파티션) + 일별 스냅샷
스케줄링: 파티셔닝된 딜 스케줄러 (10개 워커)
이상 감지: 1분 주기 평균 가격 모니터링, 50% 하락 시 자동 정지
Phase 4 — 상품 5,000만 개, 동시 프로모션 10만 개 (대규모 플랫폼)
월 비용: 약 5,000만 원
ML 기반 동적 가격 보조 레이어를 추가합니다(룰 기반 가격을 기준으로 ±10% 범위 내에서만 ML이 조정). 개인화 가격(회원 등급·구매 이력 기반)을 지원합니다. 가격 데이터 레이크를 구축해 캠페인 ROI와 가격 탄력성 분석을 실시간으로 제공합니다. 글로벌 멀티 리전에서 지역별 가격 정책을 독립 운영합니다.
구성: API 서버 20대 + Redis Cluster (12노드) + MySQL 샤딩 + Kafka + Flink + Elasticsearch
가격 계산: 룰 엔진 + ML 가격 보조 (XGBoost 기반 수요 예측)
개인화: 사용자 세그먼트 연동, 회원 등급별 가격 레이어
동기화: CDC + 이벤트 이중 보장, 멀티 리전 복제 (< 1초)
분석: Flink 실시간 스트리밍 → 캠페인 ROI·가격 탄력성·경쟁사 비교 대시보드
핵심 메트릭
| 메트릭 | 설명 | 목표값 | 측정 방법 |
|---|---|---|---|
| 가격 계산 레이턴시 P99 | 룰 엔진 가격 계산 응답 시간 | < 30ms | Prometheus 타이머 |
| 가격 동기화 지연 | DB 변경 → 검색 인덱스 반영까지 | < 500ms | Kafka 이벤트 타임스탬프 기반 |
| 딜 오픈 정각 오차 | 설정 시각 대비 실제 오픈 시각 차이 | < 1초 | 딜 오픈 이벤트 타임스탬프 |
| 역마진 발생 건수 | 원가 이하 가격 결제 건수 | 0건 | 결제 완료 이벤트 기준 원가 비교 |
| 가격 불일치율 | 검색 표시 가격 ≠ 결제 가격 비율 | < 0.01% | 결제 진행 전 가격 토큰 검증 실패율 |
| 룰 엔진 캐시 히트율 | Caffeine L1 캐시 히트 비율 | > 95% | 캐시 히트/미스 카운터 |
| 이상 감지 응답 시간 | 역마진 발생 → 알림 수신까지 | < 2분 | PagerDuty 수신 타임스탬프 |
| 가격 이력 조회 P99 | 특정 시점 가격 재현 쿼리 시간 | < 100ms | price_change_events 인덱스 조회 |
실제 장애 사례
사례 1: 아마존 — 제3자 판매자 알고리즘 가격 전쟁 (2011)
아마존 마켓플레이스에서 두 제3자 판매자가 각자 “경쟁사보다 0.1% 낮은 가격을 자동 설정”하는 알고리즘을 운영했습니다. 두 알고리즘이 서로를 추적하며 가격을 내리다가, 한 판매자의 가격 하한선 로직 버그로 음수 방향으로 루프가 발생했습니다. “The Making of a Fly: The Genetics of Animal Design”이라는 책의 가격이 하루 만에 2,300만 달러로 폭등했습니다. 반대 방향으로 동일한 버그가 작동하면 0원 판매가 발생할 수 있었습니다.
근본 원인: 가격 알고리즘에 상한·하한 절대값 제약이 없었고, 두 알고리즘이 서로 반응하는 피드백 루프를 감지하는 메커니즘이 없었습니다.
대응: 아마존은 마켓플레이스 가격 정책에 “기준가 대비 ±20% 이상 변동 시 수동 승인 의무화”를 도입했습니다. 또한 동일 카테고리 내 급격한 가격 변동 패턴을 감지해 해당 알고리즘을 자동 정지하는 Circuit Breaker를 가격 레이어에 추가했습니다.
사례 2: 쿠팡 — 동적 가격 노출로 소비자 불신 (2019)
쿠팡이 ML 기반 동적 가격 실험을 진행하면서 같은 상품을 다른 사용자에게 다른 가격으로 노출했습니다. 사용자 A가 친구에게 상품 링크를 공유했더니 친구에게는 2,000원 더 비싼 가격이 표시됐습니다. SNS로 확산되며 “쿠팡이 사용자별로 다른 가격을 받는다”는 신뢰 위기가 발생했습니다.
근본 원인: 동적 가격이 기술적으로는 합법이지만, 사용자가 “같은 상품 다른 가격”을 인식하면 불공정하다고 느낍니다. 국내 소비자는 가격 차별에 특히 민감합니다.
대응: 쿠팡은 동적 가격 실험을 중단하고, 회원 등급에 따른 가격 차이는 “로켓와우 회원 혜택 가격”으로 명시적으로 표시하는 방식으로 전환했습니다. 가격 차별이 있다면 사용자에게 이유를 명확히 공개해야 합니다.
사례 3: 위메프 — 타임딜 가격 오기입 후 취소 분쟁 (2014)
위메프 운영팀이 150만 원짜리 가전제품 타임딜을 입력하면서 실수로 1만 5천 원으로 입력했습니다. 1시간 만에 2,000건이 결제됐습니다. 위메프가 일방적으로 주문을 취소하면서 소비자 분쟁이 폭발했습니다. 공정거래위원회에서 “표시된 가격으로 판매 의무”를 적용해 과태료를 부과했습니다.
근본 원인: 가격 입력 시 “기준가 대비 90% 이상 할인 가격은 운영자 이중 승인 의무화” 프로세스가 없었습니다. 또한 실시간 이상 감지 없이 판매가 진행됐습니다.
대응: 위메프는 기준가 대비 70% 이상 할인 설정 시 별도 승인 단계를 추가했습니다. 가격 입력 시 “기준가의 X% 할인 = Y원입니다, 맞습니까?” 확인 화면을 추가하고, 1분 이내 비정상적으로 높은 구매 전환율이 발생하면 해당 딜을 자동 일시 정지하는 로직을 도입했습니다.
확장 포인트
개인화 가격: 룰 엔진에 userSegment 조건을 추가하면 “최근 90일 이탈 위험 사용자에게 추가 5% 할인”을 코드 없이 설정할 수 있습니다. 단, 사용자에게 할인 이유를 명시해야 불공정 가격 시비를 피할 수 있습니다.
경쟁사 가격 모니터링 연동: 크롤링으로 수집한 경쟁사 가격을 룰 엔진 입력으로 사용해 “경쟁사 최저가보다 2% 낮게 자동 설정”을 구현할 수 있습니다. 단, 가격 하한선과 급변동 Circuit Breaker 없이 운영하면 아마존 사례처럼 가격 전쟁 루프에 빠질 수 있습니다.
번들·패키지 가격: 단품 가격의 합보다 저렴한 번들 가격을 설정할 때, 번들 해체 시 각 상품에 원가를 배분하는 로직이 필요합니다. 환불 시 번들 할인을 어떻게 처리할지 정책을 사전에 정의해야 합니다.
국제화 가격: 통화·세금·관세를 고려한 지역별 가격 레이어가 필요합니다. 환율 변동을 실시간 반영할지, 일별 고정 환율을 사용할지에 따라 가격 안정성이 달라집니다.
댓글