[운영 확장 1.3] 대기 목록 운영 명령은 API 요청과 재조회로 완성된다

병원 직원(STAFF)이 조치 큐에서 OFFERED 제안을 하나 선택했습니다. 화면에는 version=7과 아직 지나지 않은 expiresAt이 보입니다. 그러나 운영자가 확정 버튼을 누르기 직전에 다른 워커가 제안을 만료 처리했거나, 다른 직원이 같은 빈시간을 먼저 예약했을 수 있습니다. 네트워크가 끊겨 응답을 받지 못했지만 서버에서는 예약을 이미 만든 경우도 있습니다.
이 상황에서 화면이 해야 할 일은 “버튼을 눌렀으니 성공”이라고 표시하는 것이 아닙니다. 화면은 현재 범위와 버전을 확인한 명령을 보내고, 서버가 반환한 결과를 성공·재생·처리 중·충돌로 나눈 다음, 필요한 상태를 다시 읽어야 합니다.
운영 명령은 화면의 이벤트가 아니라 API가 책임지는 상태 변경 계약입니다.
STAFF는 현재offerRef와version을 읽고,Idempotency-Key를 붙여 명령을 보낸 뒤, 반환된 최종 상태 결정을 재조회해서 다음 조치를 정합니다.
운영 화면의 버튼은 상태 변경 계약을 호출한다
섹션 제목: “운영 화면의 버튼은 상태 변경 계약을 호출한다”앞 글에서는 운영 화면을 상태 목록이 아니라 조치 큐로 설계했습니다. 조치 큐에서 항목을 선택하는 일은 읽기(read)입니다. 확정이나 거절 버튼을 누르는 순간에는 별도의 쓰기(write) 계약이 시작됩니다. 두 단계 사이에 시간이 있으므로, 화면에 표시된 항목을 그대로 저장하면 안 됩니다.
현재 직원용 API의 공통 경로는 다음과 같습니다.
/api/{tenantCode}/clinics/{clinicId}/waitlisttenantCode와 clinicId는 URL에 보이지만, 실제 권한은 인증 주체의 테넌트·병원 membership과 capability에서 결정됩니다. 요청 본문으로 이 범위를 덮어쓸 수 없습니다. entryRef와 offerRef도 내부 정수 ID가 아니라 범위와 종류를 검증하는 불투명 참조(opaque reference)입니다. 형식이 잘못됐거나 다른 병원에 속한 참조는 404 WAITLIST_REFERENCE_NOT_FOUND로 처리해 존재 여부를 추측하지 못하게 합니다.
이 경계를 먼저 고정해야 운영 화면이 “어떤 항목을 눌렀는가”가 아니라 “어떤 병원의 어떤 제안에 어떤 버전으로 명령했는가”를 추적할 수 있습니다.
명령을 보내기 전에 최신 근거를 다시 읽는다
섹션 제목: “명령을 보내기 전에 최신 근거를 다시 읽는다”조치 큐의 조치 메시지(작업 요청)는 후보를 좁히는 신호입니다. 명령의 입력으로 바로 사용하지 않고, 다음 순서로 읽기 단계를 다시 거칩니다.
GET /offers에서 현재 조치 큐를 읽습니다. 목록은nextCursor를 사용하는 bounded keyset 페이지네이션이며 기본 50건, 최대 100건입니다.- 선택한
offerRef로GET /offers/{offerRef}를 호출해status,version,expiresAt,deliveryState를 확인합니다. - 기존 결과가 있거나 이전 요청이 처리 중일 가능성이 있으면
GET /offers/{offerRef}/decision을 호출합니다. - 현재
STAFF역할에서 실행할 명령과 금지된 명령을 확인한 뒤에만 쓰기 요청을 만듭니다.
| 읽기 대상 | 화면이 확보하는 값 | 다음 판단 |
|---|---|---|
GET /entries | 대기 항목의 entryRef, 상태, 버전 | 대기 항목을 철회하거나 제안 상세로 이동할 수 있는지 확인 |
GET /offers | 제안 목록과 nextCursor | 조치 큐의 우선순위를 다시 계산 |
GET /offers/{offerRef} | status, version, expiresAt, deliveryState | 현재 제안에 명령을 보낼 수 있는지 확인 |
GET /offers/{offerRef}/decision | decisionState, appointmentRef, decidedAt | 이미 처리된 명령의 결과를 화면에 반영 |
화면이 version=7을 읽었다고 해서 서버가 아직 7이라고 보장되는 것은 아닙니다. 버전은 “이 값으로 쓰기를 시도했다”는 조건이고, 최신 상태를 확인하는 재조회는 “그 시도가 어떤 결과가 되었는가”를 설명하는 단계입니다. 두 역할을 하나의 행 데이터에 맡기면 오래된 화면과 최신 서버 상태가 섞입니다.

