콘텐츠로 이동

[운영 확장 4] CRM 프로필이 바뀌어도 확정 예약은 자동으로 변경하지 않는다

CRM 변경 신호를 받은 로봇 운영자가 재평가 가능한 예약 카드와 보호된 확정 예약을 나누어 확인하는 장면
프로필이 바뀌었다는 사실은 예약을 다시 검토할 이유가 될 수 있습니다. 그렇다고 이미 확정한 예약까지 자동으로 변경하면 안 됩니다.

CRM에서 환자 프로필이 변경됐습니다. 변경 내용은 예약 판단에 영향을 줄 수 있으므로, 예약 서비스도 현재 제안과 보류(hold)를 다시 살펴봐야 합니다. 하지만 이때 예약 서비스가 CRM의 원본 프로필을 가져와 저장하거나, 이미 환자와 병원이 합의한 확정 예약을 자동으로 다른 시간으로 변경하면 책임 경계가 무너집니다.

이 글의 결론은 단순합니다.

CRM 프로필 변경은 예약 재평가의 입력 신호이지, 확정 예약을 자동으로 변경할 수 있는 명령이 아닙니다. PROPOSEDHELD만 자동 재평가하고, CONFIRMED는 현재 예약과 연결된 동의·자원 점유를 보호해야 합니다.

이번 글은 CRM이 어떤 모델로 assessment를 계산하는지는 다루지 않습니다. 예약 서비스가 받아야 할 최소 이벤트, 재평가 작업의 상태와 결과, 병원별 공정한 dispatch, 실패 복구, STAFF 운영 화면에서 다음에 할 작업을 현재 구현과 운영 런북을 기준으로 설명합니다.

운영 화면에서 먼저 확인할 것은 프로필 원문이 아니라 작업 상태다

섹션 제목: “운영 화면에서 먼저 확인할 것은 프로필 원문이 아니라 작업 상태다”

STAFF가 프로필 변경을 확인할 때 가장 먼저 필요한 정보는 환자의 이름이나 점수가 아닙니다. 지금 몇 건이 대기 중인지, 어떤 작업이 처리 중인지, 다음 시도를 기다리는 작업과 운영자 확인이 필요한 실패가 각각 몇 건인지가 먼저 보여야 합니다.

이번 글의 운영 화면 시안은 ProfileReevaluationOperationalSnapshot에서 제공하는 다음 값을 상단에 배치합니다.

  • PENDING: 아직 처리하지 않은 재평가 작업 수
  • RUNNING: 워커가 lease를 확보해 처리 중인 작업 수
  • RETRY_WAIT: 일시적인 실패 뒤 다음 시도를 기다리는 작업 수
  • FAILED: 자동 복구 한도를 소진해 운영자 조치가 필요한 작업 수
  • 활성 lease 수와 가장 오래 기다린 backlog의 대기 시간
  • lease 갱신 실패 수와 연속 assessment 실패 수
  • drainState: ACTIVE, DRAINING, DRAINED

화면의 조치 큐는 단순한 목록과 다릅니다. 목록은 현재 상태를 보여주지만, 조치 큐는 STAFF가 무엇부터 확인할지 정렬해 보여줍니다. 그래서 각 행에 targetRevision, priorityClass, dueAt, nextAttemptAt, 마지막 실패 코드와 결과 개수를 함께 표시합니다. HELD_PRESENT를 먼저 처리하고 PROPOSED_ONLY를 뒤따르게 하는 이유도 이 큐의 우선순위에서 드러납니다.

PENDING, RUNNING, RETRY_WAIT, FAILED 상태 카드와 활성 lease, backlog, 조치 큐, 선택한 재평가 작업 상세, STAFF 미리보기와 ADMIN 제한 redrive를 보여주는 프로필 재평가 운영 화면 시안

현재 구현의 운영 기준 데이터와 endpoint 계약을 바탕으로 만든 시안입니다. 수치는 예시이며 환자 이름·연락처·원본 프로필·assessment 본문은 표시하지 않습니다. STAFF는 조회와 미리보기만 하고, ADMIN만 인증된 범위에서 제한적으로 redrive를 실행합니다.

