콘텐츠로 이동

[구현 8] N회 상품 구매로 방문 계획을 만드는 과정

로봇이 구매 토큰을 받은 뒤 시간 미지정 방문 카드 세 장을 방문 계획 트레이에 나누어 넣고 비어 있는 달력을 그대로 두는 장면
구매 시점에는 방문할 권리와 순서를 만들고, 실제 예약 시간은 각 회차를 정할 때 합의합니다.

3회 상품을 샀다고 예약 세 건이 생기는 것은 아니다

섹션 제목: “3회 상품을 샀다고 예약 세 건이 생기는 것은 아니다”

환자 A가 레이저 시술 3회 상품을 구매했다고 해보겠습니다. 구매가 끝난 순간 예약 서비스가 아는 사실은 “환자 A에게 이 시술을 세 번 제공해야 한다”는 사실입니다. 2회차와 3회차의 방문 날짜, 담당 의료진, 진료실, 장비까지 정해졌다는 뜻은 아닙니다.

구매를 곧바로 예약 세 건으로 바꾸면 아직 합의하지 않은 시간이 병원 달력을 점유합니다. 첫 방문이 늦어지거나 시술 후 회복 기간이 달라질 때는 이미 만든 예약을 줄줄이 옮겨야 합니다. 반대로 방문 계획을 먼저 만들면 각 회차의 의무는 보존하되, 실제 시간은 환자 동의와 자원 확인이 끝난 뒤 정할 수 있습니다.

구분잘못된 모델: 미래 예약을 미리 생성현재 모델: 방문 계획을 생성
구매 직후예약 시간 세 개가 달력에 들어감시간 없는 회차 세 개가 계획에 들어감
환자 동의미래 회차까지 동의했다고 가정제안할 회차마다 정확한 시간과 조건에 동의
자원 점유의료진·장비·진료실을 너무 일찍 점유확정한 예약만 자원을 점유
일정 변경뒤의 예약을 연쇄 변경아직 정하지 않은 회차는 그대로 계획에 남음
회차 간격구매일이나 임의의 날짜를 기준으로 고정직전 회차의 실제 완료 시각을 기준으로 다음 구간 계산
3회 상품 구매에서 미래 예약을 미리 만드는 잘못된 모델과 시간 미지정 방문 계획을 만드는 현재 모델을 비교하고, 간격 제약 없음과 N일부터 M일 간격을 구분한 도표
구매는 방문 의무를 만듭니다. 예약 시간과 여러 회차를 같은 방문으로 묶을지는 별도로 결정합니다.

구매 완료 이벤트가 방문 계획을 만드는 경계

섹션 제목: “구매 완료 이벤트가 방문 계획을 만드는 경계”

PurchaseCompletedHandler는 구매 서비스가 보낸 완료 이벤트를 받아 정확한 상품 버전을 조회합니다. 상품 정의가 활성 상태이고 구매 소유 정보가 일치하면 AppointmentPlanFactory에 상품 정의와 구매 정보를 전달합니다. factory가 만든 계획, 수신 이벤트 기록, 아웃박스(outbox)는 같은 트랜잭션에서 저장됩니다.

이 경계에서 예약 서비스가 소유하는 것은 구매 권리를 일정으로 해석한 계획입니다. 상품 가격, 환불 가능 여부, 실제 시술 완료는 각 기준 데이터 원본이 소유합니다. 예약 서비스는 외부 판단을 대신하지 않고, 다음 예약을 계산하는 데 필요한 식별자와 상태 이력을 보존합니다.

입력계획에 남기는 정보계획이 결정하지 않는 것
구매 기준 데이터 원본과 구매 ID구매마다 하나의 AppointmentPlan을 식별결제 승인과 환불 금액
상품 ID·버전·해시구매 당시 해석한 상품 정의를 고정상품 가격 변경과 판매 정책
환자 참조와 희망 일정다음 제안에 사용할 안전한 참조와 희망 조건확정 예약 시간
상품 BOM과 반복 횟수회차별 PlannedTreatment실제 진료 완료

repeatCount는 회차별 PlannedTreatment로 펼쳐진다

