병원 예약 SaaS 개발기 Part 5: 병원 업무 규칙을 Timefold Constraint로 번역하기

Part 4에서는 한 환자의 예약 가능 시간을 빠르게 계산하는 경로와 여러 예약의 전체 배치를 비교하는 경로를 나눴습니다. Timefold Solver는 두 번째 문제에 적합하지만, 도구를 연결하는 것만으로 좋은 일정이 나오지는 않습니다.
“의사가 없는 시간에는 예약할 수 없다”는 말은 비교적 명확합니다. 반면 “환자가 원한 날짜를 가급적 지킨다”, “기존 담당 의사를 유지한다”, “의사별 업무량을 고르게 나눈다”는 목표는 서로 충돌할 수 있습니다. 병원에서 쓰는 이런 문장을 Solver가 비교할 수 있는 제약 조건과 점수로 번역해야 합니다.
이번 글에서는 현재 clinic-appointment 구현을 기준으로 계획 대상과 문제 정보를 구분하고, 12개 Hard
Constraint Stream과 6개 Soft Constraint가 HardSoftScore를 만드는 과정을 살펴봅니다. 마지막에는 테스트와
벤치마크가 무엇을 증명하고, 무엇까지는 증명하지 못하는지도 함께 짚겠습니다.
요구사항: “안 된다”와 “더 좋다”를 먼저 나눈다
섹션 제목: “요구사항: “안 된다”와 “더 좋다”를 먼저 나눈다”일정 최적화 요구사항을 한 목록에 모아 두면 우선순위가 흐려집니다. 업무시간 밖의 예약과 원래 담당 의사를 바꾼 예약이 똑같은 감점 항목이라면, Solver는 다른 선호 점수를 얻기 위해 업무시간 위반을 선택할 수도 있습니다. 그래서 현재 모델은 점수를 두 단계로 나눕니다.
- Hard Constraint: 한 번이라도 위반하면 운영 가능한 일정으로 받아들이지 않는 규칙
- Soft Constraint: Hard 규칙을 모두 지킨 일정 사이에서 어느 쪽이 더 나은지 비교하는 목표
HardSoftScore는 Hard 점수를 먼저 비교하고, Hard 점수가 같을 때 Soft 점수를 봅니다. 0hard/-500soft는
필수 규칙을 어기지 않았지만 선호 목표에서 500점의 감점이 있다는 뜻입니다. 반면 -1hard/0soft는 Soft
점수가 좋아 보여도 운영 가능한 해답으로 취급할 수 없습니다.
이 구분은 기술적인 편의를 위한 것이 아니라 병원 정책을 반영합니다. 예약을 조금 늦게 잡는 일과 휴진일에 예약을 잡는 일은 같은 종류의 손해가 아닙니다. 먼저 가능한 일정의 경계를 만들고, 그 안에서 운영 품질을 조정해야 합니다.
모델: Solver가 바꾸는 값과 판단에 쓰는 값을 분리한다
섹션 제목: “모델: Solver가 바꾸는 값과 판단에 쓰는 값을 분리한다”AppointmentPlanning은
Solver가 배치하는 예약입니다. 계획 변수는 doctorId, appointmentDate, startTime 세 가지입니다.
이미 확정됐거나 진행 중이어서 움직이면 안 되는 예약은 @PlanningPin이 붙은 pinned 값으로 고정합니다.
ScheduleSolution은 예약 목록과 함께 병원, 의사, 진료 유형, 업무시간, 휴식시간, 부재, 휴진, 공휴일, 장비와 장비 사용 불가 정보를 문제 정보로 제공합니다. Solver는 이 정보 자체를 바꾸지 않고, 예약의 세 계획 변수를 바꿔 가며 후보 일정을 평가합니다.

현재 AppointmentConstraintProvider는
Hard 제약 함수 12개와 Soft 제약 함수 6개를 등록합니다. 이름은 H1부터 H11까지지만 H4를 요일별 휴식시간
H4a와 병원 기본 휴식시간 H4b로 나눴기 때문에 실제 Hard Constraint Stream은 12개입니다.
override fun defineConstraints(factory: ConstraintFactory): Array<Constraint> = arrayOf( HardConstraints.withinOperatingHours(factory), HardConstraints.withinDoctorSchedule(factory), HardConstraints.noDoctorAbsenceConflict(factory), HardConstraints.noBreakTimeConflict(factory), HardConstraints.noDefaultBreakTimeConflict(factory), // H5~H11 생략 SoftConstraints.doctorLoadBalance(factory), SoftConstraints.minimizeGaps(factory), SoftConstraints.preferOriginalDoctor(factory), // S4~S6 생략)Hard Constraint: 운영할 수 없는 일정을 걸러낸다
섹션 제목: “Hard Constraint: 운영할 수 없는 일정을 걸러낸다”Hard Constraint는 단순히 “시간이 겹치면 안 된다” 한 줄로 끝나지 않습니다. 같은 예약이라도 병원 전체의 업무시간, 담당 의사의 근무와 부재, 휴진과 공휴일, 동시 진료 정책, 장비 수량과 고장 상태를 차례로 확인해야 합니다.
| 구분 | 현재 Constraint | 업무 규칙 |
|---|---|---|
| H1 | withinOperatingHours | 예약 전체 시간이 해당 요일의 활성 업무시간 안에 있어야 한다 |
| H2 | withinDoctorSchedule | 담당 의사의 근무시간 안에 있어야 한다 |
| H3 | noDoctorAbsenceConflict | 전일 또는 일부 시간의 의사 부재와 겹치지 않아야 한다 |
| H4a | noBreakTimeConflict | 요일별 휴식시간과 겹치지 않아야 한다 |
| H4b | noDefaultBreakTimeConflict | 병원의 기본 휴식시간과 겹치지 않아야 한다 |
| H5 | noClinicClosureConflict | 전일 또는 일부 시간의 임시 휴진과 겹치지 않아야 한다 |
| H6 | noHolidayConflict | 공휴일 진료를 하지 않는 병원은 공휴일 예약을 받지 않는다 |
| H7 | maxConcurrentPatientsPerDoctor | 같은 의사의 동시 진료 수 제한을 지킨다 |
| H8 | equipmentAvailability | 같은 장비의 동시 사용 수가 보유 수량을 넘지 않는다 |
| H9 | providerTypeMatch | 의사의 진료 제공자 유형이 진료 유형의 요구사항과 맞아야 한다 |
| H10 | doctorBelongsToClinic | 배정한 의사가 해당 병원 소속이어야 한다 |
| H11 | equipmentUnavailabilityConflict | 고장·점검 등 장비 사용 불가 시간과 겹치지 않아야 한다 |
제약 코드는 후보 예약과 문제 정보를 join, ifExists, ifNotExists로 연결합니다. 예를 들어 H3은 같은
의사와 날짜의 부재 정보를 찾고, 전일 부재이거나 시간 구간이 겹치면 Hard 점수를 감점합니다.
fun noDoctorAbsenceConflict(factory: ConstraintFactory): Constraint = factory.forEach(AppointmentPlanning::class.java) .filter { it.doctorId != null && it.appointmentDate != null && it.startTime != null } .ifExists( DoctorAbsenceRecord::class.java, Joiners.equal({ it.doctorId!! }, { it.doctorId }), Joiners.equal({ it.appointmentDate!! }, { it.absenceDate }), Joiners.filtering { appointment, absence -> absence.startTime == null || (absence.endTime != null && appointment.startTime!! < absence.endTime && absence.startTime < appointment.endTime!!) }, ) .penalize(HardSoftScore.ONE_HARD)여기서 중요한 것은 시작 시각만 비교하지 않는다는 점입니다. 10시 50분에 시작하는 30분 진료는 11시에
시작하는 휴식시간과 겹칩니다. 업무 규칙은 예약의 startTime과 진료시간으로 계산한 endTime을 함께 봐야
합니다. 전일 부재와 부분 부재, 전일 휴진과 부분 휴진도 같은 이유로 별도 데이터 형태를 해석합니다.
Soft Constraint: 가능한 일정 사이의 우선순위를 만든다
섹션 제목: “Soft Constraint: 가능한 일정 사이의 우선순위를 만든다”Hard 규칙을 모두 지킨 해답도 하나가 아닙니다. 어떤 일정은 기존 담당 의사를 유지하지만 예약 사이의 빈 시간이 길고, 다른 일정은 촘촘하지만 환자가 원한 날짜에서 멀어질 수 있습니다. 현재 구현은 다음 여섯 가지 목표를 Soft 점수로 비교합니다.
| 구분 | 목표 | 감점 방식 |
|---|---|---|
| S1 | 의사별 예약 수 분산 | 같은 날짜와 의사에 배정된 예약 쌍마다 100점 |
| S2 | 의사 일정의 빈 시간 최소화 | 떨어진 예약 사이의 분 수마다 10점 |
| S3 | 재배정 때 기존 담당 의사 유지 | originalDoctorId와 달라지면 1,000점 |
| S4 | 재배정 대상은 더 이른 날짜 선호 | 요청 날짜보다 늦어진 일 수마다 10점 |
| S5 | 같은 장비를 쓰는 예약을 연속 배치 | 장비 예약 사이의 분 수마다 5점 |
| S6 | 환자가 요청한 날짜와 가깝게 배치 | 요청 날짜와의 절대 일 수마다 500점 |
가중치가 곧 업무 우선순위입니다. 현재 값만 보면 기존 담당 의사를 바꾸는 1,000점 감점은 같은 의사에게 예약 한 쌍이 더 모이는 100점 감점보다 큽니다. 하지만 일정 전체에서는 같은 제약이 여러 번 적용되므로 단일 가중치만 보고 최종 우선순위를 단정하면 안 됩니다. 예약 수와 간격에 따라 누적 점수가 달라집니다.
S4와 S6은 비슷해 보여도 계산 방향이 다릅니다. S4는 요청 날짜보다 뒤로 밀린 경우만 감점해 빠른 재배정을 유도합니다. S6은 요청 날짜 전후의 차이를 모두 감점해 원래 희망일과 가까운 배치를 선호합니다. 두 목표를 함께 둘 것인지, 병원별로 가중치를 바꿀 것인지는 운영 정책으로 다시 검토해야 합니다.
점수는 설명할 수 있어야 한다
섹션 제목: “점수는 설명할 수 있어야 한다”Solver가 반환한 최종 점수 하나만 보여 주면 운영자는 왜 담당 의사가 바뀌었는지, 어떤 목표 때문에 예약일이
밀렸는지 알기 어렵습니다. 제약 이름에 H3 noDoctorAbsenceConflict, S3 preferOriginalDoctor처럼 식별 가능한
이름을 붙인 이유가 여기에 있습니다. Timefold의 Constraint Match 분석을 운영 화면이나 로그에 연결하면
어떤 예약이 어느 제약에서 얼마나 감점됐는지 추적할 수 있습니다.
다만 현재 SolverResult는 합산된 HardSoftScore, 실행 시간, 배정 결과와 실행 범위의 통계까지만 반환합니다.
제약별 감점 내역을 관리자에게 보여 주는 설명 API는 아직 구현하지 않았습니다. 최적화 결과를 사람이 승인해야
하는 업무라면 “점수가 더 좋다”를 넘어 변경 이유를 설명하는 기능이 다음 요구사항이 됩니다.
고정된 예약도 설명에 포함해야 합니다. pinned 예약은 Solver가 움직이지 않지만 다른 예약이 선택할 수 있는
시간과 자원을 줄입니다. 따라서 같은 점수라도 전체 예약 수와 고정 예약 수, 날짜 범위를 함께 남겨야 실행
결과를 재현하고 비교하기 쉽습니다.
테스트: 제약 하나를 작은 장면으로 검증한다
섹션 제목: “테스트: 제약 하나를 작은 장면으로 검증한다”전체 Solver를 실행하는 테스트만으로는 점수가 달라졌을 때 어느 규칙이 원인인지 찾기 어렵습니다.
ConstraintVerifierTest는
ConstraintVerifier로 특정 제약 하나만 선택하고, 최소한의 예약과 문제 정보를 넣어 예상한 감점이 발생하는지
확인합니다.
현재 테스트에는 업무시간, 의사 부재, 휴진, 제공자 유형, 병원 소속 같은 Hard 규칙과 부하 분산, 기존 담당 의사 유지 같은 Soft 목표의 대표 사례가 있습니다. 하지만 18개 제약 함수마다 독립 테스트가 하나씩 갖춰진 상태는 아닙니다. 특히 경계 시각, 동시 진료 수와 장비 수량이 2 이상인 경우, 여러 감점이 누적되는 경우는 추가 회귀 테스트가 필요합니다.
소스 리뷰에서 확인할 구현 한계도 있습니다. 현재 H7과 H8은 겹치는 예약 쌍을 만들고 허용 수가 1보다 작을 때 감점하는 방식입니다. 따라서 동시 진료 허용 수나 장비 수량이 2 이상일 때 세 번째, 네 번째 예약까지 일반화해 세는 구현은 아닙니다. 요구사항 표의 문장과 현재 계산 범위가 완전히 같다고 가정하지 말고, 다중 용량을 지원하려면 그룹별 동시 사용량을 집계하는 제약과 테스트를 보강해야 합니다.
벤치마크: 시간 제한 안에서 더 나은 해답을 찾는다
섹션 제목: “벤치마크: 시간 제한 안에서 더 나은 해답을 찾는다”이 프로젝트의 Solver 벤치마크 보고서는 2026년 4월 19일 Apple M4 Pro와 JDK 25 환경에서 작은 문제부터 100개 예약까지 실행한 기준선을 기록했습니다. 세 시나리오 모두 Hard 점수 0인 실행 가능한 해답을 반환했습니다.

| 시나리오 | 예약 수 | 측정 시간 / 제한 | 점수 | 초당 점수 계산 |
|---|---|---|---|---|
| Small | 10 | 5,030ms / 10,000ms | 0hard/0soft | 133,758 |
| Medium | 30 | 8,270ms / 15,000ms | 0hard/-500soft | 100,613 |
| Large | 100 | 16,163ms / 30,000ms | 0hard/-2000soft | 116,135 |
탐색 공간은 예약이 늘면서 3.57 × 10²²에서 3.37 × 10³²⁵까지 커집니다. 이 숫자는 모든 조합을
차례로 검사할 수 없다는 점을 보여 줍니다. 현재 Solver 설정은 FIRST_FIT_DECREASING Construction Heuristic으로
초기 해답을 만들고 LATE_ACCEPTANCE Local Search로 개선합니다. 시간 제한은 최적해를 보장하는 숫자가 아니라
탐색에 쓸 예산입니다.
벤치마크를 읽을 때는 점수와 시간, 입력 규모를 함께 봐야 합니다. Soft 점수가 더 낮다고 해서 이전 시나리오보다 알고리즘이 나빠졌다는 뜻은 아닙니다. 예약 수와 문제 정보가 다르면 발생 가능한 감점 횟수도 달라집니다. 운영 배포 전에는 실제 병원의 예약 분포와 장비 수량, 고정 예약 비율을 반영한 데이터셋으로 회귀 기준을 다시 잡아야 합니다.
다음 요구사항: 제약 조건도 운영 정책이다
섹션 제목: “다음 요구사항: 제약 조건도 운영 정책이다”Constraint 코드는 한 번 작성하고 끝나는 알고리즘 설정이 아닙니다. 병원이 공휴일 진료를 시작하거나 장비를 추가하고, 특정 진료의 동시 환자 수를 늘리면 Hard 규칙의 데이터와 계산 방식이 달라집니다. 환자 만족도를 더 우선하면 요청 날짜와 기존 담당 의사의 가중치도 다시 조정해야 합니다.
운영 가능한 제약 정책을 만들려면 다음 정보가 필요합니다.
- 제약 이름과 업무 설명, 적용 대상
- Hard/Soft 구분과 가중치를 정한 이유
- 정책 변경 전후의 대표 일정과 점수 차이
- 제약별 최소 단위 테스트와 전체 Solver 회귀 벤치마크
- 최적화 결과를 승인한 사람과 실제 반영한 시점
Part 6에서는 이런 제약이 실제 운영 사건을 만났을 때 어떻게 확장되는지 살펴봅니다. 갑작스러운 휴진과 장비
고장은 여러 예약을 PENDING_RESCHEDULE 상태로 바꾸고 후보 생성, 전체 최적화, 승인, 이력과 알림을 하나의
업무 흐름으로 연결하게 만듭니다.
구현 코드와 자료 살펴보기
섹션 제목: “구현 코드와 자료 살펴보기”- 일정 최적화 모듈: 계획 모델, 제약 조건, Solver 설정과 실행 서비스를 모아 둔 모듈입니다.
- ConstraintProvider: 현재 등록된 12개 Hard 함수와 6개 Soft 함수를 확인할 수 있습니다.
- Hard Constraints: 업무시간, 부재, 휴진, 동시 진료 수, 장비와 제공자 유형 규칙을 계산합니다.
- Soft Constraints: 부하 분산, 빈 시간, 기존 담당 의사와 요청 날짜 선호의 가중치를 정의합니다.
- 제약 조건 테스트: 작은 입력으로 개별 Constraint의 감점을 검증하는 예제입니다.
- Solver 설정: Construction Heuristic, Local Search와 종료 조건을 구성합니다.
- Solver 벤치마크 보고서: 세 가지 입력 규모의 로컬 실행 기준선을 기록합니다.
- Timefold 계획 문제 모델링: 계획 대상, 계획 변수, 문제 정보와 해답 모델을 설명하는 공식 문서입니다.
- Timefold 점수 계산과 Constraint Streams: Hard/Soft 점수, Constraint Streams와 ConstraintVerifier를 설명하는 공식 문서입니다.
시리즈 링크
섹션 제목: “시리즈 링크”- Part 1: 병원 예약은 CRUD로 끝나지 않는다
- Part 2: 예약 상태는 enum이 아니다
- Part 3: 병원마다 다른 업무시간과 자원으로 예약 가능 시간을 계산하기
- Part 4: 한 건의 예약 가능 시간 조회와 전체 일정 최적화는 다르다
- Part 5: 병원 업무 규칙을 Timefold Constraint로 번역하기
- Part 6 예정: 휴진과 장비 고장은 예약 설계를 어떻게 바꾸는가
- Part 7 예정: 완성 뒤가 진짜 시작이다
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.