grep

Engineering

PLP 최저가 계산 최적화: 정말 모든 객실을 계산해야 할까?

Joy여기어때

2025년 12월 22일

원문에서 보기 ↗

안녕하세요! 여기어때 전시개발팀 조이입니다.

숙박도메인이란? 여기어때 숙박 서비스는 호텔, 모텔, 펜션 등 다양한 숙소의 가격 정보를 제공합니다.

각 숙소는 날짜별/요일별 기본 가격이 있으며, 여기에 특가, 쿠폰, 더하기쿠폰 등 다양한 할인 정책이 적용되어 최종 가격이 동적으로 결정됩니다.

오늘은 PLP 최저가 계산 로직을 최적화하면서 겪었던 시행착오와 성과를 공유하려고 합니다.

새로운 요구사항

여기어때 PLP(Product List Page)에서는 사용자에게 다양한 숙박 옵션의 최저가 객실을 보여줘야 합니다.

이번에 새롭게 추가된 요구사항은 다음과 같습니다

1월 1일부터 30일까지, 최대 10박을 조회하면 모든 날짜/박수 조합의 최저가를 보여줘야 합니다.

1월 1일 체크인 -> 1박, 2박, 3박 ... 10박 (10개)
1월 2일 체크인 -> 1박, 2박, 3박 ... 10박 (10개)
1월 3일 체크인 -> 1박, 2박, 3박 ... 10박 (10개)
...
1월 29일 체크인 -> 1박, 2박 (2개, 31일 체크아웃 마감)
1월 30일 체크인 -> 1박 (1개, 31일 체크아웃 마감)

총 255개 조합

기존 코드는 각 체크인 날짜와 박수 조합마다 다음과 같이 동작했습니다

각 체크인 날짜와 박수 조합마다
1. 모든 객실(20개)의 가격을 전부 계산
  - 체크인 ~ 체크아웃일까지 모두
  - 특가 - 쿠폰할인 - 더하기쿠폰할인 적용
2. 계산된 객실을 최종판매가격순으로 정렬
3. 최저가 객실 1개만 선택

 계산 횟수: 20개 객실 x 255개 조합 = 5,100회

문제: 최저가 객실을 제외한 나머지 객실의 계산 결과는 버려집니다.

여기서 의문이 들었습니다.

해결 아이디어: Pruning 알고리즘

사실 처음부터 “Pruning을 써야겠다”라고 생각했던 건 아니었습니다.

로직을 다시 분석하면서 깨달은 점이 있었습니다

그러다 문득 이런 생각이 들었습니다. 애초에 최저가가 될 수 없는 객실까지 계산해야 할까?

Pruning(가지치기) : 결과에 영향을 줄 수 없는 후보를 미리 제거하는 전략

핵심 통찰

최종판매가격 = 판매원가 - 특가할인 - 쿠폰할인 - 더하기쿠폰할인
Room A: 판매원가 100,000원
Room B: 판매원가 300,000원

→ Room B는 최대 할인을 받아도 Room A보다 비쌀 가능성이 높음
→ 아예 계산하지 말자!

단순하지만 효과적인 아이디어였습니다

1차 시도: 단순 배수 접근

처음에는 간단하게 접근했습니다.

**예시:**
Room1: 판매원가 100,000원 → 기준
Room15: 판매원가 150,000원 → 100,000 × 1.5 = 제외 경계
Room16: 판매원가 200,000원 → 1.5배 초과, 제외!

**결과:**
50%이상 대상 객실을 줄였어요

하지만 치명적인 문제가 발견되었습니다

Room A: 판매원가 100,000원. (할인없음)
Room C: 판매원가 160,000원
  - 특가 30% 할인 → 48,000원
  - 쿠폰 10,000원
  - 더하기쿠폰 5,000원
  → 최종: 97,000원 ← 실제 최저가!

하지만 1.5배 기준으로는 제외됨
→ 정확도 보장 불가!

실제로 30% 이상 할인되는 상품이 존재했고, 이런 객실들이 제외되는 케이스가 발생했습니다.

단순히 배수로만 판단해서는 안 되겠다는 결론에 도달했습니다. 실제 할인 가능한 최댓값을 정확히 계산해야 했습니다.

2차 시도: Pruning 상한선 설계

실제 할인 가능한 최댓값을 모두 고려한 상한선을 계산했습니다

Pruning 상한선 = 
  min(판매원가)           // 가장 저렴한 객실의 원가
  + max(특가할인)         // 최대로 받을 수 있는 특가 할인
  + max(쿠폰할인)         // 최대로 받을 수 있는 쿠폰 할인  
  + max(더하기쿠폰할인)    // 최대로 받을 수 있는 더하기쿠폰 할인

여기서 중요한 점!!

특가나 쿠폰은 실제로 사용 조건이 까다롭습니다.

하지만 상한선을 구할 때는 이런 조건들을 모두 무시하고, 각 항목의 최대 할인금액만 고려했습니다.

이유: 최악의 경우를 가정해야 하기 때문입니다.

논리적 근거

최저 판매원가 객실이 모든 할인을 최대로 받으면

= min(판매원가) - max(특가) - max(쿠폰) - max(더하기쿠폰)

어떤 객실이 이보다 저렴하려면

판매원가 - 할인들 < min(판매원가) - max(할인들)

즉, 판매원가가 다음보다 높으면 절대 최저가가 될 수 없습니다

상한선 = min(판매원가) + max(특가) + max(쿠폰) + max(더하기쿠폰)

제외 조건

if (객실의 판매원가 > Pruning 상한선) {
    // 이 객실은 아무리 할인을 받아도 최저가가 될 수 없음
    계산 제외!
}

구현

// saleDate별로 그룹핑
val groupedByDate = serviceGoodsPrices.groupBy { it.saleDate }
// 각 날짜 그룹 내에서 필터링 적용
val filteredPrices = groupedByDate.flatMap { (saleDate, pricesForDate) ->
    if (pricesForDate.size <= 1) {
        // 해당 날짜에 가격이 1개뿐이면 그대로 유지
        pricesForDate
    } else {
        val minSalePrice = pricesForDate.minOf { it.salePrice }
        val maxSalePrice = pricesForDate.maxOf { it.salePrice }
        //특가 최대 할인금액
        val maxPolicy = getPolicyDiscount?.let { it(saleDate, maxSalePrice) } ?: 0
        
        //쿠폰 최대 할인금액
        val maxCoupon = getCouponDiscount?.let { it(saleDate, maxSalePrice) } ?: 0
        val maxAllowedPrice = minSalePrice + maxPolicy + maxCoupon
        pricesForDate.filter { it.salePrice <= maxAllowedPrice }
    }
}
return ServiceGoodsPrices(filteredPrices)

실제 적용 후 결과

실제 운영 환경에서 수집된 필터링 효과 데이터 (25건)

성능 측정 결과

마무리하며

필터링 제거율(70%)에 비해 실제 성능 개선(12~15%)은 다소 아쉬운 수준이었습니다.

이는 DB 조회 비용 자체가 상당 부분을 차지하고 있음을 의미합니다.

다음 단계에서는 조회 자체를 줄이는 방향으로 최적화를 진행할 예정입니다.

감사합니다.