Skip to main content

ADR-0002: Pricing Rules Storage & UI Patterns

  • Status: Proposed
  • Date: 2026-05-15
  • Authors: 정다운 (daun@vendit.co.kr)
  • Supersedes: —
  • Related: ADR-0001
  • Affected: apps/core-svc/src/domains/rate-plan, apps/ari-ui (또는 후속 UI 앱)

1. Context

ADR-0001에서 Rate Domain 재설계 방향(RatePlan + DailyRate + Restriction + Inclusion + Audit)을 확정했다. 그러나 다음 두 가지는 명세되지 않았다:
  1. Pricing Rules 저장 방식 — 요일 가산치(토 +87.5%), 시즌 가산치(7-8월 +30%)를 RatePlan 수준에서 어떻게 표현할 것인가
  2. UI 패턴 — RatePlan 생성·운영 화면을 어떻게 구성할 것인가
UI 측 결정이 데이터 모델(특히 DailyRate.source enum과 resolver 우선순위)에 영향을 주므로 별도 ADR로 분리하여 다룬다.

2. Decision

2.1 RatePlan 가격 결정 구조 — Rule(설정) + Snapshot(확정) 분리

ADR-0001 §2.2.1·§2.2.2의 Price Resolution ContractMaterialization Rules를 전제로 한다.
  • RatePlan설정/레시피(defaultAmount, pricingRules, occupancyPrice, derivationRule)를 보관한다.
  • DailyRate row는 특정 stayDate최종 판매가 snapshot이다. 단순 “override”가 아니라 예약 엔진·OTA에 노출되는 영속화된 확정값이다.
  • 자식 RatePlan은 부모의 snapshot에 derivationRule을 적용한 결과를 자신의 snapshot으로 가질 수 있다.
3개 옵션 비교: 채택: 옵션 C. DailyRate row 생성 조건은 ADR-0001 §2.2.2(Materialization Rules)를 따른다.

RatePlan.pricingRules JSON 스키마

기본 결합 방식: multiplicative
  • 예: base 80,000 × Saturday 1.875 × Summer 1.3 = 195,000

2.2 Rate Resolution 우선순위

ADR-0001 §2.2.1을 본 ADR의 pricingRules 계산식으로 보강한다:
캐시는 Phase 4 도입(ADR-0001 §3 Phase-gated). Phase 2의 resolver는 PostgreSQL 직조회로 동작하고, 캐시 미스는 오류가 아니라 성능 저하로 취급한다(ADR-0001 §2.7).

2.3 DailyRate.source enum 세분화

ADR-0001 §2.8의 source 개념을 다음으로 구체화한다. DailyRate는 단순 override가 아니라 확정된 최종 판매가 snapshot(ADR-0001 §2.2.1)이므로 source는 “어떤 경로로 확정되었는가”를 기록한다:
중요한 원칙:
  • wizard_default 같은 source는 존재하지 않는다. Wizard 완료 시점에 DailyRate row를 만들지 않는다. Wizard는 RatePlan.defaultAmount + pricingRules만 세팅하고, 실제 row는 사용자가 명시적으로 확정한 시점에만 생성된다(ADR-0001 §2.2.2).
  • 모든 source는 “확정 시점이 명시적으로 존재”한다. 즉 row의 존재 자체가 “이 가격은 누군가/무언가가 의도적으로 확정한 값”이라는 의미를 갖는다.
  • UI는 row가 있으면 표기하여 “확정된 snapshot”임을 즉시 식별 가능하게 한다.

2.4 Pricing Rules 변경 시 행동 — Explicit Re-materialization

ADR-0001 §2.2.2의 핵심 규칙: 규칙 변경은 기존 DailyRate row를 자동으로 덮어쓰지 않는다. 본 ADR은 이를 UI 흐름으로 구체화한다.

2.4.1 변경 발생 시 즉시 동작

RatePlan.defaultAmount, pricingRules, derivationRule, RatePlanOccupancyPrice 변경 시:
  1. RatePlan/related row만 트랜잭션으로 업데이트 — DailyRate 테이블은 건드리지 않는다.
  2. AuditLog 기록entityType='RatePlan', beforeState/afterState에 변경 diff.
  3. 영향 범위 분석 job enqueue — BullMQ로 비동기 실행:
    • 미래 24개월 중 변경된 규칙에 의해 결과값이 달라지는 (stayDate × roomType) 셀 카운트
    • 셀별로 “현재 snapshot(있다면)“과 “새 규칙 적용 시 계산값”을 비교
    • 결과를 RematerializationProposal 임시 레코드로 저장 (1시간 TTL)
  4. 사용자에게 알림 — Grid 상단에 banner 표시:

