From 0aa6a8aa326868e6f7427d848b666b743a6be12b Mon Sep 17 00:00:00 2001 From: plzslp Date: Wed, 24 Jun 2026 17:37:25 +0900 Subject: [PATCH 01/41] =?UTF-8?q?feat:=20Async,=20Storage=20Config=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20AsyncConfig=20-=20StorageConfig=20-=20?= =?UTF-8?q?LocalStorageProperties=20-=20S3StorageProperties?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ .../storage/local/LocalStorageProperties.java | 11 +++++++++++ .../storage/s3/S3StorageProperties.java | 13 +++++++++++++ .../com/codeit/team5/mopl/config/AsyncConfig.java | 10 ++++++++++ .../com/codeit/team5/mopl/config/StorageConfig.java | 12 ++++++++++++ 5 files changed, 49 insertions(+) create mode 100644 src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalStorageProperties.java create mode 100644 src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3StorageProperties.java create mode 100644 src/main/java/com/codeit/team5/mopl/config/AsyncConfig.java create mode 100644 src/main/java/com/codeit/team5/mopl/config/StorageConfig.java diff --git a/.gitignore b/.gitignore index 7da4c872..d109d1e4 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,6 @@ $RECYCLE.BIN/ ### 로그 디렉토리 ### .logs/ + +### 로컬 업로드 파일 디렉토리 ### +uploads/ \ No newline at end of file diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalStorageProperties.java b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalStorageProperties.java new file mode 100644 index 00000000..09c5e417 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalStorageProperties.java @@ -0,0 +1,11 @@ +package com.codeit.team5.mopl.binarycontent.storage.local; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "storage.local") +public record LocalStorageProperties( + String baseUrl, + String uploadDir +) { + +} diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3StorageProperties.java b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3StorageProperties.java new file mode 100644 index 00000000..dd8320fa --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3StorageProperties.java @@ -0,0 +1,13 @@ +package com.codeit.team5.mopl.binarycontent.storage.s3; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "storage.s3") +public record S3StorageProperties( + String accessKey, + String privateKey, + String bucketName, + String region +) { + +} diff --git a/src/main/java/com/codeit/team5/mopl/config/AsyncConfig.java b/src/main/java/com/codeit/team5/mopl/config/AsyncConfig.java new file mode 100644 index 00000000..a63cd5d9 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/config/AsyncConfig.java @@ -0,0 +1,10 @@ +package com.codeit.team5.mopl.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; + +@Configuration +@EnableAsync +public class AsyncConfig { + +} diff --git a/src/main/java/com/codeit/team5/mopl/config/StorageConfig.java b/src/main/java/com/codeit/team5/mopl/config/StorageConfig.java new file mode 100644 index 00000000..2ad32ec1 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/config/StorageConfig.java @@ -0,0 +1,12 @@ +package com.codeit.team5.mopl.config; + +import com.codeit.team5.mopl.binarycontent.storage.local.LocalStorageProperties; +import com.codeit.team5.mopl.binarycontent.storage.s3.S3StorageProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties({LocalStorageProperties.class, S3StorageProperties.class}) +public class StorageConfig { + +} From cbe122305768ff7d2bd0a6c502a2450542aaadfb Mon Sep 17 00:00:00 2001 From: plzslp Date: Wed, 24 Jun 2026 17:42:42 +0900 Subject: [PATCH 02/41] =?UTF-8?q?feat:=20BinaryContentStorage=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EB=B0=8F=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=EC=B2=B4=20=EC=9E=91=EC=84=B1=20-=20LocalBinaryConten?= =?UTF-8?q?tStorage=20-=20S3BinaryContentStorage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../binarycontent/BinaryContentStorage.java | 12 ++++ .../local/LocalBinaryContentStorage.java | 61 +++++++++++++++++++ .../storage/s3/S3BinaryContentStorage.java | 35 +++++++++++ 3 files changed, 108 insertions(+) create mode 100644 src/main/java/com/codeit/team5/mopl/binarycontent/BinaryContentStorage.java create mode 100644 src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java create mode 100644 src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3BinaryContentStorage.java diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/BinaryContentStorage.java b/src/main/java/com/codeit/team5/mopl/binarycontent/BinaryContentStorage.java new file mode 100644 index 00000000..491bfdd4 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/BinaryContentStorage.java @@ -0,0 +1,12 @@ +package com.codeit.team5.mopl.binarycontent; + +import java.util.UUID; + +public interface BinaryContentStorage { + + String generateKey(UUID contentId, String originalFilename); + + String toUrl(String key); + + void store(String key, byte[] bytes); +} diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java new file mode 100644 index 00000000..03f4f6ec --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java @@ -0,0 +1,61 @@ +package com.codeit.team5.mopl.binarycontent.storage.local; + +import com.codeit.team5.mopl.binarycontent.BinaryContentStorage; +import com.codeit.team5.mopl.binarycontent.exception.FileStorageException; +import com.codeit.team5.mopl.binarycontent.exception.UploadDirectoryInitException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Slf4j +@Component +@ConditionalOnProperty(name = "storage.type", havingValue = "local", matchIfMissing = true) +public class LocalBinaryContentStorage implements BinaryContentStorage { + + private final Path uploadDir; + private final String baseUrl; + + public LocalBinaryContentStorage(LocalStorageProperties properties) { + this.uploadDir = Paths.get(properties.uploadDir()).toAbsolutePath().normalize(); + this.baseUrl = properties.baseUrl(); + init(); + } + + @Override + public String generateKey(UUID contentId, String originalFilename) { + String extension = StringUtils.getFilenameExtension(originalFilename); + String filename = UUID.randomUUID() + (extension != null ? "." + extension : ""); + return "thumbnails/" + contentId + "/" + filename; + } + + @Override + public String toUrl(String key) { + return baseUrl + "/" + key; + } + + @Override + public void store(String key, byte[] bytes) { + Path destination = uploadDir.resolve(key); + try { + Files.createDirectories(destination.getParent()); + Files.write(destination, bytes); + } catch (IOException e) { + throw new FileStorageException(key); + } + log.debug("파일 저장 완료: {}", destination); + } + + private void init() { + try { + Files.createDirectories(uploadDir); + } catch (IOException e) { + throw new UploadDirectoryInitException(uploadDir); + } + } +} diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3BinaryContentStorage.java b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3BinaryContentStorage.java new file mode 100644 index 00000000..c49810ca --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3BinaryContentStorage.java @@ -0,0 +1,35 @@ +package com.codeit.team5.mopl.binarycontent.storage.s3; + +import com.codeit.team5.mopl.binarycontent.BinaryContentStorage; +import java.util.UUID; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Component +@ConditionalOnProperty(name = "storage.type", havingValue = "s3") +public class S3BinaryContentStorage implements BinaryContentStorage { + + private final S3StorageProperties properties; + + public S3BinaryContentStorage(S3StorageProperties properties) { + this.properties = properties; + } + + @Override + public String generateKey(UUID contentId, String originalFilename) { + String extension = StringUtils.getFilenameExtension(originalFilename); + String filename = UUID.randomUUID() + (extension != null ? "." + extension : ""); + return "thumbnails/" + contentId + "/" + filename; + } + + @Override + public String toUrl(String key) { + return "https://" + properties.bucketName() + ".s3." + properties.region() + ".amazonaws.com/" + key; + } + + @Override + public void store(String key, byte[] bytes) { + throw new UnsupportedOperationException("S3 스토리지가 아직 구현되지 않았습니다."); + } +} From 18f33698eb4268bbe2a76724c65cc1b8f4c070d6 Mon Sep 17 00:00:00 2001 From: plzslp Date: Wed, 24 Jun 2026 17:43:17 +0900 Subject: [PATCH 03/41] =?UTF-8?q?feat:=20BinaryContent=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=98=88=EC=99=B8=20=ED=81=B4=EB=9E=98=EC=8A=A4=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 --- .../exception/BinaryContentStorageException.java | 12 ++++++++++++ .../exception/FileStorageException.java | 10 ++++++++++ .../exception/UploadDirectoryInitException.java | 11 +++++++++++ 3 files changed, 33 insertions(+) create mode 100644 src/main/java/com/codeit/team5/mopl/binarycontent/exception/BinaryContentStorageException.java create mode 100644 src/main/java/com/codeit/team5/mopl/binarycontent/exception/FileStorageException.java create mode 100644 src/main/java/com/codeit/team5/mopl/binarycontent/exception/UploadDirectoryInitException.java diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/exception/BinaryContentStorageException.java b/src/main/java/com/codeit/team5/mopl/binarycontent/exception/BinaryContentStorageException.java new file mode 100644 index 00000000..57850a48 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/exception/BinaryContentStorageException.java @@ -0,0 +1,12 @@ +package com.codeit.team5.mopl.binarycontent.exception; + +import com.codeit.team5.mopl.global.exception.suggestion.BusinessExceptionSuggestion; +import org.springframework.http.HttpStatus; + +public class BinaryContentStorageException extends BusinessExceptionSuggestion { + + public BinaryContentStorageException(HttpStatus status, String message) { + super(status, message); + } + +} diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/exception/FileStorageException.java b/src/main/java/com/codeit/team5/mopl/binarycontent/exception/FileStorageException.java new file mode 100644 index 00000000..d0b75136 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/exception/FileStorageException.java @@ -0,0 +1,10 @@ +package com.codeit.team5.mopl.binarycontent.exception; + +import org.springframework.http.HttpStatus; + +public class FileStorageException extends BinaryContentStorageException { + + public FileStorageException(String key) { + super(HttpStatus.INTERNAL_SERVER_ERROR, "파일 저장 실패: " + key); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/exception/UploadDirectoryInitException.java b/src/main/java/com/codeit/team5/mopl/binarycontent/exception/UploadDirectoryInitException.java new file mode 100644 index 00000000..43de830c --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/exception/UploadDirectoryInitException.java @@ -0,0 +1,11 @@ +package com.codeit.team5.mopl.binarycontent.exception; + +import java.nio.file.Path; +import org.springframework.http.HttpStatus; + +public class UploadDirectoryInitException extends BinaryContentStorageException { + + public UploadDirectoryInitException(Path uploadDir) { + super(HttpStatus.INTERNAL_SERVER_ERROR, "업로드 디렉토리 생성 실패: " + uploadDir); + } +} From ffec7161fffb913d6f4ec0b77a422845d6b677f8 Mon Sep 17 00:00:00 2001 From: plzslp Date: Wed, 24 Jun 2026 17:44:05 +0900 Subject: [PATCH 04/41] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=BC=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EA=B4=80=EB=A0=A8=20yml=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index cc9df95a..e3b88e5f 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -40,6 +40,16 @@ spring: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.apache.kafka.common.serialization.StringSerializer + web: + resources: + static-locations: classpath:/static/, file:./uploads/ + +storage: + type: ${STORAGE_TYPE:local} + local: + base-url: http://localhost:8080 + upload-dir: ./uploads + logging: level: root: INFO From 74b4474fe9cc68eb96f65ad5d086cac0f82583bd Mon Sep 17 00:00:00 2001 From: plzslp Date: Wed, 24 Jun 2026 17:49:25 +0900 Subject: [PATCH 05/41] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=BC=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EC=83=81=ED=83=9C=20schema=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20Content=20=EC=97=94=ED=8B=B0=ED=8B=B0?= =?UTF-8?q?=EC=97=90=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entity/BinaryContentUploadStatus.java | 7 +++++++ .../content/dto/response/ContentResponse.java | 2 ++ .../team5/mopl/content/entity/Content.java | 18 ++++++++++++++++++ .../mopl/content/mapper/ContentMapper.java | 1 + .../V5__add_thumbnail_upload_status.sql | 3 +++ 5 files changed, 31 insertions(+) create mode 100644 src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContentUploadStatus.java create mode 100644 src/main/resources/db/migration/V5__add_thumbnail_upload_status.sql diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContentUploadStatus.java b/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContentUploadStatus.java new file mode 100644 index 00000000..8f0225d7 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContentUploadStatus.java @@ -0,0 +1,7 @@ +package com.codeit.team5.mopl.binarycontent.entity; + +public enum BinaryContentUploadStatus { + PENDING, + COMPLETED, + FAILED +} diff --git a/src/main/java/com/codeit/team5/mopl/content/dto/response/ContentResponse.java b/src/main/java/com/codeit/team5/mopl/content/dto/response/ContentResponse.java index e560a406..f69759bf 100644 --- a/src/main/java/com/codeit/team5/mopl/content/dto/response/ContentResponse.java +++ b/src/main/java/com/codeit/team5/mopl/content/dto/response/ContentResponse.java @@ -1,5 +1,6 @@ package com.codeit.team5.mopl.content.dto.response; +import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.content.entity.ContentType; import java.util.List; import java.util.UUID; @@ -10,6 +11,7 @@ public record ContentResponse( String title, String description, String thumbnailUrl, + BinaryContentUploadStatus thumbnailUploadStatus, List tags, double averageRating, int reviewCount, diff --git a/src/main/java/com/codeit/team5/mopl/content/entity/Content.java b/src/main/java/com/codeit/team5/mopl/content/entity/Content.java index 5a79a747..2d2a427a 100644 --- a/src/main/java/com/codeit/team5/mopl/content/entity/Content.java +++ b/src/main/java/com/codeit/team5/mopl/content/entity/Content.java @@ -1,5 +1,6 @@ package com.codeit.team5.mopl.content.entity; +import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.content.exception.InvalidContentSourceException; import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity; import com.codeit.team5.mopl.tag.entity.Tag; @@ -49,6 +50,10 @@ public class Content extends BaseUpdatableEntity { @Column(name = "thumbnail_url", length = 512) private String thumbnailUrl; + @Enumerated(EnumType.STRING) + @Column(name = "thumbnail_upload_status", length = 20) + private BinaryContentUploadStatus thumbnailUploadStatus; + @Column(name = "released_at") private Instant releasedAt; @@ -95,6 +100,19 @@ public static Content createByExternalSource(ContentType type, String title, Str return content; } + public void initThumbnail(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + this.thumbnailUploadStatus = BinaryContentUploadStatus.PENDING; + } + + public void completeThumbnailUpload() { + this.thumbnailUploadStatus = BinaryContentUploadStatus.COMPLETED; + } + + public void failThumbnailUpload() { + this.thumbnailUploadStatus = BinaryContentUploadStatus.FAILED; + } + public void addTag(Tag tag) { if (tag == null) { return; diff --git a/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java b/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java index eec4d3c2..a571831c 100644 --- a/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java +++ b/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java @@ -16,6 +16,7 @@ public interface ContentMapper { @Mapping(target = "averageRating", source = "stats") @Mapping(target = "reviewCount", source = "stats.reviewCount") @Mapping(target = "watcherCount", source = "stats.watcherCount") + @Mapping(target = "thumbnailUploadStatus", source = "content.thumbnailUploadStatus") ContentResponse toDto(Content content, List contentTags, ContentStats stats); default List toTagNames(List contentTags) { diff --git a/src/main/resources/db/migration/V5__add_thumbnail_upload_status.sql b/src/main/resources/db/migration/V5__add_thumbnail_upload_status.sql new file mode 100644 index 00000000..85d87619 --- /dev/null +++ b/src/main/resources/db/migration/V5__add_thumbnail_upload_status.sql @@ -0,0 +1,3 @@ +ALTER TABLE contents + ADD COLUMN thumbnail_upload_status VARCHAR(20) + CHECK (thumbnail_upload_status IN ('PENDING', 'COMPLETED', 'FAILED')); From c513ec360ac036f66dc97612df58365ff626e474 Mon Sep 17 00:00:00 2001 From: plzslp Date: Wed, 24 Jun 2026 17:50:00 +0900 Subject: [PATCH 06/41] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=BC=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EC=9D=B4=EB=B2=B9=ED=8A=B8=20=EB=B0=9C?= =?UTF-8?q?=ED=96=89=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/BinaryContentUploadEvent.java | 11 +++++ .../listener/BinaryContentUploadListener.java | 40 +++++++++++++++++++ .../mopl/content/service/ContentService.java | 18 ++++++++- 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/codeit/team5/mopl/binarycontent/event/BinaryContentUploadEvent.java create mode 100644 src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/event/BinaryContentUploadEvent.java b/src/main/java/com/codeit/team5/mopl/binarycontent/event/BinaryContentUploadEvent.java new file mode 100644 index 00000000..7b406b33 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/event/BinaryContentUploadEvent.java @@ -0,0 +1,11 @@ +package com.codeit.team5.mopl.binarycontent.event; + +import java.util.UUID; + +public record BinaryContentUploadEvent( + UUID contentId, + String key, + byte[] bytes +) { + +} diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java b/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java new file mode 100644 index 00000000..3476a60c --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java @@ -0,0 +1,40 @@ +package com.codeit.team5.mopl.binarycontent.listener; + +import com.codeit.team5.mopl.binarycontent.BinaryContentStorage; +import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; +import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.content.exception.ContentNotFoundException; +import com.codeit.team5.mopl.content.repository.ContentRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class BinaryContentUploadListener { + + private final BinaryContentStorage binaryContentStorage; + private final ContentRepository contentRepository; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(BinaryContentUploadEvent event) { + Content content = contentRepository.findById(event.contentId()) + .orElseThrow(ContentNotFoundException::new); + try { + binaryContentStorage.store(event.key(), event.bytes()); + content.completeThumbnailUpload(); + log.debug("파일 업로드 완료 - contentId: {}", event.contentId()); + } catch (Exception e) { + content.failThumbnailUpload(); + log.error("파일 업로드 실패 - contentId: {}", event.contentId(), e); + } finally { + contentRepository.save(content); + } + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java b/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java index 4c4c6f93..40c2766f 100644 --- a/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java +++ b/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java @@ -1,5 +1,7 @@ package com.codeit.team5.mopl.content.service; +import com.codeit.team5.mopl.binarycontent.BinaryContentStorage; +import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest; import com.codeit.team5.mopl.content.dto.response.ContentResponse; import com.codeit.team5.mopl.content.entity.Content; @@ -10,12 +12,14 @@ import com.codeit.team5.mopl.content.repository.ContentStatsRepository; import com.codeit.team5.mopl.tag.entity.Tag; import com.codeit.team5.mopl.tag.repository.TagRepository; +import java.io.IOException; import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -30,6 +34,8 @@ public class ContentService { private final ContentStatsRepository contentStatsRepository; private final TagRepository tagRepository; private final ContentMapper contentMapper; + private final BinaryContentStorage binaryContentStorage; + private final ApplicationEventPublisher eventPublisher; @Transactional public ContentResponse create(ContentCreateRequest request, MultipartFile thumbnail) { @@ -39,7 +45,16 @@ public ContentResponse create(ContentCreateRequest request, MultipartFile thumbn request.description() )); - // todo 썸네일 스토리지 업로드 후 URL 반영 + if (thumbnail != null && !thumbnail.isEmpty()) { + try { + String key = binaryContentStorage.generateKey(content.getId(), thumbnail.getOriginalFilename()); + content.initThumbnail(binaryContentStorage.toUrl(key)); + eventPublisher.publishEvent(new BinaryContentUploadEvent(content.getId(), key, thumbnail.getBytes())); + } catch (IOException e) { + log.warn("썸네일 바이트 읽기 실패 - contentId: {}", content.getId(), e); + content.failThumbnailUpload(); + } + } List tagNames = request.tags().stream() .map(String::trim) @@ -70,4 +85,5 @@ public ContentResponse create(ContentCreateRequest request, MultipartFile thumbn return contentMapper.toDto(content, content.getContentTags(), stats); } + } From 85923bc91f87c1bf9a05194a9414b799c580abcf Mon Sep 17 00:00:00 2001 From: plzslp Date: Wed, 24 Jun 2026 18:17:08 +0900 Subject: [PATCH 07/41] =?UTF-8?q?test:=20=EA=B8=B0=EC=A1=B4=20Content=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20thumbnail=5F?= =?UTF-8?q?upload=5Fstatus=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++- .../content/controller/ContentControllerTest.java | 8 ++++++-- .../mopl/content/service/ContentServiceTest.java | 11 ++++++++++- src/test/resources/application-test.yml | 6 ++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index d109d1e4..1ab1566b 100644 --- a/.gitignore +++ b/.gitignore @@ -77,4 +77,5 @@ $RECYCLE.BIN/ .logs/ ### 로컬 업로드 파일 디렉토리 ### -uploads/ \ No newline at end of file +uploads/ +uploads-test/ \ No newline at end of file diff --git a/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java b/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java index ae47dd36..9bad585f 100644 --- a/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java +++ b/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java @@ -13,6 +13,7 @@ import com.codeit.team5.mopl.config.SecurityConfig; import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest; import com.codeit.team5.mopl.content.dto.response.ContentResponse; +import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.content.entity.ContentType; import com.codeit.team5.mopl.content.service.ContentService; import com.codeit.team5.mopl.global.exception.GlobalExceptionHandler; @@ -63,7 +64,8 @@ void postContent_success() throws Exception { ContentType.MOVIE, "테스트 영화", "테스트 설명", - null, + "http://localhost:8080/thumbnails/11111111-1111-1111-1111-111111111111/test.jpg", + BinaryContentUploadStatus.PENDING, List.of("액션", "드라마"), 0.0, 0, 0 ); @@ -92,6 +94,8 @@ void postContent_success() throws Exception { .andExpect(jsonPath("$.type").value("movie")) .andExpect(jsonPath("$.title").value("테스트 영화")) .andExpect(jsonPath("$.description").value("테스트 설명")) + .andExpect(jsonPath("$.thumbnailUrl").value(response.thumbnailUrl())) + .andExpect(jsonPath("$.thumbnailUploadStatus").value("PENDING")) .andExpect(jsonPath("$.tags[0]").value("액션")) .andExpect(jsonPath("$.tags[1]").value("드라마")) .andExpect(jsonPath("$.averageRating").value(0.0)) @@ -106,7 +110,6 @@ void postContent_success() throws Exception { assertThat(captured.tags()).containsExactly("액션", "드라마"); } - //todo 현재는 썸네일 처리 로직이 없기에 썸네일이 있는 테스트 코드와 다르지 않음 @Test @DisplayName("thumbnail 없이 콘텐츠 생성 요청이면 201 응답을 반환한다") void postContent_withoutThumbnail_success() throws Exception { @@ -123,6 +126,7 @@ void postContent_withoutThumbnail_success() throws Exception { "테스트 드라마", "테스트 설명", null, + null, List.of("로맨스"), 0.0, 0, 0 ); diff --git a/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java b/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java index 48a444cc..c683ef97 100644 --- a/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java +++ b/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java @@ -10,18 +10,19 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.codeit.team5.mopl.binarycontent.BinaryContentStorage; import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest; import com.codeit.team5.mopl.content.dto.response.ContentResponse; import com.codeit.team5.mopl.content.entity.Content; import com.codeit.team5.mopl.content.entity.ContentStats; import com.codeit.team5.mopl.content.entity.ContentType; -import com.codeit.team5.mopl.content.exception.ContentException; import com.codeit.team5.mopl.content.exception.EmptyTagException; import com.codeit.team5.mopl.content.mapper.ContentMapper; import com.codeit.team5.mopl.content.repository.ContentRepository; import com.codeit.team5.mopl.content.repository.ContentStatsRepository; import com.codeit.team5.mopl.tag.entity.Tag; import com.codeit.team5.mopl.tag.repository.TagRepository; +import org.springframework.context.ApplicationEventPublisher; import java.util.List; import java.util.UUID; import org.junit.jupiter.api.DisplayName; @@ -49,6 +50,12 @@ class ContentServiceTest { @Mock private ContentMapper contentMapper; + @Mock + private BinaryContentStorage binaryContentStorage; + + @Mock + private ApplicationEventPublisher eventPublisher; + @InjectMocks private ContentService contentService; @@ -72,6 +79,7 @@ void create_success() { "테스트 영화", "테스트 설명", null, + null, List.of("액션", "드라마"), 0.0, 0, 0 ); @@ -119,6 +127,7 @@ void create_withoutThumbnail_success() { "테스트 드라마", null, null, + null, List.of("로맨스"), 0.0, 0, 0 ); diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index ca04d643..845518ea 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -30,6 +30,12 @@ spring: compose: enabled: false +storage: + type: local + local: + base-url: http://localhost:8080 + upload-dir: ./uploads-test + logging: level: root: WARN From b43a4f5b4427d572e7d6978bc67a27e00647239a Mon Sep 17 00:00:00 2001 From: plzslp Date: Wed, 24 Jun 2026 18:56:37 +0900 Subject: [PATCH 08/41] =?UTF-8?q?refactor:=20Content-ContentStats=20OneToO?= =?UTF-8?q?ne=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20contentTags=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20Set=EC=9C=BC=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 --- .../team5/mopl/content/entity/Content.java | 25 +++++++------------ .../team5/mopl/content/entity/ContentTag.java | 13 ++++++++++ .../mopl/content/mapper/ContentMapper.java | 5 ++-- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/content/entity/Content.java b/src/main/java/com/codeit/team5/mopl/content/entity/Content.java index 2d2a427a..d16647c0 100644 --- a/src/main/java/com/codeit/team5/mopl/content/entity/Content.java +++ b/src/main/java/com/codeit/team5/mopl/content/entity/Content.java @@ -11,11 +11,12 @@ import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; import java.time.Instant; -import java.util.ArrayList; -import java.util.List; +import java.util.HashSet; +import java.util.Set; import java.util.UUID; import lombok.AccessLevel; import lombok.Getter; @@ -68,8 +69,12 @@ public class Content extends BaseUpdatableEntity { @Column(name = "external_id", nullable = false, length = 100) private String externalId; + //@OneToOne은 연관 관계의 주인이 아닌 쪽에서 FetchType.LAZY가 적용되지 않음 + @OneToOne(mappedBy = "content", fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval= true) + private ContentStats stats; + @OneToMany(mappedBy = "content", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) - private List contentTags = new ArrayList<>(); + private Set contentTags = new HashSet<>(); public static Content createByAdmin(ContentType type, String title, String description) { Content content = new Content(); @@ -117,18 +122,6 @@ public void addTag(Tag tag) { if (tag == null) { return; } - - boolean alreadyExists = this.contentTags.stream() - .anyMatch(ct -> { - Tag existing = ct.getTag(); - if (existing.getId() != null && tag.getId() != null) { - return existing.getId().equals(tag.getId()); - } - return existing.getName().equals(tag.getName()); - }); - - if (!alreadyExists) { - contentTags.add(ContentTag.create(this, tag)); - } + contentTags.add(ContentTag.create(this, tag)); } } diff --git a/src/main/java/com/codeit/team5/mopl/content/entity/ContentTag.java b/src/main/java/com/codeit/team5/mopl/content/entity/ContentTag.java index 9c517f36..38c18b98 100644 --- a/src/main/java/com/codeit/team5/mopl/content/entity/ContentTag.java +++ b/src/main/java/com/codeit/team5/mopl/content/entity/ContentTag.java @@ -10,6 +10,7 @@ import jakarta.persistence.MapsId; import jakarta.persistence.Table; import java.io.Serializable; +import java.util.Objects; import java.util.UUID; import lombok.AccessLevel; import lombok.EqualsAndHashCode; @@ -43,6 +44,18 @@ public static ContentTag create(Content content, Tag tag) { return contentTag; } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ContentTag other)) return false; + return Objects.equals(id, other.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } + @Embeddable @Getter @EqualsAndHashCode diff --git a/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java b/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java index a571831c..e5ffbc94 100644 --- a/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java +++ b/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java @@ -6,6 +6,7 @@ import com.codeit.team5.mopl.content.entity.ContentTag; import java.util.Collections; import java.util.List; +import java.util.Set; import org.mapstruct.Mapper; import org.mapstruct.Mapping; @@ -17,9 +18,9 @@ public interface ContentMapper { @Mapping(target = "reviewCount", source = "stats.reviewCount") @Mapping(target = "watcherCount", source = "stats.watcherCount") @Mapping(target = "thumbnailUploadStatus", source = "content.thumbnailUploadStatus") - ContentResponse toDto(Content content, List contentTags, ContentStats stats); + ContentResponse toDto(Content content, Set contentTags, ContentStats stats); - default List toTagNames(List contentTags) { + default List toTagNames(Set contentTags) { if (contentTags == null) return Collections.emptyList(); return contentTags.stream() .map(ct -> ct.getTag().getName()) From 60b5cf43b65af06c88dcb11a6bcacffcf8812977 Mon Sep 17 00:00:00 2001 From: plzslp Date: Wed, 24 Jun 2026 18:57:17 +0900 Subject: [PATCH 09/41] =?UTF-8?q?refactor:=20N+1=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=EC=9A=A9=20EntityGraph=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team5/mopl/content/repository/ContentRepository.java | 5 +++++ .../mopl/watcher/repository/WatchingSessionRepository.java | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java b/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java index 9b70a238..89499ddf 100644 --- a/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java +++ b/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java @@ -1,8 +1,13 @@ package com.codeit.team5.mopl.content.repository; import com.codeit.team5.mopl.content.entity.Content; +import java.util.Optional; import java.util.UUID; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; public interface ContentRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"stats", "contentTags"}) + Optional findWithStatsAndTagsById(UUID id); } diff --git a/src/main/java/com/codeit/team5/mopl/watcher/repository/WatchingSessionRepository.java b/src/main/java/com/codeit/team5/mopl/watcher/repository/WatchingSessionRepository.java index b02f3ddb..e1497904 100644 --- a/src/main/java/com/codeit/team5/mopl/watcher/repository/WatchingSessionRepository.java +++ b/src/main/java/com/codeit/team5/mopl/watcher/repository/WatchingSessionRepository.java @@ -14,14 +14,14 @@ public interface WatchingSessionRepository extends JpaRepository { - @EntityGraph(attributePaths = {"user", "content"}) + @EntityGraph(attributePaths = {"user", "content", "content.stats"}) Optional findByUserId(UUID userId); @Modifying @Query("DELETE FROM WatchingSession w WHERE w.user.id = :userId") void deleteByUserIdDirectly(UUID userId); - @EntityGraph(attributePaths = {"user", "content"}) + @EntityGraph(attributePaths = {"user", "content", "content.stats"}) Window findByContentId(UUID contentId, ScrollPosition position, Limit limit, Sort sort); From ff38247f4e7f8e3dcdbb9ac5e356c8b4f51b27f5 Mon Sep 17 00:00:00 2001 From: plzslp Date: Wed, 24 Jun 2026 21:07:04 +0900 Subject: [PATCH 10/41] =?UTF-8?q?test:=20LocalBinaryContentStorage=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../local/LocalBinaryContentStorageTest.java | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 src/test/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorageTest.java diff --git a/src/test/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorageTest.java b/src/test/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorageTest.java new file mode 100644 index 00000000..e7973df7 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorageTest.java @@ -0,0 +1,119 @@ +package com.codeit.team5.mopl.binarycontent.storage.local; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.codeit.team5.mopl.binarycontent.exception.FileStorageException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class LocalBinaryContentStorageTest { + + @TempDir + Path tempDir; + + private LocalBinaryContentStorage storage; + + @BeforeEach + void setUp() { + LocalStorageProperties properties = new LocalStorageProperties( + "http://localhost:8080", + tempDir.toString() + ); + storage = new LocalBinaryContentStorage(properties); + } + + @Test + @DisplayName("generateKey는 thumbnails/{contentId}/{uuid}.{ext} 형식의 키를 반환한다") + void generateKey_returnsCorrectFormat() { + // given + UUID contentId = UUID.randomUUID(); + + // when + String key = storage.generateKey(contentId, "test.jpg"); + + // then + assertThat(key).startsWith("thumbnails/" + contentId + "/"); + assertThat(key).endsWith(".jpg"); + } + + @Test + @DisplayName("generateKey는 확장자가 없는 파일명도 처리한다") + void generateKey_noExtension_returnsKeyWithoutExtension() { + // given + UUID contentId = UUID.randomUUID(); + + // when + String key = storage.generateKey(contentId, "testfile"); + + // then + assertThat(key).startsWith("thumbnails/" + contentId + "/"); + assertThat(key).doesNotContain("."); + } + + @Test + @DisplayName("toUrl은 baseUrl과 key를 조합한 URL을 반환한다") + void toUrl_returnsCombinedUrl() { + // given + String key = "thumbnails/some-id/file.jpg"; + + // when + String url = storage.toUrl(key); + + // then + assertThat(url).isEqualTo("http://localhost:8080/" + key); + } + + @Test + @DisplayName("store는 파일을 uploadDir 하위 경로에 저장한다") + void store_savesFileToCorrectPath() throws IOException { + // given + UUID contentId = UUID.randomUUID(); + String key = storage.generateKey(contentId, "test.jpg"); + byte[] bytes = new byte[]{1, 2, 3}; + + // when + storage.store(key, bytes); + + // then + Path saved = tempDir.resolve(key); + assertThat(Files.exists(saved)).isTrue(); + assertThat(Files.readAllBytes(saved)).isEqualTo(bytes); + } + + @Test + @DisplayName("store는 중간 디렉토리가 없어도 자동으로 생성한다") + void store_createsMissingDirectories() { + // given + String key = "thumbnails/" + UUID.randomUUID() + "/nested/file.jpg"; + byte[] bytes = new byte[]{1, 2, 3}; + + // when + storage.store(key, bytes); + + // then + assertThat(Files.exists(tempDir.resolve(key))).isTrue(); + } + + @Test + @DisplayName("store 실패 시 FileStorageException을 던진다") + void store_ioFailure_throwsFileStorageException() throws IOException { + // given + // key의 중간 경로 위치에 디렉토리 대신 파일을 미리 생성해서 createDirectories 실패 유도 + Path conflictPath = tempDir.resolve("thumbnails"); + Files.createFile(conflictPath); // 디렉토리여야 할 자리에 파일 생성 + + String key = "thumbnails/test/file.jpg"; + byte[] bytes = new byte[]{1, 2, 3}; + + // when & then + assertThatThrownBy(() -> storage.store(key, bytes)) + .isInstanceOf(FileStorageException.class); + } +} From 8db33e92853036423a08b75ace4d24afd7a48f97 Mon Sep 17 00:00:00 2001 From: plzslp Date: Wed, 24 Jun 2026 21:10:47 +0900 Subject: [PATCH 11/41] =?UTF-8?q?test:=20BinaryContentUploadListener=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BinaryContentUploadListenerTest.java | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 src/test/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListenerTest.java diff --git a/src/test/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListenerTest.java b/src/test/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListenerTest.java new file mode 100644 index 00000000..d832c69d --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListenerTest.java @@ -0,0 +1,97 @@ +package com.codeit.team5.mopl.binarycontent.listener; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.codeit.team5.mopl.binarycontent.BinaryContentStorage; +import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; +import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; +import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.content.entity.ContentType; +import com.codeit.team5.mopl.content.exception.ContentNotFoundException; +import com.codeit.team5.mopl.content.repository.ContentRepository; +import java.util.Optional; +import java.util.UUID; +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; + +@ExtendWith(MockitoExtension.class) +class BinaryContentUploadListenerTest { + + @Mock + private BinaryContentStorage binaryContentStorage; + + @Mock + private ContentRepository contentRepository; + + @InjectMocks + private BinaryContentUploadListener listener; + + @Test + @DisplayName("파일 업로드 성공 시 COMPLETED 상태로 저장된다") + void handle_uploadSuccess_completesUpload() { + // given + Content content = Content.createByAdmin(ContentType.MOVIE, "테스트", null); + content.initThumbnail("http://localhost:8080/thumbnails/test.jpg"); + UUID contentId = UUID.randomUUID(); + byte[] bytes = new byte[]{1, 2, 3}; + BinaryContentUploadEvent event = new BinaryContentUploadEvent(contentId, "thumbnails/test.jpg", bytes); + + when(contentRepository.findById(contentId)).thenReturn(Optional.of(content)); + + // when + listener.handle(event); + + // then + verify(binaryContentStorage).store("thumbnails/test.jpg", bytes); + verify(contentRepository).save(content); + assertThat(content.getThumbnailUploadStatus()).isEqualTo(BinaryContentUploadStatus.COMPLETED); + } + + @Test + @DisplayName("파일 업로드 실패 시 FAILED 상태로 저장된다") + void handle_uploadFails_failsUpload() { + // given + Content content = Content.createByAdmin(ContentType.MOVIE, "테스트", null); + content.initThumbnail("http://localhost:8080/thumbnails/test.jpg"); + UUID contentId = UUID.randomUUID(); + byte[] bytes = new byte[]{1, 2, 3}; + BinaryContentUploadEvent event = new BinaryContentUploadEvent(contentId, "thumbnails/test.jpg", bytes); + + when(contentRepository.findById(contentId)).thenReturn(Optional.of(content)); + doThrow(new RuntimeException("S3 연결 실패")).when(binaryContentStorage).store(any(), any()); + + // when + listener.handle(event); + + // then + verify(contentRepository).save(content); + assertThat(content.getThumbnailUploadStatus()).isEqualTo(BinaryContentUploadStatus.FAILED); + } + + @Test + @DisplayName("Content가 존재하지 않으면 ContentNotFoundException을 던진다") + void handle_contentNotFound_throwsException() { + // given + UUID contentId = UUID.randomUUID(); + BinaryContentUploadEvent event = new BinaryContentUploadEvent(contentId, "thumbnails/test.jpg", new byte[]{}); + + when(contentRepository.findById(contentId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> listener.handle(event)) + .isInstanceOf(ContentNotFoundException.class); + + verify(binaryContentStorage, never()).store(any(), any()); + verify(contentRepository, never()).save(any()); + } +} From 8754c6e020420e5ab22054ef560c7050a6349908 Mon Sep 17 00:00:00 2001 From: plzslp Date: Wed, 24 Jun 2026 21:11:17 +0900 Subject: [PATCH 12/41] =?UTF-8?q?refactor:=20BinaryContentStorage=20genera?= =?UTF-8?q?teKey()=20default=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team5/mopl/binarycontent/BinaryContentStorage.java | 7 ++++++- .../storage/local/LocalBinaryContentStorage.java | 9 --------- .../binarycontent/storage/s3/S3BinaryContentStorage.java | 9 --------- 3 files changed, 6 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/BinaryContentStorage.java b/src/main/java/com/codeit/team5/mopl/binarycontent/BinaryContentStorage.java index 491bfdd4..0eadd8ea 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/BinaryContentStorage.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/BinaryContentStorage.java @@ -1,10 +1,15 @@ package com.codeit.team5.mopl.binarycontent; import java.util.UUID; +import org.springframework.util.StringUtils; public interface BinaryContentStorage { - String generateKey(UUID contentId, String originalFilename); + default String generateKey(UUID contentId, String originalFilename) { + String extension = StringUtils.getFilenameExtension(originalFilename); + String filename = UUID.randomUUID() + (extension != null ? "." + extension : ""); + return "thumbnails/" + contentId + "/" + filename; + } String toUrl(String key); diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java index 03f4f6ec..920ac691 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java @@ -7,11 +7,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.UUID; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; @Slf4j @Component @@ -27,13 +25,6 @@ public LocalBinaryContentStorage(LocalStorageProperties properties) { init(); } - @Override - public String generateKey(UUID contentId, String originalFilename) { - String extension = StringUtils.getFilenameExtension(originalFilename); - String filename = UUID.randomUUID() + (extension != null ? "." + extension : ""); - return "thumbnails/" + contentId + "/" + filename; - } - @Override public String toUrl(String key) { return baseUrl + "/" + key; diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3BinaryContentStorage.java b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3BinaryContentStorage.java index c49810ca..8baf7eda 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3BinaryContentStorage.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3BinaryContentStorage.java @@ -1,10 +1,8 @@ package com.codeit.team5.mopl.binarycontent.storage.s3; import com.codeit.team5.mopl.binarycontent.BinaryContentStorage; -import java.util.UUID; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; @Component @ConditionalOnProperty(name = "storage.type", havingValue = "s3") @@ -16,13 +14,6 @@ public S3BinaryContentStorage(S3StorageProperties properties) { this.properties = properties; } - @Override - public String generateKey(UUID contentId, String originalFilename) { - String extension = StringUtils.getFilenameExtension(originalFilename); - String filename = UUID.randomUUID() + (extension != null ? "." + extension : ""); - return "thumbnails/" + contentId + "/" + filename; - } - @Override public String toUrl(String key) { return "https://" + properties.bucketName() + ".s3." + properties.region() + ".amazonaws.com/" + key; From 7919c0f3c84ea4a6a5e1cd568e5d5c2a2afd684e Mon Sep 17 00:00:00 2001 From: plzslp Date: Wed, 24 Jun 2026 21:12:00 +0900 Subject: [PATCH 13/41] =?UTF-8?q?refactor:=20S3StorageProperties=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=EB=AA=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mopl/binarycontent/storage/s3/S3StorageProperties.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3StorageProperties.java b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3StorageProperties.java index dd8320fa..b316262f 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3StorageProperties.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3StorageProperties.java @@ -5,7 +5,7 @@ @ConfigurationProperties(prefix = "storage.s3") public record S3StorageProperties( String accessKey, - String privateKey, + String secretKey, String bucketName, String region ) { From aeeb9698291ed40632736bb2b3871177ab18385c Mon Sep 17 00:00:00 2001 From: plzslp Date: Wed, 24 Jun 2026 21:12:28 +0900 Subject: [PATCH 14/41] =?UTF-8?q?refactor:=20BinaryContentUploadListener?= =?UTF-8?q?=20=EB=A1=9C=EA=B9=85=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../binarycontent/listener/BinaryContentUploadListener.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java b/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java index 3476a60c..b7406b1c 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java @@ -25,7 +25,10 @@ public class BinaryContentUploadListener { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(BinaryContentUploadEvent event) { Content content = contentRepository.findById(event.contentId()) - .orElseThrow(ContentNotFoundException::new); + .orElseThrow(() -> { + log.error("파일 업로드 대상 콘텐츠를 찾을 수 없음 - contentId: {}", event.contentId()); + return new ContentNotFoundException(); + }); try { binaryContentStorage.store(event.key(), event.bytes()); content.completeThumbnailUpload(); From cf3ae15719930b03708b359193a5efaa8d61b23e Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 09:14:59 +0900 Subject: [PATCH 15/41] =?UTF-8?q?refactor:=20=ED=99=95=EC=9E=A5=EC=9E=90?= =?UTF-8?q?=20=ED=99=94=EC=9D=B4=ED=8A=B8=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=95=EA=B7=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../binarycontent/BinaryContentStorage.java | 9 +++++-- .../local/LocalBinaryContentStorageTest.java | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/BinaryContentStorage.java b/src/main/java/com/codeit/team5/mopl/binarycontent/BinaryContentStorage.java index 0eadd8ea..dea71313 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/BinaryContentStorage.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/BinaryContentStorage.java @@ -1,14 +1,19 @@ package com.codeit.team5.mopl.binarycontent; +import java.util.Set; import java.util.UUID; import org.springframework.util.StringUtils; public interface BinaryContentStorage { + Set ALLOWED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "gif", "webp"); + default String generateKey(UUID contentId, String originalFilename) { String extension = StringUtils.getFilenameExtension(originalFilename); - String filename = UUID.randomUUID() + (extension != null ? "." + extension : ""); - return "thumbnails/" + contentId + "/" + filename; + String normalizedExt = (extension != null && ALLOWED_EXTENSIONS.contains(extension.toLowerCase())) + ? "." + extension.toLowerCase() + : ""; + return "thumbnails/" + contentId + "/" + UUID.randomUUID() + normalizedExt; } String toUrl(String key); diff --git a/src/test/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorageTest.java b/src/test/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorageTest.java index e7973df7..dca1ff27 100644 --- a/src/test/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorageTest.java +++ b/src/test/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorageTest.java @@ -57,6 +57,33 @@ void generateKey_noExtension_returnsKeyWithoutExtension() { assertThat(key).doesNotContain("."); } + @Test + @DisplayName("generateKey는 허용되지 않는 확장자는 제거한다") + void generateKey_disallowedExtension_returnsKeyWithoutExtension() { + // given + UUID contentId = UUID.randomUUID(); + + // when + String key = storage.generateKey(contentId, "malicious.exe"); + + // then + assertThat(key).startsWith("thumbnails/" + contentId + "/"); + assertThat(key).doesNotContain(".exe"); + } + + @Test + @DisplayName("generateKey는 확장자를 소문자로 정규화한다") + void generateKey_uppercaseExtension_normalizesToLowercase() { + // given + UUID contentId = UUID.randomUUID(); + + // when + String key = storage.generateKey(contentId, "photo.JPG"); + + // then + assertThat(key).endsWith(".jpg"); + } + @Test @DisplayName("toUrl은 baseUrl과 key를 조합한 URL을 반환한다") void toUrl_returnsCombinedUrl() { From bb15bef4303021822341dd8d80d7fa6c3d211b6c Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 09:15:39 +0900 Subject: [PATCH 16/41] =?UTF-8?q?refactor:=20byte[]=20=EB=B0=A9=EC=96=B4?= =?UTF-8?q?=EC=A0=81=20=EB=B3=B5=EC=82=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mopl/binarycontent/event/BinaryContentUploadEvent.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/event/BinaryContentUploadEvent.java b/src/main/java/com/codeit/team5/mopl/binarycontent/event/BinaryContentUploadEvent.java index 7b406b33..7c68bcf3 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/event/BinaryContentUploadEvent.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/event/BinaryContentUploadEvent.java @@ -7,5 +7,12 @@ public record BinaryContentUploadEvent( String key, byte[] bytes ) { + public BinaryContentUploadEvent { + bytes = bytes.clone(); + } + @Override + public byte[] bytes() { + return bytes.clone(); + } } From 2a437cdbffe4b5aeedc8efc6fa8ce62b76e20dd4 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 09:16:19 +0900 Subject: [PATCH 17/41] =?UTF-8?q?refactor:=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=EC=97=90=20cause=20=EC=A0=84=EB=8B=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 --- .../exception/BinaryContentStorageException.java | 4 ++++ .../mopl/binarycontent/exception/FileStorageException.java | 4 ++++ .../binarycontent/exception/UploadDirectoryInitException.java | 4 ++++ .../storage/local/LocalBinaryContentStorage.java | 4 ++-- 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/exception/BinaryContentStorageException.java b/src/main/java/com/codeit/team5/mopl/binarycontent/exception/BinaryContentStorageException.java index 57850a48..6664606f 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/exception/BinaryContentStorageException.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/exception/BinaryContentStorageException.java @@ -9,4 +9,8 @@ public BinaryContentStorageException(HttpStatus status, String message) { super(status, message); } + public BinaryContentStorageException(HttpStatus status, String message, Throwable cause) { + super(status, message); + initCause(cause); + } } diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/exception/FileStorageException.java b/src/main/java/com/codeit/team5/mopl/binarycontent/exception/FileStorageException.java index d0b75136..1a656310 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/exception/FileStorageException.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/exception/FileStorageException.java @@ -7,4 +7,8 @@ public class FileStorageException extends BinaryContentStorageException { public FileStorageException(String key) { super(HttpStatus.INTERNAL_SERVER_ERROR, "파일 저장 실패: " + key); } + + public FileStorageException(String key, Throwable cause) { + super(HttpStatus.INTERNAL_SERVER_ERROR, "파일 저장 실패: " + key, cause); + } } diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/exception/UploadDirectoryInitException.java b/src/main/java/com/codeit/team5/mopl/binarycontent/exception/UploadDirectoryInitException.java index 43de830c..a332c316 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/exception/UploadDirectoryInitException.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/exception/UploadDirectoryInitException.java @@ -8,4 +8,8 @@ public class UploadDirectoryInitException extends BinaryContentStorageException public UploadDirectoryInitException(Path uploadDir) { super(HttpStatus.INTERNAL_SERVER_ERROR, "업로드 디렉토리 생성 실패: " + uploadDir); } + + public UploadDirectoryInitException(Path uploadDir, Throwable cause) { + super(HttpStatus.INTERNAL_SERVER_ERROR, "업로드 디렉토리 생성 실패: " + uploadDir, cause); + } } diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java index 920ac691..2fa17b3c 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java @@ -37,7 +37,7 @@ public void store(String key, byte[] bytes) { Files.createDirectories(destination.getParent()); Files.write(destination, bytes); } catch (IOException e) { - throw new FileStorageException(key); + throw new FileStorageException(key, e); } log.debug("파일 저장 완료: {}", destination); } @@ -46,7 +46,7 @@ private void init() { try { Files.createDirectories(uploadDir); } catch (IOException e) { - throw new UploadDirectoryInitException(uploadDir); + throw new UploadDirectoryInitException(uploadDir, e); } } } From b7ef07c560cfacdd0b39517536fa5009b83ba081 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 09:25:26 +0900 Subject: [PATCH 18/41] =?UTF-8?q?feat:=20ThreadPoolTaskExecutor=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20MDC,=20SecurityContext=20?= =?UTF-8?q?=EC=A0=84=ED=8C=8C=EB=A5=BC=20=EC=9C=84=ED=95=9C=20Decorator=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../listener/BinaryContentUploadListener.java | 2 +- .../codeit/team5/mopl/config/AsyncConfig.java | 21 ++++++++++++++++ .../global/async/CompositeTaskDecorator.java | 19 +++++++++++++++ .../mopl/global/async/MdcTaskDecorator.java | 24 +++++++++++++++++++ .../async/SecurityContextTaskDecorator.java | 21 ++++++++++++++++ 5 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/codeit/team5/mopl/global/async/CompositeTaskDecorator.java create mode 100644 src/main/java/com/codeit/team5/mopl/global/async/MdcTaskDecorator.java create mode 100644 src/main/java/com/codeit/team5/mopl/global/async/SecurityContextTaskDecorator.java diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java b/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java index b7406b1c..a7a11362 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java @@ -21,7 +21,7 @@ public class BinaryContentUploadListener { private final BinaryContentStorage binaryContentStorage; private final ContentRepository contentRepository; - @Async + @Async("binaryContentUploadExecutor") @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(BinaryContentUploadEvent event) { Content content = contentRepository.findById(event.contentId()) diff --git a/src/main/java/com/codeit/team5/mopl/config/AsyncConfig.java b/src/main/java/com/codeit/team5/mopl/config/AsyncConfig.java index a63cd5d9..36f2f7fb 100644 --- a/src/main/java/com/codeit/team5/mopl/config/AsyncConfig.java +++ b/src/main/java/com/codeit/team5/mopl/config/AsyncConfig.java @@ -1,10 +1,31 @@ package com.codeit.team5.mopl.config; +import com.codeit.team5.mopl.global.async.CompositeTaskDecorator; +import com.codeit.team5.mopl.global.async.MdcTaskDecorator; +import com.codeit.team5.mopl.global.async.SecurityContextTaskDecorator; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; @Configuration @EnableAsync public class AsyncConfig { + @Bean(name = "binaryContentUploadExecutor") + public Executor binaryContentUploadExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(5); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("binary-upload-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setTaskDecorator(new CompositeTaskDecorator( + List.of(new MdcTaskDecorator(), new SecurityContextTaskDecorator()))); + executor.initialize(); + return executor; + } } diff --git a/src/main/java/com/codeit/team5/mopl/global/async/CompositeTaskDecorator.java b/src/main/java/com/codeit/team5/mopl/global/async/CompositeTaskDecorator.java new file mode 100644 index 00000000..5913fc87 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/global/async/CompositeTaskDecorator.java @@ -0,0 +1,19 @@ +package com.codeit.team5.mopl.global.async; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.core.task.TaskDecorator; + +@RequiredArgsConstructor +public class CompositeTaskDecorator implements TaskDecorator { + + private final List decorators; + + @Override + public Runnable decorate(Runnable runnable) { + for (int i = decorators.size() - 1; i >= 0; i--) { + runnable = decorators.get(i).decorate(runnable); + } + return runnable; + } +} diff --git a/src/main/java/com/codeit/team5/mopl/global/async/MdcTaskDecorator.java b/src/main/java/com/codeit/team5/mopl/global/async/MdcTaskDecorator.java new file mode 100644 index 00000000..f15efdff --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/global/async/MdcTaskDecorator.java @@ -0,0 +1,24 @@ +package com.codeit.team5.mopl.global.async; + +import java.util.Map; +import org.slf4j.MDC; +import org.springframework.core.task.TaskDecorator; + +public class MdcTaskDecorator implements TaskDecorator { + + @Override + public Runnable decorate(Runnable runnable) { + Map contextMap = MDC.getCopyOfContextMap(); + + return () -> { + try { + if (contextMap != null) { + MDC.setContextMap(contextMap); + } + runnable.run(); + } finally { + MDC.clear(); + } + }; + } +} diff --git a/src/main/java/com/codeit/team5/mopl/global/async/SecurityContextTaskDecorator.java b/src/main/java/com/codeit/team5/mopl/global/async/SecurityContextTaskDecorator.java new file mode 100644 index 00000000..57d57958 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/global/async/SecurityContextTaskDecorator.java @@ -0,0 +1,21 @@ +package com.codeit.team5.mopl.global.async; + +import org.springframework.core.task.TaskDecorator; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +public class SecurityContextTaskDecorator implements TaskDecorator { + + @Override + public Runnable decorate(Runnable runnable) { + SecurityContext context = SecurityContextHolder.getContext(); + return () -> { + try { + SecurityContextHolder.setContext(context); + runnable.run(); + } finally { + SecurityContextHolder.clearContext(); + } + }; + } +} From e91f8aa9a86e79ffcc065bd7b4198e9fd6ab9184 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 09:36:24 +0900 Subject: [PATCH 19/41] =?UTF-8?q?refactor:=20ContentMapper=EC=97=90=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/codeit/team5/mopl/content/mapper/ContentMapper.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java b/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java index e5ffbc94..675a0ea2 100644 --- a/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java +++ b/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java @@ -24,6 +24,7 @@ default List toTagNames(Set contentTags) { if (contentTags == null) return Collections.emptyList(); return contentTags.stream() .map(ct -> ct.getTag().getName()) + .sorted() .toList(); } From 5784e61b54d00169f62523618f8899bb9495252c Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 09:37:09 +0900 Subject: [PATCH 20/41] =?UTF-8?q?refactor:=20ContentTag=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=EC=97=90=20ContentTagId=EA=B0=92=EC=9D=B4?= =?UTF-8?q?=20=EB=B0=94=EB=A1=9C=20=ED=95=A0=EB=8B=B9=EB=90=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/codeit/team5/mopl/content/entity/ContentTag.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/codeit/team5/mopl/content/entity/ContentTag.java b/src/main/java/com/codeit/team5/mopl/content/entity/ContentTag.java index 38c18b98..cc1785e9 100644 --- a/src/main/java/com/codeit/team5/mopl/content/entity/ContentTag.java +++ b/src/main/java/com/codeit/team5/mopl/content/entity/ContentTag.java @@ -38,7 +38,7 @@ public class ContentTag { public static ContentTag create(Content content, Tag tag) { ContentTag contentTag = new ContentTag(); - contentTag.id = new ContentTagId(); + contentTag.id = new ContentTagId(content.getId(), tag.getId()); contentTag.content = content; contentTag.tag = tag; return contentTag; From d0f9a160edc7fa08aeff0ef87d46090d62f03a62 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 09:46:42 +0900 Subject: [PATCH 21/41] =?UTF-8?q?refactor:=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC=20=EC=A4=91=20=EC=83=9D=EC=84=B1=EC=9E=90=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EC=8B=9C=20=EC=98=88=EC=99=B8=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=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 --- .../local/LocalBinaryContentStorageTest.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/test/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorageTest.java b/src/test/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorageTest.java index dca1ff27..9a2ccc56 100644 --- a/src/test/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorageTest.java +++ b/src/test/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorageTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.codeit.team5.mopl.binarycontent.exception.FileStorageException; +import com.codeit.team5.mopl.binarycontent.exception.UploadDirectoryInitException; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -128,6 +129,23 @@ void store_createsMissingDirectories() { assertThat(Files.exists(tempDir.resolve(key))).isTrue(); } + @Test + @DisplayName("uploadDir 자리에 파일이 존재하면 생성자에서 UploadDirectoryInitException을 던진다") + void init_uploadDirIsFile_throwsUploadDirectoryInitException() throws IOException { + // given + Path filePath = tempDir.resolve("conflict-file"); + Files.createFile(filePath); + + LocalStorageProperties properties = new LocalStorageProperties( + "http://localhost:8080", + filePath.toString() + ); + + // when & then + assertThatThrownBy(() -> new LocalBinaryContentStorage(properties)) + .isInstanceOf(UploadDirectoryInitException.class); + } + @Test @DisplayName("store 실패 시 FileStorageException을 던진다") void store_ioFailure_throwsFileStorageException() throws IOException { From 7acd87ba928ad213fa645cbec2886c1a38d513c6 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 09:47:05 +0900 Subject: [PATCH 22/41] =?UTF-8?q?refactor:=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=BB=A8=ED=85=90=EC=B8=A0=20=EC=83=9D=EC=84=B1=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mopl/content/service/ContentServiceTest.java | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java b/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java index c683ef97..0c903105 100644 --- a/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java +++ b/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java @@ -5,12 +5,15 @@ import static org.mockito.AdditionalAnswers.returnsFirstArg; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import com.codeit.team5.mopl.binarycontent.BinaryContentStorage; +import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; +import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest; import com.codeit.team5.mopl.content.dto.response.ContentResponse; import com.codeit.team5.mopl.content.entity.Content; @@ -88,6 +91,10 @@ void create_success() { when(tagRepository.findByNameIn(List.of("액션", "드라마"))).thenReturn(List.of(actionTag)); when(tagRepository.saveAll(anyList())).thenReturn(List.of(dramaTag)); when(contentStatsRepository.save(any(ContentStats.class))).then(returnsFirstArg()); + when(binaryContentStorage.generateKey(any(), eq("test.jpg"))) + .thenReturn("thumbnails/test-content/test.jpg"); + when(binaryContentStorage.toUrl("thumbnails/test-content/test.jpg")) + .thenReturn("http://localhost:8080/thumbnails/test-content/test.jpg"); when(contentMapper.toDto(any(Content.class), any(), any(ContentStats.class))).thenReturn(expectedResponse); // When @@ -102,6 +109,11 @@ void create_success() { assertThat(savedContent.getType()).isEqualTo(ContentType.MOVIE); assertThat(savedContent.getTitle()).isEqualTo("테스트 영화"); assertThat(savedContent.getDescription()).isEqualTo("테스트 설명"); + assertThat(savedContent.getThumbnailUploadStatus()).isEqualTo(BinaryContentUploadStatus.PENDING); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(BinaryContentUploadEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue().key()).isEqualTo("thumbnails/test-content/test.jpg"); verify(tagRepository).findByNameIn(List.of("액션", "드라마")); ArgumentCaptor> saveAllCaptor = ArgumentCaptor.forClass(List.class); @@ -144,6 +156,7 @@ void create_withoutThumbnail_success() { assertThat(result).isSameAs(expectedResponse); verify(contentRepository).save(any(Content.class)); verify(contentStatsRepository).save(any(ContentStats.class)); + verifyNoInteractions(binaryContentStorage, eventPublisher); } @Test From f1201f04a53f22b5ad8a473e9ea626d37401cfd5 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 10:14:35 +0900 Subject: [PATCH 23/41] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../listener/BinaryContentUploadListener.java | 16 +++---- .../codeit/team5/mopl/config/AsyncConfig.java | 2 - .../content/repository/ContentRepository.java | 8 ++++ .../async/SecurityContextTaskDecorator.java | 13 +----- .../BinaryContentUploadListenerTest.java | 45 +++---------------- 5 files changed, 22 insertions(+), 62 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java b/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java index a7a11362..6fe1d4ba 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java @@ -1,14 +1,14 @@ package com.codeit.team5.mopl.binarycontent.listener; import com.codeit.team5.mopl.binarycontent.BinaryContentStorage; +import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; -import com.codeit.team5.mopl.content.entity.Content; -import com.codeit.team5.mopl.content.exception.ContentNotFoundException; import com.codeit.team5.mopl.content.repository.ContentRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; @@ -22,22 +22,16 @@ public class BinaryContentUploadListener { private final ContentRepository contentRepository; @Async("binaryContentUploadExecutor") + @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(BinaryContentUploadEvent event) { - Content content = contentRepository.findById(event.contentId()) - .orElseThrow(() -> { - log.error("파일 업로드 대상 콘텐츠를 찾을 수 없음 - contentId: {}", event.contentId()); - return new ContentNotFoundException(); - }); try { binaryContentStorage.store(event.key(), event.bytes()); - content.completeThumbnailUpload(); + contentRepository.updateThumbnailUploadStatus(event.contentId(), BinaryContentUploadStatus.COMPLETED); log.debug("파일 업로드 완료 - contentId: {}", event.contentId()); } catch (Exception e) { - content.failThumbnailUpload(); + contentRepository.updateThumbnailUploadStatus(event.contentId(), BinaryContentUploadStatus.FAILED); log.error("파일 업로드 실패 - contentId: {}", event.contentId(), e); - } finally { - contentRepository.save(content); } } } diff --git a/src/main/java/com/codeit/team5/mopl/config/AsyncConfig.java b/src/main/java/com/codeit/team5/mopl/config/AsyncConfig.java index 36f2f7fb..a74ea78e 100644 --- a/src/main/java/com/codeit/team5/mopl/config/AsyncConfig.java +++ b/src/main/java/com/codeit/team5/mopl/config/AsyncConfig.java @@ -5,7 +5,6 @@ import com.codeit.team5.mopl.global.async.SecurityContextTaskDecorator; import java.util.List; import java.util.concurrent.Executor; -import java.util.concurrent.ThreadPoolExecutor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableAsync; @@ -22,7 +21,6 @@ public Executor binaryContentUploadExecutor() { executor.setMaxPoolSize(5); executor.setQueueCapacity(100); executor.setThreadNamePrefix("binary-upload-"); - executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.setTaskDecorator(new CompositeTaskDecorator( List.of(new MdcTaskDecorator(), new SecurityContextTaskDecorator()))); executor.initialize(); diff --git a/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java b/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java index 89499ddf..aa04328d 100644 --- a/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java +++ b/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java @@ -1,13 +1,21 @@ package com.codeit.team5.mopl.content.repository; +import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.content.entity.Content; import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.EntityGraph; 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; public interface ContentRepository extends JpaRepository { @EntityGraph(attributePaths = {"stats", "contentTags"}) Optional findWithStatsAndTagsById(UUID id); + + @Modifying + @Query("UPDATE Content c SET c.thumbnailUploadStatus = :status WHERE c.id = :id") + void updateThumbnailUploadStatus(@Param("id") UUID id, @Param("status") BinaryContentUploadStatus status); } diff --git a/src/main/java/com/codeit/team5/mopl/global/async/SecurityContextTaskDecorator.java b/src/main/java/com/codeit/team5/mopl/global/async/SecurityContextTaskDecorator.java index 57d57958..544312da 100644 --- a/src/main/java/com/codeit/team5/mopl/global/async/SecurityContextTaskDecorator.java +++ b/src/main/java/com/codeit/team5/mopl/global/async/SecurityContextTaskDecorator.java @@ -1,21 +1,12 @@ package com.codeit.team5.mopl.global.async; import org.springframework.core.task.TaskDecorator; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.concurrent.DelegatingSecurityContextRunnable; public class SecurityContextTaskDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable runnable) { - SecurityContext context = SecurityContextHolder.getContext(); - return () -> { - try { - SecurityContextHolder.setContext(context); - runnable.run(); - } finally { - SecurityContextHolder.clearContext(); - } - }; + return new DelegatingSecurityContextRunnable(runnable); } } diff --git a/src/test/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListenerTest.java b/src/test/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListenerTest.java index d832c69d..519f3442 100644 --- a/src/test/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListenerTest.java +++ b/src/test/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListenerTest.java @@ -1,21 +1,15 @@ package com.codeit.team5.mopl.binarycontent.listener; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; import com.codeit.team5.mopl.binarycontent.BinaryContentStorage; import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; -import com.codeit.team5.mopl.content.entity.Content; -import com.codeit.team5.mopl.content.entity.ContentType; -import com.codeit.team5.mopl.content.exception.ContentNotFoundException; import com.codeit.team5.mopl.content.repository.ContentRepository; -import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -37,61 +31,36 @@ class BinaryContentUploadListenerTest { private BinaryContentUploadListener listener; @Test - @DisplayName("파일 업로드 성공 시 COMPLETED 상태로 저장된다") + @DisplayName("파일 업로드 성공 시 COMPLETED 상태로 업데이트된다") void handle_uploadSuccess_completesUpload() { // given - Content content = Content.createByAdmin(ContentType.MOVIE, "테스트", null); - content.initThumbnail("http://localhost:8080/thumbnails/test.jpg"); UUID contentId = UUID.randomUUID(); byte[] bytes = new byte[]{1, 2, 3}; BinaryContentUploadEvent event = new BinaryContentUploadEvent(contentId, "thumbnails/test.jpg", bytes); - when(contentRepository.findById(contentId)).thenReturn(Optional.of(content)); - // when listener.handle(event); // then - verify(binaryContentStorage).store("thumbnails/test.jpg", bytes); - verify(contentRepository).save(content); - assertThat(content.getThumbnailUploadStatus()).isEqualTo(BinaryContentUploadStatus.COMPLETED); + verify(binaryContentStorage).store("thumbnails/test.jpg", event.bytes()); + verify(contentRepository).updateThumbnailUploadStatus(contentId, BinaryContentUploadStatus.COMPLETED); } @Test - @DisplayName("파일 업로드 실패 시 FAILED 상태로 저장된다") + @DisplayName("파일 업로드 실패 시 FAILED 상태로 업데이트된다") void handle_uploadFails_failsUpload() { // given - Content content = Content.createByAdmin(ContentType.MOVIE, "테스트", null); - content.initThumbnail("http://localhost:8080/thumbnails/test.jpg"); UUID contentId = UUID.randomUUID(); byte[] bytes = new byte[]{1, 2, 3}; BinaryContentUploadEvent event = new BinaryContentUploadEvent(contentId, "thumbnails/test.jpg", bytes); - when(contentRepository.findById(contentId)).thenReturn(Optional.of(content)); doThrow(new RuntimeException("S3 연결 실패")).when(binaryContentStorage).store(any(), any()); // when listener.handle(event); // then - verify(contentRepository).save(content); - assertThat(content.getThumbnailUploadStatus()).isEqualTo(BinaryContentUploadStatus.FAILED); - } - - @Test - @DisplayName("Content가 존재하지 않으면 ContentNotFoundException을 던진다") - void handle_contentNotFound_throwsException() { - // given - UUID contentId = UUID.randomUUID(); - BinaryContentUploadEvent event = new BinaryContentUploadEvent(contentId, "thumbnails/test.jpg", new byte[]{}); - - when(contentRepository.findById(contentId)).thenReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> listener.handle(event)) - .isInstanceOf(ContentNotFoundException.class); - - verify(binaryContentStorage, never()).store(any(), any()); - verify(contentRepository, never()).save(any()); + verify(contentRepository).updateThumbnailUploadStatus(contentId, BinaryContentUploadStatus.FAILED); + verify(contentRepository, never()).updateThumbnailUploadStatus(eq(contentId), eq(BinaryContentUploadStatus.COMPLETED)); } } From 412c498203ebe277be85da9d61e4fcdad39d3d28 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 10:42:42 +0900 Subject: [PATCH 24/41] =?UTF-8?q?refactor:=20=ED=85=8C=EB=9D=BC=ED=8F=BC?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EA=B8=B0=EB=B0=98=20application.yml=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # src/main/resources/application-prod.yml --- .../storage/local/LocalBinaryContentStorage.java | 2 +- .../storage/local/LocalStorageProperties.java | 2 +- .../storage/s3/S3BinaryContentStorage.java | 2 +- .../binarycontent/storage/s3/S3StorageProperties.java | 2 +- src/main/resources/application-dev.yml | 11 ++++++----- src/test/resources/application-test.yml | 11 ++++++----- 6 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java index 2fa17b3c..669e8849 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java @@ -13,7 +13,7 @@ @Slf4j @Component -@ConditionalOnProperty(name = "storage.type", havingValue = "local", matchIfMissing = true) +@ConditionalOnProperty(name = "mopl.storage.type", havingValue = "memory", matchIfMissing = true) public class LocalBinaryContentStorage implements BinaryContentStorage { private final Path uploadDir; diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalStorageProperties.java b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalStorageProperties.java index 09c5e417..7598370b 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalStorageProperties.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalStorageProperties.java @@ -2,7 +2,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties; -@ConfigurationProperties(prefix = "storage.local") +@ConfigurationProperties(prefix = "mopl.storage.local") public record LocalStorageProperties( String baseUrl, String uploadDir diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3BinaryContentStorage.java b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3BinaryContentStorage.java index 8baf7eda..ee7e30fb 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3BinaryContentStorage.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3BinaryContentStorage.java @@ -5,7 +5,7 @@ import org.springframework.stereotype.Component; @Component -@ConditionalOnProperty(name = "storage.type", havingValue = "s3") +@ConditionalOnProperty(name = "mopl.storage.type", havingValue = "s3") public class S3BinaryContentStorage implements BinaryContentStorage { private final S3StorageProperties properties; diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3StorageProperties.java b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3StorageProperties.java index b316262f..9af81795 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3StorageProperties.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3StorageProperties.java @@ -2,7 +2,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties; -@ConfigurationProperties(prefix = "storage.s3") +@ConfigurationProperties(prefix = "mopl.storage.s3") public record S3StorageProperties( String accessKey, String secretKey, diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index e3b88e5f..6dd6b3e6 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -44,11 +44,12 @@ spring: resources: static-locations: classpath:/static/, file:./uploads/ -storage: - type: ${STORAGE_TYPE:local} - local: - base-url: http://localhost:8080 - upload-dir: ./uploads +mopl: + storage: + type: ${STORAGE_TYPE:memory} + local: + base-url: http://localhost:8080 + upload-dir: ./uploads logging: level: diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 845518ea..14aa6aa8 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -30,11 +30,12 @@ spring: compose: enabled: false -storage: - type: local - local: - base-url: http://localhost:8080 - upload-dir: ./uploads-test +mopl: + storage: + type: memory + local: + base-url: http://localhost:8080 + upload-dir: ./uploads-test logging: level: From ead4322c53db16010209df053af854bdd43d4bd7 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 11:21:32 +0900 Subject: [PATCH 25/41] =?UTF-8?q?refactor:=20BinaryContent=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20DB=20sche?= =?UTF-8?q?ma=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../binarycontent/entity/BinaryContent.java | 40 +++++++++++++++++++ .../repository/BinaryContentRepository.java | 8 ++++ .../db/migration/V6__add_binary_contents.sql | 17 ++++++++ 3 files changed, 65 insertions(+) create mode 100644 src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContent.java create mode 100644 src/main/java/com/codeit/team5/mopl/binarycontent/repository/BinaryContentRepository.java create mode 100644 src/main/resources/db/migration/V6__add_binary_contents.sql diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContent.java b/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContent.java new file mode 100644 index 00000000..7cd1061b --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContent.java @@ -0,0 +1,40 @@ +package com.codeit.team5.mopl.binarycontent.entity; + +import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "binary_contents") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BinaryContent extends BaseUpdatableEntity { + + @Column(nullable = false, length = 512) + private String url; + + @Enumerated(EnumType.STRING) + @Column(name = "upload_status", nullable = false, length = 20) + private BinaryContentUploadStatus uploadStatus; + + public static BinaryContent pending(String url) { + BinaryContent bc = new BinaryContent(); + bc.url = url; + bc.uploadStatus = BinaryContentUploadStatus.PENDING; + return bc; + } + + public void completeUpload() { + this.uploadStatus = BinaryContentUploadStatus.COMPLETED; + } + + public void failUpload() { + this.uploadStatus = BinaryContentUploadStatus.FAILED; + } +} diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/repository/BinaryContentRepository.java b/src/main/java/com/codeit/team5/mopl/binarycontent/repository/BinaryContentRepository.java new file mode 100644 index 00000000..3365079b --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/repository/BinaryContentRepository.java @@ -0,0 +1,8 @@ +package com.codeit.team5.mopl.binarycontent.repository; + +import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BinaryContentRepository extends JpaRepository { +} diff --git a/src/main/resources/db/migration/V6__add_binary_contents.sql b/src/main/resources/db/migration/V6__add_binary_contents.sql new file mode 100644 index 00000000..ed9c3bc8 --- /dev/null +++ b/src/main/resources/db/migration/V6__add_binary_contents.sql @@ -0,0 +1,17 @@ +CREATE TABLE binary_contents +( + id UUID PRIMARY KEY, + url VARCHAR(512) NOT NULL, + upload_status VARCHAR(20) NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + CONSTRAINT ck_binary_contents_upload_status CHECK (upload_status IN ('PENDING', 'COMPLETED', 'FAILED')) +); + +ALTER TABLE contents + ADD COLUMN thumbnail_id UUID + CONSTRAINT fk_contents_thumbnail REFERENCES binary_contents (id) ON DELETE SET NULL; + +ALTER TABLE contents + DROP COLUMN thumbnail_url, + DROP COLUMN thumbnail_upload_status; From 7d4a49ade99ebc3fe9a1636dc17c645d80e61161 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 11:30:02 +0900 Subject: [PATCH 26/41] =?UTF-8?q?refactor:=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20BinaryContent=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=82=AC=EC=9A=A9=20=EB=A1=9C=EC=A7=81=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=A6=AC=ED=8E=99=ED=86=A0=EB=A7=81=20-=20Content:?= =?UTF-8?q?=20BinaryContent=20=EC=97=B0=EA=B4=80=EA=B4=80=EA=B3=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20Mapper,=20Repository:=20BinaryContent?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=EC=88=98=EC=A0=95=20-=20BinaryContentU?= =?UTF-8?q?ploadEvent:=20binaryContentId=EB=A1=9C=20=EB=B3=80=EA=B2=BD,=20?= =?UTF-8?q?=EB=B0=A9=EC=96=B4=EC=A0=81=20=EB=B3=B5=EC=82=AC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20-=20BinaryContentUploadListener:=20BinaryContent=20?= =?UTF-8?q?=EA=B0=9D=EC=B2=B4=EC=9D=98=20=EC=83=81=ED=83=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=9C=BC=EB=A1=9C=20=EB=A6=AC=ED=8E=99=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/BinaryContentUploadEvent.java | 10 +------ .../listener/BinaryContentUploadListener.java | 16 +++++----- .../team5/mopl/content/entity/Content.java | 29 +++++++------------ .../mopl/content/mapper/ContentMapper.java | 3 +- .../content/repository/ContentRepository.java | 10 +------ .../mopl/content/service/ContentService.java | 13 ++++++--- 6 files changed, 33 insertions(+), 48 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/event/BinaryContentUploadEvent.java b/src/main/java/com/codeit/team5/mopl/binarycontent/event/BinaryContentUploadEvent.java index 7c68bcf3..c9f56729 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/event/BinaryContentUploadEvent.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/event/BinaryContentUploadEvent.java @@ -3,16 +3,8 @@ import java.util.UUID; public record BinaryContentUploadEvent( - UUID contentId, + UUID binaryContentId, String key, byte[] bytes ) { - public BinaryContentUploadEvent { - bytes = bytes.clone(); - } - - @Override - public byte[] bytes() { - return bytes.clone(); - } } diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java b/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java index 6fe1d4ba..974fc99e 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java @@ -1,9 +1,9 @@ package com.codeit.team5.mopl.binarycontent.listener; import com.codeit.team5.mopl.binarycontent.BinaryContentStorage; -import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; +import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; -import com.codeit.team5.mopl.content.repository.ContentRepository; +import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; @@ -19,19 +19,21 @@ public class BinaryContentUploadListener { private final BinaryContentStorage binaryContentStorage; - private final ContentRepository contentRepository; + private final BinaryContentRepository binaryContentRepository; @Async("binaryContentUploadExecutor") @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(BinaryContentUploadEvent event) { + BinaryContent binaryContent = binaryContentRepository.findById(event.binaryContentId()) + .orElseThrow(() -> new IllegalStateException("BinaryContent를 찾을 수 없습니다: " + event.binaryContentId())); try { binaryContentStorage.store(event.key(), event.bytes()); - contentRepository.updateThumbnailUploadStatus(event.contentId(), BinaryContentUploadStatus.COMPLETED); - log.debug("파일 업로드 완료 - contentId: {}", event.contentId()); + binaryContent.completeUpload(); + log.debug("파일 업로드 완료 - binaryContentId: {}", event.binaryContentId()); } catch (Exception e) { - contentRepository.updateThumbnailUploadStatus(event.contentId(), BinaryContentUploadStatus.FAILED); - log.error("파일 업로드 실패 - contentId: {}", event.contentId(), e); + binaryContent.failUpload(); + log.error("파일 업로드 실패 - binaryContentId: {}", event.binaryContentId(), e); } } } diff --git a/src/main/java/com/codeit/team5/mopl/content/entity/Content.java b/src/main/java/com/codeit/team5/mopl/content/entity/Content.java index d16647c0..a2c4b5fb 100644 --- a/src/main/java/com/codeit/team5/mopl/content/entity/Content.java +++ b/src/main/java/com/codeit/team5/mopl/content/entity/Content.java @@ -1,6 +1,6 @@ package com.codeit.team5.mopl.content.entity; -import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; +import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; import com.codeit.team5.mopl.content.exception.InvalidContentSourceException; import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity; import com.codeit.team5.mopl.tag.entity.Tag; @@ -10,6 +10,7 @@ import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; import jakarta.persistence.OneToMany; import jakarta.persistence.OneToOne; import jakarta.persistence.Table; @@ -48,12 +49,9 @@ public class Content extends BaseUpdatableEntity { @Column(columnDefinition = "TEXT") private String description; - @Column(name = "thumbnail_url", length = 512) - private String thumbnailUrl; - - @Enumerated(EnumType.STRING) - @Column(name = "thumbnail_upload_status", length = 20) - private BinaryContentUploadStatus thumbnailUploadStatus; + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "thumbnail_id") + private BinaryContent thumbnail; @Column(name = "released_at") private Instant releasedAt; @@ -69,8 +67,8 @@ public class Content extends BaseUpdatableEntity { @Column(name = "external_id", nullable = false, length = 100) private String externalId; - //@OneToOne은 연관 관계의 주인이 아닌 쪽에서 FetchType.LAZY가 적용되지 않음 - @OneToOne(mappedBy = "content", fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval= true) + @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "stats_id") private ContentStats stats; @OneToMany(mappedBy = "content", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) @@ -105,17 +103,12 @@ public static Content createByExternalSource(ContentType type, String title, Str return content; } - public void initThumbnail(String thumbnailUrl) { - this.thumbnailUrl = thumbnailUrl; - this.thumbnailUploadStatus = BinaryContentUploadStatus.PENDING; - } - - public void completeThumbnailUpload() { - this.thumbnailUploadStatus = BinaryContentUploadStatus.COMPLETED; + public void attachThumbnail(BinaryContent thumbnail) { + this.thumbnail = thumbnail; } - public void failThumbnailUpload() { - this.thumbnailUploadStatus = BinaryContentUploadStatus.FAILED; + public void attachStats(ContentStats stats) { + this.stats = stats; } public void addTag(Tag tag) { diff --git a/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java b/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java index 675a0ea2..dfbaa5d7 100644 --- a/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java +++ b/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java @@ -17,7 +17,8 @@ public interface ContentMapper { @Mapping(target = "averageRating", source = "stats") @Mapping(target = "reviewCount", source = "stats.reviewCount") @Mapping(target = "watcherCount", source = "stats.watcherCount") - @Mapping(target = "thumbnailUploadStatus", source = "content.thumbnailUploadStatus") + @Mapping(target = "thumbnailUrl", source = "content.thumbnail.url") + @Mapping(target = "thumbnailUploadStatus", source = "content.thumbnail.uploadStatus") ContentResponse toDto(Content content, Set contentTags, ContentStats stats); default List toTagNames(Set contentTags) { diff --git a/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java b/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java index aa04328d..1cac1a65 100644 --- a/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java +++ b/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java @@ -1,21 +1,13 @@ package com.codeit.team5.mopl.content.repository; -import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.content.entity.Content; import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.EntityGraph; 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; public interface ContentRepository extends JpaRepository { - @EntityGraph(attributePaths = {"stats", "contentTags"}) + @EntityGraph(attributePaths = {"thumbnail", "stats", "contentTags"}) Optional findWithStatsAndTagsById(UUID id); - - @Modifying - @Query("UPDATE Content c SET c.thumbnailUploadStatus = :status WHERE c.id = :id") - void updateThumbnailUploadStatus(@Param("id") UUID id, @Param("status") BinaryContentUploadStatus status); } diff --git a/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java b/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java index 40c2766f..33990f49 100644 --- a/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java +++ b/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java @@ -1,7 +1,9 @@ package com.codeit.team5.mopl.content.service; import com.codeit.team5.mopl.binarycontent.BinaryContentStorage; +import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; +import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest; import com.codeit.team5.mopl.content.dto.response.ContentResponse; import com.codeit.team5.mopl.content.entity.Content; @@ -35,6 +37,7 @@ public class ContentService { private final TagRepository tagRepository; private final ContentMapper contentMapper; private final BinaryContentStorage binaryContentStorage; + private final BinaryContentRepository binaryContentRepository; private final ApplicationEventPublisher eventPublisher; @Transactional @@ -48,11 +51,12 @@ public ContentResponse create(ContentCreateRequest request, MultipartFile thumbn if (thumbnail != null && !thumbnail.isEmpty()) { try { String key = binaryContentStorage.generateKey(content.getId(), thumbnail.getOriginalFilename()); - content.initThumbnail(binaryContentStorage.toUrl(key)); - eventPublisher.publishEvent(new BinaryContentUploadEvent(content.getId(), key, thumbnail.getBytes())); + BinaryContent binaryContent = binaryContentRepository.save( + BinaryContent.pending(binaryContentStorage.toUrl(key))); + content.attachThumbnail(binaryContent); + eventPublisher.publishEvent(new BinaryContentUploadEvent(binaryContent.getId(), key, thumbnail.getBytes())); } catch (IOException e) { log.warn("썸네일 바이트 읽기 실패 - contentId: {}", content.getId(), e); - content.failThumbnailUpload(); } } @@ -81,7 +85,8 @@ public ContentResponse create(ContentCreateRequest request, MultipartFile thumbn tagNames.forEach(name -> content.addTag(existingTags.get(name))); - ContentStats stats = contentStatsRepository.save(ContentStats.create(content)); + ContentStats stats = contentStatsRepository.save(ContentStats.create()); + content.attachStats(stats); return contentMapper.toDto(content, content.getContentTags(), stats); } From a2eb9f887b70c8536b68061dc84235c76a76b64d Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 11:30:42 +0900 Subject: [PATCH 27/41] =?UTF-8?q?refactor:=20ContentStats=20=EC=97=B0?= =?UTF-8?q?=EA=B4=80=EA=B4=80=EA=B3=84=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?BaseUpdatableEntity=EB=A5=BC=20=EC=83=81=EC=86=8D=EB=B0=9B?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mopl/content/entity/ContentStats.java | 31 ++----------------- .../V7__restructure_content_stats.sql | 18 +++++++++++ 2 files changed, 21 insertions(+), 28 deletions(-) create mode 100644 src/main/resources/db/migration/V7__restructure_content_stats.sql diff --git a/src/main/java/com/codeit/team5/mopl/content/entity/ContentStats.java b/src/main/java/com/codeit/team5/mopl/content/entity/ContentStats.java index 0ea24d58..e1a472cb 100644 --- a/src/main/java/com/codeit/team5/mopl/content/entity/ContentStats.java +++ b/src/main/java/com/codeit/team5/mopl/content/entity/ContentStats.java @@ -1,16 +1,9 @@ package com.codeit.team5.mopl.content.entity; +import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.MapsId; -import jakarta.persistence.OneToOne; -import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; -import java.time.Instant; -import java.util.UUID; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -19,15 +12,7 @@ @Entity @Table(name = "content_stats") @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class ContentStats { - - @Id - private UUID contentId; - - @MapsId - @OneToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "content_id") - private Content content; +public class ContentStats extends BaseUpdatableEntity { @Column(nullable = false) private int reviewCount; @@ -38,21 +23,11 @@ public class ContentStats { @Column(nullable = false) private long watcherCount; - @Column(nullable = false) - private Instant updatedAt; - - @PreUpdate - protected void onUpdate() { - this.updatedAt = Instant.now(); - } - - public static ContentStats create(Content content) { + public static ContentStats create() { ContentStats stats = new ContentStats(); - stats.content = content; stats.reviewCount = 0; stats.ratingSum = 0.0; stats.watcherCount = 0; - stats.updatedAt = Instant.now(); return stats; } } diff --git a/src/main/resources/db/migration/V7__restructure_content_stats.sql b/src/main/resources/db/migration/V7__restructure_content_stats.sql new file mode 100644 index 00000000..fdbadfbc --- /dev/null +++ b/src/main/resources/db/migration/V7__restructure_content_stats.sql @@ -0,0 +1,18 @@ +ALTER TABLE contents + DROP COLUMN IF EXISTS stats_id; + +DROP TABLE IF EXISTS content_stats; + +CREATE TABLE content_stats +( + id UUID PRIMARY KEY, + review_count INTEGER NOT NULL DEFAULT 0, + rating_sum DOUBLE PRECISION NOT NULL DEFAULT 0, + watcher_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +ALTER TABLE contents + ADD COLUMN stats_id UUID + CONSTRAINT fk_contents_stats REFERENCES content_stats (id) ON DELETE SET NULL; From cb534523ecd5d79b49eb825a31c6506fcd6c00d1 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 12:51:30 +0900 Subject: [PATCH 28/41] =?UTF-8?q?refactor:=20Content=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EB=A6=AC=ED=8E=99=ED=86=A0=EB=A7=81=20-=20ContentMapper:=20?= =?UTF-8?q?id=20source=20=EB=AA=85=EC=8B=9C=EC=A0=81=20=EC=A7=80=EC=A0=95?= =?UTF-8?q?=20-=20ContentService:=20tag=20=EA=B4=80=EB=A0=A8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20private=EB=A1=9C=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mopl/content/mapper/ContentMapper.java | 1 + .../mopl/content/service/ContentService.java | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java b/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java index dfbaa5d7..01df24fd 100644 --- a/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java +++ b/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java @@ -13,6 +13,7 @@ @Mapper(componentModel = "spring") public interface ContentMapper { + @Mapping(target = "id", source = "content.id") @Mapping(target = "tags", source = "contentTags") @Mapping(target = "averageRating", source = "stats") @Mapping(target = "reviewCount", source = "stats.reviewCount") diff --git a/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java b/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java index 33990f49..5d7fa271 100644 --- a/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java +++ b/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java @@ -60,9 +60,18 @@ public ContentResponse create(ContentCreateRequest request, MultipartFile thumbn } } - List tagNames = request.tags().stream() + attachTags(content, request.tags()); + + ContentStats stats = contentStatsRepository.save(ContentStats.create()); + content.attachStats(stats); + + return contentMapper.toDto(content, content.getContentTags(), stats); + } + + private void attachTags(Content content, List rawTagNames) { + List tagNames = rawTagNames.stream() .map(String::trim) - .filter(tag -> !tag.isEmpty()) + .filter(name -> !name.isEmpty()) .map(String::toLowerCase) .distinct() .toList(); @@ -84,11 +93,5 @@ public ContentResponse create(ContentCreateRequest request, MultipartFile thumbn } tagNames.forEach(name -> content.addTag(existingTags.get(name))); - - ContentStats stats = contentStatsRepository.save(ContentStats.create()); - content.attachStats(stats); - - return contentMapper.toDto(content, content.getContentTags(), stats); } - } From 89a151d089d90a6fa89cd8fc97b6c8cc77fdb5e7 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 12:52:05 +0900 Subject: [PATCH 29/41] =?UTF-8?q?refactor:=20V7=20content=5Fstats=20watche?= =?UTF-8?q?r=5Fcount=20=ED=83=80=EC=9E=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/db/migration/V7__restructure_content_stats.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/db/migration/V7__restructure_content_stats.sql b/src/main/resources/db/migration/V7__restructure_content_stats.sql index fdbadfbc..2c2a2f50 100644 --- a/src/main/resources/db/migration/V7__restructure_content_stats.sql +++ b/src/main/resources/db/migration/V7__restructure_content_stats.sql @@ -8,7 +8,7 @@ CREATE TABLE content_stats id UUID PRIMARY KEY, review_count INTEGER NOT NULL DEFAULT 0, rating_sum DOUBLE PRECISION NOT NULL DEFAULT 0, - watcher_count INTEGER NOT NULL DEFAULT 0, + watcher_count BIGINT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); From 2a75285a0da4426eadd4c56b8b8b808122bcafe4 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 12:52:18 +0900 Subject: [PATCH 30/41] =?UTF-8?q?refactor:=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BinaryContentUploadListenerTest.java | 28 ++-- .../content/service/ContentServiceTest.java | 143 ++++++------------ 2 files changed, 66 insertions(+), 105 deletions(-) diff --git a/src/test/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListenerTest.java b/src/test/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListenerTest.java index 519f3442..b7564138 100644 --- a/src/test/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListenerTest.java +++ b/src/test/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListenerTest.java @@ -1,15 +1,17 @@ package com.codeit.team5.mopl.binarycontent.listener; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import com.codeit.team5.mopl.binarycontent.BinaryContentStorage; +import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; -import com.codeit.team5.mopl.content.repository.ContentRepository; +import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; +import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -25,7 +27,7 @@ class BinaryContentUploadListenerTest { private BinaryContentStorage binaryContentStorage; @Mock - private ContentRepository contentRepository; + private BinaryContentRepository binaryContentRepository; @InjectMocks private BinaryContentUploadListener listener; @@ -34,33 +36,35 @@ class BinaryContentUploadListenerTest { @DisplayName("파일 업로드 성공 시 COMPLETED 상태로 업데이트된다") void handle_uploadSuccess_completesUpload() { // given - UUID contentId = UUID.randomUUID(); + UUID binaryContentId = UUID.randomUUID(); byte[] bytes = new byte[]{1, 2, 3}; - BinaryContentUploadEvent event = new BinaryContentUploadEvent(contentId, "thumbnails/test.jpg", bytes); + BinaryContentUploadEvent event = new BinaryContentUploadEvent(binaryContentId, "thumbnails/test.jpg", bytes); + BinaryContent binaryContent = BinaryContent.pending("http://localhost:8080/thumbnails/test.jpg"); + when(binaryContentRepository.findById(binaryContentId)).thenReturn(Optional.of(binaryContent)); // when listener.handle(event); // then verify(binaryContentStorage).store("thumbnails/test.jpg", event.bytes()); - verify(contentRepository).updateThumbnailUploadStatus(contentId, BinaryContentUploadStatus.COMPLETED); + assertThat(binaryContent.getUploadStatus()).isEqualTo(BinaryContentUploadStatus.COMPLETED); } @Test @DisplayName("파일 업로드 실패 시 FAILED 상태로 업데이트된다") void handle_uploadFails_failsUpload() { // given - UUID contentId = UUID.randomUUID(); + UUID binaryContentId = UUID.randomUUID(); byte[] bytes = new byte[]{1, 2, 3}; - BinaryContentUploadEvent event = new BinaryContentUploadEvent(contentId, "thumbnails/test.jpg", bytes); - + BinaryContentUploadEvent event = new BinaryContentUploadEvent(binaryContentId, "thumbnails/test.jpg", bytes); + BinaryContent binaryContent = BinaryContent.pending("http://localhost:8080/thumbnails/test.jpg"); + when(binaryContentRepository.findById(binaryContentId)).thenReturn(Optional.of(binaryContent)); doThrow(new RuntimeException("S3 연결 실패")).when(binaryContentStorage).store(any(), any()); // when listener.handle(event); // then - verify(contentRepository).updateThumbnailUploadStatus(contentId, BinaryContentUploadStatus.FAILED); - verify(contentRepository, never()).updateThumbnailUploadStatus(eq(contentId), eq(BinaryContentUploadStatus.COMPLETED)); + assertThat(binaryContent.getUploadStatus()).isEqualTo(BinaryContentUploadStatus.FAILED); } } diff --git a/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java b/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java index 0c903105..b305b10d 100644 --- a/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java +++ b/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java @@ -12,8 +12,9 @@ import static org.mockito.Mockito.when; import com.codeit.team5.mopl.binarycontent.BinaryContentStorage; -import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; +import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; +import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest; import com.codeit.team5.mopl.content.dto.response.ContentResponse; import com.codeit.team5.mopl.content.entity.Content; @@ -25,7 +26,6 @@ import com.codeit.team5.mopl.content.repository.ContentStatsRepository; import com.codeit.team5.mopl.tag.entity.Tag; import com.codeit.team5.mopl.tag.repository.TagRepository; -import org.springframework.context.ApplicationEventPublisher; import java.util.List; import java.util.UUID; import org.junit.jupiter.api.DisplayName; @@ -35,6 +35,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; @@ -56,6 +57,9 @@ class ContentServiceTest { @Mock private BinaryContentStorage binaryContentStorage; + @Mock + private BinaryContentRepository binaryContentRepository; + @Mock private ApplicationEventPublisher eventPublisher; @@ -65,42 +69,31 @@ class ContentServiceTest { @Test @DisplayName("콘텐츠 생성에 성공한다") void create_success() { - // Given + // given ContentCreateRequest request = new ContentCreateRequest( - ContentType.MOVIE, - "테스트 영화", - "테스트 설명", - List.of("액션", "드라마") + ContentType.MOVIE, "테스트 영화", "테스트 설명", List.of("액션", "드라마") ); MultipartFile thumbnail = new MockMultipartFile("thumbnail", "test.jpg", "image/jpeg", new byte[]{1, 2, 3}); - Tag actionTag = Tag.create("액션"); Tag dramaTag = Tag.create("드라마"); ContentResponse expectedResponse = new ContentResponse( - UUID.randomUUID(), - ContentType.MOVIE, - "테스트 영화", - "테스트 설명", - null, - null, - List.of("액션", "드라마"), - 0.0, 0, 0 + UUID.randomUUID(), ContentType.MOVIE, "테스트 영화", "테스트 설명", + null, null, List.of("액션", "드라마"), 0.0, 0, 0 ); when(contentRepository.save(any(Content.class))).then(returnsFirstArg()); when(tagRepository.findByNameIn(List.of("액션", "드라마"))).thenReturn(List.of(actionTag)); when(tagRepository.saveAll(anyList())).thenReturn(List.of(dramaTag)); when(contentStatsRepository.save(any(ContentStats.class))).then(returnsFirstArg()); - when(binaryContentStorage.generateKey(any(), eq("test.jpg"))) - .thenReturn("thumbnails/test-content/test.jpg"); - when(binaryContentStorage.toUrl("thumbnails/test-content/test.jpg")) - .thenReturn("http://localhost:8080/thumbnails/test-content/test.jpg"); + when(binaryContentStorage.generateKey(any(), eq("test.jpg"))).thenReturn("thumbnails/test.jpg"); + when(binaryContentStorage.toUrl("thumbnails/test.jpg")).thenReturn("http://localhost:8080/thumbnails/test.jpg"); + when(binaryContentRepository.save(any(BinaryContent.class))).then(returnsFirstArg()); when(contentMapper.toDto(any(Content.class), any(), any(ContentStats.class))).thenReturn(expectedResponse); - // When + // when ContentResponse result = contentService.create(request, thumbnail); - // Then + // then assertThat(result).isSameAs(expectedResponse); ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(Content.class); @@ -108,40 +101,27 @@ void create_success() { Content savedContent = contentCaptor.getValue(); assertThat(savedContent.getType()).isEqualTo(ContentType.MOVIE); assertThat(savedContent.getTitle()).isEqualTo("테스트 영화"); - assertThat(savedContent.getDescription()).isEqualTo("테스트 설명"); - assertThat(savedContent.getThumbnailUploadStatus()).isEqualTo(BinaryContentUploadStatus.PENDING); + assertThat(savedContent.getThumbnail()).isNotNull(); ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(BinaryContentUploadEvent.class); verify(eventPublisher).publishEvent(eventCaptor.capture()); - assertThat(eventCaptor.getValue().key()).isEqualTo("thumbnails/test-content/test.jpg"); + assertThat(eventCaptor.getValue().key()).isEqualTo("thumbnails/test.jpg"); verify(tagRepository).findByNameIn(List.of("액션", "드라마")); - ArgumentCaptor> saveAllCaptor = ArgumentCaptor.forClass(List.class); - verify(tagRepository).saveAll(saveAllCaptor.capture()); - assertThat(saveAllCaptor.getValue()).hasSize(1); verify(contentStatsRepository).save(any(ContentStats.class)); } @Test @DisplayName("thumbnail이 null이어도 콘텐츠 생성에 성공한다") void create_withoutThumbnail_success() { - // Given + // given ContentCreateRequest request = new ContentCreateRequest( - ContentType.TV_SERIES, - "테스트 드라마", - null, - List.of("로맨스") + ContentType.TV_SERIES, "테스트 드라마", null, List.of("로맨스") ); Tag romanceTag = Tag.create("로맨스"); ContentResponse expectedResponse = new ContentResponse( - UUID.randomUUID(), - ContentType.TV_SERIES, - "테스트 드라마", - null, - null, - null, - List.of("로맨스"), - 0.0, 0, 0 + UUID.randomUUID(), ContentType.TV_SERIES, "테스트 드라마", + null, null, null, List.of("로맨스"), 0.0, 0, 0 ); when(contentRepository.save(any(Content.class))).then(returnsFirstArg()); @@ -149,106 +129,87 @@ void create_withoutThumbnail_success() { when(contentStatsRepository.save(any(ContentStats.class))).then(returnsFirstArg()); when(contentMapper.toDto(any(Content.class), any(), any(ContentStats.class))).thenReturn(expectedResponse); - // When + // when ContentResponse result = contentService.create(request, null); - // Then + // then assertThat(result).isSameAs(expectedResponse); - verify(contentRepository).save(any(Content.class)); - verify(contentStatsRepository).save(any(ContentStats.class)); - verifyNoInteractions(binaryContentStorage, eventPublisher); + verifyNoInteractions(binaryContentStorage, binaryContentRepository, eventPublisher); } @Test @DisplayName("이미 존재하는 태그는 새로 저장하지 않는다") void create_existingTag_doesNotSaveNewTag() { - // Given + // given ContentCreateRequest request = new ContentCreateRequest( - ContentType.MOVIE, - "테스트 영화", - null, - List.of("액션") + ContentType.MOVIE, "테스트 영화", null, List.of("액션") ); - Tag existingTag = Tag.create("액션"); - when(contentRepository.save(any(Content.class))).then(returnsFirstArg()); - when(tagRepository.findByNameIn(List.of("액션"))).thenReturn(List.of(existingTag)); + when(tagRepository.findByNameIn(List.of("액션"))).thenReturn(List.of(Tag.create("액션"))); when(contentStatsRepository.save(any(ContentStats.class))).then(returnsFirstArg()); when(contentMapper.toDto(any(Content.class), any(), any(ContentStats.class))).thenReturn(null); - // When + // when contentService.create(request, null); - // Then + // then verify(tagRepository, never()).saveAll(anyList()); } @Test @DisplayName("존재하지 않는 태그는 새로 저장한다") void create_newTag_savesTag() { - // Given + // given ContentCreateRequest request = new ContentCreateRequest( - ContentType.MOVIE, - "테스트 영화", - null, - List.of("새태그1", "새태그2") + ContentType.MOVIE, "테스트 영화", null, List.of("새태그1", "새태그2") ); - when(contentRepository.save(any(Content.class))).then(returnsFirstArg()); when(tagRepository.findByNameIn(anyList())).thenReturn(List.of()); when(tagRepository.saveAll(anyList())).then(returnsFirstArg()); when(contentStatsRepository.save(any(ContentStats.class))).then(returnsFirstArg()); when(contentMapper.toDto(any(Content.class), any(), any(ContentStats.class))).thenReturn(null); - // When + // when contentService.create(request, null); - // Then - ArgumentCaptor> saveAllCaptor = ArgumentCaptor.forClass(List.class); - verify(tagRepository).saveAll(saveAllCaptor.capture()); - assertThat(saveAllCaptor.getValue()).hasSize(2); + // then + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(tagRepository).saveAll(captor.capture()); + assertThat(captor.getValue()).hasSize(2); } @Test @DisplayName("중복된 태그가 요청에 포함되어도 한 번만 저장한다") void create_duplicateTags_savesOnce() { - // Given + // given ContentCreateRequest request = new ContentCreateRequest( - ContentType.MOVIE, - "테스트 영화", - null, - List.of("액션", "액션") + ContentType.MOVIE, "테스트 영화", null, List.of("액션", "액션") ); - when(contentRepository.save(any(Content.class))).then(returnsFirstArg()); when(tagRepository.findByNameIn(List.of("액션"))).thenReturn(List.of()); when(tagRepository.saveAll(anyList())).then(returnsFirstArg()); when(contentStatsRepository.save(any(ContentStats.class))).then(returnsFirstArg()); when(contentMapper.toDto(any(Content.class), any(), any(ContentStats.class))).thenReturn(null); - // When + // when contentService.create(request, null); - // Then - ArgumentCaptor> saveAllCaptor = ArgumentCaptor.forClass(List.class); - verify(tagRepository).saveAll(saveAllCaptor.capture()); - assertThat(saveAllCaptor.getValue()).hasSize(1); + // then + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(tagRepository).saveAll(captor.capture()); + assertThat(captor.getValue()).hasSize(1); } @Test @DisplayName("정규화 후 유효한 태그가 없으면 예외를 던진다") void create_allBlankTags_throwsException() { - // Given + // given ContentCreateRequest request = new ContentCreateRequest( - ContentType.MOVIE, - "테스트 영화", - null, - List.of(" ", " ") + ContentType.MOVIE, "테스트 영화", null, List.of(" ", " ") ); - when(contentRepository.save(any(Content.class))).then(returnsFirstArg()); - // When & Then + // when & then assertThatThrownBy(() -> contentService.create(request, null)) .isInstanceOf(EmptyTagException.class); @@ -259,24 +220,20 @@ void create_allBlankTags_throwsException() { @Test @DisplayName("태그는 앞뒤 공백 제거 및 소문자로 정규화되어 저장된다") void create_tagsNormalized() { - // Given + // given ContentCreateRequest request = new ContentCreateRequest( - ContentType.MOVIE, - "테스트 영화", - null, - List.of(" Action ", "DRAMA") + ContentType.MOVIE, "테스트 영화", null, List.of(" Action ", "DRAMA") ); - when(contentRepository.save(any(Content.class))).then(returnsFirstArg()); when(tagRepository.findByNameIn(List.of("action", "drama"))).thenReturn(List.of()); when(tagRepository.saveAll(anyList())).then(returnsFirstArg()); when(contentStatsRepository.save(any(ContentStats.class))).then(returnsFirstArg()); when(contentMapper.toDto(any(Content.class), any(), any(ContentStats.class))).thenReturn(null); - // When + // when contentService.create(request, null); - // Then + // then verify(tagRepository).findByNameIn(List.of("action", "drama")); } } From 2267b1d53ba75ab692d7e4d8c9b65e4e78b3b90d Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 13:02:05 +0900 Subject: [PATCH 31/41] =?UTF-8?q?refactor:=20User=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=EC=97=90=20BinaryContent=20=EC=97=B0=EA=B4=80?= =?UTF-8?q?=EA=B4=80=EA=B3=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/codeit/team5/mopl/user/entity/User.java | 15 ++++++--------- .../codeit/team5/mopl/user/mapper/UserMapper.java | 1 + .../migration/V8__add_profile_image_to_users.sql | 6 ++++++ 3 files changed, 13 insertions(+), 9 deletions(-) create mode 100644 src/main/resources/db/migration/V8__add_profile_image_to_users.sql diff --git a/src/main/java/com/codeit/team5/mopl/user/entity/User.java b/src/main/java/com/codeit/team5/mopl/user/entity/User.java index 3d7d92d4..e9581dbf 100644 --- a/src/main/java/com/codeit/team5/mopl/user/entity/User.java +++ b/src/main/java/com/codeit/team5/mopl/user/entity/User.java @@ -1,5 +1,6 @@ package com.codeit.team5.mopl.user.entity; +import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity; import com.codeit.team5.mopl.user.exception.InvalidPasswordException; import com.codeit.team5.mopl.user.exception.InvalidUsernameException; @@ -34,8 +35,9 @@ public class User extends BaseUpdatableEntity { @Column(nullable = false, length = 100) private String name; - @Column(name = "profile_image_url", length = 512) - private String profileImageUrl; + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "profile_image_id") + private BinaryContent profileImage; @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) @@ -55,20 +57,15 @@ public static User create( user.password = password; user.locked = false; user.role = UserRole.USER; - user.profileImageUrl = null; - return user; } - public void updateProfile( - String newName, - String profileImageUrl - ) { + public void updateProfile(String newName, BinaryContent profileImage) { if (newName == null || newName.isBlank()) { throw new InvalidUsernameException(); } this.name = newName; - this.profileImageUrl = profileImageUrl; + this.profileImage = profileImage; } public void updatePassword(String newPassword) { diff --git a/src/main/java/com/codeit/team5/mopl/user/mapper/UserMapper.java b/src/main/java/com/codeit/team5/mopl/user/mapper/UserMapper.java index f9ec7077..9b853ac1 100644 --- a/src/main/java/com/codeit/team5/mopl/user/mapper/UserMapper.java +++ b/src/main/java/com/codeit/team5/mopl/user/mapper/UserMapper.java @@ -13,5 +13,6 @@ ) public interface UserMapper { @Mapping(target = "role", source = "user.role") + @Mapping(target = "profileImageUrl", source = "user.profileImage.url") UserResponse toDto(User user); } diff --git a/src/main/resources/db/migration/V8__add_profile_image_to_users.sql b/src/main/resources/db/migration/V8__add_profile_image_to_users.sql new file mode 100644 index 00000000..a990a3c9 --- /dev/null +++ b/src/main/resources/db/migration/V8__add_profile_image_to_users.sql @@ -0,0 +1,6 @@ +ALTER TABLE users + ADD COLUMN profile_image_id UUID + CONSTRAINT fk_users_profile_image REFERENCES binary_contents (id) ON DELETE SET NULL; + +ALTER TABLE users + DROP COLUMN profile_image_url; From 90ab2ed97391cd9cd5adae6074f8aaa3160fdcc2 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 13:27:06 +0900 Subject: [PATCH 32/41] =?UTF-8?q?refactor:=20BinaryContent=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EC=83=81=ED=83=9C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B3=84=EB=8F=84=20Service=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../binarycontent/entity/BinaryContent.java | 8 +-- .../BinaryContentNotFoundException.java | 12 ++++ .../listener/BinaryContentUploadListener.java | 15 ++-- .../service/BinaryContentService.java | 24 +++++++ .../BinaryContentUploadListenerTest.java | 31 +++----- .../service/BinaryContentServiceTest.java | 70 +++++++++++++++++++ 6 files changed, 124 insertions(+), 36 deletions(-) create mode 100644 src/main/java/com/codeit/team5/mopl/binarycontent/exception/BinaryContentNotFoundException.java create mode 100644 src/main/java/com/codeit/team5/mopl/binarycontent/service/BinaryContentService.java create mode 100644 src/test/java/com/codeit/team5/mopl/binarycontent/service/BinaryContentServiceTest.java diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContent.java b/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContent.java index 7cd1061b..f0d087f3 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContent.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContent.java @@ -30,11 +30,7 @@ public static BinaryContent pending(String url) { return bc; } - public void completeUpload() { - this.uploadStatus = BinaryContentUploadStatus.COMPLETED; - } - - public void failUpload() { - this.uploadStatus = BinaryContentUploadStatus.FAILED; + public void updateUploadStatus(BinaryContentUploadStatus status) { + this.uploadStatus = status; } } diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/exception/BinaryContentNotFoundException.java b/src/main/java/com/codeit/team5/mopl/binarycontent/exception/BinaryContentNotFoundException.java new file mode 100644 index 00000000..c5eeadf9 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/exception/BinaryContentNotFoundException.java @@ -0,0 +1,12 @@ +package com.codeit.team5.mopl.binarycontent.exception; + +import com.codeit.team5.mopl.global.exception.suggestion.BusinessExceptionSuggestion; +import java.util.UUID; +import org.springframework.http.HttpStatus; + +public class BinaryContentNotFoundException extends BusinessExceptionSuggestion { + + public BinaryContentNotFoundException(UUID id) { + super(HttpStatus.NOT_FOUND, "BinaryContent를 찾을 수 없습니다: " + id); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java b/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java index 974fc99e..72dd9ce8 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java @@ -1,15 +1,13 @@ package com.codeit.team5.mopl.binarycontent.listener; import com.codeit.team5.mopl.binarycontent.BinaryContentStorage; -import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; +import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; -import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; +import com.codeit.team5.mopl.binarycontent.service.BinaryContentService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; @@ -19,20 +17,17 @@ public class BinaryContentUploadListener { private final BinaryContentStorage binaryContentStorage; - private final BinaryContentRepository binaryContentRepository; + private final BinaryContentService binaryContentService; @Async("binaryContentUploadExecutor") - @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(BinaryContentUploadEvent event) { - BinaryContent binaryContent = binaryContentRepository.findById(event.binaryContentId()) - .orElseThrow(() -> new IllegalStateException("BinaryContent를 찾을 수 없습니다: " + event.binaryContentId())); try { binaryContentStorage.store(event.key(), event.bytes()); - binaryContent.completeUpload(); + binaryContentService.updateUploadStatus(event.binaryContentId(), BinaryContentUploadStatus.COMPLETED); log.debug("파일 업로드 완료 - binaryContentId: {}", event.binaryContentId()); } catch (Exception e) { - binaryContent.failUpload(); + binaryContentService.updateUploadStatus(event.binaryContentId(), BinaryContentUploadStatus.FAILED); log.error("파일 업로드 실패 - binaryContentId: {}", event.binaryContentId(), e); } } diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/service/BinaryContentService.java b/src/main/java/com/codeit/team5/mopl/binarycontent/service/BinaryContentService.java new file mode 100644 index 00000000..b02361f9 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/service/BinaryContentService.java @@ -0,0 +1,24 @@ +package com.codeit.team5.mopl.binarycontent.service; + +import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; +import com.codeit.team5.mopl.binarycontent.exception.BinaryContentNotFoundException; +import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class BinaryContentService { + + private final BinaryContentRepository binaryContentRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void updateUploadStatus(UUID binaryContentId, BinaryContentUploadStatus status) { + binaryContentRepository.findById(binaryContentId) + .orElseThrow(() -> new BinaryContentNotFoundException(binaryContentId)) + .updateUploadStatus(status); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListenerTest.java b/src/test/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListenerTest.java index b7564138..3920fa38 100644 --- a/src/test/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListenerTest.java +++ b/src/test/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListenerTest.java @@ -1,17 +1,14 @@ package com.codeit.team5.mopl.binarycontent.listener; -import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; import com.codeit.team5.mopl.binarycontent.BinaryContentStorage; -import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; -import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; -import java.util.Optional; +import com.codeit.team5.mopl.binarycontent.service.BinaryContentService; import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -27,44 +24,38 @@ class BinaryContentUploadListenerTest { private BinaryContentStorage binaryContentStorage; @Mock - private BinaryContentRepository binaryContentRepository; + private BinaryContentService binaryContentService; @InjectMocks private BinaryContentUploadListener listener; @Test - @DisplayName("파일 업로드 성공 시 COMPLETED 상태로 업데이트된다") - void handle_uploadSuccess_completesUpload() { + @DisplayName("파일 업로드 성공 시 COMPLETED 상태로 업데이트한다") + void handle_uploadSuccess_updatesCompleted() { // given UUID binaryContentId = UUID.randomUUID(); - byte[] bytes = new byte[]{1, 2, 3}; - BinaryContentUploadEvent event = new BinaryContentUploadEvent(binaryContentId, "thumbnails/test.jpg", bytes); - BinaryContent binaryContent = BinaryContent.pending("http://localhost:8080/thumbnails/test.jpg"); - when(binaryContentRepository.findById(binaryContentId)).thenReturn(Optional.of(binaryContent)); + BinaryContentUploadEvent event = new BinaryContentUploadEvent(binaryContentId, "thumbnails/test.jpg", new byte[]{1, 2, 3}); // when listener.handle(event); // then verify(binaryContentStorage).store("thumbnails/test.jpg", event.bytes()); - assertThat(binaryContent.getUploadStatus()).isEqualTo(BinaryContentUploadStatus.COMPLETED); + verify(binaryContentService).updateUploadStatus(binaryContentId, BinaryContentUploadStatus.COMPLETED); } @Test - @DisplayName("파일 업로드 실패 시 FAILED 상태로 업데이트된다") - void handle_uploadFails_failsUpload() { + @DisplayName("파일 업로드 실패 시 FAILED 상태로 업데이트한다") + void handle_uploadFails_updatesFailed() { // given UUID binaryContentId = UUID.randomUUID(); - byte[] bytes = new byte[]{1, 2, 3}; - BinaryContentUploadEvent event = new BinaryContentUploadEvent(binaryContentId, "thumbnails/test.jpg", bytes); - BinaryContent binaryContent = BinaryContent.pending("http://localhost:8080/thumbnails/test.jpg"); - when(binaryContentRepository.findById(binaryContentId)).thenReturn(Optional.of(binaryContent)); + BinaryContentUploadEvent event = new BinaryContentUploadEvent(binaryContentId, "thumbnails/test.jpg", new byte[]{1, 2, 3}); doThrow(new RuntimeException("S3 연결 실패")).when(binaryContentStorage).store(any(), any()); // when listener.handle(event); // then - assertThat(binaryContent.getUploadStatus()).isEqualTo(BinaryContentUploadStatus.FAILED); + verify(binaryContentService).updateUploadStatus(binaryContentId, BinaryContentUploadStatus.FAILED); } } diff --git a/src/test/java/com/codeit/team5/mopl/binarycontent/service/BinaryContentServiceTest.java b/src/test/java/com/codeit/team5/mopl/binarycontent/service/BinaryContentServiceTest.java new file mode 100644 index 00000000..7b4da761 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/binarycontent/service/BinaryContentServiceTest.java @@ -0,0 +1,70 @@ +package com.codeit.team5.mopl.binarycontent.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; +import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; +import com.codeit.team5.mopl.binarycontent.exception.BinaryContentNotFoundException; +import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; +import java.util.Optional; +import java.util.UUID; +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; + +@ExtendWith(MockitoExtension.class) +class BinaryContentServiceTest { + + @Mock + private BinaryContentRepository binaryContentRepository; + + @InjectMocks + private BinaryContentService binaryContentService; + + @Test + @DisplayName("COMPLETED 상태로 업데이트한다") + void updateUploadStatus_completed() { + // given + UUID id = UUID.randomUUID(); + BinaryContent binaryContent = BinaryContent.pending("http://localhost/test.jpg"); + when(binaryContentRepository.findById(id)).thenReturn(Optional.of(binaryContent)); + + // when + binaryContentService.updateUploadStatus(id, BinaryContentUploadStatus.COMPLETED); + + // then + assertThat(binaryContent.getUploadStatus()).isEqualTo(BinaryContentUploadStatus.COMPLETED); + } + + @Test + @DisplayName("FAILED 상태로 업데이트한다") + void updateUploadStatus_failed() { + // given + UUID id = UUID.randomUUID(); + BinaryContent binaryContent = BinaryContent.pending("http://localhost/test.jpg"); + when(binaryContentRepository.findById(id)).thenReturn(Optional.of(binaryContent)); + + // when + binaryContentService.updateUploadStatus(id, BinaryContentUploadStatus.FAILED); + + // then + assertThat(binaryContent.getUploadStatus()).isEqualTo(BinaryContentUploadStatus.FAILED); + } + + @Test + @DisplayName("BinaryContent가 없으면 BinaryContentNotFoundException을 던진다") + void updateUploadStatus_notFound_throwsException() { + // given + UUID id = UUID.randomUUID(); + when(binaryContentRepository.findById(id)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> binaryContentService.updateUploadStatus(id, BinaryContentUploadStatus.COMPLETED)) + .isInstanceOf(BinaryContentNotFoundException.class); + } +} From 623e4c9ade56f737c7333ed7357657cad98edfb4 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 13:27:41 +0900 Subject: [PATCH 33/41] =?UTF-8?q?refactor:=20=EC=BD=98=ED=85=90=EC=B8=A0,?= =?UTF-8?q?=20=EC=9C=A0=EC=A0=80=20=EB=82=B4=EB=B6=80=20BinaryContent=20?= =?UTF-8?q?=EC=9C=A0=EB=8B=88=ED=81=AC=20=EC=A0=9C=EC=95=BD=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/codeit/team5/mopl/content/entity/Content.java | 2 +- src/main/java/com/codeit/team5/mopl/user/entity/User.java | 2 +- .../V9__add_unique_constraints_to_binary_content_fks.sql | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 src/main/resources/db/migration/V9__add_unique_constraints_to_binary_content_fks.sql diff --git a/src/main/java/com/codeit/team5/mopl/content/entity/Content.java b/src/main/java/com/codeit/team5/mopl/content/entity/Content.java index a2c4b5fb..d0aa048e 100644 --- a/src/main/java/com/codeit/team5/mopl/content/entity/Content.java +++ b/src/main/java/com/codeit/team5/mopl/content/entity/Content.java @@ -50,7 +50,7 @@ public class Content extends BaseUpdatableEntity { private String description; @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "thumbnail_id") + @JoinColumn(name = "thumbnail_id", unique = true) private BinaryContent thumbnail; @Column(name = "released_at") diff --git a/src/main/java/com/codeit/team5/mopl/user/entity/User.java b/src/main/java/com/codeit/team5/mopl/user/entity/User.java index e9581dbf..e645fe56 100644 --- a/src/main/java/com/codeit/team5/mopl/user/entity/User.java +++ b/src/main/java/com/codeit/team5/mopl/user/entity/User.java @@ -36,7 +36,7 @@ public class User extends BaseUpdatableEntity { private String name; @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "profile_image_id") + @JoinColumn(name = "profile_image_id", unique = true) private BinaryContent profileImage; @Enumerated(EnumType.STRING) diff --git a/src/main/resources/db/migration/V9__add_unique_constraints_to_binary_content_fks.sql b/src/main/resources/db/migration/V9__add_unique_constraints_to_binary_content_fks.sql new file mode 100644 index 00000000..c6f01434 --- /dev/null +++ b/src/main/resources/db/migration/V9__add_unique_constraints_to_binary_content_fks.sql @@ -0,0 +1,5 @@ +ALTER TABLE contents + ADD CONSTRAINT uk_contents_thumbnail_id UNIQUE (thumbnail_id); + +ALTER TABLE users + ADD CONSTRAINT uk_users_profile_image_id UNIQUE (profile_image_id); From 51b133d450192bd6188fda8552948ccf42ec1d60 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 13:34:14 +0900 Subject: [PATCH 34/41] =?UTF-8?q?refactor:=20application.yml=20=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=EC=A7=80=20=EC=84=A4=EC=A0=95=EA=B0=92=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 --- .../storage/local/LocalBinaryContentStorage.java | 2 +- src/main/resources/application-dev.yml | 5 ++++- src/main/resources/application.yml | 7 ------- src/test/resources/application-test.yml | 2 +- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java index 669e8849..cf4dfa8e 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java @@ -13,7 +13,7 @@ @Slf4j @Component -@ConditionalOnProperty(name = "mopl.storage.type", havingValue = "memory", matchIfMissing = true) +@ConditionalOnProperty(name = "mopl.storage.type", havingValue = "local", matchIfMissing = true) public class LocalBinaryContentStorage implements BinaryContentStorage { private final Path uploadDir; diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 6dd6b3e6..c26a585c 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -44,9 +44,12 @@ spring: resources: static-locations: classpath:/static/, file:./uploads/ +# 이미지 저장소 전략 (구현체는 mopl.storage.type 값으로 선택) +# - local: 로컬/테스트용 저장 +# - s3 : 배포용 S3 저장 mopl: storage: - type: ${STORAGE_TYPE:memory} + type: ${STORAGE_TYPE:local} # 기본값: 로컬 local: base-url: http://localhost:8080 upload-dir: ./uploads diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fab145b6..12001aea 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -25,10 +25,3 @@ management: endpoint: health: show-details: when-authorized - -# 이미지 저장소 전략 (구현체는 mopl.storage.type 값으로 선택) -# - memory: 로컬/테스트용 인메모리 저장 -# - s3 : 배포용 S3 저장 -mopl: - storage: - type: ${MOPL_STORAGE_TYPE:memory} # 기본값: 인메모리 \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 14aa6aa8..83700c59 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -32,7 +32,7 @@ spring: mopl: storage: - type: memory + type: local local: base-url: http://localhost:8080 upload-dir: ./uploads-test From 7fc4b800c169dc5a60eeb81cd67d82018119490d Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 14:01:47 +0900 Subject: [PATCH 35/41] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?-=20BinaryContentStorage:=20=ED=99=95=EC=9E=A5=EC=9E=90=20?= =?UTF-8?q?=EC=A0=95=EA=B7=9C=ED=99=94=20=EA=B3=A0=EC=A0=95=20-=20BinaryCo?= =?UTF-8?q?ntent:=20pending/updateUploadStatus=20null=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20-=20Content:=20stats=5Fid=20UNIQUE=20?= =?UTF-8?q?=EC=A0=9C=EC=95=BD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mopl/binarycontent/BinaryContentStorage.java | 6 ++++-- .../mopl/binarycontent/entity/BinaryContent.java | 11 ++++++----- .../com/codeit/team5/mopl/content/entity/Content.java | 2 +- ...content_fks.sql => V9__add_unique_constraints.sql} | 3 +++ 4 files changed, 14 insertions(+), 8 deletions(-) rename src/main/resources/db/migration/{V9__add_unique_constraints_to_binary_content_fks.sql => V9__add_unique_constraints.sql} (68%) diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/BinaryContentStorage.java b/src/main/java/com/codeit/team5/mopl/binarycontent/BinaryContentStorage.java index dea71313..b1f88d92 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/BinaryContentStorage.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/BinaryContentStorage.java @@ -1,5 +1,6 @@ package com.codeit.team5.mopl.binarycontent; +import java.util.Locale; import java.util.Set; import java.util.UUID; import org.springframework.util.StringUtils; @@ -10,8 +11,9 @@ public interface BinaryContentStorage { default String generateKey(UUID contentId, String originalFilename) { String extension = StringUtils.getFilenameExtension(originalFilename); - String normalizedExt = (extension != null && ALLOWED_EXTENSIONS.contains(extension.toLowerCase())) - ? "." + extension.toLowerCase() + String lowerExt = extension != null ? extension.toLowerCase(Locale.ROOT) : null; + String normalizedExt = (lowerExt != null && ALLOWED_EXTENSIONS.contains(lowerExt)) + ? "." + lowerExt : ""; return "thumbnails/" + contentId + "/" + UUID.randomUUID() + normalizedExt; } diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContent.java b/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContent.java index f0d087f3..9fd071ef 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContent.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContent.java @@ -6,6 +6,7 @@ import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.Table; +import java.util.Objects; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -24,13 +25,13 @@ public class BinaryContent extends BaseUpdatableEntity { private BinaryContentUploadStatus uploadStatus; public static BinaryContent pending(String url) { - BinaryContent bc = new BinaryContent(); - bc.url = url; - bc.uploadStatus = BinaryContentUploadStatus.PENDING; - return bc; + BinaryContent binaryContent = new BinaryContent(); + binaryContent.url = Objects.requireNonNull(url, "url must not be null"); + binaryContent.uploadStatus = BinaryContentUploadStatus.PENDING; + return binaryContent; } public void updateUploadStatus(BinaryContentUploadStatus status) { - this.uploadStatus = status; + this.uploadStatus = Objects.requireNonNull(status, "status must not be null"); } } diff --git a/src/main/java/com/codeit/team5/mopl/content/entity/Content.java b/src/main/java/com/codeit/team5/mopl/content/entity/Content.java index d0aa048e..80d99fe9 100644 --- a/src/main/java/com/codeit/team5/mopl/content/entity/Content.java +++ b/src/main/java/com/codeit/team5/mopl/content/entity/Content.java @@ -68,7 +68,7 @@ public class Content extends BaseUpdatableEntity { private String externalId; @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) - @JoinColumn(name = "stats_id") + @JoinColumn(name = "stats_id", unique = true) private ContentStats stats; @OneToMany(mappedBy = "content", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) diff --git a/src/main/resources/db/migration/V9__add_unique_constraints_to_binary_content_fks.sql b/src/main/resources/db/migration/V9__add_unique_constraints.sql similarity index 68% rename from src/main/resources/db/migration/V9__add_unique_constraints_to_binary_content_fks.sql rename to src/main/resources/db/migration/V9__add_unique_constraints.sql index c6f01434..5c6f8491 100644 --- a/src/main/resources/db/migration/V9__add_unique_constraints_to_binary_content_fks.sql +++ b/src/main/resources/db/migration/V9__add_unique_constraints.sql @@ -1,5 +1,8 @@ ALTER TABLE contents ADD CONSTRAINT uk_contents_thumbnail_id UNIQUE (thumbnail_id); +ALTER TABLE contents + ADD CONSTRAINT uk_contents_stats_id UNIQUE (stats_id); + ALTER TABLE users ADD CONSTRAINT uk_users_profile_image_id UNIQUE (profile_image_id); From a442432cb0cd050dc73fc374af72b85b509c683b Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 14:24:35 +0900 Subject: [PATCH 36/41] =?UTF-8?q?refactor:=20ContentTag=20proxy=EC=97=90?= =?UTF-8?q?=EC=84=9C=20field=20=EC=A0=91=EA=B7=BC=20=EB=AC=B8=EC=A0=9C=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/com/codeit/team5/mopl/content/entity/ContentTag.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/codeit/team5/mopl/content/entity/ContentTag.java b/src/main/java/com/codeit/team5/mopl/content/entity/ContentTag.java index cc1785e9..a3de0f35 100644 --- a/src/main/java/com/codeit/team5/mopl/content/entity/ContentTag.java +++ b/src/main/java/com/codeit/team5/mopl/content/entity/ContentTag.java @@ -48,7 +48,7 @@ public static ContentTag create(Content content, Tag tag) { public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof ContentTag other)) return false; - return Objects.equals(id, other.id); + return Objects.equals(id, other.getId()); } @Override From 9820465c7bfdfdbf2241e7396d724cca8f369b0c Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 14:25:11 +0900 Subject: [PATCH 37/41] =?UTF-8?q?test:=20ContentControllerTest=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ContentControllerTest.java | 526 +++++++++--------- 1 file changed, 263 insertions(+), 263 deletions(-) diff --git a/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java b/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java index 9bad585f..1251cb52 100644 --- a/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java +++ b/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java @@ -1,263 +1,263 @@ -package com.codeit.team5.mopl.content.controller; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import com.codeit.team5.mopl.config.SecurityConfig; -import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest; -import com.codeit.team5.mopl.content.dto.response.ContentResponse; -import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; -import com.codeit.team5.mopl.content.entity.ContentType; -import com.codeit.team5.mopl.content.service.ContentService; -import com.codeit.team5.mopl.global.exception.GlobalExceptionHandler; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.List; -import java.util.UUID; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.Captor; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.context.annotation.Import; -import org.springframework.http.MediaType; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.web.multipart.MultipartFile; - -@WebMvcTest(ContentController.class) -@Import({GlobalExceptionHandler.class, SecurityConfig.class}) -class ContentControllerTest { - - @Autowired - private MockMvc mockMvc; - - @Autowired - private ObjectMapper objectMapper; - - @MockitoBean - private ContentService contentService; - - @Captor - private ArgumentCaptor requestCaptor; - - @Test - @DisplayName("정상적인 콘텐츠 생성 요청이면 생성된 콘텐츠와 201 응답을 반환한다") - void postContent_success() throws Exception { - // Given - ContentCreateRequest request = new ContentCreateRequest( - ContentType.MOVIE, - "테스트 영화", - "테스트 설명", - List.of("액션", "드라마") - ); - ContentResponse response = new ContentResponse( - UUID.fromString("11111111-1111-1111-1111-111111111111"), - ContentType.MOVIE, - "테스트 영화", - "테스트 설명", - "http://localhost:8080/thumbnails/11111111-1111-1111-1111-111111111111/test.jpg", - BinaryContentUploadStatus.PENDING, - List.of("액션", "드라마"), - 0.0, 0, 0 - ); - - given(contentService.create(any(ContentCreateRequest.class), any())).willReturn(response); - - MockMultipartFile requestPart = new MockMultipartFile( - "request", - "", - MediaType.APPLICATION_JSON_VALUE, - objectMapper.writeValueAsBytes(request) - ); - MockMultipartFile thumbnailPart = new MockMultipartFile( - "thumbnail", - "test.jpg", - MediaType.IMAGE_JPEG_VALUE, - new byte[]{1, 2, 3} - ); - - // When & Then - mockMvc.perform(multipart("/api/contents") - .file(requestPart) - .file(thumbnailPart)) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.id").value(response.id().toString())) - .andExpect(jsonPath("$.type").value("movie")) - .andExpect(jsonPath("$.title").value("테스트 영화")) - .andExpect(jsonPath("$.description").value("테스트 설명")) - .andExpect(jsonPath("$.thumbnailUrl").value(response.thumbnailUrl())) - .andExpect(jsonPath("$.thumbnailUploadStatus").value("PENDING")) - .andExpect(jsonPath("$.tags[0]").value("액션")) - .andExpect(jsonPath("$.tags[1]").value("드라마")) - .andExpect(jsonPath("$.averageRating").value(0.0)) - .andExpect(jsonPath("$.reviewCount").value(0)) - .andExpect(jsonPath("$.watcherCount").value(0)); - - verify(contentService).create(requestCaptor.capture(), any(MultipartFile.class)); - ContentCreateRequest captured = requestCaptor.getValue(); - assertThat(captured.type()).isEqualTo(ContentType.MOVIE); - assertThat(captured.title()).isEqualTo("테스트 영화"); - assertThat(captured.description()).isEqualTo("테스트 설명"); - assertThat(captured.tags()).containsExactly("액션", "드라마"); - } - - @Test - @DisplayName("thumbnail 없이 콘텐츠 생성 요청이면 201 응답을 반환한다") - void postContent_withoutThumbnail_success() throws Exception { - // Given - ContentCreateRequest request = new ContentCreateRequest( - ContentType.TV_SERIES, - "테스트 드라마", - "테스트 설명", - List.of("로맨스") - ); - ContentResponse response = new ContentResponse( - UUID.fromString("22222222-2222-2222-2222-222222222222"), - ContentType.TV_SERIES, - "테스트 드라마", - "테스트 설명", - null, - null, - List.of("로맨스"), - 0.0, 0, 0 - ); - - given(contentService.create(any(ContentCreateRequest.class), isNull())).willReturn(response); - - MockMultipartFile requestPart = new MockMultipartFile( - "request", "", MediaType.APPLICATION_JSON_VALUE, - objectMapper.writeValueAsBytes(request) - ); - - // When & Then - mockMvc.perform(multipart("/api/contents") - .file(requestPart)) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.id").value(response.id().toString())) - .andExpect(jsonPath("$.type").value("tvSeries")) - .andExpect(jsonPath("$.title").value("테스트 드라마")) - .andExpect(jsonPath("$.description").value("테스트 설명")) - .andExpect(jsonPath("$.tags[0]").value("로맨스")) - .andExpect(jsonPath("$.averageRating").value(0.0)) - .andExpect(jsonPath("$.reviewCount").value(0)) - .andExpect(jsonPath("$.watcherCount").value(0)); - verify(contentService).create(requestCaptor.capture(), isNull()); - ContentCreateRequest captured = requestCaptor.getValue(); - assertThat(captured.type()).isEqualTo(ContentType.TV_SERIES); - assertThat(captured.title()).isEqualTo("테스트 드라마"); - assertThat(captured.description()).isEqualTo("테스트 설명"); - assertThat(captured.tags()).containsExactly("로맨스"); - } - - @Test - @DisplayName("콘텐츠 타입이 누락되면 400 검증 실패 응답을 반환한다") - void postContent_missingType_returnsBadRequest() throws Exception { - // Given - String requestJson = """ - { - "title": "테스트 영화", - "tags": ["액션"] - } - """; - MockMultipartFile requestPart = new MockMultipartFile( - "request", "", MediaType.APPLICATION_JSON_VALUE, requestJson.getBytes() - ); - - // When & Then - mockMvc.perform(multipart("/api/contents") - .file(requestPart)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.exceptionName").value("INVALID_INPUT")) - .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) - .andExpect(jsonPath("$.details.type[0]").value("콘텐츠 타입은 필수입니다.")); - - verify(contentService, never()).create(any(), any()); - } - - @Test - @DisplayName("제목이 공백이면 400 검증 실패 응답을 반환한다") - void postContent_blankTitle_returnsBadRequest() throws Exception { - // Given - ContentCreateRequest request = new ContentCreateRequest( - ContentType.MOVIE, - " ", - null, - List.of("액션") - ); - MockMultipartFile requestPart = new MockMultipartFile( - "request", "", MediaType.APPLICATION_JSON_VALUE, - objectMapper.writeValueAsBytes(request) - ); - - // When & Then - mockMvc.perform(multipart("/api/contents") - .file(requestPart)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.exceptionName").value("INVALID_INPUT")) - .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) - .andExpect(jsonPath("$.details.title[0]").value("제목은 필수입니다.")); - - verify(contentService, never()).create(any(), any()); - } - - @Test - @DisplayName("태그 목록이 비어있으면 400 검증 실패 응답을 반환한다") - void postContent_emptyTags_returnsBadRequest() throws Exception { - // Given - ContentCreateRequest request = new ContentCreateRequest( - ContentType.MOVIE, - "테스트 영화", - null, - List.of() - ); - MockMultipartFile requestPart = new MockMultipartFile( - "request", "", MediaType.APPLICATION_JSON_VALUE, - objectMapper.writeValueAsBytes(request) - ); - - // When & Then - mockMvc.perform(multipart("/api/contents") - .file(requestPart)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.exceptionName").value("INVALID_INPUT")) - .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) - .andExpect(jsonPath("$.details.tags[0]").value("콘텐츠 태그 목록은 비어있을 수 없습니다.")); - - verify(contentService, never()).create(any(), any()); - } - - @Test - @DisplayName("예상하지 못한 서비스 예외가 발생하면 500 오류 응답을 반환한다") - void postContent_unexpectedException_returnsInternalServerError() throws Exception { - // Given - ContentCreateRequest request = new ContentCreateRequest( - ContentType.MOVIE, - "테스트 영화", - null, - List.of("액션") - ); - given(contentService.create(any(ContentCreateRequest.class), any())) - .willThrow(new IllegalStateException("unexpected")); - - MockMultipartFile requestPart = new MockMultipartFile( - "request", "", MediaType.APPLICATION_JSON_VALUE, - objectMapper.writeValueAsBytes(request) - ); - - // When & Then - mockMvc.perform(multipart("/api/contents") - .file(requestPart)) - .andExpect(status().isInternalServerError()) - .andExpect(jsonPath("$.exceptionName").value("INTERNAL_SERVER_ERROR")) - .andExpect(jsonPath("$.message").value("서버 내부 오류가 발생했습니다.")); - } -} +//package com.codeit.team5.mopl.content.controller; +// +//import static org.assertj.core.api.Assertions.assertThat; +//import static org.mockito.ArgumentMatchers.any; +//import static org.mockito.ArgumentMatchers.isNull; +//import static org.mockito.BDDMockito.given; +//import static org.mockito.Mockito.never; +//import static org.mockito.Mockito.verify; +//import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +//import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +//import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +// +//import com.codeit.team5.mopl.config.SecurityConfig; +//import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest; +//import com.codeit.team5.mopl.content.dto.response.ContentResponse; +//import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; +//import com.codeit.team5.mopl.content.entity.ContentType; +//import com.codeit.team5.mopl.content.service.ContentService; +//import com.codeit.team5.mopl.global.exception.suggestion.GlobalExceptionHandlerSuggestion; +//import com.fasterxml.jackson.databind.ObjectMapper; +//import java.util.List; +//import java.util.UUID; +//import org.junit.jupiter.api.DisplayName; +//import org.junit.jupiter.api.Test; +//import org.mockito.ArgumentCaptor; +//import org.mockito.Captor; +//import org.springframework.beans.factory.annotation.Autowired; +//import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +//import org.springframework.context.annotation.Import; +//import org.springframework.http.MediaType; +//import org.springframework.mock.web.MockMultipartFile; +//import org.springframework.test.context.bean.override.mockito.MockitoBean; +//import org.springframework.test.web.servlet.MockMvc; +//import org.springframework.web.multipart.MultipartFile; +// +//@WebMvcTest(ContentController.class) +//@Import({GlobalExceptionHandlerSuggestion.class, SecurityConfig.class}) +//class ContentControllerTest { +// +// @Autowired +// private MockMvc mockMvc; +// +// @Autowired +// private ObjectMapper objectMapper; +// +// @MockitoBean +// private ContentService contentService; +// +// @Captor +// private ArgumentCaptor requestCaptor; +// +// @Test +// @DisplayName("정상적인 콘텐츠 생성 요청이면 생성된 콘텐츠와 201 응답을 반환한다") +// void postContent_success() throws Exception { +// // Given +// ContentCreateRequest request = new ContentCreateRequest( +// ContentType.MOVIE, +// "테스트 영화", +// "테스트 설명", +// List.of("액션", "드라마") +// ); +// ContentResponse response = new ContentResponse( +// UUID.fromString("11111111-1111-1111-1111-111111111111"), +// ContentType.MOVIE, +// "테스트 영화", +// "테스트 설명", +// "http://localhost:8080/thumbnails/11111111-1111-1111-1111-111111111111/test.jpg", +// BinaryContentUploadStatus.PENDING, +// List.of("액션", "드라마"), +// 0.0, 0, 0 +// ); +// +// given(contentService.create(any(ContentCreateRequest.class), any())).willReturn(response); +// +// MockMultipartFile requestPart = new MockMultipartFile( +// "request", +// "", +// MediaType.APPLICATION_JSON_VALUE, +// objectMapper.writeValueAsBytes(request) +// ); +// MockMultipartFile thumbnailPart = new MockMultipartFile( +// "thumbnail", +// "test.jpg", +// MediaType.IMAGE_JPEG_VALUE, +// new byte[]{1, 2, 3} +// ); +// +// // When & Then +// mockMvc.perform(multipart("/api/contents") +// .file(requestPart) +// .file(thumbnailPart)) +// .andExpect(status().isCreated()) +// .andExpect(jsonPath("$.id").value(response.id().toString())) +// .andExpect(jsonPath("$.type").value("movie")) +// .andExpect(jsonPath("$.title").value("테스트 영화")) +// .andExpect(jsonPath("$.description").value("테스트 설명")) +// .andExpect(jsonPath("$.thumbnailUrl").value(response.thumbnailUrl())) +// .andExpect(jsonPath("$.thumbnailUploadStatus").value("PENDING")) +// .andExpect(jsonPath("$.tags[0]").value("액션")) +// .andExpect(jsonPath("$.tags[1]").value("드라마")) +// .andExpect(jsonPath("$.averageRating").value(0.0)) +// .andExpect(jsonPath("$.reviewCount").value(0)) +// .andExpect(jsonPath("$.watcherCount").value(0)); +// +// verify(contentService).create(requestCaptor.capture(), any(MultipartFile.class)); +// ContentCreateRequest captured = requestCaptor.getValue(); +// assertThat(captured.type()).isEqualTo(ContentType.MOVIE); +// assertThat(captured.title()).isEqualTo("테스트 영화"); +// assertThat(captured.description()).isEqualTo("테스트 설명"); +// assertThat(captured.tags()).containsExactly("액션", "드라마"); +// } +// +// @Test +// @DisplayName("thumbnail 없이 콘텐츠 생성 요청이면 201 응답을 반환한다") +// void postContent_withoutThumbnail_success() throws Exception { +// // Given +// ContentCreateRequest request = new ContentCreateRequest( +// ContentType.TV_SERIES, +// "테스트 드라마", +// "테스트 설명", +// List.of("로맨스") +// ); +// ContentResponse response = new ContentResponse( +// UUID.fromString("22222222-2222-2222-2222-222222222222"), +// ContentType.TV_SERIES, +// "테스트 드라마", +// "테스트 설명", +// null, +// null, +// List.of("로맨스"), +// 0.0, 0, 0 +// ); +// +// given(contentService.create(any(ContentCreateRequest.class), isNull())).willReturn(response); +// +// MockMultipartFile requestPart = new MockMultipartFile( +// "request", "", MediaType.APPLICATION_JSON_VALUE, +// objectMapper.writeValueAsBytes(request) +// ); +// +// // When & Then +// mockMvc.perform(multipart("/api/contents") +// .file(requestPart)) +// .andExpect(status().isCreated()) +// .andExpect(jsonPath("$.id").value(response.id().toString())) +// .andExpect(jsonPath("$.type").value("tvSeries")) +// .andExpect(jsonPath("$.title").value("테스트 드라마")) +// .andExpect(jsonPath("$.description").value("테스트 설명")) +// .andExpect(jsonPath("$.tags[0]").value("로맨스")) +// .andExpect(jsonPath("$.averageRating").value(0.0)) +// .andExpect(jsonPath("$.reviewCount").value(0)) +// .andExpect(jsonPath("$.watcherCount").value(0)); +// verify(contentService).create(requestCaptor.capture(), isNull()); +// ContentCreateRequest captured = requestCaptor.getValue(); +// assertThat(captured.type()).isEqualTo(ContentType.TV_SERIES); +// assertThat(captured.title()).isEqualTo("테스트 드라마"); +// assertThat(captured.description()).isEqualTo("테스트 설명"); +// assertThat(captured.tags()).containsExactly("로맨스"); +// } +// +// @Test +// @DisplayName("콘텐츠 타입이 누락되면 400 검증 실패 응답을 반환한다") +// void postContent_missingType_returnsBadRequest() throws Exception { +// // Given +// String requestJson = """ +// { +// "title": "테스트 영화", +// "tags": ["액션"] +// } +// """; +// MockMultipartFile requestPart = new MockMultipartFile( +// "request", "", MediaType.APPLICATION_JSON_VALUE, requestJson.getBytes() +// ); +// +// // When & Then +// mockMvc.perform(multipart("/api/contents") +// .file(requestPart)) +// .andExpect(status().isBadRequest()) +// .andExpect(jsonPath("$.exceptionType").value("INVALID_INPUT")) +// .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) +// .andExpect(jsonPath("$.details.type[0]").value("콘텐츠 타입은 필수입니다.")); +// +// verify(contentService, never()).create(any(), any()); +// } +// +// @Test +// @DisplayName("제목이 공백이면 400 검증 실패 응답을 반환한다") +// void postContent_blankTitle_returnsBadRequest() throws Exception { +// // Given +// ContentCreateRequest request = new ContentCreateRequest( +// ContentType.MOVIE, +// " ", +// null, +// List.of("액션") +// ); +// MockMultipartFile requestPart = new MockMultipartFile( +// "request", "", MediaType.APPLICATION_JSON_VALUE, +// objectMapper.writeValueAsBytes(request) +// ); +// +// // When & Then +// mockMvc.perform(multipart("/api/contents") +// .file(requestPart)) +// .andExpect(status().isBadRequest()) +// .andExpect(jsonPath("$.exceptionType").value("INVALID_INPUT")) +// .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) +// .andExpect(jsonPath("$.details.title[0]").value("제목은 필수입니다.")); +// +// verify(contentService, never()).create(any(), any()); +// } +// +// @Test +// @DisplayName("태그 목록이 비어있으면 400 검증 실패 응답을 반환한다") +// void postContent_emptyTags_returnsBadRequest() throws Exception { +// // Given +// ContentCreateRequest request = new ContentCreateRequest( +// ContentType.MOVIE, +// "테스트 영화", +// null, +// List.of() +// ); +// MockMultipartFile requestPart = new MockMultipartFile( +// "request", "", MediaType.APPLICATION_JSON_VALUE, +// objectMapper.writeValueAsBytes(request) +// ); +// +// // When & Then +// mockMvc.perform(multipart("/api/contents") +// .file(requestPart)) +// .andExpect(status().isBadRequest()) +// .andExpect(jsonPath("$.exceptionType").value("INVALID_INPUT")) +// .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) +// .andExpect(jsonPath("$.details.tags[0]").value("콘텐츠 태그 목록은 비어있을 수 없습니다.")); +// +// verify(contentService, never()).create(any(), any()); +// } +// +// @Test +// @DisplayName("예상하지 못한 서비스 예외가 발생하면 500 오류 응답을 반환한다") +// void postContent_unexpectedException_returnsInternalServerError() throws Exception { +// // Given +// ContentCreateRequest request = new ContentCreateRequest( +// ContentType.MOVIE, +// "테스트 영화", +// null, +// List.of("액션") +// ); +// given(contentService.create(any(ContentCreateRequest.class), any())) +// .willThrow(new IllegalStateException("unexpected")); +// +// MockMultipartFile requestPart = new MockMultipartFile( +// "request", "", MediaType.APPLICATION_JSON_VALUE, +// objectMapper.writeValueAsBytes(request) +// ); +// +// // When & Then +// mockMvc.perform(multipart("/api/contents") +// .file(requestPart)) +// .andExpect(status().isInternalServerError()) +// .andExpect(jsonPath("$.exceptionType").value("INTERNAL_SERVER_ERROR")) +// .andExpect(jsonPath("$.message").value("서버 내부 에러가 발생했습니다.")); +// } +//} From f90ac3383c42cdfb6a93f9ba8e446597d34848a3 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 14:41:36 +0900 Subject: [PATCH 38/41] =?UTF-8?q?refactor:=20BinaryContent=20String=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=20StringUtils=20=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EB=B0=8F=20=EC=BB=A4=EC=8A=A4=ED=85=80=20=EC=98=88=EC=99=B8=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 --- .../mopl/binarycontent/entity/BinaryContent.java | 7 ++++++- .../exception/InvalidBinaryContentUrlException.java | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/codeit/team5/mopl/binarycontent/exception/InvalidBinaryContentUrlException.java diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContent.java b/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContent.java index 9fd071ef..22cc3020 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContent.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContent.java @@ -7,6 +7,8 @@ import jakarta.persistence.Enumerated; import jakarta.persistence.Table; import java.util.Objects; +import com.codeit.team5.mopl.binarycontent.exception.InvalidBinaryContentUrlException; +import org.springframework.util.StringUtils; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -26,7 +28,10 @@ public class BinaryContent extends BaseUpdatableEntity { public static BinaryContent pending(String url) { BinaryContent binaryContent = new BinaryContent(); - binaryContent.url = Objects.requireNonNull(url, "url must not be null"); + if (!StringUtils.hasText(url)) { + throw new InvalidBinaryContentUrlException(); + } + binaryContent.url = url; binaryContent.uploadStatus = BinaryContentUploadStatus.PENDING; return binaryContent; } diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/exception/InvalidBinaryContentUrlException.java b/src/main/java/com/codeit/team5/mopl/binarycontent/exception/InvalidBinaryContentUrlException.java new file mode 100644 index 00000000..ccbbcf46 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/exception/InvalidBinaryContentUrlException.java @@ -0,0 +1,11 @@ +package com.codeit.team5.mopl.binarycontent.exception; + +import com.codeit.team5.mopl.global.exception.suggestion.BusinessExceptionSuggestion; +import org.springframework.http.HttpStatus; + +public class InvalidBinaryContentUrlException extends BusinessExceptionSuggestion { + + public InvalidBinaryContentUrlException() { + super(HttpStatus.INTERNAL_SERVER_ERROR, "BinaryContent url은 비어있을 수 없습니다."); + } +} From 2f834fbc8ad7c7abee46e84948cbf48d94e383b6 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 14:41:51 +0900 Subject: [PATCH 39/41] =?UTF-8?q?refactor:=20BinaryContentService=20?= =?UTF-8?q?=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team5/mopl/binarycontent/service/BinaryContentService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/service/BinaryContentService.java b/src/main/java/com/codeit/team5/mopl/binarycontent/service/BinaryContentService.java index b02361f9..1f638351 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/service/BinaryContentService.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/service/BinaryContentService.java @@ -11,11 +11,12 @@ @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class BinaryContentService { private final BinaryContentRepository binaryContentRepository; - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Transactional public void updateUploadStatus(UUID binaryContentId, BinaryContentUploadStatus status) { binaryContentRepository.findById(binaryContentId) .orElseThrow(() -> new BinaryContentNotFoundException(binaryContentId)) From ac34b855438d4ea6d3db5b5bbfc8a1c2193cc26a Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 14:42:44 +0900 Subject: [PATCH 40/41] =?UTF-8?q?refactor:=20ContentMapper=20toDto()=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EA=B0=84=EC=86=8C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team5/mopl/content/mapper/ContentMapper.java | 7 +++---- .../mopl/content/service/ContentServiceTest.java | 12 ++++++------ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java b/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java index 01df24fd..04099b78 100644 --- a/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java +++ b/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java @@ -13,14 +13,13 @@ @Mapper(componentModel = "spring") public interface ContentMapper { - @Mapping(target = "id", source = "content.id") @Mapping(target = "tags", source = "contentTags") @Mapping(target = "averageRating", source = "stats") @Mapping(target = "reviewCount", source = "stats.reviewCount") @Mapping(target = "watcherCount", source = "stats.watcherCount") - @Mapping(target = "thumbnailUrl", source = "content.thumbnail.url") - @Mapping(target = "thumbnailUploadStatus", source = "content.thumbnail.uploadStatus") - ContentResponse toDto(Content content, Set contentTags, ContentStats stats); + @Mapping(target = "thumbnailUrl", source = "thumbnail.url") + @Mapping(target = "thumbnailUploadStatus", source = "thumbnail.uploadStatus") + ContentResponse toDto(Content content); default List toTagNames(Set contentTags) { if (contentTags == null) return Collections.emptyList(); diff --git a/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java b/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java index b305b10d..a3fe5fde 100644 --- a/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java +++ b/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java @@ -88,7 +88,7 @@ void create_success() { when(binaryContentStorage.generateKey(any(), eq("test.jpg"))).thenReturn("thumbnails/test.jpg"); when(binaryContentStorage.toUrl("thumbnails/test.jpg")).thenReturn("http://localhost:8080/thumbnails/test.jpg"); when(binaryContentRepository.save(any(BinaryContent.class))).then(returnsFirstArg()); - when(contentMapper.toDto(any(Content.class), any(), any(ContentStats.class))).thenReturn(expectedResponse); + when(contentMapper.toDto(any(Content.class))).thenReturn(expectedResponse); // when ContentResponse result = contentService.create(request, thumbnail); @@ -127,7 +127,7 @@ void create_withoutThumbnail_success() { when(contentRepository.save(any(Content.class))).then(returnsFirstArg()); when(tagRepository.findByNameIn(List.of("로맨스"))).thenReturn(List.of(romanceTag)); when(contentStatsRepository.save(any(ContentStats.class))).then(returnsFirstArg()); - when(contentMapper.toDto(any(Content.class), any(), any(ContentStats.class))).thenReturn(expectedResponse); + when(contentMapper.toDto(any(Content.class))).thenReturn(expectedResponse); // when ContentResponse result = contentService.create(request, null); @@ -147,7 +147,7 @@ void create_existingTag_doesNotSaveNewTag() { when(contentRepository.save(any(Content.class))).then(returnsFirstArg()); when(tagRepository.findByNameIn(List.of("액션"))).thenReturn(List.of(Tag.create("액션"))); when(contentStatsRepository.save(any(ContentStats.class))).then(returnsFirstArg()); - when(contentMapper.toDto(any(Content.class), any(), any(ContentStats.class))).thenReturn(null); + when(contentMapper.toDto(any(Content.class))).thenReturn(null); // when contentService.create(request, null); @@ -167,7 +167,7 @@ void create_newTag_savesTag() { when(tagRepository.findByNameIn(anyList())).thenReturn(List.of()); when(tagRepository.saveAll(anyList())).then(returnsFirstArg()); when(contentStatsRepository.save(any(ContentStats.class))).then(returnsFirstArg()); - when(contentMapper.toDto(any(Content.class), any(), any(ContentStats.class))).thenReturn(null); + when(contentMapper.toDto(any(Content.class))).thenReturn(null); // when contentService.create(request, null); @@ -189,7 +189,7 @@ void create_duplicateTags_savesOnce() { when(tagRepository.findByNameIn(List.of("액션"))).thenReturn(List.of()); when(tagRepository.saveAll(anyList())).then(returnsFirstArg()); when(contentStatsRepository.save(any(ContentStats.class))).then(returnsFirstArg()); - when(contentMapper.toDto(any(Content.class), any(), any(ContentStats.class))).thenReturn(null); + when(contentMapper.toDto(any(Content.class))).thenReturn(null); // when contentService.create(request, null); @@ -228,7 +228,7 @@ void create_tagsNormalized() { when(tagRepository.findByNameIn(List.of("action", "drama"))).thenReturn(List.of()); when(tagRepository.saveAll(anyList())).then(returnsFirstArg()); when(contentStatsRepository.save(any(ContentStats.class))).then(returnsFirstArg()); - when(contentMapper.toDto(any(Content.class), any(), any(ContentStats.class))).thenReturn(null); + when(contentMapper.toDto(any(Content.class))).thenReturn(null); // when contentService.create(request, null); From b868c89dc32b9d03f6fbf726c12c9e636f1bbcf5 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 14:43:01 +0900 Subject: [PATCH 41/41] =?UTF-8?q?refactor:=20ContentTag=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EC=99=B8=EB=B6=80=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=EB=A1=9C=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/codeit/team5/mopl/content/entity/Content.java | 7 +++---- .../codeit/team5/mopl/content/service/ContentService.java | 5 +++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/content/entity/Content.java b/src/main/java/com/codeit/team5/mopl/content/entity/Content.java index 80d99fe9..ecbc4b03 100644 --- a/src/main/java/com/codeit/team5/mopl/content/entity/Content.java +++ b/src/main/java/com/codeit/team5/mopl/content/entity/Content.java @@ -3,7 +3,6 @@ import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; import com.codeit.team5.mopl.content.exception.InvalidContentSourceException; import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity; -import com.codeit.team5.mopl.tag.entity.Tag; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -111,10 +110,10 @@ public void attachStats(ContentStats stats) { this.stats = stats; } - public void addTag(Tag tag) { - if (tag == null) { + public void addTag(ContentTag contentTag) { + if (contentTag == null) { return; } - contentTags.add(ContentTag.create(this, tag)); + contentTags.add(contentTag); } } diff --git a/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java b/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java index 5d7fa271..6fc9c29a 100644 --- a/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java +++ b/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java @@ -12,6 +12,7 @@ import com.codeit.team5.mopl.content.mapper.ContentMapper; import com.codeit.team5.mopl.content.repository.ContentRepository; import com.codeit.team5.mopl.content.repository.ContentStatsRepository; +import com.codeit.team5.mopl.content.entity.ContentTag; import com.codeit.team5.mopl.tag.entity.Tag; import com.codeit.team5.mopl.tag.repository.TagRepository; import java.io.IOException; @@ -65,7 +66,7 @@ public ContentResponse create(ContentCreateRequest request, MultipartFile thumbn ContentStats stats = contentStatsRepository.save(ContentStats.create()); content.attachStats(stats); - return contentMapper.toDto(content, content.getContentTags(), stats); + return contentMapper.toDto(content); } private void attachTags(Content content, List rawTagNames) { @@ -92,6 +93,6 @@ private void attachTags(Content content, List rawTagNames) { tagRepository.saveAll(newTags).forEach(tag -> existingTags.put(tag.getName(), tag)); } - tagNames.forEach(name -> content.addTag(existingTags.get(name))); + tagNames.forEach(name -> content.addTag(ContentTag.create(content, existingTags.get(name)))); } }