Timefold Server 실전 적용: 계획 모델에서 Exposed 영속화 경계까지

빠른 시작 예제가 끝난 자리에서 애플리케이션 설계가 시작된다
섹션 제목: “빠른 시작 예제가 끝난 자리에서 애플리케이션 설계가 시작된다”최적화 예제를 따라 하면 계획 대상을 정의하고, Solver가 바꿀 값을 지정하고, 제약 조건으로 해답의 점수를 계산하는 흐름을 빠르게 익힐 수 있습니다. 여기까지 실행되면 알고리즘은 동작합니다. 하지만 실제 서비스에 넣으려면 곧바로 다음 질문이 생깁니다.
- 최적화를 요청한 시점의 문제 데이터를 어디에 보관할까?
- 실행 중인 작업과 지금까지 찾은 가장 좋은 해답을 어떻게 조회할까?
- Solver가 반환한 후보를 누가 승인하며, 언제 업무 데이터에 반영할까?
- 애플리케이션이 재시작되어도 작업과 결과를 다시 찾을 수 있을까?
timefold-workshop은 이 경계를 살펴보기 좋은 재료를 함께
가지고 있습니다. bed-allocation과 school-timetabling은 서로 다른 계획 문제를 보여 주고, Exposed JDBC와
R2DBC 예제는 Timefold의 Score를 데이터베이스 컬럼에 저장하는 방법을 검증합니다.
다만 두 예제가 곧바로 하나의 완성된 영속화 애플리케이션을 이루는 것은 아닙니다. 빠른 시작 예제는 계획과 실행을, Exposed 예제는 Score 값의 저장과 조회를 각각 증명합니다. 그 사이의 작업 상태, 문제 스냅숏, 해답, 승인, 업무 반영은 서비스가 채워야 할 영역입니다.
병상 배정: 입원 기간 전체에 하나의 병상을 고른다
섹션 제목: “병상 배정: 입원 기간 전체에 하나의 병상을 고른다”병상 배정 빠른 시작 예제에서 Solver가 바꾸는 계획 대상은
Stay입니다.
환자와 입·퇴원일, 필요한 장비와 진료과는 이미 정해져 있고, bed만 계획 변수로 둡니다. 한 입원 일정에는
입원 기간 전체에 사용할 병상 하나가 배정됩니다.
@PlanningEntityclass Stay { var arrivalDate: LocalDate? = null var departureDate: LocalDate? = null
@PlanningVariable(allowsUnassigned = true) var bed: Bed? = null}allowsUnassigned = true이므로 병상이 부족하거나 조건을 만족하는 병상이 없을 때 bed가 null인 해답도
만들 수 있습니다. 이것은 실패를 숨기는 장치가 아닙니다. 배정하지 못한 입원 일정을 점수에 드러내고, 불가능한
상황에서도 비교 가능한 후보 해답을 남기기 위한 모델링입니다.
BedPlan은
진료과에서 병실과 병상 목록을 만들고, 입원 일정 목록과 HardMediumSoftScore를 한 계획 문제로 묶습니다.
BedAllocationConstraintProvider는
같은 밤의 병상 중복, 병실의 성별 제한, 진료과 연령 범위, 필수 장비를 필수(Hard) 제약으로 다룹니다. 병상을
배정하지 못한 기간은 중간(Medium) 계층에서 감점하고, 병실 정원과 진료과 우선순위 같은 선호는 선호(Soft)
계층에서 비교합니다.
여기에는 리뷰가 필요한 작은 불일치도 있습니다. preferredPatientEquipment()는 선호 제약 구역에 놓여 있지만
현재 코드는 HardMediumSoftScore.ofHard(50)으로 감점합니다. 이름과 배치만 보고 “선호 장비는 선호 계층”이라고 문서화하면
실제 실행 결과와 달라집니다. 제약의 이름, 주석, 점수 계층을 함께 검토해야 하는 이유입니다.
학교 시간표: 수업마다 시간과 교실을 함께 고른다
섹션 제목: “학교 시간표: 수업마다 시간과 교실을 함께 고른다”학교 시간표 빠른 시작 예제의 계획 대상은
Lesson입니다.
과목, 교사, 학생 그룹은 고정된 정보이고, Solver는 각 수업의 timeslot과 room을 함께 바꿉니다.
@PlanningEntitydata class Lesson( val subject: String, val teacher: String, val studentGroup: String,) { @PlanningVariable var timeslot: Timeslot? = null
@PlanningVariable var room: Room? = null}Timetable은
선택 가능한 시간과 교실을 문제 정보로 제공하고, 수업 목록과 HardSoftScore를 보관합니다.
TimetableConstraintProvider는
한 시간에 같은 교실·교사·학생 그룹을 겹치지 않게 하는 규칙을 필수 제약으로 둡니다. 같은 교사가 자주 교실을
옮기지 않도록 하고, 수업 사이의 빈 시간을 줄이며, 학생 그룹의 하루 과목 구성을 다양하게 만드는 목표는
선호 계층에서 비교합니다.
병상 배정과 달리 한 계획 대상에서 두 값을 함께 골라야 합니다. 수업 시간이 달라지면 가능한 교실 조합도 달라집니다. 계획 변수의 수만 늘어난 것이 아니라 탐색해야 할 조합의 모양이 달라진 셈입니다.
같은 Timefold라도 점수 구조가 다른 이유
섹션 제목: “같은 Timefold라도 점수 구조가 다른 이유”
HardSoftScore와 HardMediumSoftScore의 차이는 더 정교해 보이는 형식을 고르는 문제가 아닙니다. 점수 계층은
상위 계층을 먼저 비교하는 업무 우선순위입니다. 병상 배정에서는 “조건을 만족하는 병상을 최대한 많은 환자에게
배정한다”는 목표를 단순 선호보다 앞세우기 위해 중간 계층을 따로 둡니다. 학교 시간표는 충돌이 없는가를
필수 계층에서 판단한 뒤 나머지 품질 목표를 선호 계층에서 비교합니다.
Timefold는 어느 해답이 업무에 더 좋은지 스스로 알지 못합니다. 개발자가 계획 모델과 점수 계산으로 그 기준을 표현해야 합니다. 공식 문서의 계획 문제 모델링과 점수와 제약 조건을 함께 읽으면, 주석 사용법보다 먼저 정해야 할 업무 경계를 이해하는 데 도움이 됩니다.
Solver 실행을 API 요청과 분리한다
섹션 제목: “Solver 실행을 API 요청과 분리한다”학교 시간표의
TimetableController는
SolverManager로 최적화를 비동기로 시작합니다. POST /timetables는 해답이 나올 때까지 연결을 붙잡아 두지
않고 작업 ID를 반환합니다. 클라이언트는 그 ID로 해답과 상태를 다시 조회할 수 있습니다.
val jobId = UUID.randomUUID().toString()jobIdToJob[jobId] = Job.ofTimetable(problem)
solverManager.solveBuilder() .withProblemId(jobId) .withProblemFinder { jobIdToJob[it.toString()]!!.timetable!! } .withBestSolutionEventConsumer { event -> jobIdToJob[jobId] = Job.ofTimetable(event.solution()!!) } .run()이 구조는 시간이 걸리는 Solver 실행을 HTTP 요청 수명과 분리하는 핵심을 잘 보여 줍니다. 하지만 현재 작업과
해답은 프로세스 안의 ConcurrentHashMap에 들어갑니다. 서버를 재시작하면 사라지고, 소스의 주석대로 TTL이
없어 오래 실행하면 맵이 계속 커질 수 있습니다. 여러 서버 인스턴스가 같은 작업을 조회하는 구조도 아닙니다.
즉, 빠른 시작 예제의 맵을 데이터베이스로 바꾸기만 하면 끝나는 것이 아닙니다. 작업 ID의 유일성, 상태 전이,
재시도와 취소, 문제 스냅숏의 버전, 중간 해답 보관 정책까지 정해야 합니다. SolverManager의 실행 모델은 공식
문서의 라이브러리 통합에서
Solver가 끝났다고 최적화에 성공한 것은 아니다
섹션 제목: “Solver가 끝났다고 최적화에 성공한 것은 아니다”Solver가 종료되면 그때까지 찾은 가장 좋은 해답을 돌려줍니다. 하지만 이 말은 전역 최적 해를 찾았다는
뜻이 아닙니다. 메타휴리스틱 탐색은 보통 최적 해의 점수를 미리 알지 못하므로, 제한 시간이나 개선 정체로
끝났을 때 현재 해답보다 더 좋은 조합이 남아 있는지 증명하지 못합니다. Timefold 공식 문서도 현실의 계획
문제에서는 최적성 증명보다 주어진 시간 안에 가장 좋은 해답을 찾는 것을
목표로 설명합니다.
그래서 작업 상태의 COMPLETED는 “실행이 끝났다”는 뜻으로만 써야 합니다. 업무에 반영해도 된다는 판정은
별도로 내려야 합니다.
| 결과 | 의미 | 서비스의 대응 |
|---|---|---|
| 실행 가능 해 없음 | 가장 좋은 해답에도 필수 제약 위반이 남아 있다 | 위반 제약과 부족한 자원을 보여 주고, 입력·자원·모델을 검토한다 |
| 실행 가능하지만 최적성 미확인 | 필수 제약은 만족하지만 더 좋은 해답이 존재하지 않는다고 증명하지 못했다 | 업무별 최소 품질 기준과 실행 시간 한도를 보고 승인하거나 다시 실행한다 |
| 실행 실패 | 문제 조회, Score 계산, Solver 실행 또는 결과 처리 중 예외가 발생했다 | 실패 단계와 원인을 저장하고 재시도 가능 여부를 판정한다 |
| 취소 | 운영자나 시스템이 탐색을 중단했다 | 마지막 후보가 있더라도 자동 반영하지 않고 승인 가능 여부를 다시 확인한다 |
여기서 “최적 해를 찾지 못했다”는 표현은 두 경우를 가리킬 수 있습니다. 필수 제약을 만족하는 해답 자체가 없을 수도 있고, 실행 가능 해는 찾았지만 그것이 최적인지 알 수 없을 수도 있습니다. 전자는 자원 부족이나 모델 충돌을 해결해야 하는 문제이고, 후자는 시간과 품질 사이의 운영 정책 문제입니다. 실제 필수 규칙보다 필수 제약을 넓게 잡았거나, 계획 값의 범위가 너무 좁거나, 자원 자체가 부족하면 실행 가능 해를 찾기 어려워집니다. 병상 배정 예제처럼 미배정을 허용하고 중간 점수로 드러내는 과잉 제약 계획(overconstrained planning)은 자원이 부족할 때도 비교 가능한 후보를 남기는 한 가지 방법입니다.
학교 시간표 빠른 시작 예제는 실행 중 예외도 숨기지 않습니다. withExceptionHandler에서 오류를 작업에 저장하고,
이후 조회 요청이 들어오면 TimetableSolverException으로 전달합니다.
.withExceptionHandler { id, exception -> jobIdToJob[id.toString()] = Job.ofException(exception) log.error(exception) { "Solver failed for jobId: $id" }}실제 서비스라면 여기에 실패한 단계, 재시도 횟수, 종료 이유, 마지막으로 확인한 Score를 더 남길 수 있습니다. 단, 마지막 후보가 있다는 이유만으로 성공 처리해서는 안 됩니다. 작업 종료 여부를 확인한 뒤 Score가 실행 가능한지, 업무별 최소 품질 기준을 넘었는지, 현재 데이터 버전과 충돌하지 않는지를 차례로 검사해야 합니다. 이 검사를 통과한 후보만 승인 단계로 보냅니다.
Exposed 예제가 실제로 저장하는 것은 Score다
섹션 제목: “Exposed 예제가 실제로 저장하는 것은 Score다”timefold-workshop의 Exposed 모듈은 Timefold Score 전용 컬럼 형식을 JDBC와 R2DBC에서 검증합니다. 예를 들어
JDBC의 HardSoftScoreTest는
hardSoftScore("hardsoft_score") 컬럼을 선언하고, Score를 넣은 뒤 같은 값으로 읽히는지 확인합니다.
object T1 : IntIdTable() { val name = varchar("name", 255) val hardSoftScore = hardSoftScore("hardsoft_score")}
val id = T1.insertAndGetId { it[T1.hardSoftScore] = HardSoftScore.of(hard, soft)}
val loaded = T1.selectAll().where { T1.id eq id }.single()loaded[T1.hardSoftScore] shouldBeEqualTo expectedR2DBC의 HardSoftScoreTest도
같은 컬럼 왕복을 코루틴과 R2DBC DSL로 확인합니다. JDBC 예제에는 DAO 엔티티를 이용한 검증도 있고, R2DBC
예제는 suspend 기반 DSL에 초점을 맞춥니다. 둘의 차이는 Solver 모델이 아니라 애플리케이션의 데이터 접근
방식입니다.
이 테스트가 증명하는 범위는 분명합니다. HardSoftScore를 DB 컬럼에 넣었다가 같은 Score로 읽을 수 있습니다.
반면 Timetable이나 BedPlan 전체, 작업 상태, 가장 좋은 해답의 여러 버전, 승인 정보까지 저장하는 저장소는
현재 예제에 없습니다. Score 영속화 지원을 PlanningSolution 영속화 구현으로 확대 해석하면 안 됩니다.
실제 서비스의 참조 설계에서는 무엇을 더 저장해야 할까
섹션 제목: “실제 서비스의 참조 설계에서는 무엇을 더 저장해야 할까”
현재 timefold-workshop에는 이 그림의 OptimizationJobService나 두 Exposed 저장소가 구현되어 있지 않습니다.
다음 구성은 빠른 시작 예제와 Score 컬럼 검증 사이의 빈 경계를 구체화한 애플리케이션 참조 설계입니다.
실제 서비스에서는 적어도 다음 데이터를 서로 구분해 저장하는 편이 좋습니다.
| 저장 대상 | 필요한 이유 |
|---|---|
| 문제 스냅숏 | 어떤 업무 데이터와 제약 설정으로 실행했는지 재현한다 |
| 작업 상태 | 대기·실행·완료·실패·취소 상태와 시간을 조회한다 |
| 후보 해답과 Score | 결과를 비교하고, 왜 선택했는지 설명한다 |
| 승인 정보 | 누가 어떤 결과를 언제 받아들였는지 남긴다 |
| 업무 반영 이력 | 후보 해답과 실제 변경된 원본 데이터를 연결한다 |
여기서 가장 중요한 경계는 Solver 결과가 곧 업무 원본은 아니라는 것입니다. Solver가 탐색하는 동안 예약, 병상, 교실 같은 원본 데이터가 바뀔 수 있습니다. 결과를 반영하기 전에는 스냅숏의 버전과 현재 데이터를 비교하고, 필요한 충돌 검사를 다시 해야 합니다. 승인된 해답만 트랜잭션 안에서 업무 데이터에 적용하고, 실패하면 부분 반영을 남기지 않아야 합니다.
중간 해답을 모두 보관할 필요는 없습니다. 운영자가 진행 중인 품질을 봐야 하는지, 장애 분석에 어떤 기록이 필요한지에 따라 최신 해답만 덮어쓰거나 일정 간격의 버전만 남길 수 있습니다. 중요한 것은 저장량보다 “어떤 문제로 만든 어떤 결과를 누가 반영했는가”를 추적할 수 있는 연결입니다.
테스트와 리뷰: 제약 하나와 경계 하나를 따로 검증한다
섹션 제목: “테스트와 리뷰: 제약 하나와 경계 하나를 따로 검증한다”최적화 테스트는 전체 실행 한 번으로 끝내기보다 두 층으로 나누는 편이 빠르고 정확합니다.
첫째, 제약 조건은 작은 입력으로 하나씩 검증합니다. 병상 중복이 겹친 밤 수만큼 감점되는지, 교사 충돌이 같은 시간에만 발생하는지처럼 규칙 하나의 점수를 고정합니다. 이 테스트는 Solver의 탐색 품질이 아니라 점수 계산의 계약을 확인합니다. 특히 병상 예제의 선호 장비처럼 이름·주석·실제 점수 계층이 어긋난 경우를 잡는 데 효과적입니다.
둘째, 애플리케이션 경계는 상태와 데이터의 수명주기로 검증합니다.
- 서버 재시작 뒤에도 완료된 작업과 해답을 조회할 수 있는가
- 같은 작업 ID로 중복 실행하지 않는가
- 취소와 실패가 명확한 상태와 원인을 남기는가
- 오래된 스냅숏의 해답이 현재 업무 데이터를 덮어쓰지 않는가
- 승인과 업무 반영이 한 번만 실행되는가
제약 테스트가 “이 해답의 점수가 맞는가?”를 묻는다면, 경계 테스트는 “이 후보를 안전하게 보관하고 업무에 반영할 수 있는가?”를 묻습니다. 둘을 섞으면 알고리즘 오류와 애플리케이션 상태 오류를 구분하기 어렵습니다.
데모를 애플리케이션 기능으로 옮기는 체크리스트
섹션 제목: “데모를 애플리케이션 기능으로 옮기는 체크리스트”빠른 시작 예제를 실제 기능으로 확장할 때는 다음 순서가 실용적입니다.
- 업무 질문을 한 문장으로 고정한다. Solver가 무엇을 바꿔 어떤 결과를 더 좋다고 판단해야 하는지 정합니다.
- 계획 대상과 문제 정보를 분리한다. 실행 중 바뀌는 값만 계획 변수로 두고, 나머지는 스냅숏으로 묶습니다.
- 점수 계층을 업무 용어로 합의한다. 필수·중간·선호 계층이 각각 어떤 운영 결정을 뜻하는지 문서와 테스트에 남깁니다.
- API 요청과 Solver 실행을 분리한다. 작업 ID와 상태 조회 계약을 먼저 만들고 실행 시간 제한과 취소를 정합니다.
- 저장 단위를 구분한다. Score 컬럼, 후보 해답, 문제 스냅숏, 작업 상태, 승인, 업무 반영 이력을 한 덩어리로 보지 않습니다.
- 승인 전에는 원본을 바꾸지 않는다. 결과를 후보로 저장하고 현재 데이터와의 충돌을 다시 확인합니다.
- JDBC와 R2DBC는 접근 방식으로 선택한다. Solver의 모델과 Score 의미는 같게 유지하고 서비스의 트랜잭션·동시성 모델에 맞춥니다.
- 재시작과 중복 실행을 테스트한다. 정상 실행만큼 실패·취소·재시도·오래된 결과 반영을 중요하게 다룹니다.
Timefold는 조합이 많은 계획 문제를 탐색하고 점수로 해답을 비교하는 역할을 맡습니다. Exposed의 Score 컬럼은 그 결과의 중요한 일부를 저장합니다. 하지만 두 도구 사이에서 작업과 해답의 수명주기를 설계하고, 후보를 업무 데이터로 받아들이는 책임은 애플리케이션에 남습니다. 이 경계를 먼저 그려 두면 빠른 시작 예제의 코드를 그대로 키우지 않고도 실제 서비스에 필요한 다음 작업을 구체적으로 정할 수 있습니다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.