Skip to main content

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 구조적 결함

#결함영향
C1Daily Rate 매트릭스 부재(ratePlanId, roomTypeId, date) → amount 테이블 없음OTA 채널 매니저 일별 push 불가, Yield Engine 연동 불가, 일별 캘린더 조회 매번 런타임 계산
C2Restriction(CTA/CTD/StopSell/MinLOS) 부재PMS 인증 자체가 불가능. 채널 매니저 연동의 전제 조건 미충족
C3Occupancy 기반 가격 부재baseOccupancy 필드 자체가 스키마에 없음 (mdx 문서엔 존재)다인실 요금 책정 불가, 패밀리룸·스위트룸 표현 불가
C4Package 의미 역전 — Package가 RatePlan을 담는 컨테이너글로벌 표준 정반대. OTA·GDS 어디서도 쓰지 않는 구조
C5Adjustment 폴리모픽 과잉 — 단일 테이블이 channel/period/base 세 컨텍스트 동시 표현의미 불명확, 디버깅 난이도 ↑, 계산 공식이 코드에만 존재
C6CurrencySymbol 중복 저장 (5개 테이블)데이터 무결성 리스크. 변경 시 누락 위험
C7checkInAt/checkOutAt 타입 불일치 — VARCHAR(8) vs VARCHAR(5)원본/히스토리 포맷 다름. 타임존·파싱 일관성 ↓
C8Period 표현 잡종daysOfWeek/daysOfMonth/monthsOfYear JSON + 명시적 startDate/endDate + AriPeriodRange 동시 존재우선순위·결합 규칙 스키마로 표현 불가
C9이벤트 소싱(Saga) 패턴 과적용 — CRUD 단순 케이스(AriPackage)에 saga 적용응답 지연, 코드 복잡도 ↑, 학습 비용 ↑
C10History의 정규화/JSON 혼재Immutability 깨짐. snapshot 의미 모호

1.2 글로벌 PMS 공통 패턴

시스템Rate 모델 핵심 구조
Oracle OperaRATE_HEADERRATE_DETAIL(per date×room class) → RATE_RESTRICTIONS
MewsRateGroupRateRateValue(per date) + Restriction
ApaleoRatePlanRate(per date) + Restriction, Promotion(derived)
CloudbedsRate(plan) → RoomTypeRate(per date×roomType)
HTNG/OpenTravelRatePlan + BookingRule + Restriction + Offer
공통 패턴 5가지:
  1. RatePlan(설정 메타데이터) ↔ DailyRate(날짜별 가격) 명확 분리
  2. Master-Derived Rate 계층
  3. Restriction을 별도 도메인으로 격리
  4. Occupancy 가격 매트릭스
  5. Package는 RatePlan의 inclusion bundle 속성

2. Decision

2.1 전체 도메인 재설계 (Clean Slate)

운영 데이터가 없으므로 legacy 4개 도메인을 즉시 폐기하고 신규 5개 도메인으로 재구성한다. 제거:
  • AriRate, AriRateAdjustment, AriRateAdjustmentMapping
  • AriPackage (컨테이너 역할로서)
  • AriPerioddaysOfWeek/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 + derivationRuledefaultAmount + occupancyPrice
근거:
  • Dense(전체 펼치기)는 가격 변동 적은 시설(펜션·모텔)에 storage 낭비
  • Sparse(변경분만)는 OTA push 시 매번 resolve 부담
  • Hybrid는 운영 시점에 정책 전환 가능 (yield engine 도입 시 dense화)

2.2.1 Price Resolution Contract

DailyRate는 단순 override가 아니라, 특정 날짜에 예약 엔진과 OTA에 노출되는 resolved final amount의 영속 snapshot 으로 정의한다. 계산 순서는 다음과 같다:
  1. DailyRate(ratePlanId, roomTypeId, stayDate) row가 존재하면 그 값을 최종값으로 사용한다.
  2. row가 없고 parentRatePlanId가 있으면 부모 RatePlan의 동일 날짜 가격을 기준으로 derivationRule을 적용한다.
  3. 부모가 없으면 RatePlan.defaultAmount를 기준으로 사용한다.
  4. RoomType.defaultSleeps 초과 인원이 있으면 RatePlanOccupancyPrice를 적용한다.
    • 점유 기준(base/max occupancy)은 RatePlan 이 아니라 RoomTypedefaultSleeps/maxSleeps 가 source of truth 다. 같은 RatePlan 이 정원이 다른 여러 RoomType 에 매핑될 때의 모순을 피하기 위함.
    • RatePlanOccupancyPrice 는 sparse 하게 입력한다 (필요한 (roomType, occupancy) 슬롯만). 단순한 1인당 추가요금은 RatePlanGuestTypeAdjustment(adult, appliesAfterBaseOccupancy=true) 로 표현하는 것이 권장 패턴.
  5. 금액 계산 결과는 통화 반올림 규칙(예: 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

  1. 동일 날짜에 stopSell=true가 존재하면 다른 restriction보다 항상 우선한다.
  2. minLengthOfStay, maxLengthOfStay는 더 보수적인 값이 우선한다.
  3. 부모 RatePlan과 자식 RatePlan이 동시에 restriction을 가지는 경우, 자식 row가 있으면 자식 값을 사용한다.
  4. 자식 row가 없으면 부모 restriction을 상속한다.
  5. 채널별 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 history13개월동일 테이블 (PostgreSQL native partitioning)
Cold archive7년별도 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)
TTL1시간
무효화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/auditpublishAuditLog / 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, checkOutTimeInt (minutes from midnight) 로 통일:
  • 14:00840
  • 11:30690
  • 타임존 영향 없음, 비교·정렬 자명, 파싱 불필요

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

RiskMitigation
파티션 누락 → 미래 날짜 insert 실패pg_partman 자동 생성 + alert
Redis cache poisoningTTL 1h 강제 + write 직후 invalidate
AuditLog 무제한 증가1년 후 cold archive 이관 정책 (별도 ADR로 분리)
Derivation cascade infinite loopparent 체인 최대 깊이 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작업예상
1Prisma 스키마 작성 (legacy drop + 신규 7개 모델, resolution/restriction contract 반영)3일
2RateResolverService + AuditInterceptor3일
3GraphQL schema + resolver 재구현3일
4Redis 캐시 + horizon extension + archive/partition 운영 작업2일
Total~11일
각 Phase는 별도 PR로 분리. Phase 1 PR이 base가 되어 후속 PR이 stack.

7. References