Room.cleanState/Room.state/Room.lastCleanedAt이 언제·누구에 의해
바뀌는지, 클라이언트에 어떻게 노출되는지, 그리고 “자동청소 정책”이 여기에 어떻게(안) 관여하는지
파악할 수 있도록 정리한다.
1. 데이터 모델
apps/core-svc/src/domains/room/schema.prisma:
apps/core-svc/src/domains/base.prisma:
state— 객실의 물리적 사용 상태 (투숙 중/미사용/전원차단 등). 자동청소 정책과 무관.cleanState— 객실의 청소 상태. 이 문서의 핵심.lastCleanedAt—cleanState가CLEAN으로 바뀐 마지막 시각.CLEAN전환이 아니면 갱신되지 않는다. 자동청소 정책의 idempotency(“오늘 이미 청소됐는가”) 판단에 쓰이는 유일한 필드.
CalculatedState(GraphQL enum, room.gql)는 DB 컬럼이 아니라 매 조회 시점에 계산되는 파생 값이다
(§3.2).
2. 쓰기 경로 — 전부 이벤트소싱
cleanState/state/lastCleanedAt은 오직 room.saga.ts(ROOM aggregate)의 두 이벤트 핸들러를
통해서만 바뀐다. 리졸버가 직접 prisma.room.update를 호출하는 경로는 없다.
2.1 CLEAN_STATE_CHANGED 핸들러
cleanState가 CLEAN으로 바뀔 때만 lastCleanedAt도 함께 갱신된다.
2.2 STATE_CHANGED_BY_STAFF 핸들러
state와 cleanState를 각각 독립적으로, 페이로드에 존재할 때만 갱신한다.
현재 이 이벤트를 발행하는 유일한 호출자(setRoomStateByStaff, §2.3)는 cleanState만 보내므로
실질적으로 state 분기는 항상 no-op이지만, 핸들러 구조상 두 필드는 서로 독립이다.
2.3 core-svc 안에서 이 이벤트들을 발행하는 곳 (전체 5곳)
즉 core-svc가 직접 아는 범위에서는
NEED_CLEANING이 “퇴실(혹은 방 이동으로 인한 공실화)”
시점에만 쓰인다. CLEAN도 마찬가지로 core-svc 안에서는 setRoomCleanState/setRoomStateByStaff,
즉 사람(스태프/메이드)의 명시적 조작으로만 쓰인다. CCU가 별도 경로로 쓰는지는 §2.4 참고.
2.4 saga의 부수효과 — CCU 물리 신호 전송
ROOM aggregate의 모든build 호출마다 sendRoomControlByEvent 프로세서가 무조건 실행되어,
방금 반영된 state/cleanState에 대응하는 RCU_CONTROL 패킷을 CCU로 쏜다:
3. 읽기 경로 — 클라이언트에 노출되는 두 계산 필드
3.1 Room.cleanState 필드 리졸버 — 카드 상태 오버라이드
CardState.CLEAN), DB 값과 무관하게 무조건
CLEANING을 반환한다. 그 외에는 DB의 원본 값을 그대로 내려준다.
3.2 Room.calculatedState — 우선순위 캐스케이드
DB 컬럼이 아니라 매 조회 시 계산되는 값. 순서대로 먼저 매칭되는 조건이 이긴다:
state === SELECTED→selectedstate === POWER_DOWN→powerDown- (타임라인/아웃오브세일 없음 + Redis 상 임시점유 있음) →
ephemeralOccupied - 아웃오브세일/아웃오브오더 활성 →
outOfService(Suspicious)/outOfOrder(Suspicious) - 활성 예약 타임라인 있음(현재/최근 종료분 포함):
CHECKED_IN+state === LEFT→left{Lodge|Rent|LongTerm}CHECKED_IN+ 퇴실예정시각 +assumeCheckoutBefore지남 + 카드 NULL +!defaultCheckedIn→expiredEstCHECKED_IN→using{Lodge|Rent|LongTerm}- 그 외(미입실) →
reserved{Lodge|Rent|LongTerm}
- 배정된 예정 예약(미입실) 있음 →
reserved{...} - 타임라인 없음 +
state ∈ {USING, LEFT}: 카드=GUEST →usingExpired, 카드≠NULL →usingUnknown, 아니면expired - 타임라인 없음 + 카드=CLEAN(카드리더 이상) →
cleaning - 여기까지 아무 것도 안 걸리면 비로소
cleanState기반 매핑:DIRTY→availableDirty,NEED_CLEANING→needCleaning,URGENT_CLEANING→urgentCleaning,INSPECTION→inspection,NEED_INSPECTION→needInspection,CLEANING→cleaning - 기본값 →
available
calculatedState는 §3.1의 카드 오버라이드를 거치지 않은 **원본 cleanState(parent 값)**를
직접 참조한다 — 이미 자체적으로 카드 상태(4, 7, 8단계)를 더 정교하게 반영하기 때문이다.
4. 자동청소 정책(needsAutoCleaning) — 완전히 별개의 read-only 기능
4.1 무엇인가
getReservations는 filter 인자로 needsAutoCleaning: Boolean을 받는다(입력값이지, 응답
필드가 아니다). getReservations(filter: { needsAutoCleaning: true })처럼 호출하면, 결과 목록이
“지금 자동청소 정책상 청소 대상인 예약”만으로 좁혀져서 반환된다 — 다른 필터 조건(날짜 범위 등)과
동일하게 그냥 WHERE 절 하나 더 붙는 것이다.
이 필터는 Room.cleanState를 절대 읽지도 쓰지도 않는다 — 완전히 무관하다. 오직 “이 예약이
지금 정책상 청소 대상인가”를 매 조회 시점에 계산할 뿐이며, 그 계산 결과로 실제 DB에 쓰기가
일어나는 곳은 어디에도 없다(순수 조회). 클라이언트는 이 필터로 좁혀진 목록을 “청소 예정” 화면에
그대로 표시하는 용도로만 쓴다.
“오늘”의 기준: 판정에 쓰이는 “오늘”은 매 호출 시점의 실시간 현재 시각을 업장 타임존 기준
캘린더 날짜로 변환한 값이다 — night-audit 도메인이 일마감 처리로만 전진시키는 영속화된
영업일자와는 별개다. 즉 일마감이 며칠 밀려 있어도 이 필터는 그와 무관하게 항상 실제 오늘
날짜를 기준으로 판정한다.
4.2 구현 (apps/core-svc/src/domains/reservation/modules/reservation-filter.ts)
evaluateNeedsAutoCleaning(reservation, evalContext):
- 정책 꺼짐 /
CHECKED_IN아님 / 취소됨 / 객실 미배정 / 오늘 재실 중 아님 →false getCleaningDueDateStr(...)로 “청소 예정일”을 계산 → 없으면(null)falseRoom.lastCleanedAt >= 그 예정일의 시작 시각이면 이미 충족된 것으로 보고false, 아니면true
getCleaningDueDateStr — 순수 날짜 계산 함수, DB 접근 없음:
totalNights < minNights면 대상 아님(null)- 체크인일 기준 1부터 세는
nightNumber가intervalNights미만이면 아직 대상 아님(null) - 체크아웃 당일 이후면 대상 아님(
null) — 체크아웃 당일 자체도 제외(엄격 경계) - 그 외에는 가장 최근에 도래한
intervalNights의 배수 날짜를 “청소 예정일”로 반환
핵심 동작 — 놓친 청소는 사라지지 않는다. 정확히 그 배수 날짜 하루만 대상으로 보는 게 아니라 “가장 최근 도래한 배수 날짜” 자체를 반환하므로, 예정일에 청소를 안 하면 다음 배수가 오기 전까지 계속 청소 대상으로 남는다. 반대로 예정일에 맞춰 실제로CLEAN전환(→lastCleanedAt갱신)이 일어나면 다음 배수 전까지는 다시 뜨지 않는다.
4.3 정책 값 검증
validateAutoCleaningPolicyInputs(설정 변경 시점, setRoomSettings 뮤테이션에서 호출):
autoCleaningMinNights≥ 1autoCleaningIntervalNights≥ 1autoCleaningIntervalNights≤autoCleaningMinNights— 둘 중 하나라도 이번 변경에 포함된 경우에만 검사(관련 없는 다른 설정 변경까지 막지 않기 위해). 이 관계가 깨지면 minNights를 충족하는 일부 예약이 체크아웃할 때까지 단 한 번도 청소 예정일에 도달하지 못하는 모순이 생기기 때문.
4.4 관련 없는 값들과의 구분 (자주 헷갈리는 포인트)
5. 알려진 데드코드 (정리 대상, 동작에 영향 없음)
expireReservation.processor.ts에 아래 주석 블록이 남아있다:
return; 때문에 주석을 풀어도 도달 불가능한 코드이고, 애초에 ROOM.STATE_CHANGED/RoomState에
'NEED_CLEANING'(존재하지 않는 RoomState 값)을 쓰는 등 지금 구조와도 맞지 않는 훨씬 이전
아이디어의 흔적이다. §4의 현재 설계와는 무관하며 언제든 지워도 안전하다.