제가 개발하고 있는 서비스는 이미지를 많이 다루기 때문에 썸네일 서버가 필수적입니다.
기존에는 줌(zum.com)의 썸네일 서버를 함께 사용했으나,
사용 중 이슈가 발생했고 외부 의존성을 제거하기 위해 썸네일 작업을 자체적으로 처리하기로 결정했습니다.
그래서 구글링을 통해 찾을 수 있었던 대표적인 라이브러리들을 비교해서 적합한 라이브러리를 선택하기로 했습니다.
비교 대상 라이브러리
- Graphics2D - Java 기본 API
- Image.getScaledInstance() - Java 기본 API
- Imgscalr - 간단하고 효율적인 리사이징 전용 라이브러리
- Thumbnailator - 사용하기 쉽고 기능이 풍부한 라이브러리
- 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를 제공하며 다양한 기능을 지원합니다.
- 빌더 패턴으로 읽기 쉬운 코드
- 워터마크, 회전, 크롭 등 추가 기능 풍부
- 활발한 유지보수 (최근까지 업데이트)
- 직관적인 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*?(원본 크기와 동일하게)로 변경하는 경우

속도 순위 (빠른 순):
- Imgscalr (SPEED)
- Imgscalr (QUALITY)
- Graphics2D (압축)
메모리 사용량 순위 (적은 순):
- Thumbnailator (압축)
- Thumbnailator(기본)
- Imgscalr (SPEED)
두 라이브러리의 성능을 비교하기 위해 메모리 사용량과 실행 시간을 측정했습니다.
실행 시간은 CPU 사용량을 간접적으로 나타내는 지표로 판단했습니다.
테스트 결과 Imgscalr와 Thumbnailator 모두 안정적인 성능을 보여주었지만, Imgscalr를 SPEED 설정과 함께 사용했을 때 메모리 사용량은 비슷하면서도 평균 연산 시간이 더 짧고 압축률도 높았습니다.
다만 Imgscalr는 2013년 이후 업데이트가 중단된 반면, Thumbnailator는 비교적 최근까지 활발히 유지보수되고 있습니다. 또한 코드 가독성 측면에서도 Thumbnailator가 더 읽기 쉬운 것은 사실입니다.
하지만 제가 맡고 있는 서비스에서 현재 필요한 기능은 '이미지 용량 및 사이즈 줄이기'라는 단순한 작업이기 때문에, 많은 기능을 제공하는 라이브러리까지는 필요하지 않다고 판단했습니다. 향후 더 복잡한 이미지 처리 기능이 필요해지면 그때 다른 라이브러리로 전환하는 것도 충분히 가능할 것 같습니다.
실제로 두 라이브러리로 생성된 썸네일 이미지를 육안으로 비교했을 때도 품질 차이를 거의 느낄 수 없었기 때문에, Imgscalr를 선택하기로 했습니다.
'프로젝트 > 프로젝트 과정' 카테고리의 다른 글
| [잔디 일기] Spring Boot에서 CORS 오류 해결과 클래스 관심사 분리: JwtAuthFilter와 WebMvcConfig 활용하기 (8) | 2024.11.03 |
|---|---|
| [hot deal] API 설계 과정에서의 고민 (0) | 2024.11.03 |
| [hot deal] UUID의 사용 범위에 대한 고민 (3) | 2024.11.03 |
| [hot deal] 새로운 객체 생성에 Builder 패턴을 사용하지 않은 이유 (1) | 2024.11.02 |
| [Spring] 기본 자료형이 아닌 구체적인 자료형 생성하기 (0) | 2024.11.02 |