2.4.2 사용자 선택지

2.4.3 핵심 보장

  • 확정된 snapshot은 사용자 명시 승인 없이 절대 변경되지 않는다.
  • 사용자가 명시적으로 “초기화” 클릭한 셀의 DailyRate row는 삭제되어 resolver가 규칙 기반 계산값으로 복귀.
  • 부모 RatePlan 변경에 따른 자식 cascade도 동일 규칙: 자식의 snapshot은 자동 변경되지 않고 proposal로만 표시.

2.4.4 Drift 방지 운영 가드

  • 미확정 상태 proposal이 30일 이상 누적되면 운영 대시보드에 경고
  • “이번 달 적용 안된 규칙 변경” 위젯을 Grid 상단에 노출
  • 채널 매니저 push 시점에는 항상 resolver 최종값을 사용 (proposal 미적용 상태와 무관)

2.5 RatePlan 생성 UI — Wizard 5단계

Wizard UX 원칙

  • 실시간 미리보기: 각 Step에서 “2026-05-17 토요일 가격 = ?” 즉시 계산 표시
  • 이전 Step 수정 가능: 진행도 인디케이터 클릭 시 이전 Step으로 이동
  • Skip 허용: Step 3·4·5는 선택사항 (default값 사용 가능)
  • 완료 시 행동:
    1. RatePlan + 관련 row 생성 (트랜잭션)
    2. AuditLog create 기록 (afterState=전체 snapshot)
    3. Grid 화면으로 자동 이동

2.6 일상 운영 화면 — Hybrid Grid + Side Panel

좌측 Grid

셀 내용:
  • 가격(천원 단위 표시, 클릭 시 정확한 값)
  • 상태 아이콘: 🔒 CTA / 🚫 CTD / ⛔ StopSell / ◆ override
상호작용:
  • 단일 셀 클릭 → 우측 Side Panel에 상세 로드
  • 셀 인라인 수정 → 가격만 빠르게 변경 가능
  • 셀 드래그 → 범위 선택 → 일괄 수정 다이얼로그
  • 더블클릭 → 풀스크린 상세 모달

우측 Side Panel

선택된 (날짜 × RoomType) 셀에 대해:
  • 가격 상태 라벨:
    • snapshot이 있는 경우: ◆ 확정 snapshot + source 표시 (예: manual_user, rematerialized, channel_pull)
    • snapshot이 없는 경우: 🧮 규칙 계산값 (resolver step 3 결과)
  • 가격 값: 현재값 + “규칙 계산값 대비 ±X” diff 표시
    • 입력 후 [확정 저장] → DailyRate row 생성/갱신 (source=manual_user)
    • [초기화] → DailyRate row 삭제 → 규칙 계산값으로 복귀
  • 점유 가격: 1/2/3/4인 가격 미리보기 (resolver step 4 결과)
  • 제약 (ADR-0001 §2.3 — restriction도 날짜별 snapshot이 source of truth):
    • CTA / CTD / StopSell 토글, MinLOS / MaxLOS 입력
    • 저장 시 RatePlanRestriction row 생성/갱신
    • [초기화] → row 삭제 → 부모/RatePlan default로 상속 복귀
  • 포함서비스: 이 날짜에 적용되는 inclusion 목록 (변경 불가, 정보성)
  • 이력: AuditLog 최근 3건 (“정다운, 2026-05-10 14:32, ₩120,000 → ₩150,000, source=manual_user”)

상단 툴바

  • RatePlan 셀렉터 (BAR ▾)
  • 월 네비게이션 (◀ 2026-05 ▶, [오늘])
  • 액션 버튼: [✎ 편집], [⚡ 일괄수정], [💰 프로모션]
  • 검색 (특정 룸타입·기간)

하단 KPI 바 — Phase 후속

  • 평균가격 / 점유율 / RevPAR
  • 이번 ADR 범위 밖 (Booking 도메인 의존)
  • 자리는 비워두고 <KpiBarPlaceholder /> 컴포넌트로 마크
  • 별도 PR(Booking 도메인 연계)에서 채움