섹션 제목: “repeatCount는 회차별 PlannedTreatment로 펼쳐진다”

AppointmentPlanFactory의 핵심은 repeatCount를 그대로 카운터로 보관하지 않고, 회차별 계획 항목으로 전개하는 데 있습니다. 3회 상품이라면 sequenceNo가 1, 2, 3인 PlannedTreatmentRecord가 생깁니다.

val treatments = definition.items.flatMapIndexed { bomOrder, item ->
(1..item.repeatCount).map { sequenceNo ->
PlannedTreatmentRecord(
bomItemId = item.bomItemId,
sequenceNo = sequenceNo,
bomOrder = bomOrder,
minimumIntervalDays = item.minimumIntervalDays,
preferredIntervalDays = item.preferredIntervalDays,
maximumIntervalDays = item.maximumIntervalDays,
earliestStartAt = null,
latestStartAt = null,
status = PlannedTreatmentStatus.PLANNED,
// 시술 시간과 필요한 의료진·장비·공간도 함께 복사한다.
)
}
}

earliestStartAtlatestStartAtnull입니다. 회차는 만들어졌지만 예약 가능 구간은 아직 계산되지 않았습니다. 구매 희망 일정도 확정 예약으로 바꾸지 않고 별도의 불변 값으로 보존합니다.

회차별 항목을 정리하면 다음 질문에 구체적으로 답할 수 있습니다.

  • 어떤 회차가 완료됐는가?
  • 어떤 회차가 아직 계획 상태인가?
  • 특정 회차에만 적용되는 시술 시간과 자원 요구는 무엇인가?
  • 환불이나 부분 완료가 어느 회차에 영향을 줬는가?

가변 remainingCount만 저장하면 이 질문을 다시 구성하기 어렵습니다. 계획 항목과 변경 이력을 기준으로 잔여 횟수를 계산하면, 운영 화면의 숫자와 실제 상태가 어떻게 연결되는지 설명할 수 있습니다.

총 3회 중 완료 1회와 남은 2회, 다음 예약 가능 구간, 회차별 이행 상태, 운영자의 다음 작업과 현재 구현 경계를 함께 보여주는 방문 계획 관리 화면 시안

실제 제품 화면을 캡처한 것이 아니라 현재 구현의 데이터를 바탕으로 구성한 운영 화면 시안입니다. 운영자는 구매 권리, 회차별 상태, 임상 완료 근거와 다음 예약 가능 구간을 한 화면에서 대조할 수 있습니다. 더 넓은 흐름은 진료 계획·예약·수용량 시각 자료상품 예약 운영 특성 분류에서 확인할 수 있습니다.

회차 간격은 미래 예약 시간이 아니라 다음 예약 가능 구간이다

섹션 제목: “회차 간격은 미래 예약 시간이 아니라 다음 예약 가능 구간이다”

대부분의 반복 상품은 회차 사이에 별도의 임상 간격이 없을 수 있습니다. 이 경우 간격 필드는 비어 있고, 다음 회차는 환자 희망 일정과 병원 수용량 같은 다른 조건으로 제안할 수 있습니다. 다만 간격 제약이 없다는 말은 여러 회차를 같은 방문으로 자동으로 묶는다는 뜻이 아닙니다. 방문 묶음은 패키지 실행 규칙이 담당할 별도 문제입니다.

일부 시술은 직전 회차가 끝난 뒤 최소 N일에서 최대 M일 사이에 다음 회차를 진행해야 합니다. 이때 기준은 구매일이나 미리 만든 예약일이 아니라 직전 회차의 실제 완료 시각입니다. 예를 들어 최소 21일, 권장 28일, 최대 42일이라면 예약 후보는 완료 시각에 각 간격을 더한 범위에서 찾아야 합니다.

현재 구현은 두 부분으로 나뉩니다.

  1. 상품 항목의 minimumIntervalDays, preferredIntervalDays, maximumIntervalDays를 검증하고 각 회차에 복사한다.
  2. 실행 계획에 BLOCKING 의존 관계가 있으면 AppointmentProposalService가 선행 회차의 실제 완료 시각을 읽어 최소·최대 구간을 검사한다.

