Skip to main content
대상 독자: core-svc의 Room/Reservation 도메인을 수정하는 개발자. 이 문서 하나만 읽으면 Room.cleanState/Room.state/Room.lastCleanedAt이 언제·누구에 의해 바뀌는지, 클라이언트에 어떻게 노출되는지, 그리고 “자동청소 정책”이 여기에 어떻게(안) 관여하는지 파악할 수 있도록 정리한다.

1. 데이터 모델

apps/core-svc/src/domains/room/schema.prisma:
apps/core-svc/src/domains/base.prisma:
  • state — 객실의 물리적 사용 상태 (투숙 중/미사용/전원차단 등). 자동청소 정책과 무관.
  • cleanState — 객실의 청소 상태. 이 문서의 핵심.
  • lastCleanedAtcleanStateCLEAN으로 바뀐 마지막 시각. 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 핸들러

cleanStateCLEAN으로 바뀔 때만 lastCleanedAt도 함께 갱신된다.

2.2 STATE_CHANGED_BY_STAFF 핸들러

이벤트 이름과 달리 statecleanState각각 독립적으로, 페이로드에 존재할 때만 갱신한다. 현재 이 이벤트를 발행하는 유일한 호출자(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 컬럼이 아니라 매 조회 시 계산되는 값. 순서대로 먼저 매칭되는 조건이 이긴다:
  1. state === SELECTEDselected
  2. state === POWER_DOWNpowerDown
  3. (타임라인/아웃오브세일 없음 + Redis 상 임시점유 있음) → ephemeralOccupied
  4. 아웃오브세일/아웃오브오더 활성 → outOfService(Suspicious) / outOfOrder(Suspicious)
  5. 활성 예약 타임라인 있음(현재/최근 종료분 포함):
    • CHECKED_IN + state === LEFTleft{Lodge|Rent|LongTerm}
    • CHECKED_IN + 퇴실예정시각 + assumeCheckoutBefore 지남 + 카드 NULL + !defaultCheckedInexpiredEst
    • CHECKED_INusing{Lodge|Rent|LongTerm}
    • 그 외(미입실) → reserved{Lodge|Rent|LongTerm}
  6. 배정된 예정 예약(미입실) 있음 → reserved{...}
  7. 타임라인 없음 + state ∈ {USING, LEFT}: 카드=GUEST → usingExpired, 카드≠NULL → usingUnknown, 아니면 expired
  8. 타임라인 없음 + 카드=CLEAN(카드리더 이상) → cleaning
  9. 여기까지 아무 것도 안 걸리면 비로소 cleanState 기반 매핑: DIRTY→availableDirty, NEED_CLEANING→needCleaning, URGENT_CLEANING→urgentCleaning, INSPECTION→inspection, NEED_INSPECTION→needInspection, CLEANING→cleaning
  10. 기본값 → available
calculatedState는 §3.1의 카드 오버라이드를 거치지 않은 **원본 cleanState(parent 값)**를 직접 참조한다 — 이미 자체적으로 카드 상태(4, 7, 8단계)를 더 정교하게 반영하기 때문이다.

4. 자동청소 정책(needsAutoCleaning) — 완전히 별개의 read-only 기능

4.1 무엇인가

getReservationsfilter 인자로 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):
  1. 정책 꺼짐 / CHECKED_IN 아님 / 취소됨 / 객실 미배정 / 오늘 재실 중 아님 → false
  2. getCleaningDueDateStr(...)로 “청소 예정일”을 계산 → 없으면(null) false
  3. Room.lastCleanedAt >= 그 예정일의 시작 시각 이면 이미 충족된 것으로 보고 false, 아니면 true
getCleaningDueDateStr순수 날짜 계산 함수, DB 접근 없음:
  • totalNights < minNights 면 대상 아님(null)
  • 체크인일 기준 1부터 세는 nightNumberintervalNights 미만이면 아직 대상 아님(null)
  • 체크아웃 당일 이후면 대상 아님(null) — 체크아웃 당일 자체도 제외(엄격 경계)
  • 그 외에는 가장 최근에 도래한 intervalNights의 배수 날짜를 “청소 예정일”로 반환
핵심 동작 — 놓친 청소는 사라지지 않는다. 정확히 그 배수 날짜 하루만 대상으로 보는 게 아니라 “가장 최근 도래한 배수 날짜” 자체를 반환하므로, 예정일에 청소를 안 하면 다음 배수가 오기 전까지 계속 청소 대상으로 남는다. 반대로 예정일에 맞춰 실제로 CLEAN 전환(→ lastCleanedAt 갱신)이 일어나면 다음 배수 전까지는 다시 뜨지 않는다.

4.3 정책 값 검증

validateAutoCleaningPolicyInputs(설정 변경 시점, setRoomSettings 뮤테이션에서 호출):
  • autoCleaningMinNights ≥ 1
  • autoCleaningIntervalNights ≥ 1
  • autoCleaningIntervalNightsautoCleaningMinNights — 둘 중 하나라도 이번 변경에 포함된 경우에만 검사(관련 없는 다른 설정 변경까지 막지 않기 위해). 이 관계가 깨지면 minNights를 충족하는 일부 예약이 체크아웃할 때까지 단 한 번도 청소 예정일에 도달하지 못하는 모순이 생기기 때문.

4.4 관련 없는 값들과의 구분 (자주 헷갈리는 포인트)


5. 알려진 데드코드 (정리 대상, 동작에 영향 없음)

expireReservation.processor.ts에 아래 주석 블록이 남아있다:
return; 때문에 주석을 풀어도 도달 불가능한 코드이고, 애초에 ROOM.STATE_CHANGED/RoomState'NEED_CLEANING'(존재하지 않는 RoomState 값)을 쓰는 등 지금 구조와도 맞지 않는 훨씬 이전 아이디어의 흔적이다. §4의 현재 설계와는 무관하며 언제든 지워도 안전하다.

6. 요약