From dacccc749ec98ff98c16e07daa813f727bea8a6b Mon Sep 17 00:00:00 2001 From: jaejo Date: Tue, 4 Aug 2026 17:19:25 +0900 Subject: [PATCH 1/9] refact: refactor image validation and file storage security handling --- .../common/storage/LocalStorageService.java | 66 +++++++--- .../core/common/storage/S3StorageService.java | 42 +++--- .../core/common/util/ImageFileValidator.java | 120 ++++++++++++++---- 3 files changed, 161 insertions(+), 67 deletions(-) diff --git a/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java b/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java index 9ca610e..1eaf6de 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java +++ b/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java @@ -23,37 +23,43 @@ public class LocalStorageService implements StorageService { private final ImageFileValidator imageFileValidator; - private final String uploadDir; + private final Path uploadRoot; public LocalStorageService( ImageFileValidator imageFileValidator, @Value("${app.file.upload.dir:./uploads}") String uploadDir ) { this.imageFileValidator = imageFileValidator; - this.uploadDir = Paths.get(uploadDir).toAbsolutePath().normalize().toString(); - log.info("[LocalStorageService] 파일 저장 절대 경로 지정 완료: {}", this.uploadDir); + this.uploadRoot = Paths.get(uploadDir).toAbsolutePath().normalize(); + log.info("[LocalStorageService] 파일 저장 절대 경로 지정 완료: {}", this.uploadRoot); } @Override public String upload(InputStream inputStream, String originalFileName, String contentType, String directory) { - // 이미지 유효성 정밀 검증 및 스트림 초기화 - InputStream validatedStream = imageFileValidator.validateImage(inputStream, originalFileName, contentType); + // try-with-resources 구문으로 원본 inputStream 및 validatedStream 자원 수명주기를 안전하게 관리 + try (InputStream src = inputStream) { + ImageFileValidator.ImageValidationResult validationResult = + imageFileValidator.validateImage(src, originalFileName, contentType); - String extension = ""; - if (originalFileName != null && originalFileName.contains(".")) { - extension = originalFileName.substring(originalFileName.lastIndexOf(".")); - } + try (InputStream validatedStream = validationResult.inputStream()) { + String extension = ""; + if (originalFileName != null && originalFileName.contains(".")) { + extension = originalFileName.substring(originalFileName.lastIndexOf(".")); + } - String savedFileName = UUID.randomUUID() + extension; - Path uploadPath = Paths.get(uploadDir, directory); - try { - Files.createDirectories(uploadPath); - Path targetPath = uploadPath.resolve(savedFileName); - Files.copy(validatedStream, targetPath, StandardCopyOption.REPLACE_EXISTING); + String savedFileName = UUID.randomUUID() + extension; + + // directory가 uploadRoot 바깥으로 빠져나가지 않는지 검증 (Path Traversal 방지) + Path uploadPath = resolveSafely(uploadRoot, directory); + Files.createDirectories(uploadPath); - return savedFileName; + Path targetPath = resolveSafely(uploadPath, savedFileName); + Files.copy(validatedStream, targetPath, StandardCopyOption.REPLACE_EXISTING); + + return savedFileName; + } } catch (IOException e) { - log.error("[StorageService] 파일 업로드 실패 - originalFileName: {}, directory: {}", originalFileName, directory, e); + log.error("[LocalStorageService] 파일 업로드 실패 - originalFileName: {}, directory: {}", originalFileName, directory, e); throw new BusinessException( GlobalErrorCode.FILE_UPLOAD_FAILED, "파일 저장 중 시스템 오류가 발생했습니다.", @@ -79,16 +85,34 @@ public void delete(String fileUrl) { } try { - Path filePath = Paths.get(uploadDir).resolve(relativePath); + Path filePath = resolveSafely(uploadRoot, relativePath); boolean deleted = Files.deleteIfExists(filePath); if (deleted) { - log.info("[StorageService] 물리 파일 삭제 완료: {}", filePath.toAbsolutePath()); + log.info("[LocalStorageService] 물리 파일 삭제 완료: {}", filePath); } else { - log.warn("[StorageService] 삭제할 파일이 디스크에 존재하지 않습니다. {}", filePath.toAbsolutePath()); + log.warn("[LocalStorageService] 삭제할 파일이 디스크에 존재하지 않습니다. {}", filePath); } + } catch (BusinessException e) { + log.warn("[LocalStorageService] 허용되지 않는 삭제 경로 요청 차단: {}", fileUrl); } catch (IOException e) { - log.error("[StorageService] 물리 파일 삭제 실패: {}", fileUrl, e); + log.error("[LocalStorageService] 물리 파일 삭제 실패: {}", fileUrl, e); + } + } + + /** + * base 경로 하위로 relative를 결합한 뒤 정규화하고 결과 경로가 base 바깥으로 벗어나지 않는지 검증합니다. + * 절대 경로 위장( "../") 경로 조작을 모두 차단합니다. + */ + private Path resolveSafely(Path base, String relative) { + if (relative == null || relative.isBlank()) { + throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "유효하지 않은 경로입니다."); + } + Path resolved = base.resolve(relative).normalize(); + if (!resolved.startsWith(base)) { + log.warn("[LocalStorageService] 허용된 경로를 벗어난 요청 차단: base={}, relative={}", base, relative); + throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "허용되지 않는 경로입니다."); } + return resolved; } } diff --git a/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java b/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java index a4e6161..ffce521 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java +++ b/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java @@ -45,29 +45,29 @@ public S3StorageService( @Override public String upload(InputStream inputStream, String originalFileName, String contentType, String directory) { - // try-with-resources 구문으로 원본 inputStream 및 validatedStream 자원 수명주기를 안전하게 관리 - try (InputStream src = inputStream; - InputStream validatedStream = imageFileValidator.validateImage(src, originalFileName, contentType)) { - - String extension = ""; - if (originalFileName != null && originalFileName.contains(".")) { - extension = originalFileName.substring(originalFileName.lastIndexOf(".")); - } + try (InputStream src = inputStream) { + ImageFileValidator.ImageValidationResult validationResult = imageFileValidator.validateImage(src, originalFileName, contentType); + // try-with-resources 구문으로 원본 inputStream 및 validatedStream 자원 수명주기를 안전하게 관리 + try (InputStream validatedStream = validationResult.inputStream()) { + String extension = ""; + if (originalFileName != null && originalFileName.contains(".")) { + extension = originalFileName.substring(originalFileName.lastIndexOf(".")); + } - String savedFileName = UUID.randomUUID() + extension; - String key = directory + "/" + savedFileName; - - byte[] bytes = validatedStream.readAllBytes(); - s3Client.putObject( - PutObjectRequest.builder() - .bucket(bucket) - .key(key) - .contentType(contentType) - .build(), - RequestBody.fromBytes(bytes) - ); - return savedFileName; + String savedFileName = UUID.randomUUID() + extension; + String key = directory + "/" + savedFileName; + byte[] bytes = validatedStream.readAllBytes(); + s3Client.putObject( + PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .contentType(validationResult.detectedContentType()) + .build(), + RequestBody.fromBytes(bytes) + ); + return savedFileName; + } } catch (IOException | SdkException e) { log.error("[S3StorageService] S3 파일 업로드 실패 - originalFileName: {}, directory: {}", originalFileName, directory, e); throw new BusinessException( diff --git a/momogo-core/src/main/java/com/momogo/core/common/util/ImageFileValidator.java b/momogo-core/src/main/java/com/momogo/core/common/util/ImageFileValidator.java index 17195cc..c07d075 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/util/ImageFileValidator.java +++ b/momogo-core/src/main/java/com/momogo/core/common/util/ImageFileValidator.java @@ -7,70 +7,132 @@ import org.springframework.util.unit.DataSize; import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.InputStream; +import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; /** * 프로필 이미지 등 파일 업로드 시 업로드될 파일의 유효성과 보안성을 검증하는 컴포넌트입니다. - * 확장자 및 MIME 타입을 검증하며, ImageIO를 사용하여 실제 이미지 파일 구조인지 검증합니다. + * 확장자를 1차 검증하고, 실제 바이트(매직바이트)를 기반으로 ImageIO가 인식하는 실제 이미지 포맷을 감지하여 + * 확장자와 일치하는지 교차 검증합니다. 클라이언트가 전달한 Content-Type 헤더는 신뢰하지 않습니다. */ @Component public class ImageFileValidator { + // 픽셀 폭탄(decompression bomb) 방어용 최대 허용 픽셀 수 (예: 4000 x 4000 => 1600만 화소) + // 최대 1600만 * 4bytes = 64MB까지 + private static final long MAX_PIXEL_COUNT = 4000L * 4000L; + + // 파일 확장자를 ImageIO 표준 포맷명으로 변환하여, 이미지 소스 분석 결과와 대조하기 위한 매핑 테이블 + private static final Map EXTENSION_TO_FORMAT = Map.of( + "jpg", "jpeg", + "jpeg", "jpeg", + "png", "png", + "gif", "gif", + "webp", "webp" + ); + + // 감지된 이미지 포맷을 웹 표준 HTTP Content-Type (MIME Type)으로 변환하는 매핑 테이블 + private static final Map FORMAT_TO_MIME_TYPE = Map.of( + "jpeg", "image/jpeg", + "png", "image/png", + "gif", "image/gif", + "webp", "image/webp" + ); + private final Set allowedExtensions; - private final Set allowedMimeTypes; private final int maxMarkSize; public ImageFileValidator( @Value("${app.file.upload.allowed-extensions}") List allowedExtensions, - @Value("${app.file.upload.allowed-mime-types}") List allowedMimeTypes, @Value("${app.file.upload.max-mark-size:10MB}") DataSize maxMarkSize ) { - this.allowedExtensions = Set.copyOf(allowedExtensions); - this.allowedMimeTypes = Set.copyOf(allowedMimeTypes); + this.allowedExtensions = allowedExtensions.stream() + .map(String::toLowerCase) + .collect(Collectors.toUnmodifiableSet()); this.maxMarkSize = (int) maxMarkSize.toBytes(); } /** - * 파일 업로드 시 확장자, MIME 타입 및 실제 이미지 바이트 무결성을 일괄 검증합니다. + * 파일 업로드 시 확장자, 실제 이미지 바이트 무결성, 확장자-실제포맷 일치 여부, 해상도 상한을 일괄 검증합니다. * * @param inputStream 파일 데이터 스트림 * @param originalFilename 원본 파일 이름 - * @param contentType 파일의 Content-Type - * @return 검증 후 다시 처음부터 읽을 수 있도록 분리 및 복사된 InputStream + * @param contentType 클라이언트가 전달한 Content-Type (참고용, 신뢰하지 않음) + * @return 검증된 스트림과 실제 감지된 Content-Type을 담은 결과 객체 */ - public InputStream validateImage(InputStream inputStream, String originalFilename, String contentType) { + public ImageValidationResult validateImage(InputStream inputStream, String originalFilename, String contentType) { // 1. 파일 이름 및 확장자 검사 if (originalFilename == null || !originalFilename.contains(".")) { throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "올바르지 않은 파일명입니다."); } String ext = originalFilename.substring(originalFilename.lastIndexOf(".") + 1).toLowerCase(); - if (!allowedExtensions.contains(ext)) { + if (!allowedExtensions.contains(ext) || !EXTENSION_TO_FORMAT.containsKey(ext)) { throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "허용되지 않는 파일 확장자입니다."); } - // 2. MIME 타입 검증 - if (contentType == null || !allowedMimeTypes.contains(contentType.toLowerCase())) { - throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "허용되지 않는 파일 타입(MIME)입니다."); + // 2. 업로드 상한 내에서 바이트를 안전하게 복사 (OOM 방지 및 스트림 복제) + byte[] fileBytes = readWithLimit(inputStream, maxMarkSize); + + // 3. 실제 바이트 기반 이미지 포맷 감지 및 검증 (contentType 파라미터는 사용하지 않음(변조 방지)) + String detectedFormat = detectAndValidateFormat(fileBytes); + + // 4. 확장자와 실제 감지된 포맷이 일치하는지 교차 검증 (위장 확장자 차단) + String expectedFormat = EXTENSION_TO_FORMAT.get(ext); + if (!expectedFormat.equals(detectedFormat)) { + throw new BusinessException( + GlobalErrorCode.INVALID_INPUT, + "파일 내용이 확장자와 일치하지 않습니다.", + "ext=" + ext + ", detectedFormat=" + detectedFormat + ); } - // 3. 업로드 상한 내에서 바이트를 안전하게 복사 (OOM 방지 및 스트림 복제) - byte[] fileBytes = readWithLimit(inputStream, maxMarkSize); + String detectedMimeType = FORMAT_TO_MIME_TYPE.get(detectedFormat); + return new ImageValidationResult(new ByteArrayInputStream(fileBytes), detectedMimeType); + } - try { - // 4. 검증용 ByteArrayInputStream 생성하여 ImageIO 검증 - try (InputStream validationStream = new ByteArrayInputStream(fileBytes)) { - if (ImageIO.read(validationStream) == null) { - throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "손상되었거나 변조된 이미지 파일입니다."); - } + /** + * ImageIO 리더를 사용해 실제 이미지 포맷을 감지하고, 디코딩 전에 해상도(픽셀 수) 상한을 검사합니다. + * 전체 픽셀 디코딩 없이 헤더 수준에서 width/height를 읽어 압축 폭탄(decompression bomb)을 방어합니다. + */ + private String detectAndValidateFormat(byte[] fileBytes) { + try (ImageInputStream iis = ImageIO.createImageInputStream(new ByteArrayInputStream(fileBytes))) { + if (iis == null) { + throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "손상되었거나 변조된 이미지 파일입니다."); } - // 5. 저장용으로 사용할 새로운 ByteArrayInputStream 반환 - return new ByteArrayInputStream(fileBytes); + Iterator readers = ImageIO.getImageReaders(iis); + if (!readers.hasNext()) { + throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "손상되었거나 변조된 이미지 파일입니다."); + } + + // 파일 바이트를 해독할 수 있는 전용 디코더 객체 + ImageReader reader = readers.next(); + try { + // seekForwardOnly: 스트림을 순방향으로만 읽어 메모리 절약 + // ignoreMetadata: 부가 메타데이터를 무시하여 읽기 속도를 최대로 끌어올림 + reader.setInput(iis, true, true); + + // 전체 이미지 픽셀을 메모리에 올리지 않고 가로, 세로 픽셀 크기만 즉시 읽어옴 + int width = reader.getWidth(0); + int height = reader.getHeight(0); + // 초과 시 예외를 발생시켜 압축 폭탄 공격으로 인한 서버 메모리 고갈을 사전에 차단 + if ((long) width * height > MAX_PIXEL_COUNT) { + throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "이미지 해상도가 허용 범위를 초과했습니다."); + } + return reader.getFormatName().toLowerCase(); + } finally { + // 사용이 끝난 reader 객체를 메모리에서 해제 + reader.dispose(); + } } catch (BusinessException e) { throw e; } catch (Exception e) { @@ -83,16 +145,18 @@ public InputStream validateImage(InputStream inputStream, String originalFilenam * 상한을 초과할 경우 즉시 예외를 발생시켜 OOM을 예방합니다. */ private byte[] readWithLimit(InputStream inputStream, int limit) { + // 읽어들인 조각 바이트들을 하나로 모아 저장할 메모리 스트림을 생성 try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) { byte[] buffer = new byte[4096]; int bytesRead; int totalBytes = 0; + // 스트림의 끝(EOF)에 도달할 때까지 4KB 조각 단위로 계속 읽음 while ((bytesRead = inputStream.read(buffer)) != -1) { - totalBytes += bytesRead; - if (totalBytes > limit) { + if (totalBytes + bytesRead > limit) { throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "업로드 가능한 최대 파일 크기를 초과했습니다."); } + totalBytes += bytesRead; bos.write(buffer, 0, bytesRead); } return bos.toByteArray(); @@ -102,4 +166,10 @@ private byte[] readWithLimit(InputStream inputStream, int limit) { throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "파일을 읽는 중 오류가 발생했습니다."); } } + + /** + * 검증이 완료된 스트림과, 실제 바이트 기반으로 감지된 신뢰 가능한 Content-Type을 함께 담는 결과 객체. + */ + public record ImageValidationResult(InputStream inputStream, String detectedContentType) { + } } From 4235d79f88ada1afdbe4fa3ecf9de4d7a28a56ef Mon Sep 17 00:00:00 2001 From: jaejo Date: Tue, 4 Aug 2026 20:29:05 +0900 Subject: [PATCH 2/9] feat: impl resizing image logic --- momogo-core/build.gradle | 3 +- .../common/storage/LocalStorageService.java | 12 ++-- .../core/common/storage/S3StorageService.java | 10 +-- ...FileValidator.java => ImageProcessor.java} | 61 +++++++++++++++---- 4 files changed, 61 insertions(+), 25 deletions(-) rename momogo-core/src/main/java/com/momogo/core/common/util/{ImageFileValidator.java => ImageProcessor.java} (75%) diff --git a/momogo-core/build.gradle b/momogo-core/build.gradle index d6ecfb9..3ebc9ed 100644 --- a/momogo-core/build.gradle +++ b/momogo-core/build.gradle @@ -27,10 +27,11 @@ dependencies { // PDF 라이브러리 implementation 'com.github.librepdf:openpdf:2.2.2' - // S3 프로필 이미지 저장용 및 WebP 이미지 포맷 지원 + // S3 프로필 이미지 저장용 및 WebP 이미지 포맷 지원, 이미지 리사이징 implementation platform('software.amazon.awssdk:bom:2.29.52') implementation 'software.amazon.awssdk:s3' implementation 'com.twelvemonkeys.imageio:imageio-webp:3.12.0' + implementation 'net.coobird:thumbnailator:0.4.20' // 분산환경 공용 인프라 (Redis, Kafka) - api/realtime/batch가 공통으로 사용 api 'org.springframework.boot:spring-boot-starter-data-redis' diff --git a/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java b/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java index 1eaf6de..4ca2b23 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java +++ b/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java @@ -2,7 +2,7 @@ import com.momogo.core.common.exception.BusinessException; import com.momogo.core.common.exception.GlobalErrorCode; -import com.momogo.core.common.util.ImageFileValidator; +import com.momogo.core.common.util.ImageProcessor; import com.momogo.core.common.util.UrlUtils; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -22,14 +22,14 @@ @ConditionalOnProperty(name = "app.storage.type", havingValue = "local", matchIfMissing = true) public class LocalStorageService implements StorageService { - private final ImageFileValidator imageFileValidator; + private final ImageProcessor imageProcessor; private final Path uploadRoot; public LocalStorageService( - ImageFileValidator imageFileValidator, + ImageProcessor imageProcessor, @Value("${app.file.upload.dir:./uploads}") String uploadDir ) { - this.imageFileValidator = imageFileValidator; + this.imageProcessor = imageProcessor; this.uploadRoot = Paths.get(uploadDir).toAbsolutePath().normalize(); log.info("[LocalStorageService] 파일 저장 절대 경로 지정 완료: {}", this.uploadRoot); } @@ -38,8 +38,8 @@ public LocalStorageService( public String upload(InputStream inputStream, String originalFileName, String contentType, String directory) { // try-with-resources 구문으로 원본 inputStream 및 validatedStream 자원 수명주기를 안전하게 관리 try (InputStream src = inputStream) { - ImageFileValidator.ImageValidationResult validationResult = - imageFileValidator.validateImage(src, originalFileName, contentType); + ImageProcessor.ImageValidationResult validationResult = + imageProcessor.validateImage(src, originalFileName, contentType); try (InputStream validatedStream = validationResult.inputStream()) { String extension = ""; diff --git a/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java b/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java index ffce521..040f23e 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java +++ b/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java @@ -2,7 +2,7 @@ import com.momogo.core.common.exception.BusinessException; import com.momogo.core.common.exception.GlobalErrorCode; -import com.momogo.core.common.util.ImageFileValidator; +import com.momogo.core.common.util.ImageProcessor; import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -28,16 +28,16 @@ @ConditionalOnProperty(name = "app.storage.type", havingValue = "s3") public class S3StorageService implements StorageService { - private final ImageFileValidator imageFileValidator; + private final ImageProcessor imageProcessor; private final S3Client s3Client; private final String bucket; public S3StorageService( - ImageFileValidator imageFileValidator, + ImageProcessor imageProcessor, @Value("${app.aws.s3-bucket}") String bucket, @Value("${app.aws.region}") String region ) { - this.imageFileValidator = imageFileValidator; + this.imageProcessor = imageProcessor; this.bucket = bucket; this.s3Client = S3Client.builder().region(Region.of(region)).build(); log.info("[S3StorageService] 버킷 지정 완료: {}", bucket); @@ -46,7 +46,7 @@ public S3StorageService( @Override public String upload(InputStream inputStream, String originalFileName, String contentType, String directory) { try (InputStream src = inputStream) { - ImageFileValidator.ImageValidationResult validationResult = imageFileValidator.validateImage(src, originalFileName, contentType); + ImageProcessor.ImageValidationResult validationResult = imageProcessor.validateImage(src, originalFileName, contentType); // try-with-resources 구문으로 원본 inputStream 및 validatedStream 자원 수명주기를 안전하게 관리 try (InputStream validatedStream = validationResult.inputStream()) { String extension = ""; diff --git a/momogo-core/src/main/java/com/momogo/core/common/util/ImageFileValidator.java b/momogo-core/src/main/java/com/momogo/core/common/util/ImageProcessor.java similarity index 75% rename from momogo-core/src/main/java/com/momogo/core/common/util/ImageFileValidator.java rename to momogo-core/src/main/java/com/momogo/core/common/util/ImageProcessor.java index c07d075..d30fe55 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/util/ImageFileValidator.java +++ b/momogo-core/src/main/java/com/momogo/core/common/util/ImageProcessor.java @@ -2,6 +2,9 @@ import com.momogo.core.common.exception.BusinessException; import com.momogo.core.common.exception.GlobalErrorCode; +import lombok.extern.slf4j.Slf4j; +import net.coobird.thumbnailator.Thumbnails; +import net.coobird.thumbnailator.geometry.Positions; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.util.unit.DataSize; @@ -19,15 +22,21 @@ import java.util.stream.Collectors; /** - * 프로필 이미지 등 파일 업로드 시 업로드될 파일의 유효성과 보안성을 검증하는 컴포넌트입니다. + * 프로필 이미지 등 파일 업로드 시 업로드될 파일의 유효성과 보안성을 검증하고 + * 검증이 끝난 이미지를 프로필 규격(300*300)으로 리사이징/압축까지 수행하는 컴포넌트입니다. * 확장자를 1차 검증하고, 실제 바이트(매직바이트)를 기반으로 ImageIO가 인식하는 실제 이미지 포맷을 감지하여 * 확장자와 일치하는지 교차 검증합니다. 클라이언트가 전달한 Content-Type 헤더는 신뢰하지 않습니다. */ +@Slf4j @Component -public class ImageFileValidator { +public class ImageProcessor { + + private static final int PROFILE_TARGET_WIDTH = 300; + private static final int PROFILE_TARGET_HEIGHT = 300; + private static final double OUTPUT_QUALITY = 0.85; // 픽셀 폭탄(decompression bomb) 방어용 최대 허용 픽셀 수 (예: 4000 x 4000 => 1600만 화소) - // 최대 1600만 * 4bytes = 64MB까지 + // 최대 1600만 * 4bytes 디코딩 기준 최대 약 61MB 메모리 사용 private static final long MAX_PIXEL_COUNT = 4000L * 4000L; // 파일 확장자를 ImageIO 표준 포맷명으로 변환하여, 이미지 소스 분석 결과와 대조하기 위한 매핑 테이블 @@ -35,7 +44,6 @@ public class ImageFileValidator { "jpg", "jpeg", "jpeg", "jpeg", "png", "png", - "gif", "gif", "webp", "webp" ); @@ -43,14 +51,13 @@ public class ImageFileValidator { private static final Map FORMAT_TO_MIME_TYPE = Map.of( "jpeg", "image/jpeg", "png", "image/png", - "gif", "image/gif", "webp", "image/webp" ); private final Set allowedExtensions; private final int maxMarkSize; - public ImageFileValidator( + public ImageProcessor( @Value("${app.file.upload.allowed-extensions}") List allowedExtensions, @Value("${app.file.upload.max-mark-size:10MB}") DataSize maxMarkSize ) { @@ -82,27 +89,48 @@ public ImageValidationResult validateImage(InputStream inputStream, String origi byte[] fileBytes = readWithLimit(inputStream, maxMarkSize); // 3. 실제 바이트 기반 이미지 포맷 감지 및 검증 (contentType 파라미터는 사용하지 않음(변조 방지)) - String detectedFormat = detectAndValidateFormat(fileBytes); + ImageDimension dimension = detectAndValidateFormat(fileBytes); // 4. 확장자와 실제 감지된 포맷이 일치하는지 교차 검증 (위장 확장자 차단) String expectedFormat = EXTENSION_TO_FORMAT.get(ext); - if (!expectedFormat.equals(detectedFormat)) { + if (!expectedFormat.equals(dimension.format())) { throw new BusinessException( GlobalErrorCode.INVALID_INPUT, "파일 내용이 확장자와 일치하지 않습니다.", - "ext=" + ext + ", detectedFormat=" + detectedFormat + "ext=" + ext + ", detectedFormat=" + dimension.format() ); } - String detectedMimeType = FORMAT_TO_MIME_TYPE.get(detectedFormat); - return new ImageValidationResult(new ByteArrayInputStream(fileBytes), detectedMimeType); + // 5. 프로필 규격(300 * 300)으로 리사이징 및 압축 수행 + byte[] resizedBytes = resizeImage(fileBytes, dimension, PROFILE_TARGET_WIDTH, PROFILE_TARGET_HEIGHT); + + String detectedMimeType = FORMAT_TO_MIME_TYPE.get(dimension.format()); + return new ImageValidationResult(new ByteArrayInputStream(resizedBytes), detectedMimeType); + } + + private byte[] resizeImage(byte[] originalBytes, ImageDimension dimension, int targetWidth, int targetHeight) { + // 원본의 짧은 변과 목표 규격 중 더 작은 값을 정사각형 한 변으로 사용 + int targetSize = Math.min(Math.min(dimension.width(), dimension.height()), targetWidth); + + try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + Thumbnails.of(new ByteArrayInputStream(originalBytes)) + .size(targetSize, targetSize) + .crop(Positions.CENTER) + .outputQuality(OUTPUT_QUALITY) // 85% 품질 압축 (용량 절감) + .outputFormat(dimension.format()) + .toOutputStream(bos); + return bos.toByteArray(); + } catch (Exception e) { + log.error("[ImageProcessor] 이미지 리사이징 실패 - 원본 바이트 유지", e); + return originalBytes; + } } /** * ImageIO 리더를 사용해 실제 이미지 포맷을 감지하고, 디코딩 전에 해상도(픽셀 수) 상한을 검사합니다. * 전체 픽셀 디코딩 없이 헤더 수준에서 width/height를 읽어 압축 폭탄(decompression bomb)을 방어합니다. */ - private String detectAndValidateFormat(byte[] fileBytes) { + private ImageDimension detectAndValidateFormat(byte[] fileBytes) { try (ImageInputStream iis = ImageIO.createImageInputStream(new ByteArrayInputStream(fileBytes))) { if (iis == null) { throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "손상되었거나 변조된 이미지 파일입니다."); @@ -128,7 +156,7 @@ private String detectAndValidateFormat(byte[] fileBytes) { throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "이미지 해상도가 허용 범위를 초과했습니다."); } - return reader.getFormatName().toLowerCase(); + return new ImageDimension(reader.getFormatName().toLowerCase(), width, height); } finally { // 사용이 끝난 reader 객체를 메모리에서 해제 reader.dispose(); @@ -172,4 +200,11 @@ private byte[] readWithLimit(InputStream inputStream, int limit) { */ public record ImageValidationResult(InputStream inputStream, String detectedContentType) { } + + /** + * detectAndValidateFormat()의 반환값으로, 감지된 실제 이미지 포맷명과 픽셀 단위의 가로/세로 크기를 담습니다. + * 리사이징 여부(원본이 목표 규격보다 작은지) 판단에 사용됩니다. + */ + private record ImageDimension(String format, int width, int height) { + } } From 0eedab3f237db00eb118b2e157adf875c6853e38 Mon Sep 17 00:00:00 2001 From: jaejo Date: Tue, 4 Aug 2026 22:25:19 +0900 Subject: [PATCH 3/9] fix: modify CodeRabbit Review --- .../src/main/resources/application.yaml | 2 +- momogo-core/build.gradle | 2 +- .../common/storage/LocalStorageService.java | 12 +++--- .../core/common/storage/S3StorageService.java | 37 ++++++++----------- .../core/common/util/ImageProcessor.java | 17 +++++---- .../util/StorageDirectoryValidator.java | 27 ++++++++++++++ 6 files changed, 60 insertions(+), 37 deletions(-) create mode 100644 momogo-core/src/main/java/com/momogo/core/common/util/StorageDirectoryValidator.java diff --git a/momogo-api/src/main/resources/application.yaml b/momogo-api/src/main/resources/application.yaml index d17e9ae..7aee3db 100644 --- a/momogo-api/src/main/resources/application.yaml +++ b/momogo-api/src/main/resources/application.yaml @@ -136,7 +136,7 @@ app: upload: allowed-extensions: jpg,jpeg,png,webp allowed-mime-types: image/jpeg,image/png,image/webp - max-mark-size: 10MB + max-file-size: 10MB # 물리 디스크 저장 경로 dir: ${APP_FILE_UPLOAD_DIR:./uploads} oauth2: diff --git a/momogo-core/build.gradle b/momogo-core/build.gradle index 3ebc9ed..12328cc 100644 --- a/momogo-core/build.gradle +++ b/momogo-core/build.gradle @@ -31,7 +31,7 @@ dependencies { implementation platform('software.amazon.awssdk:bom:2.29.52') implementation 'software.amazon.awssdk:s3' implementation 'com.twelvemonkeys.imageio:imageio-webp:3.12.0' - implementation 'net.coobird:thumbnailator:0.4.20' + implementation 'net.coobird:thumbnailator:0.4.21' // 분산환경 공용 인프라 (Redis, Kafka) - api/realtime/batch가 공통으로 사용 api 'org.springframework.boot:spring-boot-starter-data-redis' diff --git a/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java b/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java index 4ca2b23..d610c2d 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java +++ b/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java @@ -3,6 +3,7 @@ import com.momogo.core.common.exception.BusinessException; import com.momogo.core.common.exception.GlobalErrorCode; import com.momogo.core.common.util.ImageProcessor; +import com.momogo.core.common.util.StorageDirectoryValidator; import com.momogo.core.common.util.UrlUtils; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -36,21 +37,18 @@ public LocalStorageService( @Override public String upload(InputStream inputStream, String originalFileName, String contentType, String directory) { + String safeDirectory = StorageDirectoryValidator.validate(directory); + // try-with-resources 구문으로 원본 inputStream 및 validatedStream 자원 수명주기를 안전하게 관리 try (InputStream src = inputStream) { ImageProcessor.ImageValidationResult validationResult = imageProcessor.validateImage(src, originalFileName, contentType); try (InputStream validatedStream = validationResult.inputStream()) { - String extension = ""; - if (originalFileName != null && originalFileName.contains(".")) { - extension = originalFileName.substring(originalFileName.lastIndexOf(".")); - } - - String savedFileName = UUID.randomUUID() + extension; + String savedFileName = UUID.randomUUID() + validationResult.extension(); // directory가 uploadRoot 바깥으로 빠져나가지 않는지 검증 (Path Traversal 방지) - Path uploadPath = resolveSafely(uploadRoot, directory); + Path uploadPath = resolveSafely(uploadRoot, safeDirectory); Files.createDirectories(uploadPath); Path targetPath = resolveSafely(uploadPath, savedFileName); diff --git a/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java b/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java index 040f23e..ff05102 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java +++ b/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java @@ -3,6 +3,7 @@ import com.momogo.core.common.exception.BusinessException; import com.momogo.core.common.exception.GlobalErrorCode; import com.momogo.core.common.util.ImageProcessor; +import com.momogo.core.common.util.StorageDirectoryValidator; import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -45,29 +46,23 @@ public S3StorageService( @Override public String upload(InputStream inputStream, String originalFileName, String contentType, String directory) { + String safeDirectory = StorageDirectoryValidator.validate(directory); + try (InputStream src = inputStream) { ImageProcessor.ImageValidationResult validationResult = imageProcessor.validateImage(src, originalFileName, contentType); - // try-with-resources 구문으로 원본 inputStream 및 validatedStream 자원 수명주기를 안전하게 관리 - try (InputStream validatedStream = validationResult.inputStream()) { - String extension = ""; - if (originalFileName != null && originalFileName.contains(".")) { - extension = originalFileName.substring(originalFileName.lastIndexOf(".")); - } - - String savedFileName = UUID.randomUUID() + extension; - String key = directory + "/" + savedFileName; - - byte[] bytes = validatedStream.readAllBytes(); - s3Client.putObject( - PutObjectRequest.builder() - .bucket(bucket) - .key(key) - .contentType(validationResult.detectedContentType()) - .build(), - RequestBody.fromBytes(bytes) - ); - return savedFileName; - } + String savedFileName = UUID.randomUUID() + validationResult.extension(); + String key = safeDirectory + "/" + savedFileName; + + byte[] bytes = validationResult.data(); + s3Client.putObject( + PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .contentType(validationResult.detectedContentType()) + .build(), + RequestBody.fromBytes(bytes) + ); + return savedFileName; } catch (IOException | SdkException e) { log.error("[S3StorageService] S3 파일 업로드 실패 - originalFileName: {}, directory: {}", originalFileName, directory, e); throw new BusinessException( diff --git a/momogo-core/src/main/java/com/momogo/core/common/util/ImageProcessor.java b/momogo-core/src/main/java/com/momogo/core/common/util/ImageProcessor.java index d30fe55..8c7d041 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/util/ImageProcessor.java +++ b/momogo-core/src/main/java/com/momogo/core/common/util/ImageProcessor.java @@ -55,16 +55,16 @@ public class ImageProcessor { ); private final Set allowedExtensions; - private final int maxMarkSize; + private final int maxFileSize; public ImageProcessor( @Value("${app.file.upload.allowed-extensions}") List allowedExtensions, - @Value("${app.file.upload.max-mark-size:10MB}") DataSize maxMarkSize + @Value("${app.file.upload.max-file-size:10MB}") DataSize maxFileSize ) { this.allowedExtensions = allowedExtensions.stream() .map(String::toLowerCase) .collect(Collectors.toUnmodifiableSet()); - this.maxMarkSize = (int) maxMarkSize.toBytes(); + this.maxFileSize = (int) maxFileSize.toBytes(); } /** @@ -86,7 +86,7 @@ public ImageValidationResult validateImage(InputStream inputStream, String origi } // 2. 업로드 상한 내에서 바이트를 안전하게 복사 (OOM 방지 및 스트림 복제) - byte[] fileBytes = readWithLimit(inputStream, maxMarkSize); + byte[] fileBytes = readWithLimit(inputStream, maxFileSize); // 3. 실제 바이트 기반 이미지 포맷 감지 및 검증 (contentType 파라미터는 사용하지 않음(변조 방지)) ImageDimension dimension = detectAndValidateFormat(fileBytes); @@ -105,7 +105,7 @@ public ImageValidationResult validateImage(InputStream inputStream, String origi byte[] resizedBytes = resizeImage(fileBytes, dimension, PROFILE_TARGET_WIDTH, PROFILE_TARGET_HEIGHT); String detectedMimeType = FORMAT_TO_MIME_TYPE.get(dimension.format()); - return new ImageValidationResult(new ByteArrayInputStream(resizedBytes), detectedMimeType); + return new ImageValidationResult(resizedBytes, detectedMimeType, "." + ext); } private byte[] resizeImage(byte[] originalBytes, ImageDimension dimension, int targetWidth, int targetHeight) { @@ -196,9 +196,12 @@ private byte[] readWithLimit(InputStream inputStream, int limit) { } /** - * 검증이 완료된 스트림과, 실제 바이트 기반으로 감지된 신뢰 가능한 Content-Type을 함께 담는 결과 객체. + * 검증 및 리사이징이 완료된 바이트 데이터, 감지된 Content-Type, 검증된 확장자(".jpg" 등)를 담는 결과 객체. */ - public record ImageValidationResult(InputStream inputStream, String detectedContentType) { + public record ImageValidationResult(byte[] data, String detectedContentType, String extension) { + public InputStream inputStream() { + return new ByteArrayInputStream(data); + } } /** diff --git a/momogo-core/src/main/java/com/momogo/core/common/util/StorageDirectoryValidator.java b/momogo-core/src/main/java/com/momogo/core/common/util/StorageDirectoryValidator.java new file mode 100644 index 0000000..a6ef67e --- /dev/null +++ b/momogo-core/src/main/java/com/momogo/core/common/util/StorageDirectoryValidator.java @@ -0,0 +1,27 @@ +package com.momogo.core.common.util; + +import com.momogo.core.common.exception.BusinessException; +import com.momogo.core.common.exception.GlobalErrorCode; + +/** + * StorageService 구현체 간 directory 파라미터 처리 방식을 통일하기 위한 공통 검증기 + */ +public final class StorageDirectoryValidator { + + private StorageDirectoryValidator() { + } + + public static String validate(String directory) { + if (directory == null || directory.isBlank()) { + throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "저장 경로(directory)가 지정되지 않았습니다."); + } + // 정규화 후 ".." 세그먼트 포함 여부 검사 (경로 조작 차단) + // 최상위 시스템 폴더로 접근하는 것을 방지 + String normalized = directory.replace("\\", "/"); + if (normalized.startsWith("/") || normalized.contains("..")) { + throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "허용되지 않는 저장 경로입니다."); + } + // 앞뒤 슬래시 정리 (일관된 key/path 형식 보장) + return normalized.replaceAll("^/+", "").replaceAll("/+$", ""); + } +} From 1f524a5fde20f048aa4ee34d66d20a4156634d02 Mon Sep 17 00:00:00 2001 From: jaejo Date: Wed, 5 Aug 2026 16:01:52 +0900 Subject: [PATCH 4/9] fix: modify CodeRabbit Review --- momogo-core/build.gradle | 2 +- .../common/storage/LocalStorageService.java | 2 +- .../core/common/storage/S3StorageService.java | 2 +- .../core/common/util/ImageProcessor.java | 26 +++++++++++-------- .../util/StorageDirectoryValidator.java | 25 +++++++++++++----- 5 files changed, 37 insertions(+), 20 deletions(-) diff --git a/momogo-core/build.gradle b/momogo-core/build.gradle index 12328cc..51ef047 100644 --- a/momogo-core/build.gradle +++ b/momogo-core/build.gradle @@ -30,7 +30,7 @@ dependencies { // S3 프로필 이미지 저장용 및 WebP 이미지 포맷 지원, 이미지 리사이징 implementation platform('software.amazon.awssdk:bom:2.29.52') implementation 'software.amazon.awssdk:s3' - implementation 'com.twelvemonkeys.imageio:imageio-webp:3.12.0' + implementation 'org.sejda.imageio:webp-imageio:0.1.6' implementation 'net.coobird:thumbnailator:0.4.21' // 분산환경 공용 인프라 (Redis, Kafka) - api/realtime/batch가 공통으로 사용 diff --git a/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java b/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java index d610c2d..3556a35 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java +++ b/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java @@ -103,7 +103,7 @@ public void delete(String fileUrl) { * 절대 경로 위장( "../") 경로 조작을 모두 차단합니다. */ private Path resolveSafely(Path base, String relative) { - if (relative == null || relative.isBlank()) { + if (relative == null) { throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "유효하지 않은 경로입니다."); } Path resolved = base.resolve(relative).normalize(); diff --git a/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java b/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java index ff05102..7a5a34d 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java +++ b/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java @@ -51,7 +51,7 @@ public String upload(InputStream inputStream, String originalFileName, String co try (InputStream src = inputStream) { ImageProcessor.ImageValidationResult validationResult = imageProcessor.validateImage(src, originalFileName, contentType); String savedFileName = UUID.randomUUID() + validationResult.extension(); - String key = safeDirectory + "/" + savedFileName; + String key = safeDirectory.isEmpty() ? savedFileName : safeDirectory + "/" + savedFileName; byte[] bytes = validationResult.data(); s3Client.putObject( diff --git a/momogo-core/src/main/java/com/momogo/core/common/util/ImageProcessor.java b/momogo-core/src/main/java/com/momogo/core/common/util/ImageProcessor.java index 8c7d041..6d7fca4 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/util/ImageProcessor.java +++ b/momogo-core/src/main/java/com/momogo/core/common/util/ImageProcessor.java @@ -31,8 +31,7 @@ @Component public class ImageProcessor { - private static final int PROFILE_TARGET_WIDTH = 300; - private static final int PROFILE_TARGET_HEIGHT = 300; + private static final int PROFILE_TARGET_SIZE = 300; private static final double OUTPUT_QUALITY = 0.85; // 픽셀 폭탄(decompression bomb) 방어용 최대 허용 픽셀 수 (예: 4000 x 4000 => 1600만 화소) @@ -55,7 +54,7 @@ public class ImageProcessor { ); private final Set allowedExtensions; - private final int maxFileSize; + private final long maxFileSize; public ImageProcessor( @Value("${app.file.upload.allowed-extensions}") List allowedExtensions, @@ -64,7 +63,7 @@ public ImageProcessor( this.allowedExtensions = allowedExtensions.stream() .map(String::toLowerCase) .collect(Collectors.toUnmodifiableSet()); - this.maxFileSize = (int) maxFileSize.toBytes(); + this.maxFileSize = maxFileSize.toBytes(); } /** @@ -102,15 +101,15 @@ public ImageValidationResult validateImage(InputStream inputStream, String origi } // 5. 프로필 규격(300 * 300)으로 리사이징 및 압축 수행 - byte[] resizedBytes = resizeImage(fileBytes, dimension, PROFILE_TARGET_WIDTH, PROFILE_TARGET_HEIGHT); + byte[] resizedBytes = resizeImage(fileBytes, dimension, PROFILE_TARGET_SIZE); String detectedMimeType = FORMAT_TO_MIME_TYPE.get(dimension.format()); return new ImageValidationResult(resizedBytes, detectedMimeType, "." + ext); } - private byte[] resizeImage(byte[] originalBytes, ImageDimension dimension, int targetWidth, int targetHeight) { + private byte[] resizeImage(byte[] originalBytes, ImageDimension dimension, int profileTargetSize) { // 원본의 짧은 변과 목표 규격 중 더 작은 값을 정사각형 한 변으로 사용 - int targetSize = Math.min(Math.min(dimension.width(), dimension.height()), targetWidth); + int targetSize = Math.min(Math.min(dimension.width(), dimension.height()), profileTargetSize); try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) { Thumbnails.of(new ByteArrayInputStream(originalBytes)) @@ -121,8 +120,13 @@ private byte[] resizeImage(byte[] originalBytes, ImageDimension dimension, int t .toOutputStream(bos); return bos.toByteArray(); } catch (Exception e) { - log.error("[ImageProcessor] 이미지 리사이징 실패 - 원본 바이트 유지", e); - return originalBytes; + log.error("[ImageProcessor] 이미지 리사이징 실패 - format: {}, width: {}, height: {}", + dimension.format(), dimension.width(), dimension.height(), e); + throw new BusinessException( + GlobalErrorCode.INTERNAL_SERVER_ERROR, + "이미지 리사이징 중 오류가 발생하였습니다.", + "format=" + dimension.format() + ", width=" + dimension.width() + ", height=" + dimension.height() + ); } } @@ -172,12 +176,12 @@ private ImageDimension detectAndValidateFormat(byte[] fileBytes) { * 지정한 바이트 크기 상한(limit) 내에서만 스트림을 읽어 바이트 배열로 반환합니다. * 상한을 초과할 경우 즉시 예외를 발생시켜 OOM을 예방합니다. */ - private byte[] readWithLimit(InputStream inputStream, int limit) { + private byte[] readWithLimit(InputStream inputStream, long limit) { // 읽어들인 조각 바이트들을 하나로 모아 저장할 메모리 스트림을 생성 try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) { byte[] buffer = new byte[4096]; int bytesRead; - int totalBytes = 0; + long totalBytes = 0; // 스트림의 끝(EOF)에 도달할 때까지 4KB 조각 단위로 계속 읽음 while ((bytesRead = inputStream.read(buffer)) != -1) { diff --git a/momogo-core/src/main/java/com/momogo/core/common/util/StorageDirectoryValidator.java b/momogo-core/src/main/java/com/momogo/core/common/util/StorageDirectoryValidator.java index a6ef67e..77e78ec 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/util/StorageDirectoryValidator.java +++ b/momogo-core/src/main/java/com/momogo/core/common/util/StorageDirectoryValidator.java @@ -3,6 +3,9 @@ import com.momogo.core.common.exception.BusinessException; import com.momogo.core.common.exception.GlobalErrorCode; +import java.util.ArrayList; +import java.util.List; + /** * StorageService 구현체 간 directory 파라미터 처리 방식을 통일하기 위한 공통 검증기 */ @@ -15,13 +18,23 @@ public static String validate(String directory) { if (directory == null || directory.isBlank()) { throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "저장 경로(directory)가 지정되지 않았습니다."); } - // 정규화 후 ".." 세그먼트 포함 여부 검사 (경로 조작 차단) - // 최상위 시스템 폴더로 접근하는 것을 방지 + + // 윈도우 경로 구분자(\)를 표준 구분자(/)로 통일 String normalized = directory.replace("\\", "/"); - if (normalized.startsWith("/") || normalized.contains("..")) { - throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "허용되지 않는 저장 경로입니다."); + // 슬래시(/) 기준으로 전체 경로 세그먼트 분할 (마지막 빈 세그먼트까지 포함) + String[] segments = normalized.split("/", -1); + + List cleanSegments = new ArrayList<>(); + for (String segment : segments) { + // 상위 디렉터리 접근(Path Traversal) 세그먼트 차단 + if ("..".equals(segment)) { + throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "허용되지 않는 저장 경로입니다."); + } + // 현재 디렉터리(.) 및 중복/시작/끝 슬래시로 인한 빈 세그먼트 제외 + if (!segment.isEmpty() && !".".equals(segment)) { + cleanSegments.add(segment); + } } - // 앞뒤 슬래시 정리 (일관된 key/path 형식 보장) - return normalized.replaceAll("^/+", "").replaceAll("/+$", ""); + return String.join("/", cleanSegments); } } From 16ed38fe80826ad59b5c7566aceff6a0e0d3d6a6 Mon Sep 17 00:00:00 2001 From: jaejo Date: Wed, 5 Aug 2026 19:03:17 +0900 Subject: [PATCH 5/9] fix: modify CodeRabbit Review 2 --- .../core/common/util/ImageProcessor.java | 31 ++++++++++++++++--- .../util/StorageDirectoryValidator.java | 7 +++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/momogo-core/src/main/java/com/momogo/core/common/util/ImageProcessor.java b/momogo-core/src/main/java/com/momogo/core/common/util/ImageProcessor.java index 6d7fca4..5766716 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/util/ImageProcessor.java +++ b/momogo-core/src/main/java/com/momogo/core/common/util/ImageProcessor.java @@ -9,6 +9,7 @@ import org.springframework.stereotype.Component; import org.springframework.util.unit.DataSize; +import javax.imageio.IIOException; import javax.imageio.ImageIO; import javax.imageio.ImageReader; import javax.imageio.stream.ImageInputStream; @@ -24,6 +25,7 @@ /** * 프로필 이미지 등 파일 업로드 시 업로드될 파일의 유효성과 보안성을 검증하고 * 검증이 끝난 이미지를 프로필 규격(300*300)으로 리사이징/압축까지 수행하는 컴포넌트입니다. + * 규격이 작을 경우 강제 확대로 인한 화질 저하를 방지하기 위해 실제 너비, 높이 중 낮은 값의 길이로 리사이징됩니다. * 확장자를 1차 검증하고, 실제 바이트(매직바이트)를 기반으로 ImageIO가 인식하는 실제 이미지 포맷을 감지하여 * 확장자와 일치하는지 교차 검증합니다. 클라이언트가 전달한 Content-Type 헤더는 신뢰하지 않습니다. */ @@ -38,7 +40,7 @@ public class ImageProcessor { // 최대 1600만 * 4bytes 디코딩 기준 최대 약 61MB 메모리 사용 private static final long MAX_PIXEL_COUNT = 4000L * 4000L; - // 파일 확장자를 ImageIO 표준 포맷명으로 변환하여, 이미지 소스 분석 결과와 대조하기 위한 매핑 테이블 + // 파일 확장자를 ImageIO 표준 포맷명으로 변환하여, 이미지 소스 분석 결과와 대조하기 위한 매핑 테이 private static final Map EXTENSION_TO_FORMAT = Map.of( "jpg", "jpeg", "jpeg", "jpeg", @@ -119,13 +121,24 @@ private byte[] resizeImage(byte[] originalBytes, ImageDimension dimension, int p .outputFormat(dimension.format()) .toOutputStream(bos); return bos.toByteArray(); + } catch (IIOException e) { + // 헤더는 정상이지만 실제 픽셀 디코딩이 불가능한 경우 + // 클라이언트가 보낸 입력 자체의 문제이므로 재시도해도 성공하지 않음 + log.warn("[ImageProcessor] 이미지 디코딩 실패(손상) - format: {}, size: {}", + dimension.format(), targetSize, e); + throw new BusinessException( + GlobalErrorCode.INVALID_INPUT, + "이미지를 처리할 수 없습니다. 손상되었거나 지원되지 않는 이미지입니다.", + resizeFailureDetail(dimension, targetSize) + ); + } catch (Exception e) { - log.error("[ImageProcessor] 이미지 리사이징 실패 - format: {}, width: {}, height: {}", - dimension.format(), dimension.width(), dimension.height(), e); + log.error("[ImageProcessor] 이미지 리사이징 실패(예기치 못한 오류) - format: {}, size: {}", + dimension.format(), targetSize, e); throw new BusinessException( GlobalErrorCode.INTERNAL_SERVER_ERROR, "이미지 리사이징 중 오류가 발생하였습니다.", - "format=" + dimension.format() + ", width=" + dimension.width() + ", height=" + dimension.height() + resizeFailureDetail(dimension, targetSize) ); } } @@ -199,6 +212,16 @@ private byte[] readWithLimit(InputStream inputStream, long limit) { } } + /** + * 리사이징 실패 시 예외 detail에 담을 원본/목표 규격 정보 메서드 + */ + private String resizeFailureDetail(ImageDimension dimension, int targetSize) { + return "format=" + dimension.format() + + ", originalWidth=" + dimension.width() + + ", originalHeight=" + dimension.height() + + ", size=" + targetSize; + } + /** * 검증 및 리사이징이 완료된 바이트 데이터, 감지된 Content-Type, 검증된 확장자(".jpg" 등)를 담는 결과 객체. */ diff --git a/momogo-core/src/main/java/com/momogo/core/common/util/StorageDirectoryValidator.java b/momogo-core/src/main/java/com/momogo/core/common/util/StorageDirectoryValidator.java index 77e78ec..3ebe868 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/util/StorageDirectoryValidator.java +++ b/momogo-core/src/main/java/com/momogo/core/common/util/StorageDirectoryValidator.java @@ -21,6 +21,13 @@ public static String validate(String directory) { // 윈도우 경로 구분자(\)를 표준 구분자(/)로 통일 String normalized = directory.replace("\\", "/"); + boolean windowsAbsolute = normalized.length() >= 3 + && Character.isLetter(normalized.charAt(0)) + && normalized.charAt(1) == ':' + && normalized.charAt(2) == '/'; + if (normalized.startsWith("/") || windowsAbsolute) { + throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "허용되지 않는 저장 경로입니다."); + } // 슬래시(/) 기준으로 전체 경로 세그먼트 분할 (마지막 빈 세그먼트까지 포함) String[] segments = normalized.split("/", -1); From 5c25414b6e8d2e92949b0478db71cdade80a6db4 Mon Sep 17 00:00:00 2001 From: jaejo Date: Thu, 6 Aug 2026 13:36:40 +0900 Subject: [PATCH 6/9] fix: move directory validation logic inside try-with-resources to prevent inputStream leak on early exception --- .../com/momogo/core/common/storage/LocalStorageService.java | 5 +++-- .../com/momogo/core/common/storage/S3StorageService.java | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java b/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java index d610c2d..a8413d9 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java +++ b/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java @@ -37,10 +37,11 @@ public LocalStorageService( @Override public String upload(InputStream inputStream, String originalFileName, String contentType, String directory) { - String safeDirectory = StorageDirectoryValidator.validate(directory); - // try-with-resources 구문으로 원본 inputStream 및 validatedStream 자원 수명주기를 안전하게 관리 try (InputStream src = inputStream) { + // 검증 실패로 예외가 발생하여도 src(inputStream)가 자동으로 close() 되도록 보장한다. + String safeDirectory = StorageDirectoryValidator.validate(directory); + ImageProcessor.ImageValidationResult validationResult = imageProcessor.validateImage(src, originalFileName, contentType); diff --git a/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java b/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java index ff05102..6c676de 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java +++ b/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java @@ -44,11 +44,14 @@ public S3StorageService( log.info("[S3StorageService] 버킷 지정 완료: {}", bucket); } + @Override public String upload(InputStream inputStream, String originalFileName, String contentType, String directory) { - String safeDirectory = StorageDirectoryValidator.validate(directory); try (InputStream src = inputStream) { + // 검증 실패로 예외가 발생하여도 src(inputStream)가 자동으로 close() 되도록 보장한다. + String safeDirectory = StorageDirectoryValidator.validate(directory); + ImageProcessor.ImageValidationResult validationResult = imageProcessor.validateImage(src, originalFileName, contentType); String savedFileName = UUID.randomUUID() + validationResult.extension(); String key = safeDirectory + "/" + savedFileName; From f4ac85d73cefadc73660e3bccd021886542d41e4 Mon Sep 17 00:00:00 2001 From: jaejo Date: Thu, 6 Aug 2026 15:46:12 +0900 Subject: [PATCH 7/9] refactor: refactoring ImageProcessor --- .../common/storage/LocalStorageService.java | 27 ++++++++-- .../core/common/storage/S3StorageService.java | 22 ++++++-- .../core/common/storage/StorageService.java | 4 +- .../util/{ => storage}/ImageProcessor.java | 52 ++++++++++++++----- .../common/util/storage/ImageResizeSpec.java | 19 +++++++ .../StorageDirectoryValidator.java | 2 +- .../domain/user/service/UserServiceImpl.java | 4 +- 7 files changed, 104 insertions(+), 26 deletions(-) rename momogo-core/src/main/java/com/momogo/core/common/util/{ => storage}/ImageProcessor.java (82%) create mode 100644 momogo-core/src/main/java/com/momogo/core/common/util/storage/ImageResizeSpec.java rename momogo-core/src/main/java/com/momogo/core/common/util/{ => storage}/StorageDirectoryValidator.java (96%) diff --git a/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java b/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java index a8413d9..ae8eb3a 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java +++ b/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java @@ -2,14 +2,16 @@ import com.momogo.core.common.exception.BusinessException; import com.momogo.core.common.exception.GlobalErrorCode; -import com.momogo.core.common.util.ImageProcessor; -import com.momogo.core.common.util.StorageDirectoryValidator; +import com.momogo.core.common.util.storage.ImageProcessor; +import com.momogo.core.common.util.storage.ImageResizeSpec; +import com.momogo.core.common.util.storage.StorageDirectoryValidator; import com.momogo.core.common.util.UrlUtils; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; @@ -36,7 +38,7 @@ public LocalStorageService( } @Override - public String upload(InputStream inputStream, String originalFileName, String contentType, String directory) { + public String upload(InputStream inputStream, String originalFileName, String contentType, String directory, ImageResizeSpec resizeSpec) { // try-with-resources 구문으로 원본 inputStream 및 validatedStream 자원 수명주기를 안전하게 관리 try (InputStream src = inputStream) { // 검증 실패로 예외가 발생하여도 src(inputStream)가 자동으로 close() 되도록 보장한다. @@ -45,7 +47,15 @@ public String upload(InputStream inputStream, String originalFileName, String co ImageProcessor.ImageValidationResult validationResult = imageProcessor.validateImage(src, originalFileName, contentType); - try (InputStream validatedStream = validationResult.inputStream()) { + byte[] bytes = imageProcessor.resizeImage( + validationResult.data(), + validationResult.format(), + validationResult.width(), + validationResult.height(), + resizeSpec + ); + + try (InputStream validatedStream = new ByteArrayInputStream(bytes)) { String savedFileName = UUID.randomUUID() + validationResult.extension(); // directory가 uploadRoot 바깥으로 빠져나가지 않는지 검증 (Path Traversal 방지) @@ -57,8 +67,15 @@ public String upload(InputStream inputStream, String originalFileName, String co return savedFileName; } + } catch (BusinessException e) { + // 검증 실패(잘못된 경로/확장자/해상도 등) + log.warn("[LocalStorageService] 파일 업로드 검증 실패 - originalFileName: {}, directory: {}", + originalFileName, directory, e); + throw e; } catch (IOException e) { - log.error("[LocalStorageService] 파일 업로드 실패 - originalFileName: {}, directory: {}", originalFileName, directory, e); + // 디스크 I/O 등 시스템 오류 + log.error("[LocalStorageService] 파일 업로드 실패 - originalFileName: {}, directory: {}", + originalFileName, directory, e); throw new BusinessException( GlobalErrorCode.FILE_UPLOAD_FAILED, "파일 저장 중 시스템 오류가 발생했습니다.", diff --git a/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java b/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java index 6c676de..7c3e269 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java +++ b/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java @@ -2,8 +2,9 @@ import com.momogo.core.common.exception.BusinessException; import com.momogo.core.common.exception.GlobalErrorCode; -import com.momogo.core.common.util.ImageProcessor; -import com.momogo.core.common.util.StorageDirectoryValidator; +import com.momogo.core.common.util.storage.ImageProcessor; +import com.momogo.core.common.util.storage.ImageResizeSpec; +import com.momogo.core.common.util.storage.StorageDirectoryValidator; import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -46,17 +47,25 @@ public S3StorageService( @Override - public String upload(InputStream inputStream, String originalFileName, String contentType, String directory) { + public String upload(InputStream inputStream, String originalFileName, String contentType, String directory, ImageResizeSpec resizeSpec) { try (InputStream src = inputStream) { // 검증 실패로 예외가 발생하여도 src(inputStream)가 자동으로 close() 되도록 보장한다. String safeDirectory = StorageDirectoryValidator.validate(directory); ImageProcessor.ImageValidationResult validationResult = imageProcessor.validateImage(src, originalFileName, contentType); + + byte[] bytes = imageProcessor.resizeImage( + validationResult.data(), + validationResult.format(), + validationResult.width(), + validationResult.height(), + resizeSpec + ); + String savedFileName = UUID.randomUUID() + validationResult.extension(); String key = safeDirectory + "/" + savedFileName; - byte[] bytes = validationResult.data(); s3Client.putObject( PutObjectRequest.builder() .bucket(bucket) @@ -66,7 +75,12 @@ public String upload(InputStream inputStream, String originalFileName, String co RequestBody.fromBytes(bytes) ); return savedFileName; + } catch (BusinessException e) { + // 검증 실패(잘못된 경로/확장자 등) + log.warn("[S3StorageService] 파일 업로드 검증 실패 - originalFileName: {}, directory: {}", originalFileName, directory, e); + throw e; } catch (IOException | SdkException e) { + // 인프라/시스템 오류 log.error("[S3StorageService] S3 파일 업로드 실패 - originalFileName: {}, directory: {}", originalFileName, directory, e); throw new BusinessException( GlobalErrorCode.FILE_UPLOAD_FAILED, diff --git a/momogo-core/src/main/java/com/momogo/core/common/storage/StorageService.java b/momogo-core/src/main/java/com/momogo/core/common/storage/StorageService.java index 8c42187..0907dc1 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/storage/StorageService.java +++ b/momogo-core/src/main/java/com/momogo/core/common/storage/StorageService.java @@ -1,10 +1,12 @@ package com.momogo.core.common.storage; +import com.momogo.core.common.util.storage.ImageResizeSpec; + import java.io.InputStream; public interface StorageService { - String upload(InputStream inputStream, String originalFileName, String contentType, String directory); + String upload(InputStream inputStream, String originalFileName, String contentType, String directory, ImageResizeSpec resizeSpec); void delete(String fileUrl); } diff --git a/momogo-core/src/main/java/com/momogo/core/common/util/ImageProcessor.java b/momogo-core/src/main/java/com/momogo/core/common/util/storage/ImageProcessor.java similarity index 82% rename from momogo-core/src/main/java/com/momogo/core/common/util/ImageProcessor.java rename to momogo-core/src/main/java/com/momogo/core/common/util/storage/ImageProcessor.java index 8c7d041..2aab345 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/util/ImageProcessor.java +++ b/momogo-core/src/main/java/com/momogo/core/common/util/storage/ImageProcessor.java @@ -1,4 +1,4 @@ -package com.momogo.core.common.util; +package com.momogo.core.common.util.storage; import com.momogo.core.common.exception.BusinessException; import com.momogo.core.common.exception.GlobalErrorCode; @@ -22,17 +22,16 @@ import java.util.stream.Collectors; /** - * 프로필 이미지 등 파일 업로드 시 업로드될 파일의 유효성과 보안성을 검증하고 - * 검증이 끝난 이미지를 프로필 규격(300*300)으로 리사이징/압축까지 수행하는 컴포넌트입니다. + * 업로드될 파일의 유효성과 보안성을 검증하고, 필요 시 지정된 규격으로 + * 리사이징/압축을 수행하는 범용 이미지 처리 컴포넌트 * 확장자를 1차 검증하고, 실제 바이트(매직바이트)를 기반으로 ImageIO가 인식하는 실제 이미지 포맷을 감지하여 * 확장자와 일치하는지 교차 검증합니다. 클라이언트가 전달한 Content-Type 헤더는 신뢰하지 않습니다. + * ImageResizeSpec으로 리사이징 규격을 전달받아, 프로필 이미지 전용 규격 등 특정 도메인에 종속되지 않습니다. */ @Slf4j @Component public class ImageProcessor { - private static final int PROFILE_TARGET_WIDTH = 300; - private static final int PROFILE_TARGET_HEIGHT = 300; private static final double OUTPUT_QUALITY = 0.85; // 픽셀 폭탄(decompression bomb) 방어용 최대 허용 픽셀 수 (예: 4000 x 4000 => 1600만 화소) @@ -68,7 +67,8 @@ public ImageProcessor( } /** - * 파일 업로드 시 확장자, 실제 이미지 바이트 무결성, 확장자-실제포맷 일치 여부, 해상도 상한을 일괄 검증합니다. + * 검증이 완료되면 완료된 원본 이미지 바이트, 감지된 ContentType/포맷, 확장자, + * 원본 가로/세로 픽셀 크기를 담는 결과 객체 * * @param inputStream 파일 데이터 스트림 * @param originalFilename 원본 파일 이름 @@ -101,23 +101,40 @@ public ImageValidationResult validateImage(InputStream inputStream, String origi ); } - // 5. 프로필 규격(300 * 300)으로 리사이징 및 압축 수행 - byte[] resizedBytes = resizeImage(fileBytes, dimension, PROFILE_TARGET_WIDTH, PROFILE_TARGET_HEIGHT); - String detectedMimeType = FORMAT_TO_MIME_TYPE.get(dimension.format()); - return new ImageValidationResult(resizedBytes, detectedMimeType, "." + ext); + return new ImageValidationResult( + fileBytes, + detectedMimeType, + "." + ext, + dimension.format(), + dimension.width(), + dimension.height() + ); } - private byte[] resizeImage(byte[] originalBytes, ImageDimension dimension, int targetWidth, int targetHeight) { + /** + * 검증 완료된 이미지를 지정된 규격으로 정사각형 중앙 크롭 라사이징 및 압축합니다. + * 원본이 목표 규격보다 작은 경우, 원본의 짧은 변을 기준으로 리사이징하여 + * 이미지가 억지로 확대되지 않도록 합니다. + * + * @param originalBytes 검증이 완료된 원본 이미지 바이트 + * @param format 감지된 이미지 포맷 + * @param originalWidth 원본 이미지 가로 픽셀 크기 + * @param originalHeight 원본 이미지 세로 픽셀 크기 + * @param spec 목표 리사이징 규격 + * @return 리사이징 및 압축이 완료된 이미지 바이트 + */ + public byte[] resizeImage(byte[] originalBytes, String format, int originalWidth, int originalHeight, ImageResizeSpec spec) { + // 원본의 짧은 변과 목표 규격 중 더 작은 값을 정사각형 한 변으로 사용 - int targetSize = Math.min(Math.min(dimension.width(), dimension.height()), targetWidth); + int targetSize = Math.min(Math.min(originalWidth, originalHeight), Math.min(spec.width(), spec.height())); try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) { Thumbnails.of(new ByteArrayInputStream(originalBytes)) .size(targetSize, targetSize) .crop(Positions.CENTER) .outputQuality(OUTPUT_QUALITY) // 85% 품질 압축 (용량 절감) - .outputFormat(dimension.format()) + .outputFormat(format) .toOutputStream(bos); return bos.toByteArray(); } catch (Exception e) { @@ -198,7 +215,14 @@ private byte[] readWithLimit(InputStream inputStream, int limit) { /** * 검증 및 리사이징이 완료된 바이트 데이터, 감지된 Content-Type, 검증된 확장자(".jpg" 등)를 담는 결과 객체. */ - public record ImageValidationResult(byte[] data, String detectedContentType, String extension) { + public record ImageValidationResult( + byte[] data, + String detectedContentType, + String extension, + String format, + int width, + int height + ) { public InputStream inputStream() { return new ByteArrayInputStream(data); } diff --git a/momogo-core/src/main/java/com/momogo/core/common/util/storage/ImageResizeSpec.java b/momogo-core/src/main/java/com/momogo/core/common/util/storage/ImageResizeSpec.java new file mode 100644 index 0000000..adc19da --- /dev/null +++ b/momogo-core/src/main/java/com/momogo/core/common/util/storage/ImageResizeSpec.java @@ -0,0 +1,19 @@ +package com.momogo.core.common.util.storage; + +/** + * 이미지 리사이징 규격(목표 가로/세로 픽셀 크기)을 나타내는 값 객체. + * 도메인별로 필요한 리사이징 규격을 상수로 미리 정의해두고 재사용합니다. + */ +public record ImageResizeSpec(int width, int height) { + + public ImageResizeSpec { + if (width <= 0 || height <= 0) { + throw new IllegalArgumentException("width, height는 0보다 커야 합니다."); + } + } + + /** + * 프로필 이미지 규격 (정사각형 300x300, 중앙 크롭) + */ + public static final ImageResizeSpec PROFILE = new ImageResizeSpec(300, 300); +} diff --git a/momogo-core/src/main/java/com/momogo/core/common/util/StorageDirectoryValidator.java b/momogo-core/src/main/java/com/momogo/core/common/util/storage/StorageDirectoryValidator.java similarity index 96% rename from momogo-core/src/main/java/com/momogo/core/common/util/StorageDirectoryValidator.java rename to momogo-core/src/main/java/com/momogo/core/common/util/storage/StorageDirectoryValidator.java index a6ef67e..7a152e0 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/util/StorageDirectoryValidator.java +++ b/momogo-core/src/main/java/com/momogo/core/common/util/storage/StorageDirectoryValidator.java @@ -1,4 +1,4 @@ -package com.momogo.core.common.util; +package com.momogo.core.common.util.storage; import com.momogo.core.common.exception.BusinessException; import com.momogo.core.common.exception.GlobalErrorCode; diff --git a/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserServiceImpl.java b/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserServiceImpl.java index ce07441..fed6849 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserServiceImpl.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserServiceImpl.java @@ -8,6 +8,7 @@ import com.momogo.core.common.storage.event.FileRollbackDeleteEvent; import com.momogo.core.common.util.EmailFormatter; import com.momogo.core.common.util.UrlUtils; +import com.momogo.core.common.util.storage.ImageResizeSpec; import com.momogo.core.domain.user.dto.UserSearchCondition; import com.momogo.core.domain.user.dto.request.ProfileImageUploadRequest; import com.momogo.core.domain.user.dto.request.UserCreateRequest; @@ -135,7 +136,8 @@ public UserResponse updateUser(UUID userId, UserUpdateRequest request, ProfileIm inputStream, profile.originalFilename(), profile.contentType(), - PROFILE_IMAGE_DIR + PROFILE_IMAGE_DIR, + ImageResizeSpec.PROFILE ); user.updateProfileImage(savedFileName); From 1609c5caace1c1d36a831c84195ef889691d3943 Mon Sep 17 00:00:00 2001 From: jaejo Date: Thu, 6 Aug 2026 15:58:09 +0900 Subject: [PATCH 8/9] fix: fix build.gradle --- momogo-core/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/momogo-core/build.gradle b/momogo-core/build.gradle index 12328cc..51ef047 100644 --- a/momogo-core/build.gradle +++ b/momogo-core/build.gradle @@ -30,7 +30,7 @@ dependencies { // S3 프로필 이미지 저장용 및 WebP 이미지 포맷 지원, 이미지 리사이징 implementation platform('software.amazon.awssdk:bom:2.29.52') implementation 'software.amazon.awssdk:s3' - implementation 'com.twelvemonkeys.imageio:imageio-webp:3.12.0' + implementation 'org.sejda.imageio:webp-imageio:0.1.6' implementation 'net.coobird:thumbnailator:0.4.21' // 분산환경 공용 인프라 (Redis, Kafka) - api/realtime/batch가 공통으로 사용 From afb9eb2b04414c68cbd3f0ba5c802a9a8ade8df0 Mon Sep 17 00:00:00 2001 From: jaejo Date: Thu, 6 Aug 2026 16:29:38 +0900 Subject: [PATCH 9/9] fix: modify CodeRabbit Review --- .../com/momogo/core/common/util/storage/ImageProcessor.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/momogo-core/src/main/java/com/momogo/core/common/util/storage/ImageProcessor.java b/momogo-core/src/main/java/com/momogo/core/common/util/storage/ImageProcessor.java index 2aab345..d8a52b4 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/util/storage/ImageProcessor.java +++ b/momogo-core/src/main/java/com/momogo/core/common/util/storage/ImageProcessor.java @@ -139,7 +139,10 @@ public byte[] resizeImage(byte[] originalBytes, String format, int originalWidth return bos.toByteArray(); } catch (Exception e) { log.error("[ImageProcessor] 이미지 리사이징 실패 - 원본 바이트 유지", e); - return originalBytes; + throw new BusinessException( + GlobalErrorCode.INVALID_INPUT, + "이미지 리사이징에 실패했습니다." + ); } }