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-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 구조적 결함
| # | 결함 | 영향 |
|---|
| C1 | Daily Rate 매트릭스 부재 — (ratePlanId, roomTypeId, date) → amount 테이블 없음 | OTA 채널 매니저 일별 push 불가, Yield Engine 연동 불가, 일별 캘린더 조회 매번 런타임 계산 |
| C2 | Restriction(CTA/CTD/StopSell/MinLOS) 부재 | PMS 인증 자체가 불가능. 채널 매니저 연동의 전제 조건 미충족 |
| C3 | Occupancy 기반 가격 부재 — baseOccupancy 필드 자체가 스키마에 없음 (mdx 문서엔 존재) | 다인실 요금 책정 불가, 패밀리룸·스위트룸 표현 불가 |
| C4 | Package 의미 역전 — Package가 RatePlan을 담는 컨테이너 | 글로벌 표준 정반대. OTA·GDS 어디서도 쓰지 않는 구조 |
| C5 | Adjustment 폴리모픽 과잉 — 단일 테이블이 channel/period/base 세 컨텍스트 동시 표현 | 의미 불명확, 디버깅 난이도 ↑, 계산 공식이 코드에만 존재 |
| C6 | CurrencySymbol 중복 저장 (5개 테이블) | 데이터 무결성 리스크. 변경 시 누락 위험 |
| C7 | checkInAt/checkOutAt 타입 불일치 — VARCHAR(8) vs VARCHAR(5) | 원본/히스토리 포맷 다름. 타임존·파싱 일관성 ↓ |
| C8 | Period 표현 잡종 — daysOfWeek/daysOfMonth/monthsOfYear JSON + 명시적 startDate/endDate + AriPeriodRange 동시 존재 | 우선순위·결합 규칙 스키마로 표현 불가 |
| C9 | 이벤트 소싱(Saga) 패턴 과적용 — CRUD 단순 케이스(AriPackage)에 saga 적용 | 응답 지연, 코드 복잡도 ↑, 학습 비용 ↑ |
| C10 | History의 정규화/JSON 혼재 | Immutability 깨짐. snapshot 의미 모호 |
1.2 글로벌 PMS 공통 패턴
| 시스템 | Rate 모델 핵심 구조 |
|---|
| Oracle Opera | RATE_HEADER → RATE_DETAIL(per date×room class) → RATE_RESTRICTIONS |
| Mews | RateGroup → Rate → RateValue(per date) + Restriction |
| Apaleo | RatePlan → Rate(per date) + Restriction, Promotion(derived) |
| Cloudbeds | Rate(plan) → RoomTypeRate(per date×roomType) |
| HTNG/OpenTravel | RatePlan + BookingRule + Restriction + Offer |
공통 패턴 5가지:
- RatePlan(설정 메타데이터) ↔ DailyRate(날짜별 가격) 명확 분리
- Master-Derived Rate 계층
- Restriction을 별도 도메인으로 격리
- Occupancy 가격 매트릭스
- Package는 RatePlan의 inclusion bundle 속성
2. Decision
2.1 전체 도메인 재설계 (Clean Slate)
운영 데이터가 없으므로 legacy 4개 도메인을 즉시 폐기하고 신규 5개 도메인으로 재구성한다.
제거:
AriRate, AriRateAdjustment, AriRateAdjustmentMapping
AriPackage (컨테이너 역할로서)
AriPeriod의 daysOfWeek/daysOfMonth/monthsOfYear JSON 필드
- 모든 테이블의
currencySymbol 컬럼
신규 (core-svc 내부):
RatePlan — 요금제 설정 (메타데이터, 정책, 기본 가격)
DailyRate — 일별 가격 매트릭스 (PMS의 심장)
RatePlanRestriction — 날짜별 판매 통제
RatePlanOccupancyPrice — 점유 인원별 가격
Inclusion, RatePlanInclusion — 부가 서비스 (override 지원)
RateSeason — 시즌/기간 (iCal RRULE 활용)
Audit 처리: core-svc 내 AuditLog 테이블을 두지 않는다. 모든 사용자/시스템 조작은 @vpms-cluster/audit 라이브러리를 통해 audit-svc 로 통합 발행한다. (§2.8 참고)
2.2 Storage Model: Hybrid
| 항목 | 결정 |
|---|
| 기본값 위치 | RatePlan.defaultAmount |
| 일별 영속화 위치 | DailyRate row |
DailyRate 의미 | 특정 stayDate의 최종 판매가 snapshot |
| Resolution 우선순위 | DailyRate row 존재 → 사용 → parent ratePlan + derivationRule → defaultAmount + occupancyPrice |
근거:
- 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) 로 표현하는 것이 권장 패턴.
- 금액 계산 결과는 통화 반올림 규칙(예: KRW 절사, 소수 통화는 scale 유지)에 따라 normalize 한다.
2.2.2 Materialization Rules
DailyRate row는 다음 경우에 생성 또는 갱신한다.
- 사용자가 특정 날짜 가격을 직접 수정한 경우
- 시즌/프로모션/일괄 인상 등으로 특정 날짜 범위 가격을 확정 적용한 경우
- horizon extension job이 미래 기간 기본값을 선계산하도록 선택한 경우
RatePlan.defaultAmount, derivationRule, occupancyPrice 변경 시 이미 존재하는 미래 DailyRate row는 자동 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)
stopSell
minLengthOfStay
maxLengthOfStay
- 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개월
| 근거 | 내용 |
|---|
| Booking.com 요구 | 최소 16개월 forward availability |
| 마진 | 장기 예약·MICE 대응 위해 8개월 추가 |
| Storage 예측 | 1,000 시설 × 10 roomType × 5 ratePlan × sparse 30% × 730일 ≈ 16M rows (~2.4GB) |
Daily cron으로 매일 자정 horizon을 24개월로 롤링 유지한다. 다만 이는 운영 기본값이며, Phase 1 acceptance criteria는 아니다.
2.5 History Retention
| 계층 | 기간 | 저장 위치 |
|---|
| Hot history | 13개월 | 동일 테이블 (PostgreSQL native partitioning) |
| Cold archive | 7년 | 별도 archive 테이블 (TimescaleDB 압축 또는 S3 Parquet) |
월별 cron으로 13개월 이전 partition을 archive로 이관한다. archive 파이프라인은 Phase 4에서 도입한다.
2.6 PostgreSQL Partitioning
CREATE TABLE daily_rates (
-- columns ...
) PARTITION BY RANGE (stay_date);
-- 월별 자동 파티션 (pg_partman 또는 manual cron)
- 파티션 키:
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)
| 항목 | 결정 |
|---|
| 키 패턴 | rate:{accommodationId}:{ratePlanId}:{roomTypeId}:{YYYYMM} |
| 자료형 | Hash (field=day 01~31, value=amount) |
| TTL | 1시간 |
| 무효화 | DailyRate write 시 pub/sub로 즉시 invalidate |
Redis 단독 저장 거부 이유:
- Durability(ACID) 보장 부족 → 가격 데이터 손실 위험
- Audit trail 불가
- BI·OLAP 연동 불가
- 메모리 비용 (1B rows × 100B = 100GB RAM → ~$5,000/월)
PostgreSQL이 source of truth, Redis는 hot path 가속 전용이다. Redis 미가용 시 resolver는 직접 DB 조회로 fallback 해야 하며, 캐시 미스는 오류가 아니라 성능 저하로 취급한다.
2.8 Event Sourcing(Saga) 폐기 + audit-svc 통합 발행
| 변경 | 내용 |
|---|
| 폐기 | AriPackage CRUD의 saga 패턴 제거. 일반 transactional write로 단순화 |
| 유지 검토 | DailyRate 대량 변경(시즌 일괄 적용 등)은 BullMQ job으로 비동기 처리 |
| 신규 | core-svc 내에 AuditLog 테이블을 두지 않는다. 사용자/시스템 조작은 모두 @vpms-cluster/audit 라이브러리를 통해 audit-svc 로 발행한다 |
Audit 책임 분리 원칙
| 책임 | 위치 |
|---|
| Audit log source of truth (저장, 조회, 이상 탐지) | apps/audit-svc |
| Audit 클라이언트 (publish API, GraphQL directive, 타입 정의) | libs/audit (@vpms-cluster/audit) |
| Audit 발행 (rate domain mutation 시점) | apps/core-svc/src/domains/rate-plan/** 및 관련 도메인 service layer |
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 / createdAt
AuditContext — 동일 요청/트랜잭션 내 여러 audit log 를 한 묶음으로 연결하는 컨테이너
- 익명 탐지(
isAnomaly, anomalyReason) 후크
- 시설(accommodationId) 단위 인덱스 다수
본 ADR 의 rate domain 변경은 이 모델을 그대로 활용하며 audit-svc schema 를 수정하지 않는다.
Audit 발행 전략
| 변경 유형 | Audit 발행 | 방법 |
|---|
| 사용자가 GraphQL mutation 호출 | ✅ 필수 | @audit GraphQL directive 또는 resolver 내 publishAuditLog 호출 |
| Cron job (horizon extension, archive) | ✅ 기록 (actionType=EVENT) | service layer 에서 명시적 publishAuditLog |
| Channel manager pull-in | ✅ 기록 (actionType=EVENT, eventName=‘CHANNEL_PULL’) | 통합 어댑터에서 명시적 호출 |
| Derivation cascade (parent ratePlan 변경 → 자식 cascade) | ✅ 묶음 기록 | 단일 contextId 로 묶어 publishAuditLogBatch |
| 재물질화 일괄 적용 (ADR-0002 §2.4) | ✅ 묶음 기록 | 동일 contextId + 영향 cell 수 요약을 newData 에 포함 |
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 등)에 보조 필드를 둔다:
lastModifiedBy Bytes? @database.ByteA // FK to User
updatedAt DateTime @updatedAt
lastModifiedReason String? @database.VarChar(255)
이는 캐시/최적화 목적이지 audit source of truth 가 아니다. 전체 변경 이력은 항상 audit-svc 에서 조회한다.
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 → 840
11: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 매핑)
이번 ADR은 기반 구조(Rate + DailyRate + Restriction + Inclusion + Audit) 까지만 다룬다.
추가로 다음 운영 최적화는 채택 방향은 유지하되 Phase-gated implementation 으로 취급한다:
- Redis hot cache
- PostgreSQL partitioning 자동화
- Horizon extension cron
- Cold archive 파이프라인
즉, Phase 1의 완료 기준은 “신규 도메인 모델 + 가격/resolution contract + restriction contract + audit contract” 이며, 운영 최적화는 후속 Phase에서 순차 도입한다.
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
| Risk | Mitigation |
|---|
| 파티션 누락 → 미래 날짜 insert 실패 | pg_partman 자동 생성 + alert |
| Redis cache poisoning | TTL 1h 강제 + write 직후 invalidate |
| AuditLog 무제한 증가 | 1년 후 cold archive 이관 정책 (별도 ADR로 분리) |
| Derivation cascade infinite loop | parent 체인 최대 깊이 5 제한 (RatePlan 자체 검증) |
| 기본값 변경 후 미래 가격 불일치 | 영향 범위 미리보기 + 비동기 재산정 job + 명시적 사용자 승인 |
| Restriction 충돌 규칙 모호 | stopSell 최우선, LOS는 보수적 값 우선 규칙을 ADR에 명시 |
| 다국가 진출 시 timezone | 모든 시간은 시설 로컬 타임존 기준. 표시는 사용자 타임존으로 변환 |
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 | 작업 | 예상 |
|---|
| 1 | Prisma 스키마 작성 (legacy drop + 신규 7개 모델, resolution/restriction contract 반영) | 3일 |
| 2 | RateResolverService + AuditInterceptor | 3일 |
| 3 | GraphQL schema + resolver 재구현 | 3일 |
| 4 | Redis 캐시 + horizon extension + archive/partition 운영 작업 | 2일 |
| Total | | ~11일 |
각 Phase는 별도 PR로 분리. Phase 1 PR이 base가 되어 후속 PR이 stack.
7. References