ADR-0001: Rate Domain 재설계
- Status: Proposed
- Date: 2026-05-15
- Authors: 정다운 (daun@vendit.co.kr)
- Affected modules:
apps/core-svc/src/domains/ari-rate,ari-period,ari-package,ari-inclusion - Branch:
refactor/rate-domain-redesign
1. Context
현재ari-rate, ari-period, ari-package, ari-inclusion 4개 도메인으로 구성된 요금 모델은 운영을 시작하지 않은 시점에 한 번 정리해야 한다. 글로벌 PMS 표준(Opera, Mews, Apaleo, Cloudbeds)과 OTA 분배 표준(HTNG, OpenTravel)에 비춰볼 때 다음 결함이 식별되었다.
1.1 구조적 결함
1.2 글로벌 PMS 공통 패턴
공통 패턴 5가지:
- RatePlan(설정 메타데이터) ↔ DailyRate(날짜별 가격) 명확 분리
- Master-Derived Rate 계층
- Restriction을 별도 도메인으로 격리
- Occupancy 가격 매트릭스
- Package는 RatePlan의 inclusion bundle 속성
2. Decision
2.1 전체 도메인 재설계 (Clean Slate)
운영 데이터가 없으므로 legacy 4개 도메인을 즉시 폐기하고 신규 5개 도메인으로 재구성한다. 제거:AriRate,AriRateAdjustment,AriRateAdjustmentMappingAriPackage(컨테이너 역할로서)AriPeriod의daysOfWeek/daysOfMonth/monthsOfYearJSON 필드- 모든 테이블의
currencySymbol컬럼
RatePlan— 요금제 설정 (메타데이터, 정책, 기본 가격)DailyRate— 일별 가격 매트릭스 (PMS의 심장)RatePlanRestriction— 날짜별 판매 통제RatePlanOccupancyPrice— 점유 인원별 가격Inclusion,RatePlanInclusion— 부가 서비스 (override 지원)RateSeason— 시즌/기간 (iCal RRULE 활용)
@vpms-cluster/audit 라이브러리를 통해 audit-svc 로 통합 발행한다. (§2.8 참고)
2.2 Storage Model: Hybrid
근거:
- Dense(전체 펼치기)는 가격 변동 적은 시설(펜션·모텔)에 storage 낭비
- Sparse(변경분만)는 OTA push 시 매번 resolve 부담
- Hybrid는 운영 시점에 정책 전환 가능 (yield engine 도입 시 dense화)
2.2.1 Price Resolution Contract
DailyRate는 단순 override가 아니라, 특정 날짜에 예약 엔진과 OTA에 노출되는 resolved final amount의 영속 snapshot 으로 정의한다.
계산 순서는 다음과 같다:
DailyRate(ratePlanId, roomTypeId, stayDate)row가 존재하면 그 값을 최종값으로 사용한다.- row가 없고
parentRatePlanId가 있으면 부모RatePlan의 동일 날짜 가격을 기준으로derivationRule을 적용한다. - 부모가 없으면
RatePlan.defaultAmount를 기준으로 사용한다. RoomType.defaultSleeps초과 인원이 있으면RatePlanOccupancyPrice를 적용한다.- 점유 기준(base/max occupancy)은
RatePlan이 아니라RoomType의defaultSleeps/maxSleeps가 source of truth 다. 같은 RatePlan 이 정원이 다른 여러 RoomType 에 매핑될 때의 모순을 피하기 위함. RatePlanOccupancyPrice는 sparse 하게 입력한다 (필요한 (roomType, occupancy) 슬롯만). 단순한 1인당 추가요금은RatePlanGuestTypeAdjustment(adult, appliesAfterBaseOccupancy=true)로 표현하는 것이 권장 패턴.
- 점유 기준(base/max occupancy)은
- 금액 계산 결과는 통화 반올림 규칙(예: KRW 절사, 소수 통화는 scale 유지)에 따라 normalize 한다.
2.2.2 Materialization Rules
DailyRaterow는 다음 경우에 생성 또는 갱신한다.- 사용자가 특정 날짜 가격을 직접 수정한 경우
- 시즌/프로모션/일괄 인상 등으로 특정 날짜 범위 가격을 확정 적용한 경우
- horizon extension job이 미래 기간 기본값을 선계산하도록 선택한 경우
RatePlan.defaultAmount,derivationRule,occupancyPrice변경 시 이미 존재하는 미래DailyRaterow는 자동 overwrite 하지 않는다.- 대신 영향 범위를 계산해 비동기 재산정 job을 enqueue 하고, 사용자가 승인한 경우에만 미래 구간을 재물질화한다.
- 이 규칙으로 “설정 변경”과 “이미 확정된 일별 판매가”를 분리해 가격 drift와 의도치 않은 대량 변경을 방지한다.
2.2.3 Derived Rate Guardrails
RatePlan은 선택적으로parentRatePlanId를 가질 수 있다.- parent chain 최대 깊이는 5로 제한한다.
- 순환 참조는 저장 시점에 금지한다.
derivationRule은 1차 범위에서fixed_amount,fixed_delta,percentage_delta세 종류만 허용한다.- 복수 파생 규칙 조합, 채널별 파생 규칙, 조건부 파생 규칙은 이번 ADR 범위에서 제외한다.
2.3 Restriction Model
RatePlanRestriction은 가격과 분리된 독립 판매 통제 도메인으로 정의한다.
2.3.1 Restriction Scope
- PK/unique 기준:
(ratePlanId, roomTypeId, stayDate) - 1차 지원 필드:
closedToArrival(CTA)closedToDeparture(CTD)stopSellminLengthOfStaymaxLengthOfStay
- restriction은 숙박일(
stayDate) 기준으로 평가한다. - 체크인 가능 여부는 입실일의
CTA, 체크아웃 가능 여부는 퇴실 전일의CTD, 판매 가능 여부는 숙박 구간 전체의stopSell과 LOS를 함께 평가한다.
2.3.2 Restriction Resolution Rules
- 동일 날짜에
stopSell=true가 존재하면 다른 restriction보다 항상 우선한다. minLengthOfStay,maxLengthOfStay는 더 보수적인 값이 우선한다.- 부모
RatePlan과 자식RatePlan이 동시에 restriction을 가지는 경우, 자식 row가 있으면 자식 값을 사용한다. - 자식 row가 없으면 부모 restriction을 상속한다.
- 채널별 restriction override는 이번 ADR 범위에서 제외한다.
2.3.3 Restriction Persistence Rule
- restriction도 가격과 동일하게 날짜별 snapshot row를 기본 단위로 저장한다.
- RRULE/시즌 적용은 입력 편의 기능일 뿐 source of truth는 날짜별 row이다.
2.4 Forward Horizon: 24개월
Daily cron으로 매일 자정 horizon을 24개월로 롤링 유지한다. 다만 이는 운영 기본값이며, Phase 1 acceptance criteria는 아니다.
2.5 History Retention
월별 cron으로 13개월 이전 partition을 archive로 이관한다. archive 파이프라인은 Phase 4에서 도입한다.
2.6 PostgreSQL Partitioning
- 파티션 키:
stayDate(월 단위) - 인덱스:
(ratePlanId, roomTypeId, stayDate)unique +(stayDate)+(roomTypeId, stayDate) - TimescaleDB 확장은 Phase 2 종료 시점에 도입 여부 재평가 (지금은 native partitioning으로 시작)
- 단, 초기 row volume이 낮은 동안은 단일 테이블로 시작하고 Phase 4 이전에 partitioning을 적용해도 된다.
2.7 Cache Layer: Redis (Hot Cache, NOT Source of Truth)
Redis 단독 저장 거부 이유:
- Durability(ACID) 보장 부족 → 가격 데이터 손실 위험
- Audit trail 불가
- BI·OLAP 연동 불가
- 메모리 비용 (1B rows × 100B = 100GB RAM → ~$5,000/월)
2.8 Event Sourcing(Saga) 폐기 + audit-svc 통합 발행
Audit 책임 분리 원칙
core-svc 는 audit-svc 의 데이터 모델에 의존하지 않고
@vpms-cluster/audit 의 publishAuditLog / publishAuditLogBatch API 와 CreateAuditLogData 타입에만 의존한다.
audit-svc 측 데이터 모델 요지
audit-svc 의 schema(apps/audit-svc/prisma/schema.prisma) 는 다음을 제공한다:
AuditLog— entityType / entityId / actionType(CREATE/UPDATE/DELETE/EVENT) / status / oldData / newData / highlightFields / userId / userAgent / ipAddress / accommodationId / contextId / createdAtAuditContext— 동일 요청/트랜잭션 내 여러 audit log 를 한 묶음으로 연결하는 컨테이너- 익명 탐지(
isAnomaly,anomalyReason) 후크 - 시설(accommodationId) 단위 인덱스 다수
Audit 발행 전략
Audit 호출 위치
- GraphQL Resolver 레벨:
@audit(entityType: "RatePlan", action: UPDATE)directive 를 우선 사용. resolver 가 entity 식별자와 변경 전후 데이터를 가공해 directive 가 자동 발행. - Service 레벨: directive 적용이 어려운 비-mutation 흐름(cascade, batch job 등)은 service 함수에서 직접
publishAuditLog호출. - 트랜잭션 외부 발행: audit 발행은 도메인 트랜잭션 커밋 이후 실행한다. audit 실패가 도메인 트랜잭션을 롤백하지 않도록 격리한다.
엔티티 레벨 보조 필드
audit-svc 로 모든 변경 이력은 발행되지만, “누가 마지막으로 만졌나” 를 빠르게 보여주기 위해 변경 가능 엔티티(RatePlan, DailyRate, RatePlanRestriction, Inclusion 등)에 보조 필드를 둔다:
2.9 ID 타입 통일
모든 도메인 엔티티 PK를 ULID (Bytes) 로 통일:- 분산 환경에서 충돌 없는 생성 가능
- 시간 기반 정렬 가능
- 외부 API에 안전하게 노출 가능
RatePlanRoomType 등)은 composite PK 유지.
2.10 Currency 처리
currency: ISO 4217 enum (KRW, USD, JPY 등) 그대로 유지currencySymbol: 모든 테이블에서 제거- 심볼은 프론트엔드
Intl.NumberFormat(locale, { style: 'currency', currency })로 도출
2.11 Time 표현 통일
checkInTime, checkOutTime을 Int (minutes from midnight) 로 통일:
14:00→84011:30→690- 타임존 영향 없음, 비교·정렬 자명, 파싱 불필요
3. Out of Scope (이번 ADR 범위 외)
다음은 별도 ADR로 분리한다:- ADR-0002: Cancellation / Payment Policy 1st-class 도메인화
- ADR-0003: Tax / Fee 분리 (gross/net, 다국가 세금 표현)
- ADR-0004: Yield Management 외부 엔진 연동 인터페이스
- ADR-0005: Channel Manager(OTA) 분배 표준화 (HTNG/OpenTravel 매핑)
- Redis hot cache
- PostgreSQL partitioning 자동화
- Horizon extension cron
- Cold archive 파이프라인
4. Consequences
4.1 Positive
- ✅ Daily Rate 매트릭스 확보 → OTA·Yield Engine 연동 가능
- ✅ Restriction 도메인 확보 → PMS 인증 요건 충족
- ✅ Occupancy 가격 → 다인실 요금 표현 가능
- ✅ Audit 체계화 → 회계 감사 대응 가능, “누가 가격 바꿨나” 추적 가능
- ✅ Saga 제거 → API 응답 속도 ↑, 코드 단순화
- ✅ Hybrid storage → 시설 규모에 따라 운영 모드 전환 가능
- ✅ 설정 변경과 확정된 일별 판매가를 분리 → 의도치 않은 미래 가격 덮어쓰기 방지
- ✅ Redis 캐시 → 일별 캘린더 조회 ms 단위 응답
4.2 Negative
- ❌ Breaking change: 기존 GraphQL API(
getAriPackageList등) 전부 deprecated. 프론트엔드 동시 변경 필수 - ❌ Cron 운영 추가: Horizon extension, Archive job 모니터링 필요
- ❌ Redis 의존성 추가: 단일 장애점 → fallback 경로(직접 DB 조회) 필수
- ❌ AuditLog 용량: 활성 시설 1,000개 × 일 평균 100 mutation = 100K rows/day = 36M/year. 별도 archive 정책 필요 (Phase 4에 반영)
- ❌ 마이그레이션 비용: 운영 미시작이라 데이터 마이그레이션은 없지만, 코드 베이스 전체 재작성 = 2주 이상 소요
- ❌ 재물질화 정책 필요: 기본 가격/파생 규칙 변경 시 어떤 미래 날짜를 다시 계산할지 운영 규칙이 필요
4.3 Risks & Mitigations
5. Alternatives Considered
A1. Legacy 유지하며 DailyRate만 추가
- ❌ 거부 — Adjustment 폴리모픽 문제, currencySymbol 등 다른 결함 그대로 잔존. 두 모델 공존 = 영구 기술 부채.
A2. Dense Storage (모든 날짜 row 강제 생성)
- ❌ 거부 — 가격 변동 적은 시설에 storage 낭비. Hybrid가 운영 시점 결정권을 보존.
A3. Sparse Only (DailyRate row는 override만)
- ❌ 거부 — Yield Engine 도입 시 “현재 가격이 무엇인가” 모호. dense화 옵션 필요.
A4. Redis를 Source of Truth로
- ❌ 거부 — Durability·Audit·BI 연동 불가. 메모리 비용 폭증.
A5. Event Sourcing 전면 적용 (전체 도메인 saga)
- ❌ 거부 — RatePlan CRUD는 단순 케이스. saga 오버헤드가 이득보다 큼. DailyRate 대량 변경만 비동기 job으로 처리.
A6. NoSQL(MongoDB) 도입
- ❌ 거부 — 트랜잭션·JOIN·BI 도구 호환성에서 PostgreSQL이 우월. 시계열 특화는 TimescaleDB로 충분.
6. Implementation Roadmap
각 Phase는 별도 PR로 분리. Phase 1 PR이 base가 되어 후속 PR이 stack.