요청에는 범위·버전·멱등성 키가 함께 들어간다
섹션 제목: “요청에는 범위·버전·멱등성 키가 함께 들어간다”확정 요청은 다음처럼 구성됩니다. 예시는 환자 개인정보나 내부 정수 ID를 포함하지 않습니다.
POST /api/clinic-a/clinics/42/waitlist/offers/o_dGVuYW50.../confirmIdempotency-Key: staff-confirm-20260815-01Content-Type: application/json
{ "expectedVersion": 7, "confirmationSource": "FRONT_DESK"}모든 대기 목록 상태 변경(mutation)에는 출력 가능한 ASCII 문자 16–128자로 된 Idempotency-Key가 필요합니다. 같은 키와 같은 요청을 재전송하면 새 예약을 만들지 않고 처음 처리한 결과를 다시 반환해야 합니다. 같은 키를 다른 요청 본문과 함께 사용하면 서로 다른 명령을 하나로 합칠 수 없으므로 충돌로 처리합니다.
expectedVersion은 화면이 읽은 제안 버전을 쓰기 조건으로 넘깁니다. 서버는 제안·대기 항목·보류(hold)·정책 결정·예약 수용량을 다시 잠그고 조건을 확인합니다. 이 확인에서 하나라도 달라지면 화면의 의도를 억지로 적용하지 않고 충돌을 반환해야 합니다.
confirmationSource 같은 사유·출처 값은 운영 판단을 설명하는 데 사용하지만, 테넌트·병원·회원 범위를 요청 본문에서 받는 용도로 사용하지 않습니다. 범위는 인증 주체와 URL에서 해석하고, 공개 응답과 로그에는 상관관계(correlation) ID와 안정적인 사유 코드만 남깁니다.
confirm은 세 개의 트랜잭션 경계를 가진다
섹션 제목: “confirm은 세 개의 트랜잭션 경계를 가진다”WaitlistApplicationService.confirmOffer는 확정을 하나의 긴 HTTP 작업으로 취급하지 않습니다. 현재 소스의 애플리케이션 서비스는 다음 세 단계를 분리합니다.
| 단계 | 저장하는 것 | 실패 뒤의 의미 |
|---|---|---|
| 1. 멱등성 예약 | 명령의 범위(scope), 키 다이제스트, 요청 다이제스트, PROCESSING 상태 | 같은 키가 이미 처리 중인지 판단 |
| 2. 업무 트랜잭션 | 제안 수락(claim), 대체 예약 생성, 수용량 보류(hold) 소비 | 만료·오래된 버전·슬롯 점유 충돌을 원래 상태로 기록 |
| 3. 결과 완료 | 성공한 appointmentId 또는 안정적인 실패 코드 | 네트워크 재시도에서 같은 결과를 재생 |
이 구조가 필요한 이유는 예약을 만든 직후 프로세스가 멈출 수 있기 때문입니다. 2단계는 성공했지만 3단계의 결과 기록이 빠지면 명령은 PROCESSING으로 남습니다. 다음 요청은 새 예약을 만들기 전에 기존 대체 예약을 대조하고, 이미 만들어진 결과를 완료 기록으로 복구해야 합니다.
현재 WaitlistApplicationServiceTest도 같은 경계를 검증합니다. 같은 명령을 두 번 호출해도 claim·대체 예약 생성·hold 소비가 한 번만 실행되고, 성공 기록 직전 장애를 재호출하면 기존 예약을 재생합니다. 이 테스트가 증명하는 것은 애플리케이션 서비스의 멱등성 경계이지, 모든 병원에서 운영 화면이 활성화됐다는 뜻은 아닙니다.
HTTP 결과마다 운영자의 다음 작업이 다르다
섹션 제목: “HTTP 결과마다 운영자의 다음 작업이 다르다”대기 목록 전달 API 계약은 확정 결과를 하나의 200 OK로 일률적으로 처리하지 않습니다. 결과마다 재시도와 재조회 규칙이 다릅니다.
| API 결과 | 의미 | 화면의 다음 작업 |
|---|---|---|
201 + appointmentRef | 대체 예약이 만들어지고 제안이 ACCEPTED가 됨 | 성공 배지를 표시하고 GET /offers/{offerRef}/decision으로 결정 결과를 반영 |
201 + Idempotent-Replay: true | 같은 키·같은 요청의 성공 결과를 재생함 | 새 예약을 만들지 않았다는 사실을 표시하고 같은 결정 결과를 반영 |
202 IDEMPOTENCY_IN_PROGRESS + Retry-After: 1 | 같은 명령이 아직 처리 중임 | 1초 뒤 결정 조회를 다시 하고, 새 키로 중복 명령을 만들지 않음 |
409 OFFER_EXPIRED | 제안의 만료 시각이 지남 | 현재 제안과 빈시간을 다시 읽고 다음 후보 또는 직원 검토로 보냄 |
409 DECISION_STALE | 화면의 정책 결정·버전이 최신 상태와 다름 | offerRef의 최신 결정과 version을 다시 읽은 뒤 새 명령 여부를 판단 |
409 SLOT_OCCUPIED | 다른 명령이 빈시간을 먼저 점유함 | 성공으로 표시하지 않고 조치 큐에 충돌 근거를 남김 |
여기서 202는 실패가 아닙니다. 아직 결과를 알 수 없다는 뜻이며, Retry-After를 무시하고 새 멱등성 키로 확정을 다시 보내는 것이 가장 위험한 행동입니다. 반대로 409는 서버가 성공 여부를 숨긴 상태가 아니라, 현재 화면의 전제가 더 이상 유효하지 않다는 명시적인 경계입니다.
현재 clinic-appointment의 API 문서는 위와 같은 세부 사유 코드(reason code)를 계약으로 설명하지만, 공통 예외 매핑의 WaitlistApiError에는 WAITLIST_CONFLICT라는 일반 충돌 코드도 있습니다. 따라서 클라이언트는 문서에 없는 문자열을 추측해 분기하지 말고, 실제 배포 버전의 응답 스키마와 사유 코드 매핑을 함께 확인해야 합니다. 소스에 있는 세부 설계와 실제 HTTP 어댑터가 다르면, 글도 그 차이를 숨기지 않아야 합니다.
참조 오류와 일시 장애도 같은 원칙으로 분리합니다.
| 결과 | 재시도 규칙 |
|---|---|
404 WAITLIST_REFERENCE_NOT_FOUND | 현재 목록을 새로 읽고 서버가 발급한 최신 offerRef를 사용 |
400 INVALID_IDEMPOTENCY_KEY 또는 PAYLOAD_INVALID | 요청을 고친 뒤 새 요청으로 전송; 자동 재시도하지 않음 |
503 WAITLIST_UNAVAILABLE + Retry-After | 같은 의도와 같은 멱등성 키로 지정된 간격 뒤 재시도 |
decline도 읽기·명령·재조회 순서를 따른다
섹션 제목: “decline도 읽기·명령·재조회 순서를 따른다”거절은 확정의 반대 버튼이지만, 단순히 status=DECLINED를 화면에서 바꾸는 작업은 아닙니다. POST /offers/{offerRef}/decline에도 Idempotency-Key와 expectedVersion이 필요하고, 요청 본문에는 제한된 reasonCode가 들어갑니다.
서버는 제안과 연결된 보류(hold)를 해제한 뒤 거절 결과를 기록합니다. 다음 대기 항목에 새 제안을 만들 수 있는 시점도 보류 해제가 커밋된 뒤로 제한해야 합니다. 화면은 200 응답만 보고 다음 후보를 성공으로 표시하지 않고, GET /offers와 GET /offers/{offerRef}/decision을 다시 읽어 거절·자원 해제·다음 조치가 모두 반영됐는지 확인합니다.
이 순서가 없으면 두 운영자가 같은 제안을 거절하는 동안 다음 후보가 아직 해제되지 않은 빈시간을 사용하거나, 반대로 같은 빈시간에 두 개의 제안을 만들 수 있습니다. 거절도 상태 변경 경계이며, 확정과 같은 버전·범위·멱등성 규칙을 적용해야 합니다.
재조회는 화면 새로 고침이 아니라 판단 경계다
섹션 제목: “재조회는 화면 새로 고침이 아니라 판단 경계다”운영 화면의 흐름을 브라우저 전체 새로 고침으로 표현하면 어떤 요청의 결과를 반영했는지 알 수 없습니다. 필요한 자원만 다음 순서로 다시 읽습니다.
조치 큐 조회 → 제안 상세·결정 조회 → confirm/decline 명령 → HTTP 결과 분류 → offer decision 재조회 → 조치 큐에서 종료 상태 또는 후속 검토 표시각 단계의 책임은 다릅니다.
- 조치 큐 조회는 무엇을 먼저 볼지 정합니다.
- 제안 상세 조회는 명령에 넣을
version과expiresAt을 확보합니다. - 명령 요청은 상태 변경을 DB 펜스와 멱등성 기록에 맡깁니다.
- 결과 분류는 성공·재생·처리 중·충돌을 구분합니다.
- 결정 재조회는 화면이 현재 서버 상태를 표시하도록 합니다.
따라서 202 뒤에는 “버튼을 비활성화했으니 성공”이라고 추측하지 말고 Retry-After를 기다린 뒤 결정 조회를 실행해야 합니다. 409 뒤에는 같은 요청을 무한 반복하지 말고 최신 제안과 조치 큐의 근거를 읽어 다음 작업을 선택해야 합니다.
재조회 결과는 브라우저 새로 고침으로 끝나지 않습니다. 대시보드는 그 결과를 조치 큐의 다음 상태와 연결해 운영자가 후속 작업을 선택할 수 있게 합니다.
STAFF가 실행하는 명령과 ADMIN이 바꾸는 정책을 분리한다
섹션 제목: “STAFF가 실행하는 명령과 ADMIN이 바꾸는 정책을 분리한다”이 시리즈의 운영 화면 중심은 STAFF입니다. STAFF가 할 수 있는 일은 현재 제안을 읽고, 허용된 범위에서 확정·거절·철회를 실행하는 것입니다. 정책 버전 활성화, 제한 조정, 복구 크레딧과 혜택 변경은 ADMIN capability에 속한 별도 API입니다.
두 역할의 명령을 같은 버튼 그룹에 넣으면 화면은 권한 경계를 숨깁니다. STAFF에게 보이지 않는 정책 명령도 “숨겨진 기능”으로 끝내지 말고, 현재 역할에서 실행할 수 있는 상태 변경의 범위를 표시해야 합니다. 개발자는 capability와 scope 검사를 API에서 다시 수행하고, 화면의 버튼 노출을 보안 경계로 간주해서는 안 됩니다.
현재 구현·API 계약·운영 준비를 구분한다
섹션 제목: “현재 구현·API 계약·운영 준비를 구분한다”다음 세 층을 같은 문장으로 섞지 않는 것이 중요합니다.
| 구분 | 이 글에서 확인한 내용 |
|---|---|
| 현재 구현 | WaitlistController의 entry·offer 조회와 confirm·decline·decision route, 요청 DTO의 expectedVersion, 응답 DTO의 version·expiresAt·appointmentRef, WaitlistApplicationService의 멱등성 예약·재생·복구 테스트 |
| API 계약·승인 설계 | 범위가 있는 opaque reference, Idempotency-Key 16–128자, bounded keyset cursor, 201 재생·202 처리 중·409 충돌, 알림 전달과 수락의 분리 |
| 운영 준비 | 실제 병원 허용 목록(allowlist), 전송 제공자(provider) 장애율, 카나리 결과, 상태 대조·복구 훈련, 배포 버전의 사유 코드 매핑 |
코드와 테스트가 존재한다는 사실은 애플리케이션 경계가 준비됐다는 증거입니다. 특정 병원에서 기능이 켜졌거나 운영자가 모든 결과를 실제 화면에서 확인했다는 증거는 아닙니다. 운영 적용 전에는 기능 플래그, 권한, 실제 응답 헤더, Retry-After, 재조회 결과를 별도로 검증해야 합니다.
버튼 클릭은 최종 상태 결정으로 돌아와야 한다
섹션 제목: “버튼 클릭은 최종 상태 결정으로 돌아와야 한다”운영 화면은 명령을 빠르게 보내는 곳이 아니라, 명령의 전제와 결과를 설명하는 곳이어야 합니다. STAFF가 확인할 수 있어야 하는 순서는 다음과 같습니다.
- 어떤 병원 범위의 어떤 제안인지 확인합니다.
- 현재
version,expiresAt,deliveryState, 결정 결과를 읽습니다. - 같은 의도를 재전송해도 중복 예약을 만들지 않을
Idempotency-Key를 생성합니다. expectedVersion을 포함해 confirm 또는 decline 명령을 보냅니다.- 성공·재생·처리 중·충돌을 구분하고, 결정 API를 다시 읽습니다.
- 조치 큐에 종료 상태 또는 직원 검토 사유를 남깁니다.
이 순서를 지키면 운영 화면은 “버튼이 눌렸다”와 “예약이 확정됐다”를 구분할 수 있습니다. 다음 글에서는 이 결과를 조치 큐의 재시도·복구 정책과 어떻게 연결할지 살펴보겠습니다.
시각 자료 더 보기
섹션 제목: “시각 자료 더 보기”근거 자료
섹션 제목: “근거 자료”- clinic-appointment 저장소
- 대기 목록 전달 요구사항
- 대기 목록 전달 API 계약
- 대기 목록 전달과 펜싱 설계
WaitlistControllerWaitlistRequestsWaitlistResponsesWaitlistApplicationServiceWaitlistApplicationServiceTest
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.