2.7 일괄 수정 (Bulk Edit) 다이얼로그

Grid에서 범위 선택 후 또는 [⚡ 일괄] 버튼 클릭 시:
모든 cell은 DailyRate row를 명시적으로 생성/갱신한다. 일괄 수정은 ADR-0001 §2.2.2의 “특정 날짜 범위 가격을 확정 적용한 경우”에 해당하는 source of materialization.
  • source: manual_bulk
  • 단일 AuditLog 항목으로 기록 (action=bulk_update, diff에 영향 범위 + 작업 종류 요약)
  • restriction 일괄 적용도 동일 흐름 (RatePlanRestriction row 생성/갱신)

2.8 시각적 표기 규칙


3. Out of Scope

  • Booking 데이터 의존 KPI (Phase 후속 PR)
  • 채널별 가격 분배 UI (ADR-0005 예정)
  • Yield Engine 자동 가격 룰 UI (ADR-0004 예정)
  • 모바일 전용 UI (필요 시 후속)

4. Consequences

4.1 Positive

  • ✅ Pricing Rules JSON으로 시즌·요일 가격을 RatePlan 단위에 응집 → 운영 단순화
  • ✅ DailyRate 테이블은 “예외만” 저장 → storage·성능 효율
  • ✅ Wizard로 초보자 진입 장벽 ↓
  • ✅ Grid + Panel로 숙련자 효율 ↑
  • ✅ 일괄 수정·드래그 선택으로 yield manager 워크플로우 지원
  • ✅ override가 자동 삭제되지 않음 → 사용자 작업 보호

4.2 Negative

  • ❌ Resolver 복잡도 ↑ — pricingRules 계산 로직 + override 우선순위 + parent derivation
  • ❌ JSON 필드는 type-safe하지 않음 → Zod 등 runtime 검증 필수
  • ❌ Wizard와 Grid 이중 UI → 디자인·구현 비용 증가
  • ❌ 시즌 정의가 RatePlan 내부에 들어가면 여러 RatePlan에서 공유 불가 → 추후 RateSeason 분리 필요 시 마이그레이션 부담

4.3 Risks & Mitigations


5. Alternatives Considered

A1. Pre-materialize (Wizard 완료 시 24개월 row 생성)

  • ❌ 거부 — Hybrid storage 원칙(ADR-0001 2.2) 위반. row 수 폭증.

A2. Rule Only (DailyRate row 자체를 안 만듦)

  • ❌ 거부 — override가 별도 테이블 필요 → 결국 DailyRate 재발명. 또한 Audit·Yield 연동에서 row 단위 추적 필요.

A3. Spreadsheet UI를 메인 운영화면으로

  • ❌ 거부 — 일반 사용자 학습 장벽 높음. Power user는 일괄수정 다이얼로그 + 키보드 단축키로 커버.

A4. 명령 팔레트(Cmd+K) 기반 자연어 인터페이스

  • 보류 — 흥미롭지만 NLP 정확도·다국어 대응 비용 큼. v2 후속 검토.

A5. RateSeason을 별도 테이블로 즉시 분리

  • ❌ 이번 ADR에서는 거부 — pricingRules.seasons JSON으로 시작, RatePlan 간 공유 needs가 명확해질 때 분리(YAGNI).

6. Implementation Notes

6.1 RatePlan 스키마 추가 필드 (ADR-0001 보강)

6.2 RematerializationProposal 임시 레코드

규칙 변경 시 영향 범위 분석 결과를 보관하는 임시 엔티티 (Redis 또는 PostgreSQL TTL row, Phase 4 의존성 없음):

6.3 Resolver 모듈 위치

rate-audit.publisher.ts@vpms-cluster/auditpublishAuditLog / publishAuditLogBatch 를 rate domain 관점에서 호출하는 얇은 래퍼이다. 자체 저장소를 갖지 않고 audit-svc 로 발행만 한다.

6.3 Zod 스키마 (필수)

6.4 UI 컴포넌트 분리

  • <RatePlanWizard /> — 5 Step 컨테이너
  • <DailyRateGrid /> — Grid (가상화 필수, 31 × N roomType × ratePlan 셀)
  • <DailyRateSidePanel /> — 선택된 셀 상세
  • <BulkEditDialog /> — 일괄 수정
  • <KpiBarPlaceholder /> — Booking 도메인 의존, placeholder

7. References