Java/Kotlin 이미지 리사이징 라이브러리 간단 성능 비교 및 테스트

제가 개발하고 있는 서비스는 이미지를 많이 다루기 때문에 썸네일 서버가 필수적입니다.

기존에는 줌(zum.com)의 썸네일 서버를 함께 사용했으나,

사용 중 이슈가 발생했고 외부 의존성을 제거하기 위해 썸네일 작업을 자체적으로 처리하기로 결정했습니다.

 

그래서 구글링을 통해 찾을 수 있었던 대표적인 라이브러리들을 비교해서 적합한 라이브러리를 선택하기로 했습니다.

 

비교 대상 라이브러리

  1. Graphics2D - Java 기본 API
  2. Image.getScaledInstance() - Java 기본 API
  3. Imgscalr - 간단하고 효율적인 리사이징 전용 라이브러리
  4. Thumbnailator - 사용하기 쉽고 기능이 풍부한 라이브러리
  5. Marvin - 이미지 처리 프레임워크

 

이 중에서 저희 서비스는 Imgscalr를 사용하기로 했는데요,

아래는 간단하게 테스트를 거친 후 왜 적용하게 되었는지 적어보았습니다.

 

 


1. JDK 기본 제공 방식

1.1 Graphics2D

Java의 기본 그래픽 라이브러리인 AWT(Abstract Window Toolkit)를 사용하는 방식입니다.

  • java.awt.Graphics2D
  • 외부 라이브러리 의존성 없음
  • 세밀한 렌더링 옵션 제어 가능
  • 구현이 다소 복잡할 수 있다
import java.awt.RenderingHints
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO

fun resizeWithGraphics2D(inputFile: File, targetWidth: Int): BufferedImage {
    val originalImage = ImageIO.read(inputFile)
    val targetHeight = calculateTargetHeight(
        originalImage.width, 
        originalImage.height, 
        targetWidth
    )
    
    val resizedImage = BufferedImage(
        targetWidth, 
        targetHeight, 
        BufferedImage.TYPE_INT_RGB
    )
    
    val graphics = resizedImage.createGraphics()
    
    // 고품질 렌더링 설정
    graphics.setRenderingHint(
        RenderingHints.KEY_INTERPOLATION,
        RenderingHints.VALUE_INTERPOLATION_BILINEAR
    )
    graphics.setRenderingHint(
        RenderingHints.KEY_RENDERING,
        RenderingHints.VALUE_RENDER_QUALITY
    )
    graphics.setRenderingHint(
        RenderingHints.KEY_ANTIALIASING,
        RenderingHints.VALUE_ANTIALIAS_ON
    )
    
    graphics.drawImage(originalImage, 0, 0, targetWidth, targetHeight, null)
    graphics.dispose()
    
    return resizedImage
}

private fun calculateTargetHeight(
    originalWidth: Int, 
    originalHeight: Int, 
    targetWidth: Int
): Int {
    val aspectRatio = originalHeight.toDouble() / originalWidth.toDouble()
    return (targetWidth * aspectRatio).roundToInt()
}

 

1.2 Image.getScaledInstance()

 

Image 클래스의 기본 스케일링 메서드를 사용하는 방식입니다.

  • javax.imageio.ImageIO
  • 사용이 매우 간단함
  • 성능이 가장 느림
  • 이미지 품질 제어가 제한적
import java.awt.Image
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO

fun resizeWithScaledInstance(inputFile: File, targetWidth: Int): BufferedImage {
    val originalImage = ImageIO.read(inputFile)
    val targetHeight = calculateTargetHeight(
        originalImage.width, 
        originalImage.height, 
        targetWidth
    )
    
    // Image.SCALE_SMOOTH: 가장 부드러운 스케일링
    val scaledImage = originalImage.getScaledInstance(
        targetWidth, 
        targetHeight, 
        Image.SCALE_SMOOTH
    )
    
    // Image 객체를 BufferedImage로 변환
    val bufferedImage = BufferedImage(
        targetWidth, 
        targetHeight, 
        BufferedImage.TYPE_INT_RGB
    )
    val g2d = bufferedImage.createGraphics()
    g2d.drawImage(scaledImage, 0, 0, null)
    g2d.dispose()
    
    return bufferedImage
}

 

2. 외부 라이브러리

2.1 Thumbnailator

사용하기 쉬운 Fluent API를 제공하며 다양한 기능을 지원합니다.

implementation 'net.coobird:thumbnailator:0.4.19'

 

import net.coobird.thumbnailator.Thumbnails
import java.io.File

// 간단한 리사이징
fun resizeWithThumbnailator(inputFile: File, targetWidth: Int): File {
    val outputFile = File("output_thumbnailator.jpg")
    
    Thumbnails.of(inputFile)
        .width(targetWidth)  // 너비만 지정하면 비율 자동 유지
        .toFile(outputFile)
    
    return outputFile
}

// 고급 옵션 사용
fun resizeWithThumbnailatorAdvanced(
    inputFile: File, 
    targetWidth: Int,
    quality: Double = 0.9
): File {
    val outputFile = File("output_thumbnailator_quality.jpg")
    
    Thumbnails.of(inputFile)
        .width(targetWidth)
        .outputQuality(quality)  // JPEG 압축 품질
        .outputFormat("jpg")
        .toFile(outputFile)
    
    return outputFile
}

// BufferedImage로 반환받기
fun resizeWithThumbnailatorAsImage(
    inputFile: File, 
    targetWidth: Int
): BufferedImage {
    return Thumbnails.of(inputFile)
        .width(targetWidth)
        .outputQuality(0.85)
        .asBufferedImage()
}

 

2.2 Imgscalr

간단한 API로 한 줄에 리사이징이 가능한 라이브러리입니다.

  • 성능과 품질의 균형이 좋음
  • 비율 유지 자동 처리
  • 기능이 제한적 (리사이징, 크롭, 회전만)
  • 2013년 이후 업데이트 중단
  • 2013년 이후 업데이트 중단 (마지막 릴리즈: 4.2, 2012년)
implementation 'org.imgscalr:imgscalr-lib:4.2'

 

import org.imgscalr.Scalr
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO

// QUALITY 모드 (품질 우선)
fun resizeWithImgscalrQuality(inputFile: File, targetWidth: Int): BufferedImage {
    val originalImage = ImageIO.read(inputFile)
    
    return Scalr.resize(
        originalImage,
        Scalr.Method.QUALITY,      // 품질 우선
        Scalr.Mode.FIT_TO_WIDTH,   // 너비 기준 맞춤
        targetWidth
    )
}

// SPEED 모드 (속도 우선)
fun resizeWithImgscalrSpeed(inputFile: File, targetWidth: Int): BufferedImage {
    val originalImage = ImageIO.read(inputFile)
    
    return Scalr.resize(
        originalImage,
        Scalr.Method.SPEED,        // 속도 우선
        Scalr.Mode.FIT_TO_WIDTH,
        targetWidth
    )
}

// 정확한 크기 지정
fun resizeWithImgscalrExact(
    inputFile: File, 
    width: Int, 
    height: Int
): BufferedImage {
    val originalImage = ImageIO.read(inputFile)
    
    return Scalr.resize(
        originalImage,
        Scalr.Method.QUALITY,
        Scalr.Mode.FIT_EXACT,      // 정확한 크기로 맞춤
        width,
        height
    )
}

 

2.3 Marvin

  • 이미지 처리 프레임워크
  • 필터, 효과 등 다양한 이미지 처리 가능
  • API가 다소 복잡함
implementation 'com.github.downgoon:marvin:1.5.5'
implementation 'com.github.downgoon:MarvinPlugins:1.5.5'

 

import marvin.image.MarvinImage
import org.marvinproject.image.transform.scale.Scale
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO

fun resizeWithMarvin(inputFile: File, targetWidth: Int): BufferedImage {
    val image = ImageIO.read(inputFile)
    
    val originWidth = image.width
    val originHeight = image.height
    val targetHeight = calculateTargetHeight(originWidth, originHeight, targetWidth)
    
    // 다운스케일링만 수행
    if (originWidth >= targetWidth || originHeight >= targetHeight) {
        val imageMarvin = MarvinImage(image)
        
        val scale = Scale()
        scale.load()
        scale.setAttribute("newWidth", targetWidth)
        scale.setAttribute("newHeight", targetHeight)
        scale.process(imageMarvin.clone(), imageMarvin, null, null, false)
        
        // JPEG는 알파 채널을 지원하지 않으므로 제거
        return imageMarvin.bufferedImageNoAlpha
    }
    
    return image
}

 

성능 비교 테스트 해보기

 

성능 측정 방법

실행 시간 측정 (CPU 사용량 지표)

실행 시간은 CPU 사용량을 간접적으로 나타내는 지표라고 생각했습니다.

System.nanoTime()을 사용하여 나노초 단위로 측정한 후 밀리초로 변환했습니다.

val startTime = System.nanoTime()

// 리사이징 작업 수행
for (i in 0..<TEST_ITERATIONS) {
    val resizedImage = Scalr.resize(originalImage, Scalr.Method.SPEED, Scalr.Mode.FIT_TO_WIDTH, targetWidth)
}

val endTime = System.nanoTime()
val executionTime = (endTime - startTime) / 1_000_000 // 나노초 -> 밀리초
val averageTime = executionTime / TEST_ITERATIONS

 

측정 시 주의사항

  • 각 테스트마다 10회 반복 수행하여 평균값 계산
  • 첫 실행은 JVM 워밍업 효과가 있을 수 있어 여러 번 반복 측정

 

메모리 사용량 측정

JVM의 Runtime 클래스를 사용하여 테스트 전후의 메모리 사용량을 측정했습니다.

// 메모리 측정을 위한 헬퍼 함수
private val usedMemory: Long
    get() {
        val runtime = Runtime.getRuntime()
        return runtime.totalMemory() - runtime.freeMemory()
    }

// 테스트 전 가비지 컬렉션 강제 실행
System.gc()
Thread.sleep(100) // GC 완료 대기

val startMemory = usedMemory

// 리사이징 작업 수행
for (i in 0..<TEST_ITERATIONS) {
    val resizedImage = Scalr.resize(originalImage, Scalr.Method.SPEED, Scalr.Mode.FIT_TO_WIDTH, targetWidth)
}

val endMemory = usedMemory
val memoryUsed = max(0, endMemory - startMemory) // 음수 방지

 

 

측정 시 주의사항

  • 테스트 전 System.gc()로 가비지 컬렉션을 강제 실행하여 정확도 향상
  • JVM의 메모리 관리 특성상 정확한 측정이 어려울 수 있으나, 상대적 비교에는 유효
  • totalMemory() - freeMemory()로 실제 사용 중인 메모리 계산

 

파일 크기 및 압축률

생성된 이미지 파일의 크기를 측정하고, 원본 대비 압축률을 계산했습니다.

val outputFile = File("output.jpg")
val fileSize = outputFile.length() // 바이트 단위

// 압축률 계산
val compressionRatio = (1 - (fileSize.toDouble() / originalFileSize)) * 100

// 파일 크기를 사람이 읽기 쉬운 형식으로 변환 (B, KB, MB, GB 등)
private fun formatBytes(bytes: Long): String {
    // 1KB(1024바이트) 미만이면 바이트 단위 그대로 반환
    if (bytes < 1024) return "$bytes B"
    
    // 로그를 이용해 어떤 단위를 사용할지 결정
    // ln(bytes) / ln(1024) = log₁₀₂₄(bytes)
    // 결과: 1=KB, 2=MB, 3=GB, 4=TB, 5=PB, 6=EB
    val exp = (ln(bytes.toDouble()) / ln(1024.0)).toInt()
    
    // 지수에 해당하는 단위 접두사 선택
    // exp=1이면 K(킬로), exp=2이면 M(메가), exp=3이면 G(기가)...
    val pre = "KMGTPE"[exp - 1]
    
    // 원본 바이트를 해당 단위로 나눠서 소수점 2자리까지 포맷팅
    // 예: 2048 bytes → 2048 / 1024¹ = 2.00 KB
    // 예: 5242880 bytes → 5242880 / 1024² = 5.00 MB
    return String.format("%.2f %sB", bytes / 1024.0.pow(exp.toDouble()), pre)
}

 

이러한 측정 방식으로 아래와 같이 비교 했습니다.

 


 

 

원본 이미지 

  • 원본 크기: 848 x 1264 pixels
  • 원본 파일 크기: 713.26 KB
  • 목표 크기: NNN x 626 (원본 비율 유지)
  • 테스트 반복 횟수: 10회

실제 서비스에서 사용될 이미지를 기준으로 테스트 해보았습니다.

 

테스트 1: 420x626 픽셀로 축소

원본 이미지 848*N 사이즈 이미지를 420*?(비율 유지)로 변경하는 경우

 

테스트 2: 848x1264 픽셀 유지 (원본 크기)

원본 이미지 848*N 사이즈 이미지를 848*?(원본 크기와 동일하게)로 변경하는 경우

 

속도 순위 (빠른 순):

  1. Imgscalr (SPEED)
  2. Imgscalr (QUALITY) 
  3. Graphics2D (압축)

메모리 사용량 순위 (적은 순):

  1. Thumbnailator (압축)
  2. Thumbnailator(기본)
  3. Imgscalr (SPEED)

 

두 라이브러리의 성능을 비교하기 위해 메모리 사용량과 실행 시간을 측정했습니다.

 

실행 시간은 CPU 사용량을 간접적으로 나타내는 지표로 판단했습니다.

 

테스트 결과 Imgscalr와 Thumbnailator 모두 안정적인 성능을 보여주었지만, Imgscalr를 SPEED 설정과 함께 사용했을 때 메모리 사용량은 비슷하면서도 평균 연산 시간이 더 짧고 압축률도 높았습니다.

 

다만 Imgscalr는 2013년 이후 업데이트가 중단된 반면, Thumbnailator는 비교적 최근까지 활발히 유지보수되고 있습니다. 또한 코드 가독성 측면에서도 Thumbnailator가 더 읽기 쉬운 것은 사실입니다.

 

하지만 제가 맡고 있는 서비스에서 현재 필요한 기능은 '이미지 용량 및 사이즈 줄이기'라는 단순한 작업이기 때문에, 많은 기능을 제공하는 라이브러리까지는 필요하지 않다고 판단했습니다. 향후 더 복잡한 이미지 처리 기능이 필요해지면 그때 다른 라이브러리로 전환하는 것도 충분히 가능할 것 같습니다.

 

실제로 두 라이브러리로 생성된 썸네일 이미지를 육안으로 비교했을 때도 품질 차이를 거의 느낄 수 없었기 때문에, Imgscalr를 선택하기로 했습니다.