ML 모델을 Spring Boot 단일 컨테이너에서 직접 추론하는 농업 도메인 API
환경/관리 조건 9개 피처를 받아 RandomForest 회귀 모델로 작물 수확량을 예측한다. ML 모델은 ONNX로 export하고 Spring Boot가 ONNX Runtime Java로 직접 추론
- 단일 컨테이너 ML 추론: Python 사이드카 없이 JVM에서 직접 ONNX 추론
- 배치 throughput 24.7× 개선:
/predict/batch에서[N, 24]텐서를 단일 ONNX 호출로 처리해 19.9 → 490.65 predictions/s - 모델 크기 최적화: 142MB → 24.6MB, MAE 0.4031 → 0.4015 개선
- 트러블슈팅: ONNX TreeEnsembleRegressor float32 제약 대응
# Docker
docker build -t crop-yield-api .
docker run --rm -p 8080:8080 crop-yield-api
# 또는 로컬 (개발 시)
./gradlew bootRun서버 시작 후 Swagger: http://localhost:8080/swagger-ui.html
요청:
curl -X POST http://localhost:8080/predict \
-H "Content-Type: application/json" \
-d '{
"rainfallMm": 500.0,
"temperatureCelsius": 25.0,
"fertilizerUsed": true,
"irrigationUsed": true,
"daysToHarvest": 120,
"crop": "Wheat",
"region": "South",
"soilType": "Loam",
"weatherCondition": "Rainy"
}'응답:
{
"predictedYield": 5.732,
"modelVersion": "v1.0"
}여러 케이스를 한 번에 처리. [N, 24] 텐서로 단일 ONNX 호출 → 단건 대비 약 25배 throughput.
요청:
curl -X POST http://localhost:8080/predict/batch \
-H "Content-Type: application/json" \
-d '{"items": [
{"rainfallMm":500.0,"temperatureCelsius":25.0,"fertilizerUsed":true,"irrigationUsed":true,"daysToHarvest":120,"crop":"Wheat","region":"South","soilType":"Loam","weatherCondition":"Rainy"},
{"rainfallMm":200.0,"temperatureCelsius":30.0,"fertilizerUsed":false,"irrigationUsed":true,"daysToHarvest":150,"crop":"Rice","region":"East","soilType":"Clay","weatherCondition":"Sunny"}
]}'응답:
{
"predictions": [
{"predictedYield": 5.732, "modelVersion": "v1.0"},
{"predictedYield": 2.751, "modelVersion": "v1.0"}
],
"modelVersion": "v1.0",
"count": 2
}Agriculture Crop Yield (Kaggle) — 합성 100만 rows, 9 피처 + 타겟
kaggle datasets download -d samuelotiattakorah/agriculture-crop-yield -p data/ --unzipnotebooks/baseline.ipynb — EDA, 학습, 평가, ONNX export 전 과정.
| 지표 | 값 |
|---|---|
| MAE | 0.4015 ton/ha |
| RMSE | 0.5032 ton/ha |
상위 3개 피처(Rainfall, Fertilizer_Used, Irrigation_Used)가 모델 결정의 92.7% 차지.
합성 데이터셋이라서 실제 농업 데이터의 노이즈/결측치/계절성을 반영하지 않는다. 베이스라인 모델 검증 목적이며, 실 운영엔 도메인 데이터 추가학습이 필요하다.
-
@ConfigurationProperties(record): yaml의app.model.*값을 record로 타입 안전 매핑 —ModelProperties.java -
모델 startup 로딩:
OrtEnvironment/OrtSession을 빈으로 등록해 startup 시 1회 초기화. 첫 요청 latency 제거 + 싱글톤 보장 —ModelLoader.java -
@RestControllerAdvice3단계 매핑: Validation 예외(400) / 비즈니스 예외(400) / 일반 예외(500, 메시지 마스킹) —GlobalExceptionHandler.java -
JNI 자원 관리:
OnnxTensor,OrtSession.Result는 GC 대상이 아닌 네이티브 자원 → 중첩 try-with-resources로 누수 방지try (OnnxTensor tensor = OnnxTensor.createTensor(env, input)) { try (OrtSession.Result result = session.run(Map.of(inputName, tensor))) { float[][] output = (float[][]) result.get(0).getValue(); return output[0][0]; } }```
-
Observability (Micrometer + Actuator):
/actuator/metrics로 추론 호출 수/성공/실패 카운터, 추론 latency Timer 노출. 메트릭 cardinality 관리를 위해 카테고리 태그 미사용 —InferenceMetrics.java노출 메트릭:
crop_yield_predict_requests_totalcrop_yield_predict_success_totalcrop_yield_predict_errors_totalcrop_yield_inference_duration
- 카테고리 값 검증 (메타데이터 화이트리스트)
- 9 필드 → 24 float 배열 변환 (불리언 → int, 카테고리 → one-hot)
featureOrder순서대로 정렬 (학습 컬럼 순서 일치)- ONNX 추론 → 단일 float 결과 추출
k6 기반 로컬 부하 테스트. 측정 환경: MacBook Air (Apple Silicon), Docker 단일 컨테이너.
| 지표 | 값 |
|---|---|
| p50 | 3.55 ms |
| p95 | 7.30 ms |
| p99 | 25.95 ms |
| error rate | 0.00% |
| 지표 | 값 |
|---|---|
| p50 | 13.17 ms |
| p95 | 36.66 ms |
| p99 | 184.67 ms |
| predictions/s | 490.65 |
| 단건 | 배치 (50) | 비율 | |
|---|---|---|---|
| p50 기준 prediction당 처리 시간 | 3.55 ms | 0.26 ms | 13.5× 낮음 |
| predictions/s | 19.9 | 490.65 | 24.7× 높음 |
배치는 [N, 24] 텐서를 단일 ONNX 호출로 처리. HTTP/JSON 직렬화, JNI 경계 통과, ONNX 추론 모두 1회로 압축되어 throughput 약 25배 개선.
한계: 로컬 단일 머신 측정이라 절대값 의미 제한적. 상대 비교(단건 vs 배치)에 의미. 프로덕션 측정은 별도 호스트 + 클라우드 환경에서 수행 예정.
자세한 환경/시나리오/임계값: performance/results/README.md
JUnit 5 + Mockito 단위 테스트 3개:
| # | 대상 | 방법 |
|---|---|---|
| 1 | Service 정상 케이스 | ONNX 의존성 mock. OnnxTensor.createTensor 정적 메서드는 MockedStatic으로 모킹 |
| 2 | Service 예외 케이스 | 잘못된 카테고리 → IllegalArgumentException (인코딩 도달 전 차단 확인) |
| 3 | Controller validation | @WebMvcTest + MockMvc로 웹 레이어 격리. 필드 누락 → 400 응답 검증 |
-> ONNX 파일 없이 동작하는 순수 단위 테스트다. 모델 변경/재학습 시에도 테스트 영향 X
농업 데이터 특성상 강수량/온도 같은 환경 변수와 수확량 사이엔 비선형/임계 패턴이 흔하다 (예: 강수량 X mm 이상에서 효과). RandomForest는 트리 분기점이 곧 임계값이라 이런 패턴을 자연스럽게 학습한다. 또한 합성 데이터의 노이즈에 강건하고, 9개 피처 스케일이 제각각(rainfall mm, temperature ℃, days)이라 스케일링 없이 바로 학습 가능한 점도 베이스라인으로 적합했다.
스펙상 단일 컨테이너 제약 때문. pickle을 쓰면 Python 사이드카가 별도로 필요해 docker-compose 멀티 컨테이너로 가게 된다. ONNX 포맷은 ONNX Runtime Java를 통해 JVM 내에서 직접 추론할 수 있어 단일 컨테이너 구성 유지가 가능하다.
float64 입력으로 더 높은 정밀도를 시도했으나, ONNX의 TreeEnsembleRegressor 연산자가 float32 출력을 강제하는 스펙 한계로 type mismatch가 발생했다. 트리 앙상블 모델 공통의 ONNX ML opset 한계라 판단하고 float32를 채택했다. sklearn 대비 검증 결과 최대 오차가 약 1e-6로 운영상 무시 가능한 수준으로 확인했다.
베이스라인 학습 후 ONNX 변환 시 모델 크기 문제로 두 번 재학습하며 배포 가능한 모델로 변환하였다.
| 설정 | MAE | 파일 크기 | 비고 |
|---|---|---|---|
| default (max_depth=None) | 0.4115 | GB 추정 | 변환 미완료 |
| 1차 제약 (depth=20, leaf=20) | 0.4031 | 142 MB | GitHub 100MB 초과 |
| 최종 (n=50, depth=15, leaf=50) | 0.4015 | 24.6 MB | ✅ |
정규화를 강하게 적용할수록 모델 크기뿐 아니라 MAE도 함께 개선되는 결과가 나왔다. 합성 데이터의 강한 신호 특성상 기본값(max_depth=None)으로 학습한 트리는 노이즈까지 외워 과적합한 것으로 추정한다.