[운영 확장 3] 예약 복구는 영향 범위 확인부터 시작한다

월요일 오전, 피부과의 레이저 장비 L-02가 점검에 들어갔습니다. 이날 오후까지 이 장비를 쓰기로 한 예약은
여덟 건입니다. 그중 여섯 건은 14일 안에 대체 시간을 찾을 수 있지만, 나머지 두 건은 같은 의사와 진료 항목에
맞는 빈시간이 없습니다.
장비 사용 중단은 대표 사례일 뿐입니다. 의사 휴진, 장비 정기 점검·교체, 진료실 사용 중지처럼 예약에 필요한 사람·장비·진료 공간을 사용할 수 없게 만드는 사건도 같은 복구 판단을 요구합니다. 이 글의 구현 설명은 현재 코드로 확인할 수 있는 장비 사용 불가와 병원 휴진 재배정 흐름을 기준으로 하고, 나머지는 같은 원칙을 적용해야 하는 운영 사례로 구분합니다.
이 상황을 “돌발 상황 발생 → 예약 시간 변경”으로 단순화하면 운영에 필요한 판단을 놓치게 됩니다.
- 돌발 상황의 시간·범위와 실제로 겹치는 예약은 무엇인가?
- 후보를 계산하는 동안 원래 예약의 상태가 바뀌지 않았는가?
- 대체 후보가 없거나 스트림이 중간에 끊긴 예약은 누가 확인하는가?
- 후보를 골랐다는 사실과 환자가 변경에 동의했다는 사실을 같은 것으로 봐도 되는가?
- 병원 사정으로 변경된 예약이 환자의 예약 신뢰도를 낮추지는 않는가?
예약 복구는 날짜 필드 하나를 고치는 기능이 아닙니다. 영향 범위 확인, 대체 후보 계산, 저장 직전 재검증, 상태 변경, 운영자 확인을 단계별로 나눠야 합니다.
돌발 상황의 영향 범위를 먼저 확인한 뒤 예약 복구를 시작한다
섹션 제목: “돌발 상황의 영향 범위를 먼저 확인한 뒤 예약 복구를 시작한다”돌발 상황의 영향 범위는 원인에 맞는 조회로 먼저 확인합니다. 장비 사용 중단은 충돌 조회로 확인합니다. 현재
EquipmentUnavailabilityService는 등록된
사용 불가 기간을 계산한 뒤, 같은 장비를 쓰는 예약 중 그 기간과 겹치는 항목을 반환합니다. 새 일정을 저장하기
전에는 previewConflicts로 예상 충돌을 미리 확인할 수도 있습니다.
GET /api/{tenantCode}/clinics/{clinicId}/equipments/{equipmentId}/unavailabilities/{id}/conflictsPOST /api/{tenantCode}/clinics/{clinicId}/equipments/{equipmentId}/unavailabilities/preview-conflicts이 API는 영향을 받을 수 있는 예약 목록만 반환합니다. 예약 상태를 PENDING_RESCHEDULE로 바꾸거나 대체
후보를 만들지는 않습니다. 충돌 조회와 재배정을 한 번에 처리하면 운영자가 범위를 확인하기도 전에 예약이
변경될 수 있습니다. 장비 일정 변경과 예약 복구 중 어느 작업에서 문제가 생겼는지도 구분하기 어려워집니다.
병원 휴진으로 인한 예약 변경은 별도 서비스가 처리합니다. ClosureRescheduleService는 특정 날짜의 활성 예약을
읽어 대체 후보를 만든 뒤, 현재 상태를 다시 확인하고 예약을 복구 대기 상태로 바꿉니다. 현재 구현은 병원 휴진을
기준으로 이름 붙었지만, 장비 장애를 처리할 때도 충돌 범위를 먼저 확정한 뒤 이 재배정 절차를 활용할 수
있습니다. 다만 장비 충돌 API가 휴진 재배정 서비스를 자동으로 호출하는 것은 아닙니다. 의사 휴진처럼 원인이
다른 사건은 원인 등록과 후보 규칙이 별도로 필요하므로, 모든 돌발 상황이 이 서비스를 자동으로 호출한다고
단정해서는 안 됩니다.
대체 후보를 저장하기 전에 예약 상태와 버전을 다시 확인한다
섹션 제목: “대체 후보를 저장하기 전에 예약 상태와 버전을 다시 확인한다”동기식 processClosureReschedule은 시간이 오래 걸릴 수 있는 후보 계산을 트랜잭션 밖에서 수행합니다. 처리
순서는 다음과 같습니다.
- 요청한 병원이 해당 테넌트에 속하는지 확인한다.
- 휴진 날짜의 활성 예약 ID와 예약 기준 데이터를 읽고, 영향을 받은 예약 수가 허용 범위를 넘지 않는지 확인한다.
- 예약 수와
searchDays를 기준으로 계산할 후보의 범위를 제한한다. - 의사·진료 항목·날짜가 같은 조회 결과는 캐시에서 재사용하면서 대체 빈시간을 계산한다.
- 저장 트랜잭션을 시작하고 활성 예약을 다시 읽는다.
- 예약 수, ID,
status,version이 처음 읽은 예약 기준 데이터와 같은지 확인한다. - 각 예약의 버전을 조건으로 갱신(CAS)해
PENDING_RESCHEDULE로 바꾸고, 상태 이력·상태 이벤트·후보를 저장한다.
후보를 트랜잭션 밖에서 계산한다고 해서 오래된 결과를 그대로 저장하는 것은 아닙니다. 계산하는 동안 환자가 예약을 취소하거나 다른 직원이 상태를 바꿀 수 있습니다. 따라서 저장 직전에 처음 읽은 예약 기준 데이터와 현재 예약을 다시 대조합니다. 예약 하나라도 달라졌다면 동기식 일괄 작업 전체를 롤백합니다.
이렇게 하면 트랜잭션을 짧게 유지하면서도 처음 확인한 영향 범위가 그대로인지 한 번에 검증할 수 있습니다. 후보 계산을 긴 트랜잭션 안에서 수행하면 DB 연결과 잠금을 오래 잡아 둬야 합니다. 반대로 재검증 없이 계산 결과만 믿으면 이미 취소되거나 변경된 예약을 복구 대기 상태로 되돌릴 수 있습니다.
동기식 실패는 전체를 되돌리고 스트리밍 실패는 처리 결과를 남긴다
섹션 제목: “동기식 실패는 전체를 되돌리고 스트리밍 실패는 처리 결과를 남긴다”운영 화면에서는 같은 휴진 복구 작업을 동기식 요청이나 SSE 스트림으로 시작할 수 있습니다. 두 방식은 진행률을 표시하는 방법뿐 아니라 실패했을 때 영향을 받는 범위도 다릅니다.
| 구분 | 동기식 processClosureReschedule | 스트리밍 streamClosureReschedule |
|---|---|---|
| 후보 계산 | 영향을 받은 예약 전체의 후보를 트랜잭션 밖에서 먼저 계산 | 예약별 트랜잭션 안에서 계산 |
| 저장 범위 | 영향을 받은 예약 전체를 하나의 트랜잭션으로 저장 | 예약마다 별도 트랜잭션으로 저장 |
| 동시 변경 | 전체 예약 기준 데이터가 달라지면 일괄 롤백 | 해당 예약의 CAS가 실패하면 그 예약을 건너뜀 |
| 진행 이벤트 | 응답이 끝날 때 전체 결과 반환 | 예약별 커밋 뒤 progress 이벤트 전송 |
| 연결 중단 | 요청 전체의 성공·실패로 판단 | 이미 커밋된 예약은 남고, 아직 시작하지 않은 예약은 중단될 수 있음 |
스트리밍 방식은 예약별 트랜잭션을 커밋하고 DB 연결을 반환한 뒤 onProgress를 호출합니다. 따라서 SSE 전송이
느려져도 DB 연결을 잡은 채 기다리지 않습니다. 다만 스트림이 중간에 끊겼을 때 화면에 마지막으로 표시된
순번만 보고 전체 작업이 실패했다고 판단해서는 안 됩니다. 서버에서 이미 커밋된 예약과 아직 처리하지 못한
예약을 다시 확인해야 합니다.
영향받은 예약 전체를 모두 변경하거나 모두 그대로 둬야 한다면 동기식 처리가 알맞습니다. 처리 시간이 길어 운영자가 진행 상황을 확인해야 한다면 스트리밍이 유용합니다. 대신 일부 예약만 성공할 수 있으므로 후속 작업에서 각 예약의 상태를 다시 확인해야 합니다. 두 방식의 원자성이 같다고 보면 복구 과정에서 작업을 잘못 다시 실행하거나 같은 예약을 중복으로 확인하게 됩니다.