운영 화면 오른쪽 상세 패널에는 다음 정보만 표시합니다.

  • tenant group와 clinic으로 제한한 운영 범위
  • 재평가 대상 profileRevision
  • targetPolicyReftargetPolicyGeneration
  • 현재 작업 상태와 최근 outcome
  • dueAt, nextAttemptAt, 마지막 실패 코드, 결과 개수

반대로 원본 프로필, 특징 값, 점수, 설명, assessment 본문, 환자 이름과 전화번호는 표시하지 않습니다. 운영자가 다음 작업을 결정하는 데 필요하지 않고, 예약 서비스가 해당 데이터의 기준 데이터 원본이 아니기 때문입니다.

CRM 이벤트는 최소 정보로 예약 재평가를 시작한다

섹션 제목: “CRM 이벤트는 최소 정보로 예약 재평가를 시작한다”

현재 이벤트 모델은 PatientSchedulingAssessmentChanged입니다. 이름 그대로 CRM에서 예약 적합성 assessment가 바뀌었다는 신호를 전달하지만, 프로필 자체를 전달하지는 않습니다.

data class PatientSchedulingAssessmentChanged(
val eventId: String,
val tenantGroupId: Long,
val clinicId: Long,
val patientReferenceFingerprint: String,
val profileRevision: Long,
val materialChange: Boolean,
val assessmentRef: String,
val assessmentHash: String,
val occurredAt: Instant,
)

이 이벤트가 예약 서비스 경계로 전달하는 정보는 다음과 같습니다.

  1. 이벤트 중복을 제거하고 감사 추적에 사용할 eventId
  2. 처리 범위를 정할 tenantGroupIdclinicId
  3. 환자를 직접 식별하지 않는 소문자 SHA-256 형식의 patientReferenceFingerprint
  4. 최신 여부를 판단할 profileRevision
  5. 실제 assessment를 조회할 assessmentRef와 내용을 검증할 assessmentHash
  6. 이 변경이 예약 판단에 영향을 주는지 나타내는 materialChange
  7. 원본 이벤트의 occurredAt

원본 프로필·객관적 특징·점수·설명·교정값은 이 이벤트에 없습니다. patientReferenceFingerprint가 있다고 해서 예약 서비스가 환자 식별자를 복원할 수 있는 것도 아닙니다. 이 경계를 지키면 CRM은 프로필과 assessment의 기준 데이터 원본으로 남고, 예약 서비스는 예약 상태와 자원 점유를 책임질 수 있습니다.

assessmentHash는 “CRM이 보낸 결과를 믿는다”는 뜻이 아닙니다. 예약 서비스가 참조한 assessment가 이벤트가 가리킨 revision에 해당하는 결과인지 확인하기 위한 계약 값입니다. 신뢰성·schema·scope 검증을 통과하지 못한 이벤트는 재평가 작업으로 만들지 않고 조사 또는 quarantine 대상으로 분리해야 합니다.

최신 revision을 반영한 뒤 PROPOSED와 HELD만 대상으로 삼는다

섹션 제목: “최신 revision을 반영한 뒤 PROPOSED와 HELD만 대상으로 삼는다”

같은 환자 범위에 프로필 변경 이벤트가 짧은 시간에 여러 번 들어올 수 있습니다. 이때 이벤트마다 예약을 처음부터 모두 조회하면 오래된 revision이 최신 판단을 덮어쓸 수 있습니다. 그래서 저장 모델은 scope별 최신 head와 revision별 저장된 작업을 분리합니다.

tenantGroupId + clinicId + patientReferenceFingerprint
latestRevision head
revision별 reevaluation job

ProfileReevaluationScope는 tenant group·clinic·fingerprint를 하나의 범위로 묶습니다. fingerprint는 소문자 SHA-256 형식만 허용합니다. ProfileReevaluationHeadRecord는 이 scope에서 가장 최신으로 확인한 revision과 assessmentRef·hash를 가리키고, ProfileReevaluationJobRecord는 특정 revision을 처리할 저장된 작업입니다.

작업 상태는 처리 수명주기를 드러냅니다.

