병원 예약 SaaS 개발기 Part 4: 한 건의 예약 가능 시간 조회와 전체 일정 최적화는 다르다

Part 3에서는 병원과 의사의 업무시간을 겹치고, 휴식·휴진·부재를 제외한 뒤 동시 진료 수와 장비 상태까지 확인해 예약 가능한 시간을 계산했습니다. 이 계산은 “김 환자가 박 의사에게 다음 주 화요일 진료를 받을 수 있는 시간은 언제인가?”라는 질문에 잘 답합니다.
그런데 가능한 시간 중 하나를 골랐다고 해서 그 선택이 병원의 전체 일정에도 좋은 결과를 내는 것은 아닙니다. 오전 10시에 30분 진료를 넣을 수 있어도, 그 자리를 먼저 사용하면 뒤에 들어올 90분 검사와 장비 일정을 이어서 배치하지 못할 수 있습니다. 특정 의사의 일정은 여유로운데 다른 의사에게 예약이 몰릴 수도 있습니다.
이번 글에서는 이 두 문제를 같은 알고리즘으로 해결하려 하지 않습니다. 먼저 요구사항의 범위와 응답 시점을 구분하고, Timefold Solver가 어떤 도구인지 알아본 뒤, 실시간 예약 가능 시간 조회와 전체 일정 최적화를 별도 서비스로 설계한 이유를 구현과 휴진 재배정 사례에서 살펴보겠습니다.
요구사항: 가능한 시간과 더 좋은 일정은 다른 답이다
섹션 제목: “요구사항: 가능한 시간과 더 좋은 일정은 다른 답이다”환자가 예약 화면을 열었을 때는 오래 기다리게 할 수 없습니다. 이미 병원, 날짜, 의사, 진료 유형을 선택했으므로 그 범위 안에서 가능한 시간 목록을 빠르게 보여 주면 됩니다. 이때 필요한 답은 “어떤 후보가 업무 규칙을 모두 통과하는가?”입니다.
반면 병원 관리자가 다음 주 일정을 정리하거나 휴진일의 여러 예약을 다시 배치할 때는 한 예약만 봐서는 부족합니다. 여러 예약이 같은 의사와 장비를 나눠 쓰고, 환자가 요청한 날짜와 기존 담당 의사도 가능한 한 지켜야 합니다. 이때는 “가능한 배치가 있는가?”를 넘어 “가능한 배치 중 어느 쪽이 전체 운영에 더 나은가?”를 비교해야 합니다.
| 구분 | 실시간 예약 가능 시간 조회 | 전체 일정 최적화 |
|---|---|---|
| 질문 | 이 환자가 선택할 수 있는 시간은 언제인가? | 여러 예약을 어떻게 배치해야 전체 일정이 더 나은가? |
| 처리 범위 | 한 병원·날짜·의사·진료 유형 | 한 병원의 날짜 범위·여러 예약·의사·장비 |
| 판단 기준 | 각 후보가 모든 필수 조건을 통과하는가? | 여러 가능한 배치 중 업무 목표의 점수가 더 높은가? |
| 응답 시점 | 환자의 API 요청 안에서 답한다 | 정해진 실행 시간 안에서 더 나은 해답을 탐색한다 |
| 결과 | 가능한 시간 목록 | 예약별 의사·날짜·시작 시각과 전체 점수 |
이 차이를 무시하고 모든 요청을 최적화 문제로 만들면 단순 조회가 불필요하게 무거워집니다. 반대로 먼저 찾은 빈 시간에 여러 예약을 하나씩 넣기만 하면, 각 선택은 가능해도 전체 일정의 품질은 낮아질 수 있습니다.
먼저, Timefold Solver는 무엇인가
섹션 제목: “먼저, Timefold Solver는 무엇인가”Timefold Solver는 한정된 사람·시간·장비를 여러 업무에 배정해야 하는 계획 문제를 풀기 위한 JVM 기반 최적화 도구입니다. JVM 애플리케이션에 포함할 수 있어 Kotlin과 Spring 기반 서비스에도 연결할 수 있습니다.
다만 Timefold Solver가 병원 운영을 알아서 이해하는 것은 아닙니다. 개발자가 다음 내용을 모델과 코드로 정의해야 합니다.
- 계획 대상: Solver가 배치를 바꿀 수 있는 것은 무엇인가
- 선택값: 계획 대상에서 Solver가 고를 수 있는 값은 무엇인가
- 문제 정보: 해답을 평가할 때 참고하지만 Solver가 바꾸지는 않는 정보는 무엇인가
- 제약 조건과 점수: 절대로 어기면 안 되는 규칙과, 가능하면 더 잘 만족하고 싶은 목표는 무엇인가
이 서비스에서는 다음처럼 대응합니다.
| Timefold 개념 | 병원 예약에서의 의미 |
|---|---|
| 계획 대상(Planning Entity) | 다시 배치할 수 있는 예약 |
| 선택값(Planning Variable) | 담당 의사, 예약 날짜, 시작 시각 |
| 문제 정보(Problem Fact) | 병원 업무시간, 의사 일정과 부재, 진료 유형, 장비, 휴진일과 공휴일 |
| 제약 조건과 점수 | 시간 중복과 업무시간 위반을 막고, 요청 날짜와 기존 담당 의사를 가능한 한 지키는 기준 |
AppointmentPlanning은
예약 하나를 계획 대상으로 표현합니다. 이 객체의 doctorId, appointmentDate, startTime을 Solver가 바꿔
보며 여러 해답을 만듭니다. 이미 확정됐거나 접수·진료 중이거나 완료된 예약은 pinned로 고정해 움직이지
않습니다.
@PlanningEntityclass AppointmentPlanning( @field:PlanningPin val pinned: Boolean = false,
@field:PlanningVariable(valueRangeProviderRefs = ["doctorRange"]) var doctorId: Long? = null,
@field:PlanningVariable(valueRangeProviderRefs = ["dateRange"]) var appointmentDate: LocalDate? = null,
@field:PlanningVariable(valueRangeProviderRefs = ["timeSlotRange"]) var startTime: LocalTime? = null,)ScheduleSolution은 계획 대상인 예약 목록과 판단에 필요한 병원·의사·장비 정보를 한 문제로 묶고, 계산된 점수를 보관합니다. Timefold는 이 점수를 비교하면서 실행 시간 안에 더 나은 해답을 찾습니다. 즉, Solver는 업무 우선순위를 정하는 주체가 아니라 우리가 코드로 표현한 우선순위를 일관되게 탐색하는 실행기입니다.
계획 대상과 문제 정보를 구분하는 방법은 Timefold 공식 문서의 계획 문제 모델링에서 더 자세히 볼 수 있습니다. 필수 제약과 선호 목표를 실제 병원 규칙으로 만드는 과정은 Part 5에서 다루겠습니다.
설계: 같은 일정 문제라도 서비스 경계를 나눈다
섹션 제목: “설계: 같은 일정 문제라도 서비스 경계를 나눈다”이 프로젝트는 실시간 조회와 전체 최적화를 별도 경로로 나눴습니다. SlotCalculationService는 한 요청의 범위를 먼저 제한하고 가능한 후보를 계산합니다. SolverService는 날짜 범위 안의 여러 예약과 자원을 하나의 계획 문제로 불러와 해답을 비교합니다.

이 경계를 나누면 환자 화면의 응답 시간을 전체 최적화 실행 시간에 묶지 않아도 됩니다. 전체 일정의 점수 정책을 바꾸더라도 “이 시간에 예약할 수 있는가?”라는 기본 계산 계약은 유지할 수 있습니다.
여기서 실시간 경로를 Greedy라고 부를 때 주의할 점이 있습니다. 현재 구현은 첫 번째 가능한 시간 하나만
반환하지 않습니다. 정해진 병원·날짜·의사·진료 유형의 범위에서 조건을 통과한 모든 후보를 목록으로 반환합니다.
다만 여러 예약의 조합을 만들고 전체 점수를 다시 비교하지 않으므로, 전역 최적화와 구분해 빠른 후보 계산
경로라고 부르는 것입니다.
구현: 실시간 경로는 탐색 범위를 먼저 제한한다
섹션 제목: “구현: 실시간 경로는 탐색 범위를 먼저 제한한다”환자가 예약 가능 시간을 조회하면 API는 병원, 의사, 진료 유형, 날짜를 SlotQuery에 담아 계산 서비스에
전달합니다. 서비스는 공휴일과 전일 휴진처럼 하루 전체를 막는 조건을 먼저 확인한 뒤 병원과 의사의 공통
업무시간을 구합니다. 그 안에서 예약 시작 간격마다 후보를 만들고 동시 진료 수와 장비 조건으로 거릅니다.
fun getAvailableSlots(...): ResponseEntity<ApiResponse<List<AvailableSlot>>> { val query = SlotQuery(clinicId, doctorId, treatmentTypeId, date) val slots = slotCalculationService.findAvailableSlots(query) return ResponseEntity.ok(ApiResponse.success(slots))}이 계산은 조회 범위가 작고 종료 조건이 분명합니다. 한 후보가 탈락해도 다른 날짜의 여러 예약을 다시 배치하지 않습니다. 그래서 환자 화면에서 가능한 시간 목록을 요청할 때마다 실행하기에 적합합니다.
그러나 이 목록은 예약 확정을 보장하는 좌석표가 아닙니다. 목록을 받은 뒤 다른 사용자가 같은 시간과 장비를 먼저 예약할 수 있습니다. 최종 확정 단계에서는 현재 데이터로 조건을 다시 확인하고, 트랜잭션과 동시성 제어로 중복 예약을 막아야 합니다. 실시간 계산과 Solver 어느 쪽도 데이터베이스의 동시성 제어를 대신하지 않습니다.
구현: Solver 경로는 여러 예약을 하나의 해답으로 묶는다
섹션 제목: “구현: Solver 경로는 여러 예약을 하나의 해답으로 묶는다”SolverService.optimize()는 병원과 날짜 범위를 받습니다. 해당 범위의 예약, 의사, 진료 유형, 장비,
업무시간, 휴식시간, 부재, 휴진일, 공휴일을 읽어 하나의 ScheduleSolution을 만든 뒤 Solver를 실행합니다.
fun optimize( clinicId: Long, dateRange: ClosedRange<LocalDate>, timeLimit: Duration = Duration.ofSeconds(30),): SolverResult { val solution = loadSolution(clinicId, dateRange) val factory = if (timeLimit != Duration.ofSeconds(30)) { AppointmentSolverConfig.createFactory(timeLimit) } else { solverFactory }
val entityCount = solution.appointments.size val pinnedCount = solution.appointments.count { it.pinned } val startMillis = System.currentTimeMillis() val result = factory.buildSolver().solve(solution) val solveTimeMillis = System.currentTimeMillis() - startMillis
val originalMap = transaction { appointmentRepository.findByClinicAndDateRange(clinicId, dateRange) .associateBy { it.id!! } } val optimizedAppointments = SolutionConverter.extractResults(result, originalMap) val score = result.score!!
return SolverResult( score = score, appointments = optimizedAppointments, isFeasible = score.isFeasible, solveTimeMillis = solveTimeMillis, entityCount = entityCount, pinnedCount = pinnedCount, )}기본 실행 시간은 30초지만, 이 숫자는 “30초면 항상 최적해를 찾는다”는 보장이 아닙니다. Solver는 정해진 시간 동안 여러 해답을 탐색하고 그중 가장 좋은 결과를 반환합니다. 반환값에는 예약별 배정 결과뿐 아니라 제약 점수, 필수 제약을 모두 만족했는지, 실행 시간, 전체 예약 수와 고정된 예약 수도 들어 있습니다. 운영자는 배치 결과만 보는 것이 아니라 어떤 범위와 조건으로 만든 결과인지 함께 판단할 수 있어야 합니다.
현재 SolverService는 따로 실행하는 서비스입니다. 환자의 예약 가능 시간 API가 내부에서 Solver를 호출하지
않으며, 휴진 재배정 서비스가 Solver 결과를 자동 확정하지도 않습니다. 최적화 결과를 언제 실행하고 누가
검토하며 어떤 방식으로 저장할지는 애플리케이션의 별도 업무 흐름으로 설계해야 합니다.
휴진 재배정에서 두 경로가 갈라진다
섹션 제목: “휴진 재배정에서 두 경로가 갈라진다”갑작스러운 휴진은 두 경로의 차이를 가장 잘 보여 줍니다. 현재
ClosureRescheduleService는
휴진일의 활성 예약을 PENDING_RESCHEDULE 상태로 바꾸고, 각 예약에 대해 이후 날짜의 가능한 시간 목록을
계산합니다. 후보는 가까운 날짜와 시각 순서대로 우선순위를 부여해 저장합니다. 관리자가 후보를 고르거나,
자동 재배정 기능이 가장 높은 우선순위의 후보를 선택합니다.

이 방식은 흐름이 단순하고, 예약별 후보를 사람이 검토하기 좋습니다. 하지만 먼저 처리한 예약이 선택한 후보가 다음 예약의 선택지를 좁혀도 전체를 다시 비교하지는 않습니다.
SolverService.optimizeReschedule()은 휴진일부터 검색 기간까지를 하나의 날짜 범위로 만들어 전체
최적화를 실행하는 별도 경로를 제공합니다. 여러 예약을 한꺼번에 보면서 의사와 장비의 충돌을 피하고 전체
점수를 비교할 수 있습니다. 대신 실행 시간 예산, 결과 검토, 저장과 환자 통보 같은 운영 절차가 더 필요합니다.
현재 구현은 두 경로를 자동으로 연결하지 않습니다. 이 분리는 다음 설계 질문을 분명하게 만듭니다.
- 휴진 규모가 몇 건 이상일 때 Solver를 실행할 것인가
- 확정된 예약은 고정하고 어떤 상태의 예약만 옮길 것인가
- Solver 결과를 바로 저장할 것인가, 관리자의 승인을 받을 것인가
- 재배정 전후의 상태 이력과 환자 통보는 어느 시점에 기록할 것인가
최적화 알고리즘을 붙이는 것만으로 휴진 대응 업무가 완성되지 않습니다. 계산 결과가 상태 관리와 승인, 이력, 알림으로 이어져야 실제 병원 업무가 됩니다.
어떤 문제를 어느 경로에 맡길까
섹션 제목: “어떤 문제를 어느 경로에 맡길까”알고리즘 이름부터 고르기보다 사용자가 원하는 답과 기다릴 수 있는 시간을 먼저 정하면 선택이 쉬워집니다.
| 업무 장면 | 적합한 경로 | 이유 |
|---|---|---|
| 환자가 특정 날짜와 의사의 가능한 시간을 조회 | SlotCalculationService | 범위가 작고 가능한 후보 목록을 즉시 보여 줘야 한다 |
| 상담원이 한 예약의 대체 시간을 탐색 | SlotCalculationService | 한 예약의 선택지를 사람이 비교하면 된다 |
| 다음 주 여러 예약을 의사와 장비에 함께 배치 | SolverService | 여러 배치의 전체 점수를 비교해야 한다 |
| 대규모 휴진으로 많은 예약을 다시 배치 | SolverService와 승인 흐름 | 앞선 선택이 뒤 예약에 미치는 영향을 함께 봐야 한다 |
| 사용자가 후보 하나를 최종 확정 | 트랜잭션과 동시성 제어 | 계산 결과가 최신인지 다시 확인하고 중복 확정을 막아야 한다 |
같은 업무 흐름에서도 두 경로를 이어서 쓸 수 있습니다. 예를 들어 실시간 계산으로 명백히 불가능한 후보를 먼저 제외한 뒤, 남은 값만 Solver의 선택 범위로 제공할 수 있습니다. 다만 두 단계의 계약을 분명히 해야 합니다. 첫 단계는 가능한 선택지를 만들고, 두 번째 단계는 그 선택지의 조합을 점수로 비교합니다.
테스트와 리뷰: 해답보다 경계를 검증한다
섹션 제목: “테스트와 리뷰: 해답보다 경계를 검증한다”두 경로는 테스트에서 확인할 내용도 다릅니다.
SlotCalculationServiceTest는 휴식시간과 부분 휴진, 의사 부재, 진료 소요시간, 동시 진료 수, 장비 상태를 적용했을 때 가능한 후보만 남는지 확인합니다. 조회 서비스의 계약은 입력 조건을 통과한 시간 목록을 정확히 반환하는 데 있습니다.
SolverServiceTest는 날짜 범위의 예약을 불러와 결과를 만들고, 고정된 예약을 옮기지 않으며, 휴진일부터 정한 검색 기간까지 다시 배치할 수 있는지 확인합니다. Solver 경로에서는 예약 하나의 배정 시각만 확인하기보다 필수 제약을 만족하는지, 고정 정책을 지키는지, 문제 범위와 결과가 연결되는지를 검증해야 합니다.
설계와 구현을 함께 검토하면서 설명도 보강했습니다. Greedy라는 이름만 보면 첫 번째 가능한 시간 하나를
즉시 선택한다고 오해하기 쉽지만, 현재 예약 가능 시간 서비스는 조건을 통과한 후보 목록을 반환합니다.
휴진용 Solver 메서드가 존재한다고 해서 현재 휴진 업무가 자동으로 전역 최적화를 실행하는 것도 아닙니다.
소스와 테스트에서 확인한 실제 경계를 글에 그대로 드러내야 기획자와 개발자가 다음 요구사항을 같은 기준으로
논의할 수 있습니다.
다음 요구사항: 무엇이 좋은 일정인지 설명해야 한다
섹션 제목: “다음 요구사항: 무엇이 좋은 일정인지 설명해야 한다”Timefold를 도입했다고 해서 “좋은 일정”이 자동으로 정의되지는 않습니다. 의사나 장비가 겹치면 안 된다는 규칙은 명확하지만, 환자가 요청한 날짜와 기존 담당 의사 중 무엇을 더 우선할지, 일정의 빈틈을 줄이는 것과 의사별 업무량을 고르게 나누는 것 중 무엇이 더 중요한지는 병원 정책에 따라 달라집니다.
다음 단계는 알고리즘 설정이 아니라 업무 언어의 번역입니다.
절대로 어기면 안 되는 병원 업무 규칙 → 필수 제약과 위반 점수로 표현
가능하면 더 잘 만족하고 싶은 운영 목표 → 선호 제약과 보상·감점으로 표현
서로 경쟁하는 목표 → 병원 정책에 맞는 우선순위와 가중치로 조정Part 5에서는 의사와 장비의 시간 충돌, 병원 업무시간과 휴진, 최대 동시 진료 수 같은 필수 규칙과 환자의 요청 날짜, 기존 담당 의사, 대기시간 같은 선호 목표를 Timefold Constraint로 옮기는 과정을 살펴보겠습니다.
구현 코드와 자료 살펴보기
섹션 제목: “구현 코드와 자료 살펴보기”- 예약 핵심 모듈: 실시간 예약 가능 시간 계산과 휴진 재배정 후보 생성을 담고 있습니다.
- 예약 가능 시간 계산 서비스: 한 요청의 범위에서 업무 규칙을 통과한 시간 목록을 계산합니다.
- 휴진 재배정 서비스: 영향받은 예약별 후보를 만들고 관리자 선택 또는 우선순위 기반 재배정을 처리합니다.
- 일정 최적화 모듈: Timefold 계획 모델과 제약 조건, Solver 실행 경로를 모아 둔 모듈입니다.
- 일정 최적화 서비스: 병원과 날짜 범위의 데이터를 읽어 전체 일정 최적화를 실행합니다.
- 예약 계획 대상: Solver가 선택하는 담당 의사, 예약 날짜, 시작 시각과 고정 정책을 정의합니다.
- 전체 계획 문제: 예약 목록과 병원·의사·장비 정보를 하나의 최적화 문제로 묶습니다.
- Timefold Solver 소개: 제한된 자원을 여러 업무에 배정하는 계획 문제와 Solver의 역할을 설명하는 공식 문서입니다.
- Timefold 계획 문제 모델링: 계획 대상, 선택값, 문제 정보, 전체 해답을 코드로 구성하는 방법을 설명하는 공식 문서입니다.
시리즈 링크
섹션 제목: “시리즈 링크”- Part 1: 병원 예약은 CRUD로 끝나지 않는다
- Part 2: 예약 상태는 enum이 아니다
- Part 3: 병원마다 다른 업무시간과 자원으로 예약 가능 시간을 계산하기
- Part 4: 한 건의 예약 가능 시간 조회와 전체 일정 최적화는 다르다
- Part 5: 병원 업무 규칙을 Timefold Constraint로 번역하기
- Part 6 예정: 휴진과 장비 고장은 예약 설계를 어떻게 바꾸는가
- Part 7 예정: 완성 뒤가 진짜 시작이다
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.