운영 화면은 예약 상태와 다음 작업을 함께 보여준다
섹션 제목: “운영 화면은 예약 상태와 다음 작업을 함께 보여준다”영향받은 예약을 시간순으로 나열하기만 하면 STAFF는 후보가 있는 예약과 없는 예약, 이미 상태가 바뀐 예약을
매번 직접 구분해야 합니다. 운영 화면은 병원에서 발생한 사건과 처리 현황을 먼저 요약하고, 필요한 후속 작업에
따라 예약을 조치 큐로 나눠 보여줘야 합니다.
이번 화면 시안은 다음 정보를 한 흐름으로 배치합니다.
- 사건 원인, 영향 시간 범위, 후보 검색 기간
- 영향을 받은 예약 수, 후보가 있는 예약 수, 후보가 없는 예약 수
PENDING_RESCHEDULE, 후보 수, 다음 작업을 함께 보여주는 조치 큐- 선택한 예약의 익명 참조값과 읽기 시점의
currentVersion - 우선순위, 날짜·시간, 의사와 장비를 함께 보여주는 대체 후보
- 후보 선택, 자동 재배정, 후보 없음 별도 처리
- 현재 구현 범위와 아직 구현되지 않은 기능
currentVersion은 화면이 어느 시점의 상태를 읽었는지 알려주는 값입니다. 그렇다고 현재 확정 API가
클라이언트에서 expectedVersion을 받는 것은 아닙니다. 확정 서비스는 원본 예약을 다시 읽고 그 버전을 내부
CAS 조건으로 사용합니다. 운영 화면에서 이 차이를 분명히 보여줘야 API 계약과 실제 동시성 제어 방식을
혼동하지 않습니다.

