Pure JVM에서 libvips로: bluetape4k 이미지 처리 벤치마크

bluetape4k-image는 이미지 처리를 두 경로로 제공한다. 하나는 pure JVM 기반의 scrimage 경로이고,
다른 하나는 native libvips를 사용하는 고성능 경로다.
둘 중 하나가 항상 정답은 아니다. scrimage는 배포가 단순하고 native dependency가 없다. 반면 thumbnail,
resize, encode를 대량으로 처리하는 경로에서는 libvips의 demand-driven pipeline이 훨씬 유리하다.
이 글은 images-benchmark 결과를 바탕으로 다음 질문에 답한다.
- pure JVM baseline과 native pipeline의 성능 차이는 얼마나 큰가?
- Java 21 JNI와 Java 25 FFM backend는 어떻게 봐야 하는가?
- 실제 서비스 workflow에서는 이 차이를 어디에 적용하는가?

벤치마크 구성
섹션 제목: “벤치마크 구성”벤치마크는 bluetape4k-image의 images-benchmark 모듈에서 실행한다. Gradle task는
kotlinx-benchmark가 생성한 benchmarkBenchmark를 사용한다.
./gradlew :bluetape4k-images-benchmark:benchmarkBenchmark \ -Pvips.impl=java25 --console=plain측정값은 JMH AverageTime이고 단위는 ms/op이다. 낮을수록 좋다.
이번 글에서 사용하는 macOS Java 25 결과는 다음 report에 기록되어 있다.
- Image Processing JMH Benchmark Results - 2026-05-28 Natural Photos
- Vips Backend Comparison Benchmark
입력 이미지가 결과를 바꾼다
섹션 제목: “입력 이미지가 결과를 바꾼다”이번 benchmark에서 먼저 봐야 할 것은 숫자보다 입력 이미지다. 이전 headline 수치는 단색 synthetic fallback으로 측정했기 때문에 실제 사진의 texture, noise, chroma variation이 만드는 codec 비용을 대표하기 어려웠다.
이번 run에서는 bluetape4k-image/images/src/test/resources/images/에 있던 자연 사진 fixture를
images-benchmark/src/main/resources/bench/로 복사해 classpath resource로 커밋하고, 같은
benchmark를 다시 실행했다.
cafe.jpg | landscape.jpg |
|---|---|
![]() | ![]() |
| 4032x3024 JPEG, 2.9 MiB | 4032x3024 JPEG, 3.4 MiB |
이 수치도 모든 이미지에 적용되는 일반 법칙은 아니다. 자연 사진 JPEG 두 장에 대한 결과다. 문서 스캔, flat graphic, animated image, 작은 UI asset은 decode/resize/encode 비용 구조가 달라질 수 있다. 그래도 단색 fallback보다는 실제 photo pipeline 의사결정에 훨씬 가까운 기준점이다.
같은 작업, 두 가지 처리 경로
섹션 제목: “같은 작업, 두 가지 처리 경로”Resize benchmark는 cafe와 landscape 자연 사진 fixture를 같은 방식으로 처리한다.
@Benchmarkfun scrimage_scaleTo(state: VipsBenchmarkState, bh: Blackhole) { val resized = BenchmarkImageSets.naturalPhoto(state.imageName) .scaleTo(targetWidth, targetHeight) bh.consume(resized)}
@Benchmarkfun vips_resize(state: VipsBenchmarkState, bh: Blackhole) { if (!state.vipsAvailable) { bh.consume(null) return } state.createVipsImage(state.photo4kJpegBytes).use { img -> val resized = img.resize(targetWidth, targetHeight) bh.consume(resized) }}Full source: ImageResizeBenchmark.kt
결과는 resize에서 가장 극적이다.
| Workload | Input | scrimage | libvips Java 25 FFM | Result |
|---|---|---|---|---|
| 4K resize to 1920x1080 | cafe | 114.89 ms/op | 0.257 ms/op | 446x faster |
| 4K resize to 1920x1080 | landscape | 115.64 ms/op | 0.244 ms/op | 473x faster |
| JPEG encode | cafe | 137.95 ms/op | 58.35 ms/op | 2.4x faster |
| JPEG encode | landscape | 144.96 ms/op | 46.75 ms/op | 3.1x faster |
| PNG encode | cafe | 884.10 ms/op | 585.29 ms/op | 1.5x faster |
| PNG encode | landscape | 989.37 ms/op | 546.39 ms/op | 1.8x faster |
scrimage는 JVM 안에서 BufferedImage 중심으로 전체 이미지를 다룬다. 이 방식은 portable하고
이해하기 쉽지만, 큰 이미지에서는 memory allocation과 pixel processing 비용이 그대로 드러난다.
libvips는 lazy, demand-driven pipeline을 사용한다. resize처럼 출력 픽셀에 필요한 영역만 계산해도
되는 작업에서는 이 구조 차이가 크게 드러난다.
인코딩 차이는 작지만 여전히 의미 있다
섹션 제목: “인코딩 차이는 작지만 여전히 의미 있다”Encode benchmark도 같은 원칙으로 구성했다. pure JVM path는 scrimage writer를 사용하고,
native path는 VipsImage.toBytes()를 호출한다.
@Benchmarkfun scrimage_encodeJpeg(state: VipsBenchmarkState, bh: Blackhole) { val bytes = BenchmarkImageSets.naturalPhoto(state.imageName).bytes(JPEG_WRITER) bh.consume(bytes)}
@Benchmarkfun vips_encodeJpeg(state: VipsBenchmarkState, bh: Blackhole) { if (!state.vipsAvailable) { bh.consume(null) return } state.createVipsImage(state.photo4kJpegBytes).use { img -> val bytes = img.toBytes(VipsImageFormat.JPEG) bh.consume(bytes) }}Full source: ImageEncodeBenchmark.kt
Encode는 resize만큼 차이가 크지는 않다. JPEG는 약 2.4-3.1배, PNG는 약 1.5-1.8배다. 그래도 thumbnail fan-out이나 upload processing처럼 요청 하나가 여러 derivative를 만드는 workflow에서는 이 차이가 누적된다.
Java 21 JNI와 Java 25 FFM
섹션 제목: “Java 21 JNI와 Java 25 FFM”bluetape4k-image는 두 native backend를 제공한다.
| Backend | Runtime | Role |
|---|---|---|
images-vips-java21 | JVips / JNI | Java 21 환경을 위한 native backend |
images-vips-java25 | FFM / Panama | Java 25 환경의 권장 고성능 backend |
이번 macOS arm64 run에서는 Java 21 JNI 값을 N/A로 표시했다. Java 21이 느려서가 아니다.
현재 host에서 bundled JVips dylib가 x86_64이고 JVM은 arm64라서 같은 host에서 native measurement를
만들 수 없었다.
그래서 글의 결론은 “Java 21 JNI가 느리다”가 아니다. 더 정확한 결론은 다음이다.
- Java 25 FFM은 이번 host에서 실제 측정된 고성능 path다.
- Java 21 JNI는 Linux CI나 architecture-compatible host에서 따로 측정해야 한다.
- 같은 표에서 macOS Java 25 실측값과 오래된 Linux Java 21 값을 섞어 최신 비교처럼 보이면 안 된다.
Backend 선택은 VipsBenchmarkState가 runtime classpath를 보고 reflection으로 처리한다.
Full source: VipsBenchmarkState.kt
벤치마크에서 서비스 Workflow로
섹션 제목: “벤치마크에서 서비스 Workflow로”Benchmark 숫자만으로는 실제 적용 지점이 잘 보이지 않는다. 그래서 bluetape4k-workshop에는
production-style 예제를 따로 두었다.
Example: image-processing-advanced-workflow
이 예제는 Spring Boot 4 기반 upload workflow다.
- upload file의 size, content type, magic byte를 검증한다.
- 원본 image object를 storage에 저장한다.
- Java 25
libvipsbackend로 WebP derivative를 만든다. - S3 또는 local storage에 원본과 variant를 저장한다.
- public URL과 처리 metadata를 반환한다.
실제 derivative 생성 지점은 짧다.
return suspendFfmVipsImageOf(bytes).use { image -> image.thumbnail(variant.maxDimension).use { thumbnail -> val output = thumbnail.suspendToBytes(VipsImageFormat.WEBP, webpOptions) ProcessedImageVariant( name = variant.name, key = keyFactory.variantKey(imageId, variant.name, variant.extension), bytes = output, width = thumbnail.width, height = thumbnail.height, contentType = variant.contentType, ) }}Full source: DerivativeProcessor.kt
이 코드가 benchmark와 실제 서비스가 만나는 지점이다. 서비스는 upload 하나에서 여러 variant를 만들 수 있고,
각 variant는 resize와 encode를 모두 수행한다. 이 경로에서는 scrimage와 libvips의 차이가 단일
숫자보다 더 크게 체감된다.
실무 적용 기준
섹션 제목: “실무 적용 기준”간단한 이미지 처리이거나 native dependency를 피해야 하는 환경이라면 images의 scrimage path가 좋은
출발점이다. 배포가 쉽고 JVM만 있으면 동작한다.
하지만 다음 조건이라면 images-vips-java25를 먼저 검토할 만하다.
- upload 후 thumbnail, preview, WebP variant를 여러 개 생성한다.
- 4K 이상 원본 이미지를 자주 처리한다.
- resize latency가 request time이나 worker throughput을 제한한다.
- Java 25 runtime을 사용할 수 있고
libvipsnative dependency를 운영할 수 있다.
Java 21 환경이라면 images-vips-java21도 선택지다. 다만 JNI native artifact와 host architecture를
검증해야 한다. macOS arm64처럼 bundled native artifact가 맞지 않는 환경에서는 benchmark와 integration
test를 N/A로 처리하고, Linux 또는 architecture-compatible host에서 별도로 측정하는 편이 안전하다.
AVIF/HEIC도 같은 원칙이다. API에 format constant가 있어도 실제 decode/encode 가능 여부는
host의 libvips, libheif, AV1/HEVC codec build에 달려 있다.
대표 소스 링크
섹션 제목: “대표 소스 링크”아래 링크는 단순 참고 목록이 아니라, 이 글의 주장과 예제를 따라가기 위한 진입점이다.
벤치마크 코드:
- ImageResizeBenchmark.kt는
cafe와landscape4K-to-1080p resize workload를 scrimage와 libvips 양쪽으로 실행해 비교한다. - ImageEncodeBenchmark.kt는 같은 자연 사진 fixture로 JPEG/PNG encode 경로를 맞춰 비교한다.
- VipsBackendBenchmark.kt는 JNI와 FFM backend의 libvips 호출 경로를 backend 수준에서 분리해 확인한다.
- VipsBackendEncodeBenchmark.kt는 codec과 host library 지원 여부가 중요한 native encode 경로를 따로 본다.
- VipsBenchmarkState.kt는 runtime classpath에서 활성 backend를 선택하고, 사용할 수 없는 backend는 숫자를 꾸며내지 않고 unavailable로 남긴다.
- BenchmarkImageSets.kt는
cafe와landscape자연 사진 fixture를 로드하고, 아직 커밋하지 않은 optional document/thumbnail resource에는 synthetic fallback을 유지한다.
리포트와 원본 근거:
- benchmark-results-2026-05-28-natural-photos.md는 이 글의 표와 차트에 사용한 사람이 읽기 좋은 benchmark report다.
- vips-backend-comparison.md는 Java 21 JNI와 Java 25 FFM 비교 정책, 그리고 macOS arm64
N/Alane을 설명한다. - raw macOS Java 25 benchmark JSON은 report 뒤에 있는
kotlinx-benchmark원본 출력이다.
Workshop 예제:
- image-processing-advanced-workflow는 upload부터 derivative 생성까지 이어지는 전체 service shape를 보여준다.
- ImageDerivativeWorkflowService.kt는 validation, storage, derivative generation, response metadata를 조율하는 application service다.
- DerivativeProcessor.kt는 benchmark에서 본 libvips resize/encode 호출이 실제 variant pipeline으로 연결되는 지점이다.
- UploadImageValidator.kt는 native image processing 전에 size, content type, magic byte를 검증하는 guardrail이다.
- ImageDerivativesController.kt는 workflow를 HTTP upload endpoint로 노출하는 web entrypoint다.
마무리
섹션 제목: “마무리”scrimage와 libvips는 경쟁 관계라기보다 선택 가능한 두 운영 모드에 가깝다. pure JVM path는
단순하고 portable하다. native path는 운영 준비가 필요하지만, 대량 image pipeline에서는 성능 차이가
명확하다.
bluetape4k-image의 목표는 둘 중 하나를 강요하는 것이 아니다. 서비스가 처음에는 pure JVM으로
시작하고, throughput이 필요해지는 순간 libvips backend로 이동할 수 있게 같은 생태계 안에 두
경로를 제공하는 것이다.


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