Documentation Index
Fetch the complete documentation index at: https://api-docs.vpms.io/llms.txt
Use this file to discover all available pages before exploring further.
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)을 확정했다. 그러나 다음 두 가지는 명세되지 않았다:
- Pricing Rules 저장 방식 — 요일 가산치(
토 +87.5%), 시즌 가산치(7-8월 +30%)를 RatePlan 수준에서 어떻게 표현할 것인가
- 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 Contract와 Materialization Rules를 전제로 한다.
RatePlan은 설정/레시피(defaultAmount, pricingRules, occupancyPrice, derivationRule)를 보관한다.
DailyRate row는 특정 stayDate의 최종 판매가 snapshot이다. 단순 “override”가 아니라 예약 엔진·OTA에 노출되는 영속화된 확정값이다.
- 자식
RatePlan은 부모의 snapshot에 derivationRule을 적용한 결과를 자신의 snapshot으로 가질 수 있다.
3개 옵션 비교:
| 옵션 | 저장 형태 | 트레이드오프 |
|---|
| A. Pre-materialize | Wizard 완료 시 24개월 DailyRate row 일괄 생성 | row 폭증. 시설당 5,500+ rows × roomType. Hybrid 원칙 위반 |
| B. Rule Only | RatePlan.pricingRules JSON만 저장, 매번 resolver 계산 | row가 전혀 없음 → “확정 가격” 개념 부재. OTA push·Audit·Yield 연동 모두 문제 |
| C. Rule + Selective Snapshot ✅ | 규칙은 RatePlan에 보관. 사용자가 명시적으로 확정한 날짜만 DailyRate row 생성 | resolver는 ADR-0001 §2.2.1 순서대로 row → derivation → rule 평가. 가장 깔끔 |
채택: 옵션 C. DailyRate row 생성 조건은 ADR-0001 §2.2.2(Materialization Rules)를 따른다.
RatePlan.pricingRules JSON 스키마
interface PricingRules {
// 요일별 가산치 (1.0 = 기본가, 1.5 = +50%)
dayOfWeek?: {
monday?: number;
tuesday?: number;
wednesday?: number;
thursday?: number;
friday?: number;
saturday?: number;
sunday?: number;
};
// 시즌별 가산치 (RateSeason 참조 또는 인라인)
seasons?: Array<{
name: string;
startMonth: number; // 1-12
startDay: number; // 1-31
endMonth: number;
endDay: number;
multiplier: number; // base * multiplier
priority?: number; // 충돌 시 우선순위 (높을수록 우선)
}>;
// 결합 방식
combination?: 'multiplicative' | 'additive_to_base';
// 기본 multiplicative: base × dow × season
// additive_to_base: base × (1 + (dow-1) + (season-1))
}
기본 결합 방식: multiplicative
- 예:
base 80,000 × Saturday 1.875 × Summer 1.3 = 195,000
2.2 Rate Resolution 우선순위
ADR-0001 §2.2.1을 본 ADR의 pricingRules 계산식으로 보강한다:
resolveDailyRate(ratePlanId, roomTypeId, date, occupancy):
1. DailyRate(ratePlanId, roomTypeId, stayDate) row 존재?
→ 최종 판매가 snapshot 반환 (ADR-0001 §2.2.1 step 1)
2. parentRatePlanId 존재?
→ parent.resolveDailyRate(date) × derivationRule
(derivationRule은 fixed_amount | fixed_delta | percentage_delta — ADR-0001 §2.2.3)
→ 반환
3. pricingRules로 계산 (ADR-0001 §2.2.1 step 3):
baseAmount = RatePlanRoomType(ratePlanId, roomTypeId).defaultAmount // RoomType별 override
?? ratePlan.defaultAmount // 미설정 시 RatePlan 기본가
dowMultiplier = pricingRules.dayOfWeek[weekday(date)] ?? 1.0
seasonMultiplier = matchSeasons(date, pricingRules.seasons) ?? 1.0
return baseAmount × dowMultiplier × seasonMultiplier
4. occupancyPrice 적용 (occupancy != roomType.defaultSleeps)
→ RatePlanOccupancyPrice 조회 후 mode에 따라 적용
→ 점유 기준은 RoomType.defaultSleeps/maxSleeps 가 source of truth (ADR-0001 §2.2.1).
4-1. (옵션) 게스트 유형 가산치 적용 — guestMix 가 지정된 경우만
→ RatePlanGuestTypeAdjustment 의 각 row 에 대해 (성인/아동/유아 카운트 × chargeOp)
를 누적. appliesAfterBaseOccupancy=true 인 row 는 roomType.defaultSleeps 를
초과한 인원에 대해서만 카운트한다.
5. 통화 반올림 규칙 normalize (ADR-0001 §2.2.1 step 5)
6. (Phase 4) Redis 캐시 set
캐시는 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는 “어떤 경로로 확정되었는가”를 기록한다:
enum DailyRateSource {
manual_user // 사용자가 Grid에서 단일 셀 직접 수정
manual_bulk // 사용자가 일괄수정 다이얼로그로 범위 확정
rematerialized // 규칙 변경 후 사용자 승인을 거쳐 재물질화된 결과
derived_cascade // 부모 RatePlan 변경에 따른 자식 파생 재산정 결과 (사용자 승인 후)
horizon_extension // horizon extension job이 기본값을 선계산한 결과 (Phase 4)
yield_engine // 외부 yield management 엔진 push
channel_pull // 채널 매니저(OTA) 동기화 pull
promotion // Promotion(ADR 추후 예정) 적용 결과
}
중요한 원칙:
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 변경 시:
- RatePlan/related row만 트랜잭션으로 업데이트 — DailyRate 테이블은 건드리지 않는다.
- AuditLog 기록 —
entityType='RatePlan', beforeState/afterState에 변경 diff.
- 영향 범위 분석 job enqueue — BullMQ로 비동기 실행:
- 미래 24개월 중 변경된 규칙에 의해 결과값이 달라지는 (
stayDate × roomType) 셀 카운트
- 셀별로 “현재 snapshot(있다면)“과 “새 규칙 적용 시 계산값”을 비교
- 결과를
RematerializationProposal 임시 레코드로 저장 (1시간 TTL)
- 사용자에게 알림 — Grid 상단에 banner 표시:
⚠️ 규칙 변경이 저장되었습니다.
• 영향 받는 미래 날짜 snapshot: 47건
• 영향 받지만 snapshot 없는 날짜: 683건 (자동으로 새 규칙 적용)
[ 변경 미리보기 ] [ 재물질화 진행 ] [ 나중에 ]
2.4.2 사용자 선택지
| 선택 | 결과 |
|---|
| 나중에 | snapshot은 그대로. 신규 조회만 새 규칙 사용. proposal은 1시간 후 만료 |
| 변경 미리보기 | 영향 받는 셀의 before/after 가격을 Grid 색상으로 오버레이 표시. 다시 결정 선택 가능 |
| 재물질화 진행 | proposal에 따라 DailyRate row를 갱신/생성. source=rematerialized. 단일 묶음 AuditLog (action=bulk_update) 생성 |
| 부분 재물질화 | 미리보기 단계에서 셀을 선택해 일부만 적용 가능. 선택되지 않은 셀은 기존 snapshot 유지 |
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단계
| Step | 입력 항목 | 저장 대상 |
|---|
| 1. 기본정보 | name, code, type, isPublic, checkInTime, checkOutTime, 적용 RoomType[] (base/max occupancy 는 각 RoomType.defaultSleeps/maxSleeps 가 source of truth) | RatePlan + RatePlanRoomType |
| 2. 기본가격 | defaultAmount, dayOfWeek 가산치, seasons 가산치 | RatePlan.defaultAmount + RatePlan.pricingRules |
| 3. 점유가격 | 1인/2인/3인/4인 가격 (absolute 또는 delta) | RatePlanOccupancyPrice[] |
| 4. 제약 | 기본 minLOS/maxLOS, CTA/CTD 요일 기본값, 사전예약 제한 | RatePlan.defaultMinLOS/MaxLOS + RatePlanRestriction 기본 row |
| 5. 포함서비스 | Inclusion 선택, 수량, override 여부 | RatePlanInclusion[] |
Wizard UX 원칙
- 실시간 미리보기: 각 Step에서 “2026-05-17 토요일 가격 = ?” 즉시 계산 표시
- 이전 Step 수정 가능: 진행도 인디케이터 클릭 시 이전 Step으로 이동
- Skip 허용: Step 3·4·5는 선택사항 (default값 사용 가능)
- 완료 시 행동:
- RatePlan + 관련 row 생성 (트랜잭션)
- AuditLog
create 기록 (afterState=전체 snapshot)
- Grid 화면으로 자동 이동
2.6 일상 운영 화면 — Hybrid Grid + Side Panel
좌측 Grid
| 열 | 행 |
|---|
| RoomType (default 가격 표시) | 날짜 (해당 월 1일~말일) |
셀 내용:
- 가격(천원 단위 표시, 클릭 시 정확한 값)
- 상태 아이콘:
🔒 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에서 범위 선택 후 또는 [⚡ 일괄] 버튼 클릭 시:
대상: 2026-05-15 ~ 2026-05-31 (17일)
스탠다드, 디럭스 (2 roomType)
= 34 cell
작업: ○ 가격 +10% ○ 가격 절대값 설정
○ 가격 -₩5,000 ○ CTA 적용
○ Stop Sell 적용 ○ MinLOS = 2
기존 snapshot 처리:
◉ 새 값으로 덮어쓰기 (기존 snapshot 갱신)
○ snapshot 없는 cell만 적용 (확정 가격 보호)
○ 미리보기로 cell별 선택 후 적용
모든 cell은 DailyRate row를 명시적으로 생성/갱신한다. 일괄 수정은 ADR-0001 §2.2.2의 “특정 날짜 범위 가격을 확정 적용한 경우”에 해당하는 source of materialization.
- source:
manual_bulk
- 단일 AuditLog 항목으로 기록 (
action=bulk_update, diff에 영향 범위 + 작업 종류 요약)
- restriction 일괄 적용도 동일 흐름 (RatePlanRestriction row 생성/갱신)
2.8 시각적 표기 규칙
| 표기 | 의미 | 색상 |
|---|
| ◆ | 확정 snapshot (DailyRate row 존재) | 회색 dot |
| 🧮 | 규칙 계산값 (DailyRate row 없음, resolver step 3) | (표시 없음, 기본) |
| 🔒 | CTA (Closed to Arrival) | 노랑 |
| 🚫 | CTD (Closed to Departure) | 주황 |
| ⛔ | Stop Sell | 빨강 |
| ⚠️ 셀 외곽선 | 재물질화 proposal 영향 받는 셀 (사용자 결정 대기) | 주황 외곽선 |
| ▓ 셀 배경 | 현재 선택 | 파랑 톤 |
| 음영 셀 | 과거 날짜 (수정 가능하나 경고) | 회색 톤 |
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
| Risk | Mitigation |
|---|
| pricingRules JSON 스키마 진화 | pricingRulesVersion: number 필드로 버전 관리, resolver에서 분기 |
| Snapshot이 너무 많아져 Hybrid 의미 상실 | ”snapshot 비율” 위젯 표시, 80% 초과 시 경고 |
| 사용자가 규칙 계산값과 snapshot 차이를 혼동 | UI에서 ◆/🧮 표기 + Side Panel “규칙 대비 ±X” 명시 |
| 일괄 수정 실수로 대량 snapshot 생성 | 확인 다이얼로그 + 미리보기 단계 + AuditLog bulk_update |
| 규칙 변경 후 재물질화 미진행으로 drift | proposal 30일 누적 시 경고 + 운영 위젯에 “미적용 규칙 변경” 노출 (§2.4.4) |
| 부분 재물질화 시 일관성 깨짐 | 미리보기 단계에서 영향 범위 시각화. 채널 push는 항상 resolver 최종값 사용 |
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 보강)
model RatePlan {
// ... ADR-0001의 필드들 ...
defaultAmount Decimal @database.Decimal(15, 4)
pricingRules Json? /// [PricingRules]
pricingRulesVersion Int @default(1)
// ...
}
6.2 RematerializationProposal 임시 레코드
규칙 변경 시 영향 범위 분석 결과를 보관하는 임시 엔티티 (Redis 또는 PostgreSQL TTL row, Phase 4 의존성 없음):
model RematerializationProposal {
id Bytes @id @database.ByteA // ULID
ratePlanId Bytes @database.ByteA
triggeredBy Bytes @database.ByteA // actor user
triggerKind String @database.VarChar(32) // 'pricing_rules' | 'default_amount' | 'derivation' | 'occupancy'
beforeRulesSnapshot Json
afterRulesSnapshot Json
affectedSnapshots Int // 영향 받는 기존 DailyRate row 수
affectedComputed Int // 영향 받지만 snapshot 없는 날짜 수
proposalDetails Json // cell별 before/after
status String @database.VarChar(16) // 'pending' | 'applied' | 'partial' | 'discarded' | 'expired'
expiresAt DateTime
createdAt DateTime @default(now())
@@index([ratePlanId, status])
@@index([expiresAt])
}
6.3 Resolver 모듈 위치
apps/core-svc/src/domains/rate-plan/
├── services/
│ ├── rate-resolver.service.ts // 가격 해결 (ADR-0001 §2.2.1)
│ ├── pricing-rules.service.ts // pricingRules JSON 검증·계산
│ ├── rematerialization.service.ts // proposal 생성·적용 (§2.4)
│ └── rate-audit.publisher.ts // @vpms-cluster/audit 래퍼 (ADR-0001 §2.8)
├── schemas/
│ └── pricing-rules.schema.ts // Zod 스키마
└── workers/
└── rematerialization-analyzer.worker.ts // BullMQ: 영향 범위 분석
rate-audit.publisher.ts 는 @vpms-cluster/audit 의 publishAuditLog / publishAuditLogBatch 를
rate domain 관점에서 호출하는 얇은 래퍼이다. 자체 저장소를 갖지 않고 audit-svc 로 발행만 한다.
6.3 Zod 스키마 (필수)
const PricingRulesSchema = z.object({
dayOfWeek: z.object({
monday: z.number().positive().optional(),
// ... 7개 요일
}).optional(),
seasons: z.array(z.object({
name: z.string().min(1).max(64),
startMonth: z.number().int().min(1).max(12),
// ...
multiplier: z.number().positive(),
})).optional(),
combination: z.enum(['multiplicative', 'additive_to_base']).optional(),
});
6.4 UI 컴포넌트 분리
<RatePlanWizard /> — 5 Step 컨테이너
<DailyRateGrid /> — Grid (가상화 필수, 31 × N roomType × ratePlan 셀)
<DailyRateSidePanel /> — 선택된 셀 상세
<BulkEditDialog /> — 일괄 수정
<KpiBarPlaceholder /> — Booking 도메인 의존, placeholder
7. References