현재 구현에서 제공하는 상태와 작업을 바탕으로 만든 시안이며, 실제 운영 화면을 캡처한 이미지는 아닙니다. 수치는 예시이고 환자 이름·전화번호·임상 기록은 표시하지 않았습니다. 운영자는 후보가 있는 예약과 별도 처리가 필요한 예약을 나눈 뒤, 선택한 예약의 현재 상태와 후보 자원을 함께 확인합니다.
수동 선택과 자동 재배정은 같은 트랜잭션에서 확정한다
섹션 제목: “수동 선택과 자동 재배정은 같은 트랜잭션에서 확정한다”STAFF는 예약별 후보 목록에서 대체 시간을 하나 선택할 수 있습니다. 자동 재배정도 별도의 확정 절차를 쓰지
않습니다. 현재 후보 가운데 우선순위가 가장 높은 항목을 고른 뒤 수동 선택과 같은 확정 로직을 호출합니다.
후보가 없으면 null을 반환하므로 자동 재배정 버튼을 눌렀다는 사실만으로 성공 처리해서는 안 됩니다.
확정 트랜잭션은 다음 작업을 함께 수행합니다.
- 후보와 원본 예약이 요청에 지정된 같은 테넌트·병원에 속하는지 확인한다.
- 후보 의료진이 원본 예약과 같은 병원에 속하는지 확인한다.
- 후보 날짜와 시간으로 새 예약을
CONFIRMED상태로 생성한다. - 원본 예약의 현재 버전을 조건으로 갱신(CAS)하고
RESCHEDULED상태로 바꾼다. - 원본 예약의 상태 이력을 기록한다.
- 선택한 후보를 다시 선택할 수 없도록 표시한다.
- 변경 알림을 아웃박스(outbox)에 기록한다.
원본 예약의 상태나 버전이 중간에 바뀌면 CAS가 실패하고 트랜잭션 전체가 롤백됩니다. 따라서 새 예약만 남거나
원본 예약만 RESCHEDULED 상태가 되는 일은 없습니다.
다만 현재 확정 절차에는 중요한 제한이 있습니다. 새 예약은 바로 CONFIRMED 상태로 생성됩니다. 환자에게
대체 일정을 제안하고 동의를 기록한 뒤 확정하는 단계는 아직 이 절차에 연결되어 있지 않습니다. 운영 화면의
“이 후보로 재배정” 버튼은 현재 구현에서 제공하는 재배정 기능을 실행할 뿐, 환자의 동의를 이미 받았다는
사실까지 보장하지는 않습니다.
정책 모델만으로는 환자 동의를 강제할 수 없다
섹션 제목: “정책 모델만으로는 환자 동의를 강제할 수 없다”예약 정책 모델에는 DisruptionRecoveryPolicy가 있습니다.
preserveConfirmedAppointment=true는 고객이 대체 일정을 수락하거나 명시적으로 취소하기 전까지 기존 확정
예약을 유지하라는 조건입니다. 이 조건은 병원별로 끌 수 없습니다. 정책 검증기는 값이 true가 아니면
테넌트 기본 정책을 거부하고, 병원별 재정의 대상에서도 이 항목을 제외합니다.
그러나 현재 ClosureRescheduleService.confirmReschedule은 이 정책을 읽지 않습니다. 후보를 확정하면 대체
예약을 바로 CONFIRMED 상태로 만들고 원본을 RESCHEDULED로 바꿉니다. 따라서 정책에 적힌 원칙과 현재
코드의 동작을 구분해서 설명해야 합니다.
- 현재 정책 모델: 기존 확정 예약을 고객 동의 없이 변경하지 않는 원칙을 표현하고 검증한다.
- 현재 재배정 확정 절차: 그 정책과 동의 근거를 입력으로 받지 않고 후보를 즉시 확정한다.
운영 절차에서 환자 동의를 먼저 확인할 수는 있습니다. 하지만 현재 시스템이 동의 여부를 필수로 검사하거나, 그 근거를 감사 가능한 데이터로 보존한다고 말할 수는 없습니다. 정책 모델과 상태 변경 절차가 연결되기 전까지는 운영 단계에서 따로 확인해야 합니다.
Timefold 계산 결과는 버전을 다시 확인한 뒤 한 번에 반영한다
섹션 제목: “Timefold 계산 결과는 버전을 다시 확인한 뒤 한 번에 반영한다”여러 예약을 한꺼번에 최적화할 때는 SolverService.optimizeReschedule을 사용할 수 있습니다. 그러나 solver가
계산한 배치 결과가 화면에 표시됐다고 곧바로 DB에 반영해서는 안 됩니다. 계산이 끝난 뒤 다른 작업이 원본
예약을 바꿀 수 있기 때문입니다.
applyOptimizedAssignments는 트랜잭션 안에서 원본 예약의 버전을 잠근 뒤 확인하고, 각 배치를 버전 조건부
갱신(CAS)으로 반영합니다. 오래된 결과가 하나라도 있거나 갱신에 실패하면 전체 작업을 롤백하고 false를
반환합니다.
이 작업은 ClosureRescheduleService의 후보 선택·확정 API와 다릅니다. 이 서비스는 후보를
저장하고 STAFF가 고른 대체 시간으로 새 예약을 만듭니다. 반면 solver 결과 반영은 기존 예약의 배치를
변경하며, 저장할 때 원본 버전을 확인합니다. 운영 화면에서 두 작업을 하나의 “자동 최적화” 버튼으로 합치려면
먼저 두 작업이 반환하는 결과와 실패 조건을 통일해야 합니다.
병원 책임과 환자 보상은 따로 판단한다
섹션 제목: “병원 책임과 환자 보상은 따로 판단한다”의사 휴진, 장비 고장·점검, 진료실 사용 중지는 환자에게 책임을 물을 수 없는 사건입니다. 예약 신뢰도 모델은 책임을 CLINIC이나
OPERATIONAL_EXCEPTION으로 기록할 수 있으며, 신규 예약을 제한할 때는 환자 책임으로 확인된 이벤트만
근거로 삼아야 합니다. 병원 사정으로 바뀐 예약을 환자의 노쇼나 늦은 취소처럼 계산하면 예약을 복구한 뒤에도
환자에게 불이익이 남습니다.
보상 여부도 따로 판단해야 합니다. 대기 목록 운영 기능에는 복구 크레딧(recovery credit)을 부여하고 취소하는 요청이 있지만, 휴진이나 장비 충돌 또는 다른 운영 장애가 발생했다고 자동으로 복구 크레딧을 만들지는 않습니다.
POST /api/{tenantCode}/clinics/{clinicId}/waitlist/recovery-creditsPOST /api/{tenantCode}/clinics/{clinicId}/waitlist/recovery-credits/{recoveryCreditRef}/revoke보상이 필요한지, 어떤 근거와 승인으로 얼마 동안 적용할지는 병원 운영 정책이 정해야 합니다. 예약 복구 트랜잭션에서 보상까지 함께 만들려면 재배정과 보상 중 하나만 실패했을 때의 처리, 중복 지급 방지, 취소 권한을 모두 정해야 합니다. 현재 구현처럼 두 기능을 분리해 두는 편이 안전합니다.
현재 구현과 남은 운영 과제를 구분한다
섹션 제목: “현재 구현과 남은 운영 과제를 구분한다”| 구분 | 확인된 범위 |
|---|---|
| 현재 구현 | 장비 사용 불가 기간과 충돌 예약 조회, 휴진으로 영향을 받은 예약 조회, 동기식 일괄 후보 계산·재검증·원자적 저장, 예약별 스트리밍 처리, 수동·자동 후보 확정, solver 결과의 원자적 반영 |
| 현재 정책 모델 | 운영 장애 자동 제안 여부와 지연 한도, 기존 확정 예약 보호 조건, 병원별 재정의 제한 |
| 운영 준비 | 장애 원인과 영향 범위 승인, 후보 없음 조치 큐, 스트림 중단 뒤 상태 대조, 환자 연락과 동의 확인, 병원 책임 분류, 보상 승인 |
| 후속 개선 | 동의 근거를 포함한 제안→수락→확정 단계, 재배정 확정 절차의 DisruptionRecoveryPolicy 적용, 감사할 수 있도록 복구·알림·보상의 연결 관계 기록 |
정책 모델이 있다는 이유만으로 현재 확정 절차가 환자 동의를 강제한다고 설명해서는 안 됩니다. 반대로 동기식 일괄 처리의 기준 데이터 재검증과 롤백, 스트리밍 처리의 예약별 커밋처럼 이미 구현된 안전장치를 향후 계획으로 소개해서도 안 됩니다.
예약 복구는 최종 상태를 확인해야 끝난다
섹션 제목: “예약 복구는 최종 상태를 확인해야 끝난다”병원 사정으로 예약을 바꿔야 할 때 운영자가 필요한 것은 변경 버튼 하나가 아닙니다.
- 의사 휴진·장비 고장·점검처럼 원인이 달라도, 먼저 영향을 받는 예약과 책임 범위를 확인해야 한다.
- 장비 충돌 조회는 영향을 받은 예약을 찾을 뿐, 자동으로 재배정하지 않는다.
- 동기식 휴진 복구는 후보를 트랜잭션 밖에서 계산하고, 예약 기준 데이터를 다시 확인한 뒤 한 번에 저장한다.
- 스트리밍 복구는 예약별로 커밋하므로 연결 중단 뒤 이미 처리된 예약과 남은 예약을 대조해야 한다.
- 후보 선택과 자동 재배정은 같은 확정 트랜잭션을 사용하며, 현재 구현은 대체 예약을 바로
CONFIRMED상태로 만든다. - 정책 모델은 기존 확정 예약 보호를 요구하지만, 현재 확정 절차가 환자 동의 근거를 강제하지는 않는다.
- solver 결과 반영과 복구 크레딧은 서로 다른 절차로 처리한다.
- 병원 책임으로 분류된 사건은 환자의 예약 신뢰도 불이익으로 이어지지 않아야 한다.
운영 화면은 더 많은 정보보다 더 명확한 정보를 제공해야 합니다. 어떤 예약이 영향을 받았는지, 대체 후보가 있는지, 지금 어떤 작업을 실행할 수 있는지, 실행한 뒤 어떤 상태를 다시 확인해야 하는지를 한 흐름으로 보여줘야 합니다. 그래야 병원 운영자, PO, 개발자가 같은 화면에서 현재 구현 범위와 다음 개선 과제를 함께 확인할 수 있습니다.
다음 글
섹션 제목: “다음 글”이번 글에서는 병원 사정으로 바뀐 예약을 안전하게 복구하려면 어떤 절차가 필요한지 살펴봤습니다. 다음 운영 확장 글에서는 CRM 프로필이 바뀌었을 때 예약 서비스가 어떤 제안과 보류(hold)를 다시 평가해야 하는지, 어떤 확정 예약과 동의 근거는 그대로 보호해야 하는지 다룹니다.
근거 자료
섹션 제목: “근거 자료”- clinic-appointment 저장소
ClosureRescheduleServiceRescheduleControllerRescheduleBatchStreamControllerEquipmentUnavailabilityServiceEquipmentUnavailabilityControllerSolverServiceOperationalSchedulingPoliciesSchedulingPolicyValidatorBookingReliabilityModelWaitlistOperationsControllerreschedule-list.component.ts- CRM 프로필 변경 시 예약 재평가
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.