상태의미STAFF가 보는 다음 작업
PENDING처리 대기 중대상 revision과 dueAt 확인
RUNNING워커가 lease를 획득해 처리 중active lease와 처리 시간 확인
RETRY_WAIT일시적인 실패 뒤 cooldown 대기nextAttemptAt과 실패 코드 확인
COMPLETED최신 revision 처리를 끝냄결과 개수와 outbox 기록 확인
STALE더 최신 revision이 도착해 더 진행하지 않음최신 작업으로 이동
FAILED자동 복구 한도를 소진함원인 분류 후 preview 또는 조사

예약 한 건이 자동 재평가 대상인지 판단하는 규칙은 더 좁습니다.

val AppointmentCommitmentStatus.isProfileReevaluationEligible: Boolean
get() = this == AppointmentCommitmentStatus.PROPOSED ||
this == AppointmentCommitmentStatus.HELD

CONFIRMED가 빠져 있는 것은 구현 누락이 아니라 보호 규칙입니다. CONFIRMED는 환자와 병원이 이미 합의한 예약이며, 담당자·시간·장비 같은 자원 점유와도 연결돼 있습니다. 프로필 변경이 새 판단을 요구하더라도 기존 예약을 자동으로 변경하는 대신, 필요하면 별도의 제안을 만들고 환자 동의와 운영자 확정을 거쳐야 합니다.

assessment 조회와 최종 상태 결정은 한 작업으로 기록한다

섹션 제목: “assessment 조회와 최종 상태 결정은 한 작업으로 기록한다”

재평가 대상이 PROPOSEDHELD라고 해서 곧바로 새 상태로 변경하지는 않습니다. 워커는 병원별 공정한 dispatch로 작업을 선점하고, 이벤트가 가리키는 assessment를 조회한 뒤 policy reference와 generation을 확인합니다. 작업에는 heldTarget, proposedTarget, targetPolicyRef, targetPolicyGeneration, nextAttemptAt, lease 정보와 cursor가 함께 남습니다.

병원별 공정 dispatch는 단순히 ORDER BY id LIMIT N을 반복하는 방식이 아닙니다. ClaimProfileReevaluationJobs는 전체 limit과 병원별 limit을 따로 받고, ProfileReevaluationClinicCursor로 마지막 병원 다음부터 keyset 순서를 이어 갑니다. 한 병원의 backlog가 크다고 해서 작은 clinic ID만 계속 선택하지 않도록 하는 장치입니다.

assessment 결과가 도착하면 예약마다 outcome을 남깁니다.

outcome의미
PROPOSAL_SUPERSEDED기존 제안을 새 제안으로 대체
HOLD_KEPT현재 보류(hold)가 여전히 유효해 그대로 유지
HOLD_REPLACED기존 보류(hold)를 새 보류(hold)로 원자적으로 교체
FALLBACK_TO_PROPOSED대체 후보가 없어 보류를 해제하고 제안으로 복귀
SKIPPED_INELIGIBLE현재 상태가 자동 재평가 대상이 아니어서 건너뜀
SKIPPED_UNCHANGED평가 입력이 같아 예약을 변경하지 않음

이 결과를 “assessment가 통과했다” 또는 “예약이 확정됐다”로 한데 묶으면 안 됩니다. 예를 들어 HOLD_REPLACED는 보류 자원을 바꾼 결과일 뿐 CONFIRMED 예약을 만든 결과가 아닙니다. SKIPPED_INELIGIBLE도 오류가 아니라 현재 상태를 보호하기 위해 처리하지 않은 결과일 수 있습니다.

CRM 최소 이벤트가 예약 API와 재평가 워커를 거쳐 assessment를 조회하고, 최종 상태 결정에서 PROPOSED, HELD, CONFIRMED 보호, RETRY_WAIT 또는 QUARANTINE으로 나뉘는 시퀀스 도표
수평 점선에 의미를 맡기지 않고 최종 상태 결정을 별도 노드로 둡니다. PROPOSEDHELD는 재평가 결과를 기록할 수 있지만, CONFIRMED는 보호 경로로 분기합니다.

실패는 RETRY_WAIT와 QUARANTINE을 나눠 처리한다

섹션 제목: “실패는 RETRY_WAIT와 QUARANTINE을 나눠 처리한다”

모든 실패를 자동 재시도 대상으로 분류하면 운영자는 무엇이 일시적인 장애이고 무엇이 데이터·권한 사고인지 구분할 수 없습니다.

CRM assessment endpoint가 잠시 느리거나 DB가 일시적으로 오류를 반환한 경우에는 RETRY_WAIT로 보낼 수 있습니다. 작업은 attemptCount, nextAttemptAt, lastFailureCode, lease와 redrive lineage를 보존합니다. 자동 redrive도 설정된 횟수와 cooldown 안에서만 허용합니다.

서명·issuer·audience·schema·tenant/clinic 범위를 확인할 수 없거나, 원본 프로필이 경계를 넘어온 정황이 있거나, 반복 quarantine이 발생하면 일반 retry로 처리하지 않습니다. consumer를 중단하고 증거를 보존한 뒤 CRM 팀 및 보안 당직자와 함께 조사해야 합니다.

운영 화면의 FAILED 행에는 예외 원문이나 환자 정보 대신 길이를 제한한 실패 코드만 표시합니다. 예를 들어 ASSESSMENT_TIMEOUT은 제한된 retry 후보가 될 수 있지만, PRIVACY_BOUNDARY는 운영자가 먼저 조사해야 할 항목입니다.

운영 endpoint는 STAFF 읽기와 ADMIN 실행을 분리한다

섹션 제목: “운영 endpoint는 STAFF 읽기와 ADMIN 실행을 분리한다”

운영 상태와 redrive는 일반 예약 API가 아니라 Spring Actuator endpoint로 분리되어 있습니다.

GET /actuator/profileReevaluation
POST /actuator/profileReevaluation

읽기 작업은 ProfileReevaluationOperationalSnapshot을 반환합니다. 쓰기 작업은 ProfileReevaluationAdminAction과 reason, idempotency key, tenant·clinic scope, target revision, limit을 받습니다. 하지만 요청 본문에 포함된 actor를 그대로 신뢰하지 않습니다. ProfileReevaluationAdminActorResolver는 인증된 SchedulingUserPrincipal에서만 감사 주체를 가져오고, issuer·token ID·인증 시각·허용 clinic 목록을 확인합니다.

런북의 실행 순서는 다음과 같습니다.

  1. ADMIN 역할과 SCOPE_profile-reevaluation:operate 권한을 확인합니다.
  2. tenantGroupIdclinicId를 반드시 지정하고, 인증 주체가 해당 clinic을 대상으로 작업할 권한이 있는지 확인합니다.
  3. PREVIEW로 실제 대상과 target revision을 확인합니다.
  4. preview 결과가 승인한 범위와 일치할 때만 EXECUTE를 수행합니다.
  5. 실행 후 새 job lineage, outcome, backlog, CONFIRMED 변경 여부를 다시 확인합니다.

“전체 다시 시도” 버튼을 만들지 않은 이유도 같습니다. 실패 원인이 개인정보 사고인지, CRM 지연인지, 특정 revision의 정책 불일치인지 구분하지 않은 채 범위를 넓히면 redrive가 문제를 반복할 수 있습니다.

rollout과 rollback은 구현 완료와 별개다

섹션 제목: “rollout과 rollback은 구현 완료와 별개다”

코드에 작업 모델과 endpoint가 있다고 해서 모든 병원에 바로 적용할 수 있는 것은 아닙니다. 런북은 다음 순서로 mutation mode를 확대합니다.

DISABLED → DRY_RUN → APPLY_PROPOSED → APPLY_PROPOSED_AND_HELD

기본 대기 목표는 HELD=5m, PROPOSED=30m입니다. 이는 각 작업의 완료를 보장하는 시간이 아니라 queue에서 기다리는 목표 시간입니다. 병원별 목표를 override해도 이미 생성된 작업의 처리 시각을 뒤로 미루지는 않습니다.

rollback은 이미 성공한 예약 transaction을 일괄 되돌리는 기능이 아닙니다. 새 mutation을 중단한 뒤 다음 불변 조건을 확인하는 절차입니다.

  • mutation-mode=DISABLED 또는 enabled=false
  • drainState=DRAINED, activeLeases=0
  • CONFIRMED 예약 변경 0건
  • 유효한 HELD allocation이 정상적인 원자 교체가 아닌 방식으로 사라지지 않음
  • job, outcome, inbox, quarantine, outbox, audit row 보존

운영자가 rollback을 실행했다는 사실만으로 이미 처리된 작업이 원래 상태로 돌아갔다고 판단하면 안 됩니다. 상태와 이력을 다시 읽어 어떤 작업이 끝났고 어떤 작업이 남았는지 확인해야 합니다.

현재 구현, 승인된 설계, 운영 준비를 구분한다

섹션 제목: “현재 구현, 승인된 설계, 운영 준비를 구분한다”
구분이 글에서 확인할 수 있는 범위
현재 구현최소 CRM 이벤트, scope별 최신 head, durable job 상태와 outcome, PROPOSED·HELD만 대상으로 삼는 규칙, 병원별 공정한 dispatch, 운영 기준 데이터, 인증·clinic scope를 확인하는 redrive endpoint
승인된 운영 설계PREVIEW 후 제한된 EXECUTE, DISABLED → DRY_RUN → APPLY_PROPOSED → APPLY_PROPOSED_AND_HELD, retry와 quarantine 분리, rollback 시 CONFIRMED 보호
운영 준비실제 병원 allowlist, 권한·redrive 훈련, assessment 포화 경보, drain 리허설, 개인정보 사고 대응과 배포 전 읽기 전용 기준 데이터 대조
후속 개선동의 증빙을 포함한 제안→수락→확정 흐름, 환자에게 새 제안을 전달하는 알림 계약, 운영 결과와 CRM assessment의 감사 연결

현재 구현과 운영 준비를 섞지 않는 것이 중요합니다. endpoint가 존재한다고 실제 병원에서 ADMIN redrive 훈련을 마친 것은 아닙니다. 반대로 CONFIRMED를 자동 재평가 대상에서 제외하는 규칙과 outcome 기록은 아직 구현해야 할 기능으로 미루지 말고, 현재 코드가 지키는 경계로 설명해야 합니다.

프로필 변경을 처리해도 예약은 다시 확인해야 한다

섹션 제목: “프로필 변경을 처리해도 예약은 다시 확인해야 한다”

이번 글의 흐름을 운영자의 다음 작업으로 줄이면 다음과 같습니다.

  • CRM은 원본 프로필 대신 revision·fingerprint·assessment 참조·hash가 담긴 최소 이벤트를 보낸다.
  • 예약 서비스는 최신 revision을 병합하고 현재 PROPOSED·HELD만 재평가한다.
  • assessment 조회 실패는 RETRY_WAIT로 보내고, 신뢰·개인정보·계약 실패는 QUARANTINE으로 나눈다.
  • 결과는 PROPOSAL_SUPERSEDED, HOLD_KEPT, HOLD_REPLACED, FALLBACK_TO_PROPOSED, SKIPPED_*로 기록한다.
  • CONFIRMED는 자동 변경하지 않고, 필요하면 새 제안을 만든 뒤 환자 동의와 운영자 확정을 거친다.
  • STAFF는 운영 기준 데이터를 조회하고 preview 결과를 확인하며, ADMIN은 인증된 clinic 범위에서만 redrive한다.
  • rollback이나 drain 뒤에도 이미 처리된 job·outcome·allocation을 재조회해 최종 상태를 확인한다.

운영 화면은 더 많은 정보보다 더 명확한 정보를 제공해야 합니다. 재평가 대상인지, 지금 실행할 수 있는 작업인지, 실패가 재시도 대상인지 조사 대상인지, 확정 예약을 보호했는지를 한 화면에서 구분해야 합니다.

기존 설계 경계를 더 자세히 확인하려면 CRM 프로필 변경과 예약 재평가 시각 자료를 참조할 수 있습니다. 시각 자료는 상태·권한·개인정보 경계를 별도 보드로 정리하고, 이번 글의 운영 화면은 그 경계를 STAFF가 다음에 할 작업을 중심으로 다시 설명합니다.

댓글

GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.