여기에는 아직 연결되지 않은 경계가 있습니다. CatalogDefinitionValidator는 반복 회차 사이에 임시 인접 연결을 만들어 전체 그래프가 순환하지 않는지만 확인합니다. 그러나 AppointmentPlanFactory는 상품 정의에 명시된 의존 관계만 계획의 TreatmentDependencyRecord로 만듭니다. 따라서 반복 횟수를 펼쳤다는 사실만으로 1회차에서 2회차, 2회차에서 3회차로 이어지는 실행 의존 관계가 자동 생성되지는 않습니다.

즉, 간격 값은 계획 항목에 보존되지만 반복 회차 사이의 자동 집행 연결은 아직 없습니다. 필드가 존재하는 것과 예약 후보가 실제로 그 값을 집행하는 것은 서로 다른 완료 조건입니다.

PurchaseCompletedHandler와 AppointmentPlanFactory가 세 회차를 만들고, 실제 방문 완료는 새 계획 버전을 추가하며, 외부 환불 근거는 연결된 의무를 취소하고, 반복 회차 간격의 자동 집행 연결은 현재 구현 경계로 분리한 도표
완료와 환불은 기존 계획을 덮어쓰지 않고 새 계획 버전으로 반영합니다. 점선 카드는 성공 흐름이 아니라 아직 연결되지 않은 구현 경계입니다.

방문 완료는 잔여 횟수 숫자만 줄이는 일이 아니다

섹션 제목: “방문 완료는 잔여 횟수 숫자만 줄이는 일이 아니다”

환자 A가 1회차 시술을 실제로 마치면 임상 기준 데이터 원본이 완료 근거를 보냅니다. TreatmentFulfillmentHandler는 활성 계획 버전을 복사하고, 해당 회차만 COMPLETED로 바꾼 새 버전을 추가합니다. 기존 계획 버전의 행은 변경하지 않습니다.

계획 버전1회차2회차3회차화면에서 계산할 수 있는 잔여 회차
최초 버전PENDINGPENDINGPENDING3
완료 반영 버전COMPLETEDPENDINGPENDING2

사용자가 보는 “2회 남음”은 유용한 표시입니다. 그러나 저장 모델의 핵심은 숫자 3을 2로 변경하는 것이 아닙니다. 어떤 회차가 언제 완료됐고 어떤 의무가 아직 남아 있는지 새 계획 버전에서 확인할 수 있어야 합니다. 부분 완료가 들어오면 완료된 부분은 이력으로 남기고, 남은 부분은 새 식별자를 가진 계획 항목으로 추가합니다.

다음 회차의 간격도 “사용 횟수 1”이라는 숫자로 계산할 수 없습니다. 예약 서비스는 선행 회차의 완료 상태와 실제 완료 시각을 읽어야 합니다.

환불은 완료 이력을 지우지 않는다

섹션 제목: “환불은 완료 이력을 지우지 않는다”

환불은 예외이지만 N회 상품이라면 반드시 다뤄야 합니다. 환자 A가 1회차를 완료한 뒤 남은 회차를 환불받는 경우, 환불 가능 여부와 금액은 커머스나 환불 서비스가 결정합니다. 예약 서비스는 그 기준 데이터 원본이 확정한 REFUNDED 근거를 받아 계획에 반영합니다.

PlanDirtySetResolver는 직접 환불된 계획 항목과 전이적으로 연결된 BLOCKING 후속 의무를 취소합니다. 독립적으로 진행할 수 있는 NON_BLOCKING 항목은 계획 상태로 남깁니다.

대상환불 근거 반영 뒤 상태이유
직접 환불된 남은 회차CANCELLED외부 기준 데이터 원본이 취소를 확정
해당 회차가 없으면 진행할 수 없는 BLOCKING 후속CANCELLED선행 의무가 사라져 실행할 수 없음
독립적인 NON_BLOCKING 항목PENDING 유지환불 대상과 별도로 이행 가능
이미 완료한 1회차의 과거 버전변경 없음실제 완료 이력은 환불로 삭제하지 않음

환불도 새 계획 버전으로 기록합니다. 따라서 “3회 상품 중 1회 완료 후 2회 환불”이라는 결과를 단순히 “잔여 0”으로만 표현하지 않고, 완료된 회차와 취소된 회차를 각각 설명할 수 있습니다.

같은 구매 이벤트가 다시 와도 계획은 하나다

섹션 제목: “같은 구매 이벤트가 다시 와도 계획은 하나다”

메시지 브로커는 같은 이벤트를 다시 전달할 수 있고, 처리 결과를 받지 못한 호출자는 재시도할 수 있습니다. 이때 같은 구매로 계획이 두 개 생기면 잔여 회차와 예약 후보가 모두 중복됩니다.

PurchaseCompletedHandler는 다음 두 식별 경계를 함께 사용합니다.

  • 이벤트 ID가 이미 최종 처리되었다면 중복 결과를 반환한다.
  • 같은 테넌트·병원·구매 기준 데이터 원본·구매 ID로 계획이 존재하면 PURCHASE_ALREADY_PLANNED로 수렴한다.

계획 저장과 아웃박스 기록도 같은 트랜잭션에 들어갑니다. 완료·환불 이벤트 역시 같은 이벤트를 다시 처리해 계획 버전과 아웃박스를 중복 생성하지 않는 테스트가 있습니다. 멱등성은 단순한 오류 방지가 아니라 “구매 한 건은 방문 계획 한 건”이라는 업무 규칙을 지키는 장치입니다.

현재 구현과 아직 연결되지 않은 경계

섹션 제목: “현재 구현과 아직 연결되지 않은 경계”

현재 구현은 다음 범위를 보장합니다.

  • 구매 완료 이벤트에서 활성 상품 버전을 읽어 방문 계획을 만든다.
  • repeatCount만큼 회차별 계획 항목을 만들고 예약 시간은 비워 둔다.
  • 완료·부분 완료·환불 근거를 기존 버전을 변경하지 않고 새 계획 버전으로 반영한다.
  • 명시적인 실행 의존 관계가 있으면 선행 완료 시각을 기준으로 최소·최대 예약 구간을 검사한다.
  • 같은 구매나 같은 이행 이벤트가 반복돼도 계획과 계획 버전을 중복 생성하지 않는다.

반면, 상품 항목에 저장된 회차 간격만으로 반복 회차 사이의 실행 의존 관계를 자동 생성하는 기능은 아직 연결되지 않았습니다. 간격이 없는 회차를 같은 방문으로 묶는 규칙도 이번 구현의 책임이 아닙니다. 다음 구현 단계에서는 상품 BOM, 회차 인접 관계, 방문 묶음 규칙을 실행 계획에 어떻게 명시할지 다뤄야 합니다.

저장된 값, 검증한 값, 실제 예약 후보 생성에서 집행하는 값을 구분해야 현재 시스템의 범위를 정확히 설명할 수 있습니다.

N회 상품은 미래 예약 N건이 아니라 N개의 방문 의무를 가진 계획으로 시작합니다.

  • 구매 완료 이벤트는 정확한 상품 버전과 구매 정보를 바탕으로 AppointmentPlan을 만든다.
  • repeatCountsequenceNo가 있는 회차별 PlannedTreatment로 펼쳐진다.
  • 구매 시점에는 미래 예약 시간을 만들지 않는다.
  • 방문 완료와 환불은 가변 카운터가 아니라 불변 계획 버전으로 남긴다.
  • 간격은 직전 회차의 실제 완료 시각을 기준으로 계산해야 한다.
  • 반복 회차의 자동 인접 의존 관계와 방문 묶음은 아직 별도로 연결해야 한다.

이제 구매 권리, 회차별 의무, 실제 방문 완료, 남은 의무를 서로 다른 사실로 설명할 수 있습니다. 다음 글에서는 서로 다른 시술로 구성된 패키지 상품을 실행 그래프로 전개하고, 어떤 항목을 같은 방문에 묶을 수 있는지 살펴봅니다.

댓글

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