From 4c2451791e51900e4e5b61137ad3937bafd6e029 Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Wed, 8 Jul 2026 22:05:52 +0900 Subject: [PATCH 01/11] =?UTF-8?q?Feat(global):=20=EC=9B=8C=ED=84=B0?= =?UTF-8?q?=EB=A7=88=ED=81=AC=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EA=B8=B0=EB=B0=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/WatermarkAsyncConfig.java | 21 ++++ .../global/image/WatermarkImageProcessor.java | 116 ++++++++++++++++++ .../image/exception/ImageErrorCode.java | 19 +++ .../global/s3/manager/S3FileManager.java | 15 +++ 4 files changed, 171 insertions(+) create mode 100644 src/main/java/ditda/backend/global/config/WatermarkAsyncConfig.java create mode 100644 src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java create mode 100644 src/main/java/ditda/backend/global/image/exception/ImageErrorCode.java diff --git a/src/main/java/ditda/backend/global/config/WatermarkAsyncConfig.java b/src/main/java/ditda/backend/global/config/WatermarkAsyncConfig.java new file mode 100644 index 00000000..e697de17 --- /dev/null +++ b/src/main/java/ditda/backend/global/config/WatermarkAsyncConfig.java @@ -0,0 +1,21 @@ +package ditda.backend.global.config; + +import java.util.concurrent.Executor; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +public class WatermarkAsyncConfig { + + @Bean(name = "watermarkExecutor") + public Executor watermarkExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(1); + executor.setMaxPoolSize(1); + executor.setThreadNamePrefix("watermark-"); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java b/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java new file mode 100644 index 00000000..526ab1bf --- /dev/null +++ b/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java @@ -0,0 +1,116 @@ +package ditda.backend.global.image; + +import java.awt.AlphaComposite; +import java.awt.Color; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Iterator; + +import javax.imageio.ImageIO; +import javax.imageio.ImageReadParam; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; + +import org.springframework.stereotype.Component; + +import ditda.backend.global.apipayload.exception.GeneralException; +import ditda.backend.global.image.exception.ImageErrorCode; + +@Component +public class WatermarkImageProcessor { + + private static final String WATERMARK_TEXT = "DITDA"; // 워터마크 텍스트 + private static final int TARGET_LONG_SIDE = 1600; // 출력물의 최대 길이 + private static final long MAX_PIXELS = 200_000_000L; // 이미지 픽셀 제한 + private static final float OPACITY = 0.15f; // 워터마크 투명도 + private static final double ROTATION_DEGREES = -30; // 워터마크 텍스트 기울기 + + public byte[] createWatermarkedPreview(InputStream source) throws IOException { + + // 1. 이미지 디코딩 + BufferedImage preview = readSubsampled(source); + + // 2. 워터마크 처리 + drawWatermark(preview); + + // 3. PNG로 재압축 + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(preview, "png", out); + + // 4. S3 업로드용 바이트 + return out.toByteArray(); + } + + // 서브샘플링 + private BufferedImage readSubsampled(InputStream source) throws IOException { + + try (ImageInputStream iis = ImageIO.createImageInputStream(source)) { + Iterator readers = ImageIO.getImageReaders(iis); + + // 이미지가 아니거나 심하게 손상될 경우 + if (!readers.hasNext()) { + throw new GeneralException(ImageErrorCode.IMAGE_NOT_READABLE); + } + + ImageReader reader = readers.next(); + try { + reader.setInput(iis); + + int width = reader.getWidth(0); + int height = reader.getHeight(0); + + // 픽셀 수 계산 + if ((long)width * height > MAX_PIXELS) { + throw new GeneralException(ImageErrorCode.IMAGE_RESOLUTION_EXCEEDED); + } + + // 샘플링 간격 + int sample = Math.max(1, Math.max(width, height) / TARGET_LONG_SIDE); + + ImageReadParam param = reader.getDefaultReadParam(); + param.setSourceSubsampling(sample, sample, 0, 0); + + return reader.read(0, param); + } finally { + reader.dispose(); + } + } + } + + // 텍스트 워터마크 + private void drawWatermark(BufferedImage image) { + + Graphics2D graphics = image.createGraphics(); + + try { + // 안티앨리어싱 (텍스트만 부드럽게 처리) + graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + // 기존 픽셀에 Opacity에 맞게 덧그리기 + graphics.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, OPACITY)); + // 색상 + graphics.setColor(Color.gray); + // 폰트 + graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, image.getWidth() / 8)); + // 좌표 회전 (글씨 대각선) + graphics.rotate(Math.toRadians(ROTATION_DEGREES), image.getWidth() / 2.0, image.getHeight() / 2.0); + + FontMetrics fm = graphics.getFontMetrics(); + int stepX = fm.stringWidth(WATERMARK_TEXT) * 2; // 글자폭 + int stepY = fm.getHeight() * 3; // 글자 높이 + + for (int y = -image.getHeight(); y < image.getHeight() * 2; y += stepY) { + for (int x = -image.getWidth(); x < image.getWidth() * 2; x += stepX) { + graphics.drawString(WATERMARK_TEXT, x, y); + } + } + } finally { + graphics.dispose(); + } + } +} diff --git a/src/main/java/ditda/backend/global/image/exception/ImageErrorCode.java b/src/main/java/ditda/backend/global/image/exception/ImageErrorCode.java new file mode 100644 index 00000000..1a776012 --- /dev/null +++ b/src/main/java/ditda/backend/global/image/exception/ImageErrorCode.java @@ -0,0 +1,19 @@ +package ditda.backend.global.image.exception; + +import org.springframework.http.HttpStatus; + +import ditda.backend.global.apipayload.code.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ImageErrorCode implements BaseErrorCode { + + IMAGE_NOT_READABLE(HttpStatus.BAD_REQUEST, "IMAGE_400_01", "이미지 파일을 읽을 수 없습니다."), + IMAGE_RESOLUTION_EXCEEDED(HttpStatus.CONTENT_TOO_LARGE, "IMAGE_413_01", "이미지 해상도가 제한을 초과했습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/ditda/backend/global/s3/manager/S3FileManager.java b/src/main/java/ditda/backend/global/s3/manager/S3FileManager.java index 59481263..0c27cd1c 100644 --- a/src/main/java/ditda/backend/global/s3/manager/S3FileManager.java +++ b/src/main/java/ditda/backend/global/s3/manager/S3FileManager.java @@ -1,5 +1,6 @@ package ditda.backend.global.s3.manager; +import java.io.InputStream; import java.util.List; import org.springframework.stereotype.Component; @@ -8,6 +9,7 @@ import ditda.backend.global.s3.enums.BucketType; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.S3Exception; @@ -57,4 +59,17 @@ public void copy(BucketType bucketType, String sourceKey, String destinationKey) .destinationBucket(bucket) .destinationKey(destinationKey)); } + + public InputStream download(BucketType bucketType, String key) { + String bucket = s3Properties.getBucket(bucketType); + return s3Client.getObject(req -> req.bucket(bucket).key(key)); + } + + public void upload(BucketType bucketType, String key, byte[] bytes, String contentType) { + String bucket = s3Properties.getBucket(bucketType); + s3Client.putObject( + req -> req.bucket(bucket).key(key).contentType(contentType), + RequestBody.fromBytes(bytes) + ); + } } From 95328398ce53ad539906a053041d03d6a8b6996a Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Thu, 9 Jul 2026 00:21:00 +0900 Subject: [PATCH 02/11] =?UTF-8?q?Feat(draft):=20=EC=8B=9C=EC=95=88/?= =?UTF-8?q?=EC=88=98=EC=A0=95=EB=B3=B8=20=EC=A0=9C=EC=B6=9C=20=EC=8B=9C=20?= =?UTF-8?q?=EC=9B=8C=ED=84=B0=EB=A7=88=ED=81=AC=20=EB=B9=84=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=20=EC=B2=98=EB=A6=AC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../draft/entity/CommissionDraftFile.java | 12 ++++ .../draft/event/DraftFilesSubmittedEvent.java | 4 ++ .../draft/facade/DesignerDraftFacade.java | 4 ++ .../listener/DraftWatermarkListener.java | 29 ++++++++ .../CommissionDraftFileRepository.java | 6 ++ .../draft/service/DraftWatermarkService.java | 68 +++++++++++++++++++ .../DraftWatermarkTransitionService.java | 37 ++++++++++ .../facade/DesignerRevisionFacade.java | 5 ++ .../service/DraftWatermarkServiceTest.java | 4 ++ 9 files changed, 169 insertions(+) create mode 100644 src/main/java/ditda/backend/domain/commission/draft/event/DraftFilesSubmittedEvent.java create mode 100644 src/main/java/ditda/backend/domain/commission/draft/listener/DraftWatermarkListener.java create mode 100644 src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkService.java create mode 100644 src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkTransitionService.java create mode 100644 src/test/java/ditda/backend/domain/commission/draft/service/DraftWatermarkServiceTest.java diff --git a/src/main/java/ditda/backend/domain/commission/draft/entity/CommissionDraftFile.java b/src/main/java/ditda/backend/domain/commission/draft/entity/CommissionDraftFile.java index 6bc4c6f9..f6ab301b 100644 --- a/src/main/java/ditda/backend/domain/commission/draft/entity/CommissionDraftFile.java +++ b/src/main/java/ditda/backend/domain/commission/draft/entity/CommissionDraftFile.java @@ -72,7 +72,19 @@ public static CommissionDraftFile create( .build(); } + // 시안 조회 가능 여부 (워터마크 완료 상태) public boolean isWatermarkCompleted() { return this.watermarkStatus == WatermarkStatus.COMPLETED; } + + // 워터마크 처리 완료 + public void completeWatermark(String watermarkedFileUrl) { + this.watermarkedFileUrl = watermarkedFileUrl; + this.watermarkStatus = WatermarkStatus.COMPLETED; + } + + // 워터마크 처리 실패 + public void markWatermarkFailed() { + this.watermarkStatus = WatermarkStatus.FAILED; + } } diff --git a/src/main/java/ditda/backend/domain/commission/draft/event/DraftFilesSubmittedEvent.java b/src/main/java/ditda/backend/domain/commission/draft/event/DraftFilesSubmittedEvent.java new file mode 100644 index 00000000..21d4241a --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/event/DraftFilesSubmittedEvent.java @@ -0,0 +1,4 @@ +package ditda.backend.domain.commission.draft.event; + +public record DraftFilesSubmittedEvent(Long draftId) { +} diff --git a/src/main/java/ditda/backend/domain/commission/draft/facade/DesignerDraftFacade.java b/src/main/java/ditda/backend/domain/commission/draft/facade/DesignerDraftFacade.java index 14a576e8..026137a2 100644 --- a/src/main/java/ditda/backend/domain/commission/draft/facade/DesignerDraftFacade.java +++ b/src/main/java/ditda/backend/domain/commission/draft/facade/DesignerDraftFacade.java @@ -17,6 +17,7 @@ import ditda.backend.domain.commission.draft.dto.request.DraftSubmitRequest; import ditda.backend.domain.commission.draft.dto.response.DraftSubmitResponse; import ditda.backend.domain.commission.draft.entity.CommissionDraft; +import ditda.backend.domain.commission.draft.event.DraftFilesSubmittedEvent; import ditda.backend.domain.commission.draft.service.DesignerDraftFileService; import ditda.backend.domain.commission.draft.service.DesignerDraftService; import lombok.RequiredArgsConstructor; @@ -65,6 +66,9 @@ public DraftSubmitResponse submitDraft( // 모든 지원자가 제출을 완료했는지 판단 및 처리 handleAllSubmittedIfLast(commission); + // 워터마크 진행 + eventPublisher.publishEvent(new DraftFilesSubmittedEvent(draft.getId())); + } catch (Exception original) { try { designerDraftFileService.deleteFiles(permanentKeys); diff --git a/src/main/java/ditda/backend/domain/commission/draft/listener/DraftWatermarkListener.java b/src/main/java/ditda/backend/domain/commission/draft/listener/DraftWatermarkListener.java new file mode 100644 index 00000000..7bc4914b --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/listener/DraftWatermarkListener.java @@ -0,0 +1,29 @@ +package ditda.backend.domain.commission.draft.listener; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import ditda.backend.domain.commission.draft.event.DraftFilesSubmittedEvent; +import ditda.backend.domain.commission.draft.service.DraftWatermarkService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DraftWatermarkListener { + + private final DraftWatermarkService draftWatermarkService; + + @Async("watermarkExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onDraftFilesSubmitted(DraftFilesSubmittedEvent event) { + try { + draftWatermarkService.watermarkDraftFiles(event.draftId()); + } catch (Exception exception) { + log.error("워터마크 파이프라인 실패. draftId={}", event.draftId(), exception); + } + } +} diff --git a/src/main/java/ditda/backend/domain/commission/draft/repository/CommissionDraftFileRepository.java b/src/main/java/ditda/backend/domain/commission/draft/repository/CommissionDraftFileRepository.java index efe24742..efc6d3e3 100644 --- a/src/main/java/ditda/backend/domain/commission/draft/repository/CommissionDraftFileRepository.java +++ b/src/main/java/ditda/backend/domain/commission/draft/repository/CommissionDraftFileRepository.java @@ -8,6 +8,7 @@ import org.springframework.data.repository.query.Param; import ditda.backend.domain.commission.draft.entity.CommissionDraftFile; +import ditda.backend.domain.commission.draft.entity.enums.WatermarkStatus; public interface CommissionDraftFileRepository extends JpaRepository { @@ -22,4 +23,9 @@ public interface CommissionDraftFileRepository extends JpaRepository findThumbnail(@Param("draftId") Long draftId); + + List findAllByCommissionDraftIdAndWatermarkStatus( + Long draftId, + WatermarkStatus watermarkStatus + ); } diff --git a/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkService.java b/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkService.java new file mode 100644 index 00000000..35157fb5 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkService.java @@ -0,0 +1,68 @@ +package ditda.backend.domain.commission.draft.service; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +import org.springframework.stereotype.Service; + +import ditda.backend.domain.commission.draft.entity.CommissionDraftFile; +import ditda.backend.domain.commission.draft.entity.enums.WatermarkStatus; +import ditda.backend.domain.commission.draft.repository.CommissionDraftFileRepository; +import ditda.backend.global.image.WatermarkImageProcessor; +import ditda.backend.global.s3.enums.BucketType; +import ditda.backend.global.s3.enums.S3ContentType; +import ditda.backend.global.s3.manager.S3FileManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DraftWatermarkService { + + private static final BucketType BUCKET = BucketType.PRIVATE; + private static final String WATERMARK_DIR = "wm"; + + private final CommissionDraftFileRepository commissionDraftFileRepository; + private final WatermarkImageProcessor watermarkImageProcessor; + private final S3FileManager s3FileManager; + private final DraftWatermarkTransitionService draftWatermarkTransitionService; + + public void watermarkDraftFiles(Long draftId) { + + List files = + commissionDraftFileRepository.findAllByCommissionDraftIdAndWatermarkStatus( + draftId, + WatermarkStatus.PROCESSING + ); + + for (CommissionDraftFile file : files) { + try { + String watermarkedKey = createWatermarked(file.getFileUrl()); + draftWatermarkTransitionService.complete(file.getId(), watermarkedKey); + } catch (Exception exception) { + log.error("워터마크 처리 실패. draftFileId={}, fileUrl={}", file.getId(), file.getFileUrl(), exception); + draftWatermarkTransitionService.fail(file.getId()); + } + } + } + + private String createWatermarked(String originalKey) throws IOException { + + byte[] watermarked; + // 원본 s3 다운로드 후 워터마크 진행 + try (InputStream original = s3FileManager.download(BUCKET, originalKey)) { + watermarked = watermarkImageProcessor.createWatermarkedPreview(original); + } + + // 워터마크 진행된 파일 s3 업로드 (commission/draft/{uuid}.png -> commission/draft/wm/{uuid}.png) + int lastSlash = originalKey.lastIndexOf('/'); + String watermarkedKey = originalKey.substring(0, lastSlash) + + "/" + WATERMARK_DIR + originalKey.substring(lastSlash); + + s3FileManager.upload(BUCKET, watermarkedKey, watermarked, S3ContentType.PNG.getContentType()); + + return watermarkedKey; + } +} diff --git a/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkTransitionService.java b/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkTransitionService.java new file mode 100644 index 00000000..5653dda7 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkTransitionService.java @@ -0,0 +1,37 @@ +package ditda.backend.domain.commission.draft.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import ditda.backend.domain.commission.draft.entity.CommissionDraftFile; +import ditda.backend.domain.commission.draft.repository.CommissionDraftFileRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DraftWatermarkTransitionService { + + private final CommissionDraftFileRepository commissionDraftFileRepository; + + // 워터마크 완료 전이 + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void complete(Long draftFileId, String watermarkedKey) { + commissionDraftFileRepository.findById(draftFileId) + .ifPresentOrElse( + file -> file.completeWatermark(watermarkedKey), + () -> log.warn("워터마크 완료 전이 대상 없음. draftFileId={}, key={}", draftFileId, watermarkedKey) + ); + } + + // 워터마크 실패 전이 + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void fail(Long draftFileId) { + commissionDraftFileRepository.findById(draftFileId) + .ifPresentOrElse(CommissionDraftFile::markWatermarkFailed, + () -> log.warn("워터마크 실패 전이 대상 없음. draftFileId={}", draftFileId) + ); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/revision/facade/DesignerRevisionFacade.java b/src/main/java/ditda/backend/domain/commission/revision/facade/DesignerRevisionFacade.java index dbc2b22e..1900b0a1 100644 --- a/src/main/java/ditda/backend/domain/commission/revision/facade/DesignerRevisionFacade.java +++ b/src/main/java/ditda/backend/domain/commission/revision/facade/DesignerRevisionFacade.java @@ -16,6 +16,7 @@ import ditda.backend.domain.commission.core.service.DesignerCommissionService; import ditda.backend.domain.commission.draft.entity.CommissionDraft; import ditda.backend.domain.commission.draft.entity.CommissionDraftFile; +import ditda.backend.domain.commission.draft.event.DraftFilesSubmittedEvent; import ditda.backend.domain.commission.draft.service.DesignerDraftFileService; import ditda.backend.domain.commission.draft.service.DesignerDraftService; import ditda.backend.domain.commission.draft.service.DraftQueryService; @@ -129,6 +130,10 @@ public RevisionSubmitResponse submitRevision( newDraft, request.designerComment() ); + + // 워터마크 진행 + eventPublisher.publishEvent(new DraftFilesSubmittedEvent(newDraft.getId())); + } catch (Exception original) { try { designerDraftFileService.deleteFiles(permanentKeys); diff --git a/src/test/java/ditda/backend/domain/commission/draft/service/DraftWatermarkServiceTest.java b/src/test/java/ditda/backend/domain/commission/draft/service/DraftWatermarkServiceTest.java new file mode 100644 index 00000000..26fe6ad2 --- /dev/null +++ b/src/test/java/ditda/backend/domain/commission/draft/service/DraftWatermarkServiceTest.java @@ -0,0 +1,4 @@ +package ditda.backend.domain.commission.draft.service; + +public class DraftWatermarkServiceTest { +} From 3a7a2a5b38b9a231ef3181e3ae8427f0b93ac3c7 Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Thu, 9 Jul 2026 00:21:30 +0900 Subject: [PATCH 03/11] =?UTF-8?q?Refactor(global):=20=EC=9B=8C=ED=84=B0?= =?UTF-8?q?=EB=A7=88=ED=81=AC=20=EB=82=B4=EC=9A=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/image/WatermarkImageProcessor.java | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java b/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java index 526ab1bf..6d4653fd 100644 --- a/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java +++ b/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java @@ -25,11 +25,12 @@ @Component public class WatermarkImageProcessor { - private static final String WATERMARK_TEXT = "DITDA"; // 워터마크 텍스트 + private static final String WATERMARK_TEXT = "ditda"; // 워터마크 텍스트 private static final int TARGET_LONG_SIDE = 1600; // 출력물의 최대 길이 private static final long MAX_PIXELS = 200_000_000L; // 이미지 픽셀 제한 - private static final float OPACITY = 0.15f; // 워터마크 투명도 - private static final double ROTATION_DEGREES = -30; // 워터마크 텍스트 기울기 + private static final float OPACITY = 0.25f; // 워터마크 투명도 + private static final double ROTATION_DEGREES = -45; // 워터마크 텍스트 기울기 + private static final Color WATERMARK_COLOR = new Color(0xF5F5F5); // 폰트 색상 (White Smoke) public byte[] createWatermarkedPreview(InputStream source) throws IOException { @@ -94,15 +95,15 @@ private void drawWatermark(BufferedImage image) { // 기존 픽셀에 Opacity에 맞게 덧그리기 graphics.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, OPACITY)); // 색상 - graphics.setColor(Color.gray); + graphics.setColor(WATERMARK_COLOR); // 폰트 - graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, image.getWidth() / 8)); + graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, image.getWidth() / 32)); // 좌표 회전 (글씨 대각선) graphics.rotate(Math.toRadians(ROTATION_DEGREES), image.getWidth() / 2.0, image.getHeight() / 2.0); FontMetrics fm = graphics.getFontMetrics(); - int stepX = fm.stringWidth(WATERMARK_TEXT) * 2; // 글자폭 - int stepY = fm.getHeight() * 3; // 글자 높이 + int stepX = fm.stringWidth(WATERMARK_TEXT) * 2; // 타일 가로 간격 + int stepY = fm.getHeight() * 4; // 타일 세로 간격 for (int y = -image.getHeight(); y < image.getHeight() * 2; y += stepY) { for (int x = -image.getWidth(); x < image.getWidth() * 2; x += stepX) { From 2e46f4c2318b054c87cb0c13bb0037e1a99751ac Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Thu, 9 Jul 2026 00:21:58 +0900 Subject: [PATCH 04/11] =?UTF-8?q?Test(watermark):=20=EC=9B=8C=ED=84=B0?= =?UTF-8?q?=EB=A7=88=ED=81=AC=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/DraftWatermarkServiceTest.java | 141 +++++++++++++++++- .../image/WatermarkImageProcessorTest.java | 116 ++++++++++++++ 2 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 src/test/java/ditda/backend/global/image/WatermarkImageProcessorTest.java diff --git a/src/test/java/ditda/backend/domain/commission/draft/service/DraftWatermarkServiceTest.java b/src/test/java/ditda/backend/domain/commission/draft/service/DraftWatermarkServiceTest.java index 26fe6ad2..63a58b96 100644 --- a/src/test/java/ditda/backend/domain/commission/draft/service/DraftWatermarkServiceTest.java +++ b/src/test/java/ditda/backend/domain/commission/draft/service/DraftWatermarkServiceTest.java @@ -1,4 +1,143 @@ package ditda.backend.domain.commission.draft.service; -public class DraftWatermarkServiceTest { +import static org.mockito.BDDMockito.*; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import ditda.backend.domain.commission.draft.entity.CommissionDraftFile; +import ditda.backend.domain.commission.draft.entity.enums.WatermarkStatus; +import ditda.backend.domain.commission.draft.repository.CommissionDraftFileRepository; +import ditda.backend.global.image.WatermarkImageProcessor; +import ditda.backend.global.s3.enums.BucketType; +import ditda.backend.global.s3.enums.S3ContentType; +import ditda.backend.global.s3.manager.S3FileManager; + +@ExtendWith(MockitoExtension.class) +class DraftWatermarkServiceTest { + + private static final Long DRAFT_ID = 1L; + + @Mock + private CommissionDraftFileRepository commissionDraftFileRepository; + + @Mock + private WatermarkImageProcessor watermarkImageProcessor; + + @Mock + private S3FileManager s3FileManager; + + @Mock + private DraftWatermarkTransitionService draftWatermarkTransitionService; + + @InjectMocks + private DraftWatermarkService draftWatermarkService; + + @Test + @DisplayName("원본 key로 wm 경로를 파생해 업로드하고 COMPLETED로 전이 - 성공") + void watermarkDraftFiles_success() throws IOException { + + // given + CommissionDraftFile file = draftFile(10L, "commission/draft/abc.png"); + given(commissionDraftFileRepository.findAllByCommissionDraftIdAndWatermarkStatus( + DRAFT_ID, WatermarkStatus.PROCESSING)).willReturn(List.of(file)); + given(s3FileManager.download(BucketType.PRIVATE, "commission/draft/abc.png")) + .willReturn(new ByteArrayInputStream(new byte[0])); + + byte[] watermarked = {1, 2, 3}; + given(watermarkImageProcessor.createWatermarkedPreview(any())).willReturn(watermarked); + + // when + draftWatermarkService.watermarkDraftFiles(DRAFT_ID); + + // then + then(s3FileManager).should().upload( + BucketType.PRIVATE, + "commission/draft/wm/abc.png", + watermarked, + S3ContentType.PNG.getContentType() + ); + then(draftWatermarkTransitionService).should().complete(10L, "commission/draft/wm/abc.png"); + then(draftWatermarkTransitionService).should(never()).fail(anyLong()); + } + + @Test + @DisplayName("워터마크 생성 실패 시 업로드 없이 FAILED로 전이") + void watermarkDraftFiles_markFailedOnError() throws IOException { + + // given + CommissionDraftFile file = draftFile(10L, "commission/draft/abc.png"); + given(commissionDraftFileRepository.findAllByCommissionDraftIdAndWatermarkStatus( + DRAFT_ID, WatermarkStatus.PROCESSING)).willReturn(List.of(file)); + given(s3FileManager.download(any(), anyString())) + .willReturn(new ByteArrayInputStream(new byte[0])); + given(watermarkImageProcessor.createWatermarkedPreview(any())) + .willThrow(new IOException("이미지 처리 실패")); + + // when + draftWatermarkService.watermarkDraftFiles(DRAFT_ID); + + // then + then(s3FileManager).should(never()).upload(any(), anyString(), any(), anyString()); + then(draftWatermarkTransitionService).should().fail(10L); + then(draftWatermarkTransitionService).should(never()).complete(anyLong(), anyString()); + } + + @Test + @DisplayName("한 파일이 실패해도 나머지 파일은 계속 처리") + void watermarkDraftFiles_isolatesFailurePerFile() throws IOException { + + // given + CommissionDraftFile failing = draftFile(10L, "commission/draft/aaa.png"); + CommissionDraftFile succeeding = draftFile(20L, "commission/draft/bbb.png"); + given(commissionDraftFileRepository.findAllByCommissionDraftIdAndWatermarkStatus( + DRAFT_ID, WatermarkStatus.PROCESSING)).willReturn(List.of(failing, succeeding)); + given(s3FileManager.download(any(), anyString())) + .willAnswer(invocation -> new ByteArrayInputStream(new byte[0])); + + byte[] watermarked = {1}; + given(watermarkImageProcessor.createWatermarkedPreview(any())) + .willThrow(new IOException("첫 번째 파일 실패")) + .willReturn(watermarked); + + // when + draftWatermarkService.watermarkDraftFiles(DRAFT_ID); + + // then + then(draftWatermarkTransitionService).should().fail(10L); + then(draftWatermarkTransitionService).should().complete(20L, "commission/draft/wm/bbb.png"); + } + + @Test + @DisplayName("PROCESSING 파일이 없으면 아무 작업도 하지 않음") + void watermarkDraftFiles_noopWhenEmpty() { + + // given + given(commissionDraftFileRepository.findAllByCommissionDraftIdAndWatermarkStatus( + DRAFT_ID, WatermarkStatus.PROCESSING)).willReturn(List.of()); + + // when + draftWatermarkService.watermarkDraftFiles(DRAFT_ID); + + // then + then(s3FileManager).shouldHaveNoInteractions(); + then(draftWatermarkTransitionService).shouldHaveNoInteractions(); + } + + private CommissionDraftFile draftFile(Long id, String fileUrl) { + return CommissionDraftFile.builder() + .id(id) + .fileOrder(0) + .fileUrl(fileUrl) + .watermarkStatus(WatermarkStatus.PROCESSING) + .build(); + } } diff --git a/src/test/java/ditda/backend/global/image/WatermarkImageProcessorTest.java b/src/test/java/ditda/backend/global/image/WatermarkImageProcessorTest.java new file mode 100644 index 00000000..7d1668a2 --- /dev/null +++ b/src/test/java/ditda/backend/global/image/WatermarkImageProcessorTest.java @@ -0,0 +1,116 @@ +package ditda.backend.global.image; + +import static org.assertj.core.api.Assertions.*; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +import javax.imageio.ImageIO; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import ditda.backend.global.apipayload.exception.GeneralException; + +class WatermarkImageProcessorTest { + + private final WatermarkImageProcessor watermarkImageProcessor = new WatermarkImageProcessor(); + + @Test + @DisplayName("목표보다 큰 이미지는 긴 변이 목표 크기로 축소된다") + void createWatermarkedPreview_downscalesLargeImage() throws IOException { + + // given: 긴 변이 목표(1600)의 2배인 원본 + byte[] source = pngBytes(3200, 2400); + + // when + byte[] result = watermarkImageProcessor.createWatermarkedPreview(new ByteArrayInputStream(source)); + + // then: sample=2로 절반 크기 + BufferedImage preview = ImageIO.read(new ByteArrayInputStream(result)); + assertThat(preview.getWidth()).isEqualTo(1600); + assertThat(preview.getHeight()).isEqualTo(1200); + } + + @Test + @DisplayName("목표보다 작은 이미지는 축소 없이 처리된다") + void createWatermarkedPreview_keepsSmallImageSize() throws IOException { + + // given + byte[] source = pngBytes(800, 600); + + // when + byte[] result = watermarkImageProcessor.createWatermarkedPreview(new ByteArrayInputStream(source)); + + // then + BufferedImage preview = ImageIO.read(new ByteArrayInputStream(result)); + assertThat(preview.getWidth()).isEqualTo(800); + assertThat(preview.getHeight()).isEqualTo(600); + } + + @Test + @DisplayName("이미지가 아닌 파일은 예외가 발생한다") + void createWatermarkedPreview_rejectsNonImage() { + + // given: png로 위장한 텍스트 + byte[] fake = "this is not an image".getBytes(); + + // when & then + assertThatThrownBy(() -> + watermarkImageProcessor.createWatermarkedPreview(new ByteArrayInputStream(fake))) + .isInstanceOf(GeneralException.class); + } + + @Disabled("로컬 확인 및 처리 시간 측정용 - 입력 폴더 경로를 맞추고 @Disabled를 지운 뒤 실행") + @Test + void manualPreviewForEyeCheck() throws IOException { + + // 입력: 이 폴더 안의 모든 png / 출력: 하위 out 폴더에 같은 이름으로 + Path inputDir = Path.of("/Users/jong/Desktop/watermark-test"); + Path outputDir = inputDir.resolve("out"); + Files.createDirectories(outputDir); + + List sources; + try (Stream paths = Files.list(inputDir)) { + sources = paths + .filter(path -> path.toString().endsWith(".png")) + .sorted() + .toList(); + } + + long totalStart = System.nanoTime(); + + for (Path source : sources) { + long start = System.nanoTime(); + + byte[] result; + try (InputStream in = Files.newInputStream(source)) { + result = watermarkImageProcessor.createWatermarkedPreview(in); + } + Files.write(outputDir.resolve(source.getFileName()), result); + + long elapsedMs = (System.nanoTime() - start) / 1_000_000; + System.out.printf("%s: 원본 %,dKB -> 결과 %,dKB, %,dms%n", + source.getFileName(), Files.size(source) / 1024, result.length / 1024, elapsedMs); + } + + long totalMs = (System.nanoTime() - totalStart) / 1_000_000; + System.out.printf("총 %d개 처리, %,dms (평균 %,dms)%n", + sources.size(), totalMs, sources.isEmpty() ? 0 : totalMs / sources.size()); + } + + private byte[] pngBytes(int width, int height) throws IOException { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(image, "png", out); + return out.toByteArray(); + } +} From 40d2e93b4d4eb0e0a992a69acba4832c50bca00e Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Thu, 9 Jul 2026 17:26:13 +0900 Subject: [PATCH 05/11] =?UTF-8?q?Feat(watermark):=20=EC=9B=8C=ED=84=B0?= =?UTF-8?q?=EB=A7=88=ED=81=AC=20=EB=A1=9C=EA=B3=A0=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/image/WatermarkImageProcessor.java | 69 +++++++++++------- src/main/resources/images/watermark-logo.png | Bin 0 -> 9485 bytes 2 files changed, 42 insertions(+), 27 deletions(-) create mode 100644 src/main/resources/images/watermark-logo.png diff --git a/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java b/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java index 6d4653fd..0ce73992 100644 --- a/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java +++ b/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java @@ -1,11 +1,6 @@ package ditda.backend.global.image; -import java.awt.AlphaComposite; -import java.awt.Color; -import java.awt.Font; -import java.awt.FontMetrics; -import java.awt.Graphics2D; -import java.awt.RenderingHints; +import java.awt.*; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -25,12 +20,14 @@ @Component public class WatermarkImageProcessor { - private static final String WATERMARK_TEXT = "ditda"; // 워터마크 텍스트 - private static final int TARGET_LONG_SIDE = 1600; // 출력물의 최대 길이 - private static final long MAX_PIXELS = 200_000_000L; // 이미지 픽셀 제한 - private static final float OPACITY = 0.25f; // 워터마크 투명도 - private static final double ROTATION_DEGREES = -45; // 워터마크 텍스트 기울기 - private static final Color WATERMARK_COLOR = new Color(0xF5F5F5); // 폰트 색상 (White Smoke) + private static final String LOGO_PATH = "/images/watermark-logo.png"; // 워터마크 이미지 파일 경로 + private static final int TARGET_LONG_SIDE = 1600; // 출력물의 최대 길이 + private static final long MAX_PIXELS = 200_000_000L; // 이미지 픽셀 제한 + private static final float OPACITY = 0.25f; // 워터마크 투명도 + private static final double ROTATION_DEGREES = -45; // 워터마크 텍스트 기울기 + private static final int LOGO_WIDTH_RATIO = 15; // 로고 폭 + + private final BufferedImage logo = loadLogo(); public byte[] createWatermarkedPreview(InputStream source) throws IOException { @@ -48,6 +45,21 @@ public byte[] createWatermarkedPreview(InputStream source) throws IOException { return out.toByteArray(); } + // 워터마크 로고 로드 + private BufferedImage loadLogo() { + + try (InputStream logoStream = getClass().getResourceAsStream(LOGO_PATH)) { + if (logoStream == null) { + throw new IllegalStateException("워터마크 로고 리소스가 없습니다: " + LOGO_PATH); + } + + return ImageIO.read(logoStream); + } catch (IOException exception) { + throw new IllegalStateException("워터마크 로고 로드 실패: " + LOGO_PATH, exception); + } + + } + // 서브샘플링 private BufferedImage readSubsampled(InputStream source) throws IOException { @@ -84,30 +96,33 @@ private BufferedImage readSubsampled(InputStream source) throws IOException { } } - // 텍스트 워터마크 + // 로고 기반 워터마크 private void drawWatermark(BufferedImage image) { - Graphics2D graphics = image.createGraphics(); + int logoWidth = image.getWidth() / LOGO_WIDTH_RATIO; + int logoHeight = logoWidth * logo.getHeight() / logo.getWidth(); // 원본 비율 유지 + Graphics2D graphics = image.createGraphics(); try { - // 안티앨리어싱 (텍스트만 부드럽게 처리) - graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); - // 기존 픽셀에 Opacity에 맞게 덧그리기 + // 축소+회전을 한 번의 고품질 리샘플링으로 처리 + graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + graphics.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, OPACITY)); - // 색상 - graphics.setColor(WATERMARK_COLOR); - // 폰트 - graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, image.getWidth() / 32)); - // 좌표 회전 (글씨 대각선) - graphics.rotate(Math.toRadians(ROTATION_DEGREES), image.getWidth() / 2.0, image.getHeight() / 2.0); + graphics.rotate( + Math.toRadians(ROTATION_DEGREES), + image.getWidth() / 2.0, + image.getHeight() / 2.0 + ); - FontMetrics fm = graphics.getFontMetrics(); - int stepX = fm.stringWidth(WATERMARK_TEXT) * 2; // 타일 가로 간격 - int stepY = fm.getHeight() * 4; // 타일 세로 간격 + int stepX = logoWidth * 4; // 타일 가로 간격 + int stepY = logoHeight * 5; // 타일 세로 간격 for (int y = -image.getHeight(); y < image.getHeight() * 2; y += stepY) { for (int x = -image.getWidth(); x < image.getWidth() * 2; x += stepX) { - graphics.drawString(WATERMARK_TEXT, x, y); + // 원본 로고를 목표 크기로 지정해 그리기 - 축소가 회전과 같은 패스에서 수행됨 + graphics.drawImage(logo, x, y, logoWidth, logoHeight, null); } } } finally { diff --git a/src/main/resources/images/watermark-logo.png b/src/main/resources/images/watermark-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ce4b936d015306f335155b6b27954e13d868aa25 GIT binary patch literal 9485 zcmb_?S2$c<)c-+5jgqJl1h1DMYP8WJq6?x-lqf^A=wbBHOG1!DO|#oONb?a;GjwMI>|AqFeWD5gUy^Aq zVB}z*&R5;c*Vxn1*B|!E0nl)9a);%9Nw)`pe_m@pRy7GA*`B{j8`r@6*r!Ok3Y(@B z;T2B;?s#==34B1VQZg_G{MSq1TdGc|Vf%eFcto#_(8Uw83*9?J#FZvS z3oRRT=%ejIE>8A)j}G*_TSE4hIw>3kTQdyC22y_S3vy2UZJ3&zIK{ysGrlbYcpPY5 z$DmQi1>Rm{QHXD2@%#a2?dX1ca%pf@QXWY33Xdc85}@DkoOsr2Q&wA$SE1i4LktYN zHA4b)ASiJi_l+_`U!?isd0H{`Li5ptQc?hzibB$DEt8yy;nR-fO*fp!7LO}IFZeNF zA~+XvM>hw^3w>N$@`%|i8~}injE38O7oM}U(#WABRRDmKw@A9iWFjU#QFH(u5h4sq zGGZbOj0|gizr~-`2(%%Y0e~q9Nq1EBiL;#eC+6813GlF!5+`U)-P7(o-BT1dGuloE zJe&q!eQIbQY*kt<7b&~q1pq^lE{KlP>;q^#j(h?R0R8G_nj13liJK4rAm^(pG;0g5 zGYa7#0m6A1F;Blb^{b_Mh~!d&!^I(!K1}rY8iS-22F%lcZ0gPLaiaj>p?4djVaaZ4 z#@4KDh|0O3!mJy&V!&Q9pvQ}7+s?jSj6y!i+w~`fMk14pl%Uo^Yr}-PTBazgpH#RG z-!=oyLbluZJa1cO=P1?kHd9BVHKFD(DFBe8hjD@vr;JC^fz35_GYYVY4E=YWOi|H~V>1dQQx}+Hn!HtHAWfAK z16uw6GXlU}!T%Tl2B!Z#?*9j3ZH(at%Apr0)hj2*olz9r52B9X$~t1TJ16(k*7qNK zx9IG4i|l&-*djWQHJ)1m;o|Z1IIar%#XQyFrnvjrNK&y}PJ8Q!_uy1mQQ6^yH$bn&|616}h_F*U4I?9izAP-@IQZ1LUu zt-lxe(HQ;nPp(!AeiN|y?r-zVO8b-M*QX=4c59^1ew2nSii_7i4gl8)>(cUt=KQts zVkhqQeyve#_W0R;&A4dHL=65Y5mBL>Is5b;df5YRucCcLfFiuwc^k=q@NWUu6)1s! zC?x+Bu@X|A>VL&c@0z|gjmG~Duk&2zuf$?2hymMVuPQ?y-kbxApScG4uf&5vT@*I~ z<+~YmjzeLK+`5|#rZ1{OM^b_)(*M<=*<0gpz|<_Z#kb-$FuDI9ikh zdauTx)o|c>?j4{ipxou&ECfCz8p7t!M#uJ{ z#1-JtnfDekCj;HZzP?}H0I=gOPD`C&O!q<0$cy7v0 zbqj-7BJ%Ih4V%6ZQUJ1hD%0DOg#L0=*S7fXUGTRwEA_b?f+|9CV0Q>p7KArEWC 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 Date: Thu, 9 Jul 2026 17:27:39 +0900 Subject: [PATCH 06/11] =?UTF-8?q?Rename(watermark):=20import=20=EB=AC=B8?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ditda/backend/global/image/WatermarkImageProcessor.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java b/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java index 0ce73992..15ffc319 100644 --- a/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java +++ b/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java @@ -1,6 +1,8 @@ package ditda.backend.global.image; -import java.awt.*; +import java.awt.AlphaComposite; +import java.awt.Graphics2D; +import java.awt.RenderingHints; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -121,7 +123,6 @@ private void drawWatermark(BufferedImage image) { for (int y = -image.getHeight(); y < image.getHeight() * 2; y += stepY) { for (int x = -image.getWidth(); x < image.getWidth() * 2; x += stepX) { - // 원본 로고를 목표 크기로 지정해 그리기 - 축소가 회전과 같은 패스에서 수행됨 graphics.drawImage(logo, x, y, logoWidth, logoHeight, null); } } From e9465610bb38d0c5682d1d273d10fa85ebfabed3 Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Thu, 9 Jul 2026 17:28:50 +0900 Subject: [PATCH 07/11] =?UTF-8?q?Docs(watermark):=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/ditda/backend/global/image/WatermarkImageProcessor.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java b/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java index 15ffc319..5bd03bbb 100644 --- a/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java +++ b/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java @@ -106,7 +106,6 @@ private void drawWatermark(BufferedImage image) { Graphics2D graphics = image.createGraphics(); try { - // 축소+회전을 한 번의 고품질 리샘플링으로 처리 graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); From 1ed749ee2664837d73605bdd97692faf514bcdfa Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Thu, 9 Jul 2026 22:30:20 +0900 Subject: [PATCH 08/11] =?UTF-8?q?Fix(watermark):=20=EA=B2=BD=EA=B3=84?= =?UTF-8?q?=EA=B0=92=20=EB=B0=A9=EC=96=B4=20=EB=B0=8F=20=EC=9D=B8=EC=BD=94?= =?UTF-8?q?=EB=94=A9/=EB=A1=9C=EA=B3=A0=20=EB=A1=9C=EB=93=9C=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../draft/service/DraftWatermarkService.java | 10 ++++++++-- .../global/config/WatermarkAsyncConfig.java | 2 ++ .../global/image/WatermarkImageProcessor.java | 15 +++++++++++---- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkService.java b/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkService.java index 35157fb5..19b1005b 100644 --- a/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkService.java +++ b/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkService.java @@ -58,8 +58,14 @@ private String createWatermarked(String originalKey) throws IOException { // 워터마크 진행된 파일 s3 업로드 (commission/draft/{uuid}.png -> commission/draft/wm/{uuid}.png) int lastSlash = originalKey.lastIndexOf('/'); - String watermarkedKey = originalKey.substring(0, lastSlash) - + "/" + WATERMARK_DIR + originalKey.substring(lastSlash); + if (lastSlash < 0) { + throw new IllegalArgumentException("유효하지 않은 S3 키: " + originalKey); + } + String dir = originalKey.substring(0, lastSlash); + String filename = originalKey.substring(lastSlash + 1); + int lastDot = filename.lastIndexOf('.'); + String baseName = lastDot > 0 ? filename.substring(0, lastDot) : filename; + String watermarkedKey = dir + "/" + WATERMARK_DIR + "/" + baseName + ".png"; s3FileManager.upload(BUCKET, watermarkedKey, watermarked, S3ContentType.PNG.getContentType()); diff --git a/src/main/java/ditda/backend/global/config/WatermarkAsyncConfig.java b/src/main/java/ditda/backend/global/config/WatermarkAsyncConfig.java index e697de17..1e551be3 100644 --- a/src/main/java/ditda/backend/global/config/WatermarkAsyncConfig.java +++ b/src/main/java/ditda/backend/global/config/WatermarkAsyncConfig.java @@ -15,6 +15,8 @@ public Executor watermarkExecutor() { executor.setCorePoolSize(1); executor.setMaxPoolSize(1); executor.setThreadNamePrefix("watermark-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(30); executor.initialize(); return executor; } diff --git a/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java b/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java index 5bd03bbb..c301555a 100644 --- a/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java +++ b/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java @@ -41,7 +41,9 @@ public byte[] createWatermarkedPreview(InputStream source) throws IOException { // 3. PNG로 재압축 ByteArrayOutputStream out = new ByteArrayOutputStream(); - ImageIO.write(preview, "png", out); + if (!ImageIO.write(preview, "png", out)) { + throw new IOException("PNG 인코딩에 실패했습니다."); + } // 4. S3 업로드용 바이트 return out.toByteArray(); @@ -55,7 +57,12 @@ private BufferedImage loadLogo() { throw new IllegalStateException("워터마크 로고 리소스가 없습니다: " + LOGO_PATH); } - return ImageIO.read(logoStream); + BufferedImage image = ImageIO.read(logoStream); + if (image == null) { + throw new IllegalStateException("워터마크 로고 이미지를 읽을 수 없습니다: " + LOGO_PATH); + } + + return image; } catch (IOException exception) { throw new IllegalStateException("워터마크 로고 로드 실패: " + LOGO_PATH, exception); } @@ -101,8 +108,8 @@ private BufferedImage readSubsampled(InputStream source) throws IOException { // 로고 기반 워터마크 private void drawWatermark(BufferedImage image) { - int logoWidth = image.getWidth() / LOGO_WIDTH_RATIO; - int logoHeight = logoWidth * logo.getHeight() / logo.getWidth(); // 원본 비율 유지 + int logoWidth = Math.max(1, image.getWidth() / LOGO_WIDTH_RATIO); + int logoHeight = Math.max(1, logoWidth * logo.getHeight() / logo.getWidth()); // 원본 비율 유지 Graphics2D graphics = image.createGraphics(); try { From 6bcf16050338841b09fb8518688fd7f24a38828c Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Sat, 11 Jul 2026 13:52:58 +0900 Subject: [PATCH 09/11] =?UTF-8?q?Refactor(watermark):=20=EC=B6=9C=EB=A0=A5?= =?UTF-8?q?=20=ED=8F=AC=EB=A7=B7=EC=97=90=20WatermarkedImage=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=20=EB=B0=8F=20=ED=95=B4=EC=83=81=EB=8F=84=201600=20->?= =?UTF-8?q?=201200=20=ED=95=98=ED=96=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../draft/service/DraftWatermarkService.java | 18 ++++++++++++++++-- .../global/image/WatermarkImageProcessor.java | 8 +++++--- .../global/image/dto/WatermarkedImage.java | 6 ++++++ .../service/DraftWatermarkServiceTest.java | 7 ++++--- .../image/WatermarkImageProcessorTest.java | 10 +++++++--- 5 files changed, 38 insertions(+), 11 deletions(-) create mode 100644 src/main/java/ditda/backend/global/image/dto/WatermarkedImage.java diff --git a/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkService.java b/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkService.java index 19b1005b..b3f50ca3 100644 --- a/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkService.java +++ b/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkService.java @@ -10,6 +10,7 @@ import ditda.backend.domain.commission.draft.entity.enums.WatermarkStatus; import ditda.backend.domain.commission.draft.repository.CommissionDraftFileRepository; import ditda.backend.global.image.WatermarkImageProcessor; +import ditda.backend.global.image.dto.WatermarkedImage; import ditda.backend.global.s3.enums.BucketType; import ditda.backend.global.s3.enums.S3ContentType; import ditda.backend.global.s3.manager.S3FileManager; @@ -38,11 +39,23 @@ public void watermarkDraftFiles(Long draftId) { ); for (CommissionDraftFile file : files) { + long start = System.nanoTime(); try { String watermarkedKey = createWatermarked(file.getFileUrl()); draftWatermarkTransitionService.complete(file.getId(), watermarkedKey); + log.info( + "워터마크 완료. draftFileId={}, elapsedMs={}", + file.getId(), + (System.nanoTime() - start) / 1_000_000 + ); } catch (Exception exception) { - log.error("워터마크 처리 실패. draftFileId={}, fileUrl={}", file.getId(), file.getFileUrl(), exception); + log.error( + "워터마크 실패. draftFileId={}, fileUrl={}, elapsedMs={}", + file.getId(), + file.getFileUrl(), + (System.nanoTime() - start) / 1_000_000, + exception + ); draftWatermarkTransitionService.fail(file.getId()); } } @@ -53,7 +66,8 @@ private String createWatermarked(String originalKey) throws IOException { byte[] watermarked; // 원본 s3 다운로드 후 워터마크 진행 try (InputStream original = s3FileManager.download(BUCKET, originalKey)) { - watermarked = watermarkImageProcessor.createWatermarkedPreview(original); + WatermarkedImage image = watermarkImageProcessor.createWatermarkedPreview(original); + watermarked = image.bytes(); } // 워터마크 진행된 파일 s3 업로드 (commission/draft/{uuid}.png -> commission/draft/wm/{uuid}.png) diff --git a/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java b/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java index c301555a..d7cac60a 100644 --- a/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java +++ b/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java @@ -17,13 +17,15 @@ import org.springframework.stereotype.Component; import ditda.backend.global.apipayload.exception.GeneralException; +import ditda.backend.global.image.dto.WatermarkedImage; import ditda.backend.global.image.exception.ImageErrorCode; +import ditda.backend.global.s3.enums.S3ContentType; @Component public class WatermarkImageProcessor { private static final String LOGO_PATH = "/images/watermark-logo.png"; // 워터마크 이미지 파일 경로 - private static final int TARGET_LONG_SIDE = 1600; // 출력물의 최대 길이 + private static final int TARGET_LONG_SIDE = 1200; // 출력물의 최대 길이 private static final long MAX_PIXELS = 200_000_000L; // 이미지 픽셀 제한 private static final float OPACITY = 0.25f; // 워터마크 투명도 private static final double ROTATION_DEGREES = -45; // 워터마크 텍스트 기울기 @@ -31,7 +33,7 @@ public class WatermarkImageProcessor { private final BufferedImage logo = loadLogo(); - public byte[] createWatermarkedPreview(InputStream source) throws IOException { + public WatermarkedImage createWatermarkedPreview(InputStream source) throws IOException { // 1. 이미지 디코딩 BufferedImage preview = readSubsampled(source); @@ -46,7 +48,7 @@ public byte[] createWatermarkedPreview(InputStream source) throws IOException { } // 4. S3 업로드용 바이트 - return out.toByteArray(); + return new WatermarkedImage(out.toByteArray(), S3ContentType.PNG); } // 워터마크 로고 로드 diff --git a/src/main/java/ditda/backend/global/image/dto/WatermarkedImage.java b/src/main/java/ditda/backend/global/image/dto/WatermarkedImage.java new file mode 100644 index 00000000..1347b87c --- /dev/null +++ b/src/main/java/ditda/backend/global/image/dto/WatermarkedImage.java @@ -0,0 +1,6 @@ +package ditda.backend.global.image.dto; + +import ditda.backend.global.s3.enums.S3ContentType; + +public record WatermarkedImage(byte[] bytes, S3ContentType contentType) { +} diff --git a/src/test/java/ditda/backend/domain/commission/draft/service/DraftWatermarkServiceTest.java b/src/test/java/ditda/backend/domain/commission/draft/service/DraftWatermarkServiceTest.java index 63a58b96..0f598443 100644 --- a/src/test/java/ditda/backend/domain/commission/draft/service/DraftWatermarkServiceTest.java +++ b/src/test/java/ditda/backend/domain/commission/draft/service/DraftWatermarkServiceTest.java @@ -17,6 +17,7 @@ import ditda.backend.domain.commission.draft.entity.enums.WatermarkStatus; import ditda.backend.domain.commission.draft.repository.CommissionDraftFileRepository; import ditda.backend.global.image.WatermarkImageProcessor; +import ditda.backend.global.image.dto.WatermarkedImage; import ditda.backend.global.s3.enums.BucketType; import ditda.backend.global.s3.enums.S3ContentType; import ditda.backend.global.s3.manager.S3FileManager; @@ -52,7 +53,7 @@ void watermarkDraftFiles_success() throws IOException { given(s3FileManager.download(BucketType.PRIVATE, "commission/draft/abc.png")) .willReturn(new ByteArrayInputStream(new byte[0])); - byte[] watermarked = {1, 2, 3}; + WatermarkedImage watermarked = new WatermarkedImage(new byte[]{1, 2, 3}, S3ContentType.PNG); given(watermarkImageProcessor.createWatermarkedPreview(any())).willReturn(watermarked); // when @@ -62,7 +63,7 @@ void watermarkDraftFiles_success() throws IOException { then(s3FileManager).should().upload( BucketType.PRIVATE, "commission/draft/wm/abc.png", - watermarked, + watermarked.bytes(), S3ContentType.PNG.getContentType() ); then(draftWatermarkTransitionService).should().complete(10L, "commission/draft/wm/abc.png"); @@ -103,7 +104,7 @@ void watermarkDraftFiles_isolatesFailurePerFile() throws IOException { given(s3FileManager.download(any(), anyString())) .willAnswer(invocation -> new ByteArrayInputStream(new byte[0])); - byte[] watermarked = {1}; + WatermarkedImage watermarked = new WatermarkedImage(new byte[]{1, 2, 3}, S3ContentType.PNG); given(watermarkImageProcessor.createWatermarkedPreview(any())) .willThrow(new IOException("첫 번째 파일 실패")) .willReturn(watermarked); diff --git a/src/test/java/ditda/backend/global/image/WatermarkImageProcessorTest.java b/src/test/java/ditda/backend/global/image/WatermarkImageProcessorTest.java index 7d1668a2..dc120780 100644 --- a/src/test/java/ditda/backend/global/image/WatermarkImageProcessorTest.java +++ b/src/test/java/ditda/backend/global/image/WatermarkImageProcessorTest.java @@ -19,6 +19,7 @@ import org.junit.jupiter.api.Test; import ditda.backend.global.apipayload.exception.GeneralException; +import ditda.backend.global.image.dto.WatermarkedImage; class WatermarkImageProcessorTest { @@ -32,7 +33,8 @@ void createWatermarkedPreview_downscalesLargeImage() throws IOException { byte[] source = pngBytes(3200, 2400); // when - byte[] result = watermarkImageProcessor.createWatermarkedPreview(new ByteArrayInputStream(source)); + WatermarkedImage image = watermarkImageProcessor.createWatermarkedPreview(new ByteArrayInputStream(source)); + byte[] result = image.bytes(); // then: sample=2로 절반 크기 BufferedImage preview = ImageIO.read(new ByteArrayInputStream(result)); @@ -48,7 +50,8 @@ void createWatermarkedPreview_keepsSmallImageSize() throws IOException { byte[] source = pngBytes(800, 600); // when - byte[] result = watermarkImageProcessor.createWatermarkedPreview(new ByteArrayInputStream(source)); + WatermarkedImage image = watermarkImageProcessor.createWatermarkedPreview(new ByteArrayInputStream(source)); + byte[] result = image.bytes(); // then BufferedImage preview = ImageIO.read(new ByteArrayInputStream(result)); @@ -93,7 +96,8 @@ void manualPreviewForEyeCheck() throws IOException { byte[] result; try (InputStream in = Files.newInputStream(source)) { - result = watermarkImageProcessor.createWatermarkedPreview(in); + WatermarkedImage image = watermarkImageProcessor.createWatermarkedPreview(in); + result = image.bytes(); } Files.write(outputDir.resolve(source.getFileName()), result); From 12c9c43695b0d94c598c1ed8457ef5ee591ac80a Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Sun, 12 Jul 2026 01:08:44 +0900 Subject: [PATCH 10/11] =?UTF-8?q?Feat(watermark):=20=EB=AF=B8=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EC=9B=8C=ED=84=B0=EB=A7=88=ED=81=AC=20=EC=9E=AC?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../draft/entity/CommissionDraftFile.java | 22 +++++++++ .../CommissionDraftFileRepository.java | 11 +++++ .../scheduler/WatermarkRetryScheduler.java | 25 ++++++++++ .../draft/service/DraftWatermarkService.java | 33 +++++++++++++ .../DraftWatermarkTransitionService.java | 41 ++++++++++++++-- .../draft/service/WatermarkRetryService.java | 49 +++++++++++++++++++ 6 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 src/main/java/ditda/backend/domain/commission/draft/scheduler/WatermarkRetryScheduler.java create mode 100644 src/main/java/ditda/backend/domain/commission/draft/service/WatermarkRetryService.java diff --git a/src/main/java/ditda/backend/domain/commission/draft/entity/CommissionDraftFile.java b/src/main/java/ditda/backend/domain/commission/draft/entity/CommissionDraftFile.java index f6ab301b..8a51615e 100644 --- a/src/main/java/ditda/backend/domain/commission/draft/entity/CommissionDraftFile.java +++ b/src/main/java/ditda/backend/domain/commission/draft/entity/CommissionDraftFile.java @@ -37,6 +37,8 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class CommissionDraftFile extends BaseEntity { + public static final int MAX_WATERMARK_RETRY = 3; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "commission_draft_file_id") @@ -59,6 +61,9 @@ public class CommissionDraftFile extends BaseEntity { @Column(name = "watermark_status", length = 20, nullable = false) private WatermarkStatus watermarkStatus; + @Column(name = "watermark_retry_count", nullable = false) + private int watermarkRetryCount; + public static CommissionDraftFile create( CommissionDraft commissionDraft, int fileOrder, @@ -85,6 +90,23 @@ public void completeWatermark(String watermarkedFileUrl) { // 워터마크 처리 실패 public void markWatermarkFailed() { + this.watermarkRetryCount++; + this.watermarkStatus = WatermarkStatus.FAILED; + } + + // 워터마크 재처리 시작 + public void retryWatermark() { + this.watermarkStatus = WatermarkStatus.PROCESSING; + } + + // 재시도 가능 여부 확인 + public boolean isWatermarkRetryable() { + return watermarkRetryCount < MAX_WATERMARK_RETRY; + } + + // 재시도 불가 실패 (이미지 문제) + public void markWatermarkFailedPermanently() { + this.watermarkRetryCount = MAX_WATERMARK_RETRY; this.watermarkStatus = WatermarkStatus.FAILED; } } diff --git a/src/main/java/ditda/backend/domain/commission/draft/repository/CommissionDraftFileRepository.java b/src/main/java/ditda/backend/domain/commission/draft/repository/CommissionDraftFileRepository.java index efc6d3e3..ffcc04e3 100644 --- a/src/main/java/ditda/backend/domain/commission/draft/repository/CommissionDraftFileRepository.java +++ b/src/main/java/ditda/backend/domain/commission/draft/repository/CommissionDraftFileRepository.java @@ -1,5 +1,6 @@ package ditda.backend.domain.commission.draft.repository; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -28,4 +29,14 @@ List findAllByCommissionDraftIdAndWatermarkStatus( Long draftId, WatermarkStatus watermarkStatus ); + + @Query("SELECT f FROM CommissionDraftFile f " + + "WHERE (f.watermarkStatus = :failed AND f.watermarkRetryCount < :maxRetry) " + + "OR (f.watermarkStatus = :processing AND f.updatedAt < :stuckBefore)") + List findWatermarkRetryTargets( + @Param("failed") WatermarkStatus failed, + @Param("processing") WatermarkStatus processing, + @Param("maxRetry") int maxRetry, + @Param("stuckBefore") LocalDateTime stuckBefore + ); } diff --git a/src/main/java/ditda/backend/domain/commission/draft/scheduler/WatermarkRetryScheduler.java b/src/main/java/ditda/backend/domain/commission/draft/scheduler/WatermarkRetryScheduler.java new file mode 100644 index 00000000..4feb2acf --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/scheduler/WatermarkRetryScheduler.java @@ -0,0 +1,25 @@ +package ditda.backend.domain.commission.draft.scheduler; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import ditda.backend.domain.commission.draft.service.WatermarkRetryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WatermarkRetryScheduler { + + private final WatermarkRetryService watermarkRetryService; + + // 10분마다 미완료 워터마크 재처리 + @Scheduled(cron = "0 */10 * * * *", zone = "Asia/Seoul") + public void retryIncompleteWatermarks() { + + log.info("워터마크 재처리 스케줄 시작"); + watermarkRetryService.retryIncompleteFiles(); + log.info("워터마크 재처리 스케줄 완료"); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkService.java b/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkService.java index b3f50ca3..a71eeae3 100644 --- a/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkService.java +++ b/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkService.java @@ -4,11 +4,13 @@ import java.io.InputStream; import java.util.List; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import ditda.backend.domain.commission.draft.entity.CommissionDraftFile; import ditda.backend.domain.commission.draft.entity.enums.WatermarkStatus; import ditda.backend.domain.commission.draft.repository.CommissionDraftFileRepository; +import ditda.backend.global.apipayload.exception.GeneralException; import ditda.backend.global.image.WatermarkImageProcessor; import ditda.backend.global.image.dto.WatermarkedImage; import ditda.backend.global.s3.enums.BucketType; @@ -48,6 +50,9 @@ public void watermarkDraftFiles(Long draftId) { file.getId(), (System.nanoTime() - start) / 1_000_000 ); + } catch (GeneralException exception) { + log.error("워터마크 영구 실패(이미지 문제). draftFileId={}", file.getId(), exception); + draftWatermarkTransitionService.failPermanently(file.getId()); } catch (Exception exception) { log.error( "워터마크 실패. draftFileId={}, fileUrl={}, elapsedMs={}", @@ -61,6 +66,34 @@ public void watermarkDraftFiles(Long draftId) { } } + // 워터마크 재처리 + @Async("watermarkExecutor") + public void reprocessFile(Long draftFileId) { + + long start = System.nanoTime(); + try { + String originalKey = draftWatermarkTransitionService.markProcessingAndGetKey(draftFileId); + String watermarkedKey = createWatermarked(originalKey); + draftWatermarkTransitionService.complete(draftFileId, watermarkedKey); + log.info( + "워터마크 재처리 완료. draftFileId={}, elapsedMs={}", + draftFileId, + (System.nanoTime() - start) / 1_000_000 + ); + } catch (GeneralException exception) { + log.error("워터마크 영구 실패(이미지 문제). draftFileId={}", draftFileId, exception); + draftWatermarkTransitionService.failPermanently(draftFileId); + } catch (Exception exception) { + log.error( + "워터마크 재처리 실패. draftFileId={}, elapsedMs={}", + draftFileId, + (System.nanoTime() - start) / 1_000_000, + exception + ); + draftWatermarkTransitionService.fail(draftFileId); + } + } + private String createWatermarked(String originalKey) throws IOException { byte[] watermarked; diff --git a/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkTransitionService.java b/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkTransitionService.java index 5653dda7..52baea1a 100644 --- a/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkTransitionService.java +++ b/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkTransitionService.java @@ -19,6 +19,7 @@ public class DraftWatermarkTransitionService { // 워터마크 완료 전이 @Transactional(propagation = Propagation.REQUIRES_NEW) public void complete(Long draftFileId, String watermarkedKey) { + commissionDraftFileRepository.findById(draftFileId) .ifPresentOrElse( file -> file.completeWatermark(watermarkedKey), @@ -26,12 +27,44 @@ public void complete(Long draftFileId, String watermarkedKey) { ); } - // 워터마크 실패 전이 + // 영구 실패 전이 (이미지 문제) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void failPermanently(Long draftFileId) { + + commissionDraftFileRepository.findById(draftFileId) + .ifPresentOrElse(file -> { + file.markWatermarkFailedPermanently(); + notifyPermanentFailure(draftFileId); + }, () -> log.warn("워터마크 영구 실패 전이 대상 없음. draftFileId={}", draftFileId)); + } + + // 재시도 실패 전이 @Transactional(propagation = Propagation.REQUIRES_NEW) public void fail(Long draftFileId) { + commissionDraftFileRepository.findById(draftFileId) - .ifPresentOrElse(CommissionDraftFile::markWatermarkFailed, - () -> log.warn("워터마크 실패 전이 대상 없음. draftFileId={}", draftFileId) - ); + .ifPresentOrElse(file -> { + file.markWatermarkFailed(); + if (!file.isWatermarkRetryable()) { + notifyPermanentFailure(draftFileId); + } + }, () -> log.warn("워터마크 실패 전이 대상 없음. draftFileId={}", draftFileId)); + } + + // 워터마크 재처리 전이 + @Transactional(propagation = Propagation.REQUIRES_NEW) + public String markProcessingAndGetKey(Long draftFileId) { + + CommissionDraftFile file = commissionDraftFileRepository.findById(draftFileId) + .orElseThrow(() -> new IllegalStateException("워터마크 재처리 대상 없음: " + draftFileId)); + + file.retryWatermark(); + return file.getFileUrl(); + } + + // TODO: 디스코드 웹훅 - 영구 실패 알림 (draftFileId + 사유) + private void notifyPermanentFailure(Long draftFileId) { + + log.error("워터마크 영구 실패. draftFileId={}", draftFileId); } } diff --git a/src/main/java/ditda/backend/domain/commission/draft/service/WatermarkRetryService.java b/src/main/java/ditda/backend/domain/commission/draft/service/WatermarkRetryService.java new file mode 100644 index 00000000..de163816 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/service/WatermarkRetryService.java @@ -0,0 +1,49 @@ +package ditda.backend.domain.commission.draft.service; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import ditda.backend.domain.commission.draft.entity.CommissionDraftFile; +import ditda.backend.domain.commission.draft.entity.enums.WatermarkStatus; +import ditda.backend.domain.commission.draft.repository.CommissionDraftFileRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WatermarkRetryService { + + private static final Duration STUCK_THRESHOLD = Duration.ofMinutes(30); + + private final CommissionDraftFileRepository commissionDraftFileRepository; + private final DraftWatermarkService draftWatermarkService; + + // 미완료 워터마크 파일 조회 후 워터마크 큐에 투입 + @Transactional(readOnly = true) + public void retryIncompleteFiles() { + + LocalDateTime stuckBefore = LocalDateTime.now().minus(STUCK_THRESHOLD); + + List targets = commissionDraftFileRepository.findWatermarkRetryTargets( + WatermarkStatus.FAILED, + WatermarkStatus.PROCESSING, + CommissionDraftFile.MAX_WATERMARK_RETRY, + stuckBefore + ); + + if (targets.isEmpty()) { + return; + } + + for (CommissionDraftFile target : targets) { + draftWatermarkService.reprocessFile(target.getId()); + } + + log.info("워터마크 재처리 대상 {}건 큐잉 완료", targets.size()); + } +} From 5710098ceafea64722e4a65e6c1b9fde9a237bc0 Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Sun, 12 Jul 2026 01:47:44 +0900 Subject: [PATCH 11/11] =?UTF-8?q?Fix(watermark):=20=EC=9E=AC=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=ED=81=90=EC=9E=89=20=EB=B0=A9=EC=A7=80=20=EB=B0=8F?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B3=B5=ED=86=B5?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../draft/entity/CommissionDraftFile.java | 6 -- .../CommissionDraftFileRepository.java | 29 ++++++-- .../draft/service/DraftWatermarkService.java | 69 +++++++------------ .../DraftWatermarkTransitionService.java | 14 ++-- .../draft/service/WatermarkRetryService.java | 28 +++++--- 5 files changed, 73 insertions(+), 73 deletions(-) diff --git a/src/main/java/ditda/backend/domain/commission/draft/entity/CommissionDraftFile.java b/src/main/java/ditda/backend/domain/commission/draft/entity/CommissionDraftFile.java index 8a51615e..9e03b6e0 100644 --- a/src/main/java/ditda/backend/domain/commission/draft/entity/CommissionDraftFile.java +++ b/src/main/java/ditda/backend/domain/commission/draft/entity/CommissionDraftFile.java @@ -90,15 +90,9 @@ public void completeWatermark(String watermarkedFileUrl) { // 워터마크 처리 실패 public void markWatermarkFailed() { - this.watermarkRetryCount++; this.watermarkStatus = WatermarkStatus.FAILED; } - // 워터마크 재처리 시작 - public void retryWatermark() { - this.watermarkStatus = WatermarkStatus.PROCESSING; - } - // 재시도 가능 여부 확인 public boolean isWatermarkRetryable() { return watermarkRetryCount < MAX_WATERMARK_RETRY; diff --git a/src/main/java/ditda/backend/domain/commission/draft/repository/CommissionDraftFileRepository.java b/src/main/java/ditda/backend/domain/commission/draft/repository/CommissionDraftFileRepository.java index ffcc04e3..0f6f5762 100644 --- a/src/main/java/ditda/backend/domain/commission/draft/repository/CommissionDraftFileRepository.java +++ b/src/main/java/ditda/backend/domain/commission/draft/repository/CommissionDraftFileRepository.java @@ -4,7 +4,9 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -30,13 +32,32 @@ List findAllByCommissionDraftIdAndWatermarkStatus( WatermarkStatus watermarkStatus ); - @Query("SELECT f FROM CommissionDraftFile f " + @Query("SELECT f.id FROM CommissionDraftFile f " + "WHERE (f.watermarkStatus = :failed AND f.watermarkRetryCount < :maxRetry) " - + "OR (f.watermarkStatus = :processing AND f.updatedAt < :stuckBefore)") - List findWatermarkRetryTargets( + + "OR (f.watermarkStatus = :processing " + + " AND f.updatedAt < :stuckBefore AND f.watermarkRetryCount < :maxRetry) " + + "ORDER BY f.updatedAt ASC") + List findWatermarkRetryTargetIds( @Param("failed") WatermarkStatus failed, @Param("processing") WatermarkStatus processing, @Param("maxRetry") int maxRetry, - @Param("stuckBefore") LocalDateTime stuckBefore + @Param("stuckBefore") LocalDateTime stuckBefore, + Pageable pageable + ); + + @Modifying(clearAutomatically = true) + @Query("UPDATE CommissionDraftFile f " + + "SET f.watermarkStatus = :processing, " + + " f.watermarkRetryCount = f.watermarkRetryCount + 1, " + + " f.updatedAt = :now " + + "WHERE f.id IN :ids " + + "AND f.watermarkStatus <> :completed " + + "AND f.watermarkRetryCount < :maxRetry") + int claimForRetry( + @Param("ids") List ids, + @Param("processing") WatermarkStatus processing, + @Param("completed") WatermarkStatus completed, + @Param("maxRetry") int maxRetry, + @Param("now") LocalDateTime now ); } diff --git a/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkService.java b/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkService.java index a71eeae3..e5e45f74 100644 --- a/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkService.java +++ b/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkService.java @@ -40,58 +40,15 @@ public void watermarkDraftFiles(Long draftId) { WatermarkStatus.PROCESSING ); - for (CommissionDraftFile file : files) { - long start = System.nanoTime(); - try { - String watermarkedKey = createWatermarked(file.getFileUrl()); - draftWatermarkTransitionService.complete(file.getId(), watermarkedKey); - log.info( - "워터마크 완료. draftFileId={}, elapsedMs={}", - file.getId(), - (System.nanoTime() - start) / 1_000_000 - ); - } catch (GeneralException exception) { - log.error("워터마크 영구 실패(이미지 문제). draftFileId={}", file.getId(), exception); - draftWatermarkTransitionService.failPermanently(file.getId()); - } catch (Exception exception) { - log.error( - "워터마크 실패. draftFileId={}, fileUrl={}, elapsedMs={}", - file.getId(), - file.getFileUrl(), - (System.nanoTime() - start) / 1_000_000, - exception - ); - draftWatermarkTransitionService.fail(file.getId()); - } - } + files.forEach(f -> process(f.getId(), f.getFileUrl())); } // 워터마크 재처리 @Async("watermarkExecutor") public void reprocessFile(Long draftFileId) { - long start = System.nanoTime(); - try { - String originalKey = draftWatermarkTransitionService.markProcessingAndGetKey(draftFileId); - String watermarkedKey = createWatermarked(originalKey); - draftWatermarkTransitionService.complete(draftFileId, watermarkedKey); - log.info( - "워터마크 재처리 완료. draftFileId={}, elapsedMs={}", - draftFileId, - (System.nanoTime() - start) / 1_000_000 - ); - } catch (GeneralException exception) { - log.error("워터마크 영구 실패(이미지 문제). draftFileId={}", draftFileId, exception); - draftWatermarkTransitionService.failPermanently(draftFileId); - } catch (Exception exception) { - log.error( - "워터마크 재처리 실패. draftFileId={}, elapsedMs={}", - draftFileId, - (System.nanoTime() - start) / 1_000_000, - exception - ); - draftWatermarkTransitionService.fail(draftFileId); - } + String originalKey = draftWatermarkTransitionService.getOriginalKey(draftFileId); + process(draftFileId, originalKey); } private String createWatermarked(String originalKey) throws IOException { @@ -118,4 +75,24 @@ private String createWatermarked(String originalKey) throws IOException { return watermarkedKey; } + + private void process(Long fileId, String originalKey) { + + long start = System.nanoTime(); + try { + String watermarkedKey = createWatermarked(originalKey); + draftWatermarkTransitionService.complete(fileId, watermarkedKey); + log.info("워터마크 완료. draftFileId={}, elapsedMs={}", fileId, elapsedMs(start)); + } catch (GeneralException e) { + log.error("워터마크 영구 실패(이미지 문제). draftFileId={}", fileId, e); + draftWatermarkTransitionService.failPermanently(fileId); + } catch (Exception e) { + log.error("워터마크 실패. draftFileId={}, elapsedMs={}", fileId, elapsedMs(start), e); + draftWatermarkTransitionService.fail(fileId); + } + } + + private long elapsedMs(long start) { + return (System.nanoTime() - start) / 1_000_000; + } } diff --git a/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkTransitionService.java b/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkTransitionService.java index 52baea1a..2f39f5fe 100644 --- a/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkTransitionService.java +++ b/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkTransitionService.java @@ -27,7 +27,7 @@ public void complete(Long draftFileId, String watermarkedKey) { ); } - // 영구 실패 전이 (이미지 문제) + // 워터마크 영구 실패 전이 (이미지 문제) @Transactional(propagation = Propagation.REQUIRES_NEW) public void failPermanently(Long draftFileId) { @@ -38,7 +38,7 @@ public void failPermanently(Long draftFileId) { }, () -> log.warn("워터마크 영구 실패 전이 대상 없음. draftFileId={}", draftFileId)); } - // 재시도 실패 전이 + // 워터마크 실패 전이 @Transactional(propagation = Propagation.REQUIRES_NEW) public void fail(Long draftFileId) { @@ -52,14 +52,12 @@ public void fail(Long draftFileId) { } // 워터마크 재처리 전이 - @Transactional(propagation = Propagation.REQUIRES_NEW) - public String markProcessingAndGetKey(Long draftFileId) { + @Transactional(readOnly = true) + public String getOriginalKey(Long draftFileId) { - CommissionDraftFile file = commissionDraftFileRepository.findById(draftFileId) + return commissionDraftFileRepository.findById(draftFileId) + .map(CommissionDraftFile::getFileUrl) .orElseThrow(() -> new IllegalStateException("워터마크 재처리 대상 없음: " + draftFileId)); - - file.retryWatermark(); - return file.getFileUrl(); } // TODO: 디스코드 웹훅 - 영구 실패 알림 (draftFileId + 사유) diff --git a/src/main/java/ditda/backend/domain/commission/draft/service/WatermarkRetryService.java b/src/main/java/ditda/backend/domain/commission/draft/service/WatermarkRetryService.java index de163816..5d85dc9b 100644 --- a/src/main/java/ditda/backend/domain/commission/draft/service/WatermarkRetryService.java +++ b/src/main/java/ditda/backend/domain/commission/draft/service/WatermarkRetryService.java @@ -4,6 +4,7 @@ import java.time.LocalDateTime; import java.util.List; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,31 +20,40 @@ public class WatermarkRetryService { private static final Duration STUCK_THRESHOLD = Duration.ofMinutes(30); + private static final int BATCH_SIZE = 20; private final CommissionDraftFileRepository commissionDraftFileRepository; private final DraftWatermarkService draftWatermarkService; // 미완료 워터마크 파일 조회 후 워터마크 큐에 투입 - @Transactional(readOnly = true) + @Transactional public void retryIncompleteFiles() { - LocalDateTime stuckBefore = LocalDateTime.now().minus(STUCK_THRESHOLD); + LocalDateTime now = LocalDateTime.now(); + LocalDateTime stuckBefore = now.minus(STUCK_THRESHOLD); - List targets = commissionDraftFileRepository.findWatermarkRetryTargets( + List targetIds = commissionDraftFileRepository.findWatermarkRetryTargetIds( WatermarkStatus.FAILED, WatermarkStatus.PROCESSING, CommissionDraftFile.MAX_WATERMARK_RETRY, - stuckBefore + stuckBefore, + PageRequest.of(0, BATCH_SIZE) ); - if (targets.isEmpty()) { + if (targetIds.isEmpty()) { return; } - for (CommissionDraftFile target : targets) { - draftWatermarkService.reprocessFile(target.getId()); - } + int claimed = commissionDraftFileRepository.claimForRetry( + targetIds, + WatermarkStatus.PROCESSING, + WatermarkStatus.COMPLETED, + CommissionDraftFile.MAX_WATERMARK_RETRY, + now + ); + + targetIds.forEach(draftWatermarkService::reprocessFile); - log.info("워터마크 재처리 대상 {}건 큐잉 완료", targets.size()); + log.info("워터마크 재처리 대상 선점 {}건 / 큐잉 {}건", claimed, targetIds.size()); } }