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

CRM에서 환자 프로필이 변경됐습니다. 변경 내용은 예약 판단에 영향을 줄 수 있으므로, 예약 서비스도 현재 제안과 보류(hold)를 다시 살펴봐야 합니다. 하지만 이때 예약 서비스가 CRM의 원본 프로필을 가져와 저장하거나, 이미 환자와 병원이 합의한 확정 예약을 자동으로 다른 시간으로 변경하면 책임 경계가 무너집니다.
이 글의 결론은 단순합니다.
CRM 프로필 변경은 예약 재평가의 입력 신호이지, 확정 예약을 자동으로 변경할 수 있는 명령이 아닙니다.
PROPOSED와HELD만 자동 재평가하고,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를 뒤따르게 하는 이유도 이 큐의
우선순위에서 드러납니다.

현재 구현의 운영 기준 데이터와 endpoint 계약을 바탕으로 만든 시안입니다. 수치는 예시이며 환자 이름·연락처·원본 프로필·assessment 본문은 표시하지 않습니다. STAFF는 조회와 미리보기만 하고, ADMIN만 인증된 범위에서 제한적으로 redrive를 실행합니다.
운영 화면 오른쪽 상세 패널에는 다음 정보만 표시합니다.
- tenant group와 clinic으로 제한한 운영 범위
- 재평가 대상
profileRevision targetPolicyRef와targetPolicyGeneration- 현재 작업 상태와 최근 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,)이 이벤트가 예약 서비스 경계로 전달하는 정보는 다음과 같습니다.
- 이벤트 중복을 제거하고 감사 추적에 사용할
eventId - 처리 범위를 정할
tenantGroupId와clinicId - 환자를 직접 식별하지 않는 소문자 SHA-256 형식의
patientReferenceFingerprint - 최신 여부를 판단할
profileRevision - 실제 assessment를 조회할
assessmentRef와 내용을 검증할assessmentHash - 이 변경이 예약 판단에 영향을 주는지 나타내는
materialChange - 원본 이벤트의
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 jobProfileReevaluationScope는 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.HELDCONFIRMED가 빠져 있는 것은 구현 누락이 아니라 보호 규칙입니다. CONFIRMED는 환자와 병원이 이미 합의한 예약이며,
담당자·시간·장비 같은 자원 점유와도 연결돼 있습니다. 프로필 변경이 새 판단을 요구하더라도 기존 예약을 자동으로
변경하는 대신, 필요하면 별도의 제안을 만들고 환자 동의와 운영자 확정을 거쳐야 합니다.
assessment 조회와 최종 상태 결정은 한 작업으로 기록한다
섹션 제목: “assessment 조회와 최종 상태 결정은 한 작업으로 기록한다”재평가 대상이 PROPOSED나 HELD라고 해서 곧바로 새 상태로 변경하지는 않습니다. 워커는 병원별 공정한 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도 오류가 아니라 현재
상태를 보호하기 위해 처리하지 않은 결과일 수 있습니다.

최종 상태 결정을 별도 노드로 둡니다. PROPOSED와 HELD는 재평가 결과를 기록할 수 있지만, 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/profileReevaluationPOST /actuator/profileReevaluation읽기 작업은 ProfileReevaluationOperationalSnapshot을 반환합니다. 쓰기 작업은 ProfileReevaluationAdminAction과
reason, idempotency key, tenant·clinic scope, target revision, limit을 받습니다. 하지만 요청 본문에 포함된 actor를 그대로
신뢰하지 않습니다. ProfileReevaluationAdminActorResolver는 인증된 SchedulingUserPrincipal에서만 감사 주체를
가져오고, issuer·token ID·인증 시각·허용 clinic 목록을 확인합니다.
런북의 실행 순서는 다음과 같습니다.
ADMIN역할과SCOPE_profile-reevaluation:operate권한을 확인합니다.tenantGroupId와clinicId를 반드시 지정하고, 인증 주체가 해당 clinic을 대상으로 작업할 권한이 있는지 확인합니다.PREVIEW로 실제 대상과 target revision을 확인합니다.- preview 결과가 승인한 범위와 일치할 때만
EXECUTE를 수행합니다. - 실행 후 새 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=falsedrainState=DRAINED,activeLeases=0CONFIRMED예약 변경 0건- 유효한
HELDallocation이 정상적인 원자 교체가 아닌 방식으로 사라지지 않음 - 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가 다음에 할 작업을 중심으로 다시 설명합니다.
근거 자료
섹션 제목: “근거 자료”- clinic-appointment 저장소
- 프로필 변경과 예약 재평가 설계
- 프로필 재평가 구현 계획
- 프로필 재평가 운영 런북
PatientSchedulingAssessmentChangedProfileReevaluationModelProfileReevaluationRecordsProfileReevaluationHealthIndicatorProfileReevaluationEndpoint- 병원 사정으로 바뀐 예약 복구
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.