Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,5 @@ logs
### MCP Bridge ###
mcp-bridge/node_modules/
mcp-bridge/.env

**/google-service-account.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import org.springframework.web.multipart.MultipartFile;

import com.sksamuel.scrimage.AwtImage;
import com.sksamuel.scrimage.ImmutableImage;
import com.sksamuel.scrimage.webp.WebpWriter;

import gg.agit.konect.global.code.ApiResponseCode;
Expand All @@ -30,8 +31,10 @@ public class ImageConversionService {

private static final Set<String> SKIP_CONVERSION_TYPES = Set.of("image/webp");

private static final float DEFAULT_WEBP_QUALITY = 0.8f;
private static final float DEFAULT_WEBP_QUALITY = 1.0f;
private static final int WEBP_QUALITY_PERCENT_SCALE = 100;
private static final int MAX_UPLOAD_WIDTH = 1800;
private static final int MAX_WEBP_DIMENSION = 16383;

private static final int ORIENTATION_NORMAL = 1;
private static final int ORIENTATION_FLIP_HORIZONTAL = 2;
Expand All @@ -44,10 +47,10 @@ public class ImageConversionService {

public ConversionResult convertToWebP(MultipartFile file) throws IOException {
String contentType = file.getContentType();
boolean isWebp = contentType != null && SKIP_CONVERSION_TYPES.contains(contentType.toLowerCase());

if (contentType != null && SKIP_CONVERSION_TYPES.contains(contentType.toLowerCase())) {
log.debug("WEBP 이미지는 변환을 건너뜁니다: contentType={}", contentType);
return new ConversionResult(file.getBytes(), contentType, getExtension(contentType));
if (isWebp) {
return normalizeWebp(file, contentType);
}

try (InputStream input = file.getInputStream();
Expand All @@ -60,14 +63,26 @@ public ConversionResult convertToWebP(MultipartFile file) throws IOException {

ImageReader reader = readers.next();
try {
// TwelveMonkeys reader requires the ImageInputStream to be bound explicitly
// before read/getImageMetadata; otherwise JPEG uploads can fail with getInput() == null.
reader.setInput(iis);
ImageReadParam readParam = reader.getDefaultReadParam();
BufferedImage image = reader.read(0, readParam);

if (image == null) {
throw CustomException.of(ApiResponseCode.INVALID_FILE_CONTENT_TYPE);
}

int originalWidth = image.getWidth();
int originalHeight = image.getHeight();
image = applyExifOrientation(reader, image);
image = resizeToMaxWidthIfNeeded(image);
image = resizeForWebpIfNeeded(image);
Comment thread
dh2906 marked this conversation as resolved.

if (isWebp && originalWidth == image.getWidth() && originalHeight == image.getHeight()) {
log.debug("WEBP 이미지는 크기 변경이 없어 원본을 유지합니다: contentType={}", contentType);
return new ConversionResult(file.getBytes(), contentType, getExtension(contentType));
}

byte[] webpBytes = convertImageToWebP(image, DEFAULT_WEBP_QUALITY);
log.info("이미지 WEBP 변환 완료: 원본 {} bytes → WEBP {} bytes", file.getSize(), webpBytes.length);
Expand All @@ -79,6 +94,27 @@ public ConversionResult convertToWebP(MultipartFile file) throws IOException {
}
}

private ConversionResult normalizeWebp(MultipartFile file, String contentType) throws IOException {
BufferedImage image = ImmutableImage.loader().fromBytes(file.getBytes()).awt();
if (image == null) {
throw CustomException.of(ApiResponseCode.INVALID_FILE_CONTENT_TYPE);
}

int originalWidth = image.getWidth();
int originalHeight = image.getHeight();
image = resizeToMaxWidthIfNeeded(image);
image = resizeForWebpIfNeeded(image);

if (originalWidth == image.getWidth() && originalHeight == image.getHeight()) {
log.debug("WEBP 이미지는 크기 변경이 없어 원본을 유지합니다: contentType={}", contentType);
return new ConversionResult(file.getBytes(), contentType, getExtension(contentType));
}

byte[] webpBytes = convertImageToWebP(image, DEFAULT_WEBP_QUALITY);
log.info("WEBP 이미지 크기 정규화 완료: 원본 {} bytes → WEBP {} bytes", file.getSize(), webpBytes.length);
return new ConversionResult(webpBytes, "image/webp", "webp");
}

private BufferedImage applyExifOrientation(ImageReader reader, BufferedImage image) {
try {
IIOMetadata metadata = reader.getImageMetadata(0);
Expand Down Expand Up @@ -156,9 +192,7 @@ private BufferedImage rotateImage(BufferedImage image, int orientation) {
private BufferedImage rotate90(BufferedImage image) {
int w = image.getWidth();
int h = image.getHeight();
BufferedImage rotated = new BufferedImage(h,
w,
image.getType() == 0 ? BufferedImage.TYPE_INT_RGB : image.getType());
BufferedImage rotated = createCompatibleImage(image, h, w);
Graphics2D g = rotated.createGraphics();
g.translate((h - w) / 2, (h - w) / 2);
g.rotate(Math.PI / 2, h / 2.0, w / 2.0);
Expand All @@ -170,9 +204,7 @@ private BufferedImage rotate90(BufferedImage image) {
private BufferedImage rotate180(BufferedImage image) {
int w = image.getWidth();
int h = image.getHeight();
BufferedImage rotated = new BufferedImage(w,
h,
image.getType() == 0 ? BufferedImage.TYPE_INT_RGB : image.getType());
BufferedImage rotated = createCompatibleImage(image, w, h);
Graphics2D g = rotated.createGraphics();
g.rotate(Math.PI, w / 2.0, h / 2.0);
g.drawRenderedImage(image, null);
Expand All @@ -183,9 +215,7 @@ private BufferedImage rotate180(BufferedImage image) {
private BufferedImage rotate270(BufferedImage image) {
int w = image.getWidth();
int h = image.getHeight();
BufferedImage rotated = new BufferedImage(h,
w,
image.getType() == 0 ? BufferedImage.TYPE_INT_RGB : image.getType());
BufferedImage rotated = createCompatibleImage(image, h, w);
Graphics2D g = rotated.createGraphics();
g.translate((h - w) / 2, (h - w) / 2);
g.rotate(-Math.PI / 2, h / 2.0, w / 2.0);
Expand All @@ -197,9 +227,7 @@ private BufferedImage rotate270(BufferedImage image) {
private BufferedImage flipHorizontal(BufferedImage image) {
int w = image.getWidth();
int h = image.getHeight();
BufferedImage flipped = new BufferedImage(w,
h,
image.getType() == 0 ? BufferedImage.TYPE_INT_RGB : image.getType());
BufferedImage flipped = createCompatibleImage(image, w, h);
Graphics2D g = flipped.createGraphics();
g.drawImage(image, w, 0, -w, h, null);
g.dispose();
Expand All @@ -209,9 +237,7 @@ private BufferedImage flipHorizontal(BufferedImage image) {
private BufferedImage flipVertical(BufferedImage image) {
int w = image.getWidth();
int h = image.getHeight();
BufferedImage flipped = new BufferedImage(w,
h,
image.getType() == 0 ? BufferedImage.TYPE_INT_RGB : image.getType());
BufferedImage flipped = createCompatibleImage(image, w, h);
Graphics2D g = flipped.createGraphics();
g.drawImage(image, 0, h, w, -h, null);
g.dispose();
Expand All @@ -227,6 +253,64 @@ private byte[] convertImageToWebP(BufferedImage image, float quality) throws IOE
}
}

private boolean exceedsWebpDimension(BufferedImage image) {
return image.getWidth() > MAX_WEBP_DIMENSION || image.getHeight() > MAX_WEBP_DIMENSION;
}

private BufferedImage resizeToMaxWidthIfNeeded(BufferedImage image) {
if (image.getWidth() <= MAX_UPLOAD_WIDTH) {
return image;
}

int resizedHeight = Math.max(1,
(int)Math.floor((double)image.getHeight() * MAX_UPLOAD_WIDTH / image.getWidth()));
return resizeImage(image, MAX_UPLOAD_WIDTH, resizedHeight, "업로드 최대 가로 길이에 맞게 이미지를 축소합니다");
}

private BufferedImage resizeForWebpIfNeeded(BufferedImage image) {
if (!exceedsWebpDimension(image)) {
return image;
}

int originalWidth = image.getWidth();
int originalHeight = image.getHeight();
double scale = Math.min(
(double)MAX_WEBP_DIMENSION / originalWidth,
(double)MAX_WEBP_DIMENSION / originalHeight
);
int resizedWidth = Math.max(1, (int)Math.floor(originalWidth * scale));
int resizedHeight = Math.max(1, (int)Math.floor(originalHeight * scale));
return resizeImage(image, resizedWidth, resizedHeight, "WebP 차원 제한에 맞게 이미지를 축소합니다");
}

private BufferedImage resizeImage(BufferedImage image, int resizedWidth, int resizedHeight, String logMessage) {
BufferedImage resized = createCompatibleImage(image, resizedWidth, resizedHeight);
Graphics2D g = resized.createGraphics();
g.drawImage(image, 0, 0, resizedWidth, resizedHeight, null);
g.dispose();

log.info(
"{}: {}x{} -> {}x{}",
logMessage,
image.getWidth(),
image.getHeight(),
resizedWidth,
resizedHeight
);
return resized;
}

private BufferedImage createCompatibleImage(BufferedImage source, int width, int height) {
return new BufferedImage(width, height, resolveBufferedImageType(source));
}

private int resolveBufferedImageType(BufferedImage image) {
if (image.getType() != 0) {
return image.getType();
}
return image.getColorModel().hasAlpha() ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB;
}

private int toWebpQualityPercent(float quality) {
if (quality <= 0) {
return 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
import org.springframework.test.web.servlet.ResultActions;

import com.jayway.jsonpath.JsonPath;
import com.google.auth.oauth2.GoogleCredentials;
import com.sksamuel.scrimage.AwtImage;
import com.sksamuel.scrimage.ImmutableImage;
import com.sksamuel.scrimage.webp.WebpWriter;

import gg.agit.konect.domain.upload.enums.UploadTarget;
import gg.agit.konect.support.IntegrationTestSupport;
Expand All @@ -42,6 +46,9 @@ class UploadApiTest extends IntegrationTestSupport {
@MockitoBean
private S3Client s3Client;

@MockitoBean
private GoogleCredentials googleCredentials;

@BeforeEach
void setUp() throws Exception {
mockLoginUser(LOGIN_USER_ID);
Expand Down Expand Up @@ -78,6 +85,139 @@ void uploadImageSuccess() throws Exception {
assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/webp");
}

@Test
@DisplayName("jpeg 이미지를 업로드하면 webp 로 변환해 저장한다")
void uploadJpegImageSuccess() throws Exception {
// given
MockMultipartFile file = imageFile("club.jpg", "image/jpeg", createJpegBytes(8, 8));

// when
MvcResult result = uploadImage(file, UploadTarget.CLUB)
.andExpect(status().isOk())
.andReturn();

// then
String responseBody = result.getResponse().getContentAsString();
String key = JsonPath.read(responseBody, "$.key");

assertThat(key).startsWith("test/club/");
assertThat(key).endsWith(".webp");

ArgumentCaptor<PutObjectRequest> requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class);
verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class));
assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/webp");
}

@Test
@DisplayName("jpg content type 이미지를 업로드하면 webp 로 변환해 저장한다")
void uploadJpgContentTypeImageSuccess() throws Exception {
// given
MockMultipartFile file = imageFile("club.jpg", "image/jpg", createJpegBytes(8, 8));

// when
MvcResult result = uploadImage(file, UploadTarget.CLUB)
.andExpect(status().isOk())
.andReturn();

// then
String responseBody = result.getResponse().getContentAsString();
String key = JsonPath.read(responseBody, "$.key");

assertThat(key).startsWith("test/club/");
assertThat(key).endsWith(".webp");

ArgumentCaptor<PutObjectRequest> requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class);
verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class));
assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/webp");
}

@Test
@DisplayName("webp 이미지를 업로드하면 변환 없이 그대로 저장한다")
void uploadWebpImageSuccess() throws Exception {
// given
byte[] webpBytes = createWebpBytes(8, 8);
MockMultipartFile file = imageFile("club.webp", "image/webp", webpBytes);

// when
MvcResult result = uploadImage(file, UploadTarget.CLUB)
.andExpect(status().isOk())
.andReturn();

// then
String responseBody = result.getResponse().getContentAsString();
String key = JsonPath.read(responseBody, "$.key");

assertThat(key).startsWith("test/club/");
assertThat(key).endsWith(".webp");

ArgumentCaptor<PutObjectRequest> requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class);
ArgumentCaptor<RequestBody> bodyCaptor = ArgumentCaptor.forClass(RequestBody.class);
verify(s3Client).putObject(requestCaptor.capture(), bodyCaptor.capture());
assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/webp");

ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
bodyCaptor.getValue().contentStreamProvider().newStream().transferTo(outputStream);
assertThat(outputStream.toByteArray()).isEqualTo(webpBytes);
}

@Test
@DisplayName("가로가 1800을 넘는 webp 이미지는 비율 유지로 축소한 뒤 다시 webp 로 업로드한다")
void uploadWideWebpImageResizesAndKeepsWebp() throws Exception {
byte[] webpBytes = createWebpBytes(2160, 1080);
MockMultipartFile file = imageFile("wide.webp", "image/webp", webpBytes);

MvcResult result = uploadImage(file, UploadTarget.CLUB)
.andExpect(status().isOk())
.andReturn();

String responseBody = result.getResponse().getContentAsString();
String key = JsonPath.read(responseBody, "$.key");

assertThat(key).startsWith("test/club/");
assertThat(key).endsWith(".webp");

ArgumentCaptor<PutObjectRequest> requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class);
ArgumentCaptor<RequestBody> bodyCaptor = ArgumentCaptor.forClass(RequestBody.class);
verify(s3Client).putObject(requestCaptor.capture(), bodyCaptor.capture());
assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/webp");

ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
bodyCaptor.getValue().contentStreamProvider().newStream().transferTo(outputStream);
BufferedImage uploadedImage = ImmutableImage.loader().fromBytes(outputStream.toByteArray()).awt();
assertThat(uploadedImage).isNotNull();
assertThat(uploadedImage.getWidth()).isEqualTo(1800);
assertThat(uploadedImage.getHeight()).isEqualTo(900);
assertThat(outputStream.toByteArray()).isNotEqualTo(webpBytes);
}

@Test
@DisplayName("가로가 1800을 넘는 이미지는 비율 유지로 축소한 뒤 webp 로 업로드한다")
void uploadWideImageResizesAndConvertsToWebP() throws Exception {
MockMultipartFile file = imageFile("wide.png", "image/png", createPngBytes(2160, 1080));

MvcResult result = uploadImage(file, UploadTarget.CLUB)
.andExpect(status().isOk())
.andReturn();

String responseBody = result.getResponse().getContentAsString();
String key = JsonPath.read(responseBody, "$.key");

assertThat(key).startsWith("test/club/");
assertThat(key).endsWith(".webp");

ArgumentCaptor<PutObjectRequest> requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class);
ArgumentCaptor<RequestBody> bodyCaptor = ArgumentCaptor.forClass(RequestBody.class);
verify(s3Client).putObject(requestCaptor.capture(), bodyCaptor.capture());
assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/webp");

ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
bodyCaptor.getValue().contentStreamProvider().newStream().transferTo(outputStream);
BufferedImage uploadedImage = ImmutableImage.loader().fromBytes(outputStream.toByteArray()).awt();
assertThat(uploadedImage).isNotNull();
assertThat(uploadedImage.getWidth()).isEqualTo(1800);
assertThat(uploadedImage.getHeight()).isEqualTo(900);
}

@Test
@DisplayName("빈 파일을 업로드하면 400을 반환한다")
void uploadEmptyFileFails() throws Exception {
Expand Down Expand Up @@ -241,4 +381,17 @@ private byte[] createPngBytes(int width, int height) throws Exception {
return outputStream.toByteArray();
}
}

private byte[] createJpegBytes(int width, int height) throws Exception {
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
ImageIO.write(image, "jpg", outputStream);
return outputStream.toByteArray();
}
}

private byte[] createWebpBytes(int width, int height) throws Exception {
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
return new AwtImage(image).bytes(WebpWriter.DEFAULT);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package gg.agit.konect.unit.auth;
package gg.agit.konect.unit.global.auth.web;

import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.BDDMockito.given;
Expand Down
Loading
Loading