콘텐츠로 이동

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

JVM pixel 처리에서 native acceleration으로 이어지는 image pipeline 소개용 일러스트
Image backend 선택은 CPU, memory, 배포 방식, 운영 리스크를 한꺼번에 바꾼다.

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에서는 이 차이를 어디에 적용하는가?
scrimage pure JVM과 libvips Java 25 FFM image processing benchmark 비교
scrimage와 libvips 벤치마크, Java 21 JNI host 제약, Java 25 FFM 실측값, workshop derivative pipeline 예제를 정리합니다.

벤치마크는 bluetape4k-imageimages-benchmark 모듈에서 실행한다. Gradle task는 kotlinx-benchmark가 생성한 benchmarkBenchmark를 사용한다.

Terminal window
./gradlew :bluetape4k-images-benchmark:benchmarkBenchmark \
-Pvips.impl=java25 --console=plain

측정값은 JMH AverageTime이고 단위는 ms/op이다. 낮을수록 좋다.

이번 글에서 사용하는 macOS Java 25 결과는 다음 report에 기록되어 있다.

이번 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.jpglandscape.jpg
Cafe benchmark inputLandscape benchmark input
4032x3024 JPEG, 2.9 MiB4032x3024 JPEG, 3.4 MiB

이 수치도 모든 이미지에 적용되는 일반 법칙은 아니다. 자연 사진 JPEG 두 장에 대한 결과다. 문서 스캔, flat graphic, animated image, 작은 UI asset은 decode/resize/encode 비용 구조가 달라질 수 있다. 그래도 단색 fallback보다는 실제 photo pipeline 의사결정에 훨씬 가까운 기준점이다.

Resize benchmark는 cafelandscape 자연 사진 fixture를 같은 방식으로 처리한다.

@Benchmark
fun scrimage_scaleTo(state: VipsBenchmarkState, bh: Blackhole) {
val resized = BenchmarkImageSets.naturalPhoto(state.imageName)
.scaleTo(targetWidth, targetHeight)
bh.consume(resized)
}
@Benchmark
fun 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에서 가장 극적이다.

WorkloadInputscrimagelibvips Java 25 FFMResult
4K resize to 1920x1080cafe114.89 ms/op0.257 ms/op446x faster
4K resize to 1920x1080landscape115.64 ms/op0.244 ms/op473x faster
JPEG encodecafe137.95 ms/op58.35 ms/op2.4x faster
JPEG encodelandscape144.96 ms/op46.75 ms/op3.1x faster
PNG encodecafe884.10 ms/op585.29 ms/op1.5x faster
PNG encodelandscape989.37 ms/op546.39 ms/op1.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()를 호출한다.

@Benchmark
fun scrimage_encodeJpeg(state: VipsBenchmarkState, bh: Blackhole) {
val bytes = BenchmarkImageSets.naturalPhoto(state.imageName).bytes(JPEG_WRITER)
bh.consume(bytes)
}
@Benchmark
fun 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에서는 이 차이가 누적된다.

bluetape4k-image는 두 native backend를 제공한다.

BackendRuntimeRole
images-vips-java21JVips / JNIJava 21 환경을 위한 native backend
images-vips-java25FFM / PanamaJava 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

Benchmark 숫자만으로는 실제 적용 지점이 잘 보이지 않는다. 그래서 bluetape4k-workshop에는 production-style 예제를 따로 두었다.

Example: image-processing-advanced-workflow

이 예제는 Spring Boot 4 기반 upload workflow다.

  1. upload file의 size, content type, magic byte를 검증한다.
  2. 원본 image object를 storage에 저장한다.
  3. Java 25 libvips backend로 WebP derivative를 만든다.
  4. S3 또는 local storage에 원본과 variant를 저장한다.
  5. 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를 모두 수행한다. 이 경로에서는 scrimagelibvips의 차이가 단일 숫자보다 더 크게 체감된다.

간단한 이미지 처리이거나 native dependency를 피해야 하는 환경이라면 images의 scrimage path가 좋은 출발점이다. 배포가 쉽고 JVM만 있으면 동작한다.

하지만 다음 조건이라면 images-vips-java25를 먼저 검토할 만하다.

  • upload 후 thumbnail, preview, WebP variant를 여러 개 생성한다.
  • 4K 이상 원본 이미지를 자주 처리한다.
  • resize latency가 request time이나 worker throughput을 제한한다.
  • Java 25 runtime을 사용할 수 있고 libvips native 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.ktcafelandscape 4K-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.ktcafelandscape 자연 사진 fixture를 로드하고, 아직 커밋하지 않은 optional document/thumbnail resource에는 synthetic fallback을 유지한다.

리포트와 원본 근거:

Workshop 예제:

scrimagelibvips는 경쟁 관계라기보다 선택 가능한 두 운영 모드에 가깝다. pure JVM path는 단순하고 portable하다. native path는 운영 준비가 필요하지만, 대량 image pipeline에서는 성능 차이가 명확하다.

bluetape4k-image의 목표는 둘 중 하나를 강요하는 것이 아니다. 서비스가 처음에는 pure JVM으로 시작하고, throughput이 필요해지는 순간 libvips backend로 이동할 수 있게 같은 생태계 안에 두 경로를 제공하는 것이다.

댓글

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