From 46ffd351623d0849396a276922a78ad7110632e2 Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Fri, 16 Jan 2026 13:42:56 +0900 Subject: [PATCH 01/25] =?UTF-8?q?Feat:=20image=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EC=A6=88=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatManage/worker/ChatImageManager.java | 7 ++++ .../global/file/dao/AbstractImageManager.java | 7 ++++ .../global/file/entity/ImageFile.java | 42 +++++++++++++++++++ .../global/file/entity/ImageVariantKey.java | 24 +++++++++++ 4 files changed, 80 insertions(+) create mode 100644 src/main/java/com/studypals/global/file/entity/ImageFile.java create mode 100644 src/main/java/com/studypals/global/file/entity/ImageVariantKey.java diff --git a/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java index f30640c0..c2b57804 100644 --- a/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java +++ b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java @@ -1,5 +1,6 @@ package com.studypals.domain.chatManage.worker; +import java.util.List; import java.util.UUID; import org.springframework.stereotype.Component; @@ -9,6 +10,7 @@ import com.studypals.global.file.ObjectStorage; import com.studypals.global.file.dao.AbstractImageManager; import com.studypals.global.file.entity.ImageType; +import com.studypals.global.file.entity.ImageVariantKey; /** * 파일 중 채팅 이미지를 처리하는데 사용하는 구체 클래스입니다. @@ -51,6 +53,11 @@ protected String generateObjectKeyDetail(String chatRoomId, String ext) { return CHAT_IMAGE_PATH + "/" + chatRoomId + "/" + UUID.randomUUID() + "." + ext; } + @Override + protected List variants() { + return List.of(ImageVariantKey.SMALL, ImageVariantKey.MEDIUM, ImageVariantKey.LARGE); + } + /** * 이 클래스는 채팅 이미지를 처리합니다. * @return 처리하는 이미지 종류 diff --git a/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java b/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java index 0ae6bbae..16456515 100644 --- a/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java +++ b/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java @@ -7,6 +7,7 @@ import com.studypals.global.exceptions.errorCode.FileErrorCode; import com.studypals.global.exceptions.exception.FileException; import com.studypals.global.file.ObjectStorage; +import com.studypals.global.file.entity.ImageVariantKey; /** * * 파일을 처리하는데 사용하는 추상 클래스입니다. @@ -79,6 +80,12 @@ protected void validateTargetId(Long userId, String targetId) { // 기본 구현: 검증 없음 } + /** + * 구현체에서 다루는 이미지 사이즈를 반환합니다. + * @return ImageVariantKey 리스트 + */ + protected abstract List variants(); + /** * 사전에 정해둔 파일 확장자를 가지는지 확인합니다. * @param fileName 확인할 파일 이름 diff --git a/src/main/java/com/studypals/global/file/entity/ImageFile.java b/src/main/java/com/studypals/global/file/entity/ImageFile.java new file mode 100644 index 00000000..05359765 --- /dev/null +++ b/src/main/java/com/studypals/global/file/entity/ImageFile.java @@ -0,0 +1,42 @@ +package com.studypals.global.file.entity; + +import java.time.LocalDateTime; + +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; + +/** + * 코드에 대한 전체적인 역할을 적습니다. + *

+ * 코드에 대한 작동 원리 등을 적습니다. + * + *

상속 정보:
+ * 상속 정보를 적습니다. + * + *

주요 생성자:
+ * {@code ExampleClass(String example)}
주요 생성자와 그 매개변수에 대한 설명을 적습니다.
+ * + *

빈 관리:
+ * 필요 시 빈 관리에 대한 내용을 적습니다. + * + *

외부 모듈:
+ * 필요 시 외부 모듈에 대한 내용을 적습니다. + * + * @author My + * @see + * @since 2026-01-15 + */ +@MappedSuperclass +public abstract class ImageFile { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String filePath; + + private Long sourceId; + + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/studypals/global/file/entity/ImageVariantKey.java b/src/main/java/com/studypals/global/file/entity/ImageVariantKey.java new file mode 100644 index 00000000..58f6501f --- /dev/null +++ b/src/main/java/com/studypals/global/file/entity/ImageVariantKey.java @@ -0,0 +1,24 @@ +package com.studypals.global.file.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 이미지 사이즈에 관한 enum입니다. + * + *

외부 모듈:
+ * 필요 시 외부 모듈에 대한 내용을 적습니다. + * + * @author sleepyhoon + * @see + * @since 2026-01-16 + */ +@Getter +@RequiredArgsConstructor +public enum ImageVariantKey { + SMALL(256), + MEDIUM(512), + LARGE(1024); + + private final int size; +} From 6154b5e51b3a03148036101e90740b2dca34e56d Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Sat, 17 Jan 2026 01:40:41 +0900 Subject: [PATCH 02/25] =?UTF-8?q?Refactor:=20extractExtension=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=20=EB=A9=94=EC=84=9C=EB=93=9C=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/studypals/global/file/FileUtils.java | 16 ++++++++++++++++ .../global/file/dao/AbstractFileManager.java | 15 +-------------- 2 files changed, 17 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/studypals/global/file/FileUtils.java diff --git a/src/main/java/com/studypals/global/file/FileUtils.java b/src/main/java/com/studypals/global/file/FileUtils.java new file mode 100644 index 00000000..60c505db --- /dev/null +++ b/src/main/java/com/studypals/global/file/FileUtils.java @@ -0,0 +1,16 @@ +package com.studypals.global.file; + +public class FileUtils { + /** + * 파일 이름에서 확장자를 추출합니다. + * @param fileName 파일 이름 + * @return 추출한 확장자 이름 + */ + public static String extractExtension(String fileName) { + int lastDotIndex = fileName.lastIndexOf("."); + if (lastDotIndex == -1 || lastDotIndex == fileName.length() - 1) { + return ""; // 확장자가 없는 경우 처리 + } + return fileName.substring(lastDotIndex + 1).toLowerCase(); + } +} diff --git a/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java b/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java index 56944649..4ed3e3b4 100644 --- a/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java +++ b/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java @@ -2,8 +2,8 @@ import lombok.RequiredArgsConstructor; +import com.studypals.global.file.FileType; import com.studypals.global.file.ObjectStorage; -import com.studypals.global.file.entity.FileType; /** * 파일을 처리하는데 사용하는 최상위 추상 클래스입니다. @@ -34,17 +34,4 @@ public void delete(String url) { * @return 파일 타입 */ public abstract FileType getFileType(); - - /** - * 파일 이름에서 확장자를 추출합니다. - * @param fileName 파일 이름 - * @return 추출한 확장자 이름 - */ - protected String extractExtension(String fileName) { - int lastDotIndex = fileName.lastIndexOf("."); - if (lastDotIndex == -1 || lastDotIndex == fileName.length() - 1) { - return ""; // 확장자가 없는 경우 처리 - } - return fileName.substring(lastDotIndex + 1).toLowerCase(); - } } From c1b3fd88b45256fc0529f4201c5db665f889256c Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Sat, 17 Jan 2026 01:41:34 +0900 Subject: [PATCH 03/25] =?UTF-8?q?Refactor:=20@Value=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EB=8C=80=EC=8B=A0=20@Configurati?= =?UTF-8?q?onProperties=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatManage/worker/ChatImageManager.java | 5 ++-- ...er.java => MemberProfileImageManager.java} | 14 ++++++++-- .../studypals/global/configs/FileConfig.java | 14 ++++++++++ .../studypals/global/file/FileProperties.java | 13 +++++++++ .../global/file/dao/AbstractImageManager.java | 28 ++++++++++--------- 5 files changed, 56 insertions(+), 18 deletions(-) rename src/main/java/com/studypals/domain/memberManage/worker/{MemberProfileManager.java => MemberProfileImageManager.java} (73%) create mode 100644 src/main/java/com/studypals/global/configs/FileConfig.java create mode 100644 src/main/java/com/studypals/global/file/FileProperties.java diff --git a/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java index c2b57804..5eceb3c2 100644 --- a/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java +++ b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java @@ -7,6 +7,7 @@ import com.studypals.global.exceptions.errorCode.ChatErrorCode; import com.studypals.global.exceptions.exception.ChatException; +import com.studypals.global.file.FileProperties; import com.studypals.global.file.ObjectStorage; import com.studypals.global.file.dao.AbstractImageManager; import com.studypals.global.file.entity.ImageType; @@ -31,8 +32,8 @@ public class ChatImageManager extends AbstractImageManager { private static final String CHAT_IMAGE_PATH = "chat"; private final ChatRoomReader chatRoomReader; - public ChatImageManager(ObjectStorage objectStorage, ChatRoomReader chatRoomReader) { - super(objectStorage); + public ChatImageManager(ObjectStorage objectStorage, FileProperties properties, ChatRoomReader chatRoomReader) { + super(objectStorage, properties); this.chatRoomReader = chatRoomReader; } diff --git a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileManager.java b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageManager.java similarity index 73% rename from src/main/java/com/studypals/domain/memberManage/worker/MemberProfileManager.java rename to src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageManager.java index d0cad9e8..8c3fdadd 100644 --- a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileManager.java +++ b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageManager.java @@ -1,12 +1,15 @@ package com.studypals.domain.memberManage.worker; +import java.util.List; import java.util.UUID; import org.springframework.stereotype.Component; +import com.studypals.global.file.FileProperties; import com.studypals.global.file.ObjectStorage; import com.studypals.global.file.dao.AbstractImageManager; import com.studypals.global.file.entity.ImageType; +import com.studypals.global.file.entity.ImageVariantKey; /** * 파일 중 프로필 이미지를 처리하는데 사용하는 구체 클래스입니다. @@ -23,12 +26,12 @@ * @since 2026-01-13 */ @Component -public class MemberProfileManager extends AbstractImageManager { +public class MemberProfileImageManager extends AbstractImageManager { private static final String PROFILE_PATH = "profile"; - public MemberProfileManager(ObjectStorage objectStorage) { - super(objectStorage); + public MemberProfileImageManager(ObjectStorage objectStorage, FileProperties properties) { + super(objectStorage, properties); } /** @@ -40,6 +43,11 @@ protected String generateObjectKeyDetail(String targetId, String ext) { return PROFILE_PATH + "/" + targetId + "/" + UUID.randomUUID() + "." + ext; } + @Override + protected List variants() { + return List.of(ImageVariantKey.SMALL, ImageVariantKey.LARGE); + } + /** * 이 클래스는 프로필 이미지를 처리합니다. * @return 처리하는 이미지 종류 diff --git a/src/main/java/com/studypals/global/configs/FileConfig.java b/src/main/java/com/studypals/global/configs/FileConfig.java new file mode 100644 index 00000000..c9306229 --- /dev/null +++ b/src/main/java/com/studypals/global/configs/FileConfig.java @@ -0,0 +1,14 @@ +package com.studypals.global.configs; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import com.studypals.global.file.FileProperties; + +/** + * 파일 관련 설정을 담당하는 Configuration 클래스입니다. + * FileProperties 빈으로 등록하고 활성화합니다. + */ +@Configuration +@EnableConfigurationProperties(FileProperties.class) +public class FileConfig {} diff --git a/src/main/java/com/studypals/global/file/FileProperties.java b/src/main/java/com/studypals/global/file/FileProperties.java new file mode 100644 index 00000000..e10d88c5 --- /dev/null +++ b/src/main/java/com/studypals/global/file/FileProperties.java @@ -0,0 +1,13 @@ +package com.studypals.global.file; + +import java.util.List; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Positive; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Validated +@ConfigurationProperties(prefix = "file.upload") +public record FileProperties(@NotEmpty List extensions, @Positive int presignedUrlExpireTime) {} diff --git a/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java b/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java index 16456515..ec617884 100644 --- a/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java +++ b/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java @@ -2,10 +2,10 @@ import java.util.List; -import org.springframework.beans.factory.annotation.Value; - import com.studypals.global.exceptions.errorCode.FileErrorCode; import com.studypals.global.exceptions.exception.FileException; +import com.studypals.global.file.FileProperties; +import com.studypals.global.file.FileUtils; import com.studypals.global.file.ObjectStorage; import com.studypals.global.file.entity.ImageVariantKey; @@ -22,14 +22,13 @@ */ public abstract class AbstractImageManager extends AbstractFileManager { - @Value("${file.upload.extensions}") - private List acceptableExtensions; - - @Value("${file.upload.presigned-url-expire-time}") - private int presignedUrlExpireTime; + private final List acceptableExtensions; + private final int presignedUrlExpireTime; - public AbstractImageManager(ObjectStorage objectStorage) { + public AbstractImageManager(ObjectStorage objectStorage, FileProperties properties) { super(objectStorage); + this.acceptableExtensions = properties.extensions(); + this.presignedUrlExpireTime = properties.presignedUrlExpireTime(); } /** @@ -41,11 +40,14 @@ public AbstractImageManager(ObjectStorage objectStorage) { * @param targetId 업로드 대상 식별자 (예: userId, groupId, chatRoomId) * @return 업로드 가능한 Presigned URL */ - public final String getUploadUrl(Long userId, String fileName, String targetId) { + public final String getUploadUrl(String objectKey) { + return objectStorage.createPresignedPutUrl(objectKey, presignedUrlExpireTime); + } + + public final String createObjectKey(Long userId, String fileName, String targetId) { validateFileName(fileName); validateTargetId(userId, targetId); - String objectKey = generateObjectKey(fileName, targetId); - return objectStorage.createPresignedPutUrl(objectKey, presignedUrlExpireTime); + return generateObjectKey(fileName, targetId); } /** @@ -55,7 +57,7 @@ public final String getUploadUrl(Long userId, String fileName, String targetId) * @return 생성된 ObjectKey */ private String generateObjectKey(String fileName, String targetId) { - String ext = extractExtension(fileName); + String ext = FileUtils.extractExtension(fileName); return generateObjectKeyDetail(targetId, ext); } @@ -94,7 +96,7 @@ private void validateFileName(String fileName) { if (fileName == null || !fileName.contains(".")) { throw new FileException(FileErrorCode.INVALID_FILE_NAME); } - String extension = extractExtension(fileName); + String extension = FileUtils.extractExtension(fileName); if (!acceptableExtensions.contains(extension)) { throw new FileException(FileErrorCode.UNSUPPORTED_FILE_IMAGE_EXTENSION); } From b891d4b767fe8f3d662a78cd29e0467cf3d023d6 Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Sat, 17 Jan 2026 01:42:47 +0900 Subject: [PATCH 04/25] =?UTF-8?q?Feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=ED=95=84=EB=93=9C=EB=A5=BC=20=EB=8B=B4?= =?UTF-8?q?=EB=8A=94=20ImageFile=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/file/entity/ImageFile.java | 78 ++++++++++++++----- 1 file changed, 58 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/studypals/global/file/entity/ImageFile.java b/src/main/java/com/studypals/global/file/entity/ImageFile.java index 05359765..6dabc5c2 100644 --- a/src/main/java/com/studypals/global/file/entity/ImageFile.java +++ b/src/main/java/com/studypals/global/file/entity/ImageFile.java @@ -2,41 +2,79 @@ import java.time.LocalDateTime; +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.MappedSuperclass; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + /** - * 코드에 대한 전체적인 역할을 적습니다. - *

- * 코드에 대한 작동 원리 등을 적습니다. - * - *

상속 정보:
- * 상속 정보를 적습니다. - * - *

주요 생성자:
- * {@code ExampleClass(String example)}
주요 생성자와 그 매개변수에 대한 설명을 적습니다.
+ * 객체 스토리지에 저장된 이미지 파일의 메타데이터를 관리하는 엔티티의 공통 속성을 정의하는 추상 클래스입니다. + * {@code @MappedSuperclass}를 사용하여 이 클래스를 상속하는 엔티티들은 아래 필드들을 자신의 컬럼으로 포함하게 됩니다. * - *

빈 관리:
- * 필요 시 빈 관리에 대한 내용을 적습니다. - * - *

외부 모듈:
- * 필요 시 외부 모듈에 대한 내용을 적습니다. - * - * @author My - * @see - * @since 2026-01-15 + * @author sleepyhoon + * @since 2026-01-13 */ +@Getter +@SuperBuilder +@AllArgsConstructor @MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) public abstract class ImageFile { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - private String filePath; + /** + * 객체 스토리지(예: MinIO, S3) 내에서 파일을 식별하는 고유한 키입니다. + * 예: "profile/1/uuid.jpg" + */ + @Column(nullable = false, unique = true) + private String objectKey; + + /** + * 사용자가 업로드한 원본 파일의 이름입니다. + * 예: "my_vacation_photo.jpg" + */ + @Column(nullable = false) + private String originalFileName; + + /** + * 파일의 MIME 타입입니다. + * 예: "jpg" + */ + @Column(nullable = false) + private String mimeType; - private Long sourceId; + /** + * 이미지 상태입니다. + * Presigned URL 발급하면 PENDING + * 발급 후 성공 API를 호출하면 COMPLETE + * 발급 후 일정 시간 이내 성공 API를 호출하지 않으면 EXPIRED + */ + @Column(nullable = false) + @Enumerated(EnumType.STRING) + @Builder.Default + private ImageStatus imageStatus = ImageStatus.PENDING; + /** + * 이미지가 업로드된 시간입니다. + */ + @CreatedDate + @Column(updatable = false, nullable = false) private LocalDateTime createdAt; } From c31d1556fc8d4f2b84e2316f843a3dd67784a6cd Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Sat, 17 Jan 2026 01:43:02 +0900 Subject: [PATCH 05/25] =?UTF-8?q?Feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20enum=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/studypals/global/file/entity/ImageStatus.java | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/main/java/com/studypals/global/file/entity/ImageStatus.java diff --git a/src/main/java/com/studypals/global/file/entity/ImageStatus.java b/src/main/java/com/studypals/global/file/entity/ImageStatus.java new file mode 100644 index 00000000..9b81fa86 --- /dev/null +++ b/src/main/java/com/studypals/global/file/entity/ImageStatus.java @@ -0,0 +1,7 @@ +package com.studypals.global.file.entity; + +public enum ImageStatus { + PENDING, // 대기 중 + COMPLETE, // 완료 + EXPIRED // 실패 +} From b2d13a1aa06d260d5a1c140a9cf87c0913b0ca12 Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Sat, 17 Jan 2026 01:43:42 +0900 Subject: [PATCH 06/25] =?UTF-8?q?Refactor:=20=EB=94=94=EB=A0=89=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/studypals/global/file/{entity => }/FileType.java | 2 +- src/main/java/com/studypals/global/file/entity/ImageType.java | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) rename src/main/java/com/studypals/global/file/{entity => }/FileType.java (85%) diff --git a/src/main/java/com/studypals/global/file/entity/FileType.java b/src/main/java/com/studypals/global/file/FileType.java similarity index 85% rename from src/main/java/com/studypals/global/file/entity/FileType.java rename to src/main/java/com/studypals/global/file/FileType.java index af88d389..7a3c2315 100644 --- a/src/main/java/com/studypals/global/file/entity/FileType.java +++ b/src/main/java/com/studypals/global/file/FileType.java @@ -1,4 +1,4 @@ -package com.studypals.global.file.entity; +package com.studypals.global.file; /** * 파일의 종류를 나타내는 최상위 마커 인터페이스입니다.

diff --git a/src/main/java/com/studypals/global/file/entity/ImageType.java b/src/main/java/com/studypals/global/file/entity/ImageType.java index 2868a7f4..a05fece9 100644 --- a/src/main/java/com/studypals/global/file/entity/ImageType.java +++ b/src/main/java/com/studypals/global/file/entity/ImageType.java @@ -1,5 +1,7 @@ package com.studypals.global.file.entity; +import com.studypals.global.file.FileType; + /** * 파일 이미지 타입을 정의합니다.

* 현재 프로필, 채팅 이미지만 고려합니다. From d488aded7e9a3ef0c00d6accc065d0a2acabd784 Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Sat, 17 Jan 2026 01:43:56 +0900 Subject: [PATCH 07/25] =?UTF-8?q?Feat:=20=EC=B1=84=ED=8C=85=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatManage/dao/ChatImageRepository.java | 7 +++++ .../domain/chatManage/entity/ChatImage.java | 24 ++++++++++++++ .../chatManage/worker/ChatImageWriter.java | 31 +++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 src/main/java/com/studypals/domain/chatManage/dao/ChatImageRepository.java create mode 100644 src/main/java/com/studypals/domain/chatManage/entity/ChatImage.java create mode 100644 src/main/java/com/studypals/domain/chatManage/worker/ChatImageWriter.java diff --git a/src/main/java/com/studypals/domain/chatManage/dao/ChatImageRepository.java b/src/main/java/com/studypals/domain/chatManage/dao/ChatImageRepository.java new file mode 100644 index 00000000..d3fb36eb --- /dev/null +++ b/src/main/java/com/studypals/domain/chatManage/dao/ChatImageRepository.java @@ -0,0 +1,7 @@ +package com.studypals.domain.chatManage.dao; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.studypals.domain.chatManage.entity.ChatImage; + +public interface ChatImageRepository extends JpaRepository {} diff --git a/src/main/java/com/studypals/domain/chatManage/entity/ChatImage.java b/src/main/java/com/studypals/domain/chatManage/entity/ChatImage.java new file mode 100644 index 00000000..efa72199 --- /dev/null +++ b/src/main/java/com/studypals/domain/chatManage/entity/ChatImage.java @@ -0,0 +1,24 @@ +package com.studypals.domain.chatManage.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import com.studypals.global.file.entity.ImageFile; + +@Entity +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChatImage extends ImageFile { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_room_id", nullable = false) + private ChatRoom chatRoom; +} diff --git a/src/main/java/com/studypals/domain/chatManage/worker/ChatImageWriter.java b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageWriter.java new file mode 100644 index 00000000..8aae3aea --- /dev/null +++ b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageWriter.java @@ -0,0 +1,31 @@ +package com.studypals.domain.chatManage.worker; + +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +import com.studypals.domain.chatManage.dao.ChatImageRepository; +import com.studypals.domain.chatManage.entity.ChatImage; +import com.studypals.domain.chatManage.entity.ChatRoom; +import com.studypals.global.annotations.Worker; +import com.studypals.global.file.FileUtils; + +@Worker +@RequiredArgsConstructor +public class ChatImageWriter { + private final ChatImageRepository chatImageRepository; + + @Transactional + public Long save(ChatRoom chatRoom, String objectKey, String fileName) { + String extension = FileUtils.extractExtension(fileName); + + ChatImage savedImage = chatImageRepository.save(ChatImage.builder() + .chatRoom(chatRoom) + .objectKey(objectKey) + .originalFileName(fileName) + .mimeType(extension) + .build()); + + return savedImage.getId(); + } +} From 9e121f498258fedb17ac00da38618b25010c080b Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Sat, 17 Jan 2026 01:44:06 +0900 Subject: [PATCH 08/25] =?UTF-8?q?Feat:=20=EC=9C=A0=EC=A0=80=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dao/MemberProfileImageRepository.java | 7 +++++ .../entity/MemberProfileImage.java | 24 ++++++++++++++ .../worker/MemberProfileImageWriter.java | 31 +++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 src/main/java/com/studypals/domain/memberManage/dao/MemberProfileImageRepository.java create mode 100644 src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java create mode 100644 src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java diff --git a/src/main/java/com/studypals/domain/memberManage/dao/MemberProfileImageRepository.java b/src/main/java/com/studypals/domain/memberManage/dao/MemberProfileImageRepository.java new file mode 100644 index 00000000..6c0def4d --- /dev/null +++ b/src/main/java/com/studypals/domain/memberManage/dao/MemberProfileImageRepository.java @@ -0,0 +1,7 @@ +package com.studypals.domain.memberManage.dao; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.studypals.domain.memberManage.entity.MemberProfileImage; + +public interface MemberProfileImageRepository extends JpaRepository {} diff --git a/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java b/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java new file mode 100644 index 00000000..459805c6 --- /dev/null +++ b/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java @@ -0,0 +1,24 @@ +package com.studypals.domain.memberManage.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import com.studypals.global.file.entity.ImageFile; + +@Entity +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MemberProfileImage extends ImageFile { + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Member member; +} diff --git a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java new file mode 100644 index 00000000..9a33e6e9 --- /dev/null +++ b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java @@ -0,0 +1,31 @@ +package com.studypals.domain.memberManage.worker; + +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +import com.studypals.domain.memberManage.dao.MemberProfileImageRepository; +import com.studypals.domain.memberManage.entity.Member; +import com.studypals.domain.memberManage.entity.MemberProfileImage; +import com.studypals.global.annotations.Worker; +import com.studypals.global.file.FileUtils; + +@Worker +@RequiredArgsConstructor +public class MemberProfileImageWriter { + private final MemberProfileImageRepository memberProfileImageRepository; + + @Transactional + public Long save(Member member, String objectKey, String fileName) { + String extension = FileUtils.extractExtension(fileName); + + MemberProfileImage savedImage = memberProfileImageRepository.save(MemberProfileImage.builder() + .member(member) + .objectKey(objectKey) + .originalFileName(fileName) + .mimeType(extension) + .build()); + + return savedImage.getId(); + } +} From 30bdc32a996927a85da283874cb6bdb65cb88de0 Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Sat, 17 Jan 2026 01:44:23 +0900 Subject: [PATCH 09/25] =?UTF-8?q?Fix:=20=EB=B0=98=ED=99=98=EA=B0=92?= =?UTF-8?q?=EC=97=90=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20id=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/studypals/global/file/dto/PresignedUrlRes.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/studypals/global/file/dto/PresignedUrlRes.java b/src/main/java/com/studypals/global/file/dto/PresignedUrlRes.java index 7bbf6e8e..f816d9a7 100644 --- a/src/main/java/com/studypals/global/file/dto/PresignedUrlRes.java +++ b/src/main/java/com/studypals/global/file/dto/PresignedUrlRes.java @@ -1,3 +1,3 @@ package com.studypals.global.file.dto; -public record PresignedUrlRes(String url) {} +public record PresignedUrlRes(Long id, String url) {} From 76a078e653e1fdfeae98887e82c27d4e3f68b35e Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Sat, 17 Jan 2026 01:44:32 +0900 Subject: [PATCH 10/25] =?UTF-8?q?Fix:=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file/service/ImageFileServiceImpl.java | 59 +++++++++++++++---- 1 file changed, 48 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java b/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java index 6d50baa7..c9432231 100644 --- a/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java +++ b/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java @@ -7,14 +7,22 @@ import org.springframework.stereotype.Service; +import com.studypals.domain.chatManage.entity.ChatRoom; +import com.studypals.domain.chatManage.worker.ChatImageManager; +import com.studypals.domain.chatManage.worker.ChatImageWriter; +import com.studypals.domain.chatManage.worker.ChatRoomReader; +import com.studypals.domain.memberManage.entity.Member; +import com.studypals.domain.memberManage.worker.MemberProfileImageManager; +import com.studypals.domain.memberManage.worker.MemberProfileImageWriter; +import com.studypals.domain.memberManage.worker.MemberReader; import com.studypals.global.exceptions.errorCode.FileErrorCode; import com.studypals.global.exceptions.exception.FileException; +import com.studypals.global.file.FileType; import com.studypals.global.file.dao.AbstractFileManager; import com.studypals.global.file.dao.AbstractImageManager; import com.studypals.global.file.dto.ChatPresignedUrlReq; import com.studypals.global.file.dto.PresignedUrlRes; import com.studypals.global.file.dto.ProfilePresignedUrlReq; -import com.studypals.global.file.entity.FileType; import com.studypals.global.file.entity.ImageType; /** @@ -27,9 +35,18 @@ @Service public class ImageFileServiceImpl implements ImageFileService { - private final Map managerMap; + private final Map managerMap; + private final MemberReader memberReader; + private final ChatRoomReader chatRoomReader; + private final MemberProfileImageWriter profileImageWriter; + private final ChatImageWriter chatImageWriter; - public ImageFileServiceImpl(List managers) { + public ImageFileServiceImpl( + List managers, + MemberReader memberReader, + ChatRoomReader chatRoomReader, + MemberProfileImageWriter profileImageWriter, + ChatImageWriter chatImageWriter) { this.managerMap = managers.stream() .collect(Collectors.toMap( AbstractFileManager::getFileType, Function.identity(), (existing, duplicate) -> { @@ -39,24 +56,44 @@ public ImageFileServiceImpl(List managers) { existing.getClass().getName(), duplicate.getClass().getName())); })); + this.memberReader = memberReader; + this.chatRoomReader = chatRoomReader; + this.profileImageWriter = profileImageWriter; + this.chatImageWriter = chatImageWriter; } @Override public PresignedUrlRes getProfileUploadUrl(ProfilePresignedUrlReq request, Long userId) { - AbstractImageManager manager = getManager(ImageType.PROFILE_IMAGE, AbstractImageManager.class); - String uploadUrl = manager.getUploadUrl(userId, request.fileName(), String.valueOf(userId)); - return new PresignedUrlRes(uploadUrl); + MemberProfileImageManager manager = getManager(ImageType.PROFILE_IMAGE, MemberProfileImageManager.class); + + String objectKey = manager.createObjectKey(userId, request.fileName(), String.valueOf(userId)); + + Member member = memberReader.getRef(userId); + + Long imageId = profileImageWriter.save(member, objectKey, request.fileName()); + + String uploadUrl = manager.getUploadUrl(objectKey); + + return new PresignedUrlRes(imageId, uploadUrl); } @Override public PresignedUrlRes getChatUploadUrl(ChatPresignedUrlReq request, Long userId) { - AbstractImageManager manager = getManager(ImageType.CHAT_IMAGE, AbstractImageManager.class); - String uploadUrl = manager.getUploadUrl(userId, request.fileName(), request.chatRoomId()); - return new PresignedUrlRes(uploadUrl); + ChatImageManager manager = getManager(ImageType.CHAT_IMAGE, ChatImageManager.class); + + String objectKey = manager.createObjectKey(userId, request.fileName(), request.chatRoomId()); + + ChatRoom chatRoom = chatRoomReader.getById(request.chatRoomId()); + + Long imageId = chatImageWriter.save(chatRoom, objectKey, request.fileName()); + + String uploadUrl = manager.getUploadUrl(objectKey); + + return new PresignedUrlRes(imageId, uploadUrl); } - private T getManager(FileType fileType, Class managerClass) { - AbstractFileManager manager = managerMap.get(fileType); + private T getManager(FileType fileType, Class managerClass) { + AbstractImageManager manager = managerMap.get(fileType); if (manager == null) { throw new FileException(FileErrorCode.UNSUPPORTED_FILE_IMAGE_TYPE); } From 3891ea43d7c01654f6712c02f7f92f1a9ff209c7 Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Sat, 17 Jan 2026 01:44:44 +0900 Subject: [PATCH 11/25] =?UTF-8?q?Test:=20Test=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../worker/ChatImageManagerTest.java | 51 +---- .../worker/MemberProfileImageManagerTest.java | 41 ++++ .../worker/MemberProfileManagerTest.java | 64 ------ .../file/dao/AbstractFileManagerTest.java | 3 +- .../file/dao/AbstractImageManagerTest.java | 194 ++++++++++-------- .../ImageFileControllerRestDocsTest.java | 8 +- .../service/ImageFileServiceImplTest.java | 73 ++++++- .../testModules/testUtils/CleanUp.java | 17 +- 8 files changed, 243 insertions(+), 208 deletions(-) create mode 100644 src/test/java/com/studypals/domain/memberManage/worker/MemberProfileImageManagerTest.java delete mode 100644 src/test/java/com/studypals/domain/memberManage/worker/MemberProfileManagerTest.java diff --git a/src/test/java/com/studypals/domain/chatManage/worker/ChatImageManagerTest.java b/src/test/java/com/studypals/domain/chatManage/worker/ChatImageManagerTest.java index 56173927..581a5fa2 100644 --- a/src/test/java/com/studypals/domain/chatManage/worker/ChatImageManagerTest.java +++ b/src/test/java/com/studypals/domain/chatManage/worker/ChatImageManagerTest.java @@ -1,11 +1,6 @@ package com.studypals.domain.chatManage.worker; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.verify; import java.util.List; @@ -15,10 +10,8 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; -import com.studypals.global.exceptions.errorCode.ChatErrorCode; -import com.studypals.global.exceptions.exception.ChatException; +import com.studypals.global.file.FileProperties; import com.studypals.global.file.ObjectStorage; import com.studypals.global.file.entity.ImageType; @@ -35,46 +28,8 @@ class ChatImageManagerTest { @BeforeEach void setUp() { - chatImageManager = new ChatImageManager(objectStorage, chatRoomReader); - // 부모 클래스의 @Value 필드 주입 - ReflectionTestUtils.setField(chatImageManager, "acceptableExtensions", List.of("jpg", "png")); - ReflectionTestUtils.setField(chatImageManager, "presignedUrlExpireTime", 600); - } - - @Test - @DisplayName("업로드 URL 발급 성공 - 채팅방 멤버인 경우") - void getUploadUrl_success() { - // given - Long userId = 1L; - String chatRoomId = "room1"; - String fileName = "image.jpg"; - String expectedUrl = "https://example.com/presigned-url"; - - given(chatRoomReader.isMemberOfChatRoom(userId, chatRoomId)).willReturn(true); - given(objectStorage.createPresignedPutUrl(anyString(), anyInt())).willReturn(expectedUrl); - - // when - String result = chatImageManager.getUploadUrl(userId, fileName, chatRoomId); - - // then - assertThat(result).isEqualTo(expectedUrl); - verify(chatRoomReader).isMemberOfChatRoom(userId, chatRoomId); - } - - @Test - @DisplayName("업로드 URL 발급 실패 - 채팅방 멤버가 아닌 경우") - void getUploadUrl_fail_notMember() { - // given - Long userId = 1L; - String chatRoomId = "room1"; - String fileName = "image.jpg"; - - given(chatRoomReader.isMemberOfChatRoom(userId, chatRoomId)).willReturn(false); - - // when & then - assertThatThrownBy(() -> chatImageManager.getUploadUrl(userId, fileName, chatRoomId)) - .isInstanceOf(ChatException.class) - .hasFieldOrPropertyWithValue("errorCode", ChatErrorCode.CHAT_ROOM_NOT_CONTAIN_MEMBER); + FileProperties fileUploadProperties = new FileProperties(List.of("jpg", "png"), 600); + chatImageManager = new ChatImageManager(objectStorage, fileUploadProperties, chatRoomReader); } @Test diff --git a/src/test/java/com/studypals/domain/memberManage/worker/MemberProfileImageManagerTest.java b/src/test/java/com/studypals/domain/memberManage/worker/MemberProfileImageManagerTest.java new file mode 100644 index 00000000..c15f5ed8 --- /dev/null +++ b/src/test/java/com/studypals/domain/memberManage/worker/MemberProfileImageManagerTest.java @@ -0,0 +1,41 @@ +package com.studypals.domain.memberManage.worker; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.studypals.global.file.FileProperties; +import com.studypals.global.file.ObjectStorage; +import com.studypals.global.file.entity.ImageType; + +@ExtendWith(MockitoExtension.class) +class MemberProfileImageManagerTest { + + @Mock + private ObjectStorage objectStorage; + + private MemberProfileImageManager memberProfileImageManager; + + @BeforeEach + void setUp() { + FileProperties fileUploadProperties = new FileProperties(List.of("jpg", "png"), 600); + memberProfileImageManager = new MemberProfileImageManager(objectStorage, fileUploadProperties); + } + + @Test + @DisplayName("파일 타입 반환 확인") + void getFileType() { + // when + ImageType type = memberProfileImageManager.getFileType(); + + // then + assertThat(type).isEqualTo(ImageType.PROFILE_IMAGE); + } +} diff --git a/src/test/java/com/studypals/domain/memberManage/worker/MemberProfileManagerTest.java b/src/test/java/com/studypals/domain/memberManage/worker/MemberProfileManagerTest.java deleted file mode 100644 index c74b59ec..00000000 --- a/src/test/java/com/studypals/domain/memberManage/worker/MemberProfileManagerTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.studypals.domain.memberManage.worker; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.given; - -import java.util.List; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; - -import com.studypals.global.file.ObjectStorage; -import com.studypals.global.file.entity.ImageType; - -@ExtendWith(MockitoExtension.class) -class MemberProfileManagerTest { - - @Mock - private ObjectStorage objectStorage; - - private MemberProfileManager memberProfileManager; - - @BeforeEach - void setUp() { - memberProfileManager = new MemberProfileManager(objectStorage); - // 부모 클래스의 @Value 필드 주입 - ReflectionTestUtils.setField(memberProfileManager, "acceptableExtensions", List.of("jpg", "png")); - ReflectionTestUtils.setField(memberProfileManager, "presignedUrlExpireTime", 600); - } - - @Test - @DisplayName("업로드 URL 발급 성공") - void getUploadUrl_success() { - // given - Long userId = 1L; - String fileName = "profile.jpg"; - String targetId = String.valueOf(userId); - String expectedUrl = "https://example.com/presigned-url"; - - given(objectStorage.createPresignedPutUrl(anyString(), anyInt())).willReturn(expectedUrl); - - // when - String result = memberProfileManager.getUploadUrl(userId, fileName, targetId); - - // then - assertThat(result).isEqualTo(expectedUrl); - } - - @Test - @DisplayName("파일 타입 반환 확인") - void getFileType() { - // when - ImageType type = memberProfileManager.getFileType(); - - // then - assertThat(type).isEqualTo(ImageType.PROFILE_IMAGE); - } -} diff --git a/src/test/java/com/studypals/global/file/dao/AbstractFileManagerTest.java b/src/test/java/com/studypals/global/file/dao/AbstractFileManagerTest.java index 403b7a5e..d66be842 100644 --- a/src/test/java/com/studypals/global/file/dao/AbstractFileManagerTest.java +++ b/src/test/java/com/studypals/global/file/dao/AbstractFileManagerTest.java @@ -12,6 +12,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.studypals.global.file.FileUtils; import com.studypals.global.file.ObjectStorage; import com.studypals.global.file.entity.ImageType; @@ -117,7 +118,7 @@ public ImageType getFileType() { } public String callExtractExtension(String fileName) { - return extractExtension(fileName); + return FileUtils.extractExtension(fileName); } } } diff --git a/src/test/java/com/studypals/global/file/dao/AbstractImageManagerTest.java b/src/test/java/com/studypals/global/file/dao/AbstractImageManagerTest.java index b402c523..e9774175 100644 --- a/src/test/java/com/studypals/global/file/dao/AbstractImageManagerTest.java +++ b/src/test/java/com/studypals/global/file/dao/AbstractImageManagerTest.java @@ -1,23 +1,28 @@ package com.studypals.global.file.dao; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.util.List; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; +import com.studypals.global.exceptions.exception.FileException; +import com.studypals.global.file.FileProperties; import com.studypals.global.file.ObjectStorage; import com.studypals.global.file.entity.ImageType; +import com.studypals.global.file.entity.ImageVariantKey; @ExtendWith(MockitoExtension.class) class AbstractImageManagerTest { @@ -25,106 +30,133 @@ class AbstractImageManagerTest { @Mock private ObjectStorage objectStorage; + private FileProperties fileUploadProperties; private TestImageManager imageManager; - @BeforeEach - void setUp() { - imageManager = new TestImageManager(objectStorage); - // @Value 주입을 시뮬레이션하기 위해 ReflectionTestUtils 사용 - ReflectionTestUtils.setField( - imageManager, "acceptableExtensions", List.of("jpg", "jpeg", "png", "bmp", "webp")); - ReflectionTestUtils.setField(imageManager, "presignedUrlExpireTime", 600); + // 테스트를 위한 구체 클래스 + static class TestImageManager extends AbstractImageManager { + public TestImageManager(ObjectStorage objectStorage, FileProperties fileUploadProperties) { + super(objectStorage, fileUploadProperties); + } + + @Override + protected String generateObjectKeyDetail(String targetId, String ext) { + return "test-path/" + targetId + "/" + UUID.randomUUID() + "." + ext; + } + + @Override + protected List variants() { + return List.of(); + } + + @Override + public ImageType getFileType() { + return ImageType.PROFILE_IMAGE; + } } - @Test - @DisplayName("업로드 URL 발급 성공 - 파일 이름 검증 통과") - void getUploadUrl_success() { + @BeforeEach + void setUp() { // given - Long userId = 1L; - String fileName = "image.jpg"; - String targetId = "user1"; - String expectedUrl = "https://example.com/presigned-url"; + fileUploadProperties = new FileProperties(List.of("jpg", "jpeg", "png", "bmp", "webp"), 600); + imageManager = new TestImageManager(objectStorage, fileUploadProperties); + } - given(objectStorage.createPresignedPutUrl(anyString(), anyInt())).willReturn(expectedUrl); + @Nested + @DisplayName("createObjectKey 메서드 테스트") + class CreateObjectKeyTest { - // when - String result = imageManager.getUploadUrl(userId, fileName, targetId); + @Test + @DisplayName("성공: 유효한 요청 시 ObjectKey를 정상적으로 생성한다") + void should_CreateObjectKey_When_RequestIsValid() { + // given + Long userId = 1L; + String fileName = "image.jpg"; + String targetId = "user1"; - // then - assertThat(result).isEqualTo(expectedUrl); - } + // when + String objectKey = imageManager.createObjectKey(userId, fileName, targetId); - @Test - @DisplayName("업로드 URL 발급 실패 - 대문자 확장자도 허용") - void getUploadUrl_upperCase() { - // given - Long userId = 1L; - String fileName = "image.PNG"; - String targetId = "user1"; - String expectedUrl = "https://example.com/presigned-url"; + // then + assertThat(objectKey).contains("test-path/user1/"); + assertThat(objectKey).endsWith(".jpg"); + } - given(objectStorage.createPresignedPutUrl(anyString(), anyInt())).willReturn(expectedUrl); + @Test + @DisplayName("성공: 대문자 확장자도 허용하여 ObjectKey를 생성한다") + void should_CreateObjectKey_When_ExtensionIsUpperCase() { + // given + Long userId = 1L; + String fileName = "image.PNG"; + String targetId = "user1"; - // when - String result = imageManager.getUploadUrl(userId, fileName, targetId); + // when + String objectKey = imageManager.createObjectKey(userId, fileName, targetId); - // then - assertThat(result).isEqualTo(expectedUrl); - } + // then + assertThat(objectKey).contains("test-path/user1/"); + assertThat(objectKey).endsWith(".png"); + } - @Test - @DisplayName("업로드 URL 발급 실패 - 지원하지 않는 확장자") - void getUploadUrl_invalidExtension() { - // given - Long userId = 1L; - String fileName = "document.txt"; - String targetId = "user1"; + @Test + @DisplayName("실패: 지원하지 않는 확장자이면 FileException 던진다") + void should_ThrowException_When_ExtensionIsUnsupported() { + // given + Long userId = 1L; + String fileName = "document.txt"; + String targetId = "user1"; + + // when & then + assertThatCode(() -> imageManager.createObjectKey(userId, fileName, targetId)) + .isInstanceOf(FileException.class); + } - // when & then - assertThatThrownBy(() -> imageManager.getUploadUrl(userId, fileName, targetId)) - .isInstanceOf(RuntimeException.class); // FileException이 RuntimeException을 상속한다고 가정 - } + @Test + @DisplayName("실패: 파일 이름에 확장자가 없으면 FileException 던진다") + void should_ThrowException_When_FileNameHasNoExtension() { + // given + Long userId = 1L; + String fileName = "image"; + String targetId = "user1"; + + // when & then + assertThatCode(() -> imageManager.createObjectKey(userId, fileName, targetId)) + .isInstanceOf(FileException.class); + } - @Test - @DisplayName("업로드 URL 발급 실패 - 확장자 없음") - void getUploadUrl_noExtension() { - // given - Long userId = 1L; - String fileName = "image"; - String targetId = "user1"; + @Test + @DisplayName("실패: 파일 이름이 null이면 FileException 던진다") + void should_ThrowException_When_FileNameIsNull() { + // given + Long userId = 1L; + String targetId = "user1"; - // when & then - assertThatThrownBy(() -> imageManager.getUploadUrl(userId, fileName, targetId)) - .isInstanceOf(RuntimeException.class); + // when & then + assertThatCode(() -> imageManager.createObjectKey(userId, null, targetId)) + .isInstanceOf(FileException.class); + } } - @Test - @DisplayName("업로드 URL 발급 실패 - null 파일 이름") - void getUploadUrl_null() { - // given - Long userId = 1L; - String fileName = null; - String targetId = "user1"; + @Nested + @DisplayName("getUploadUrl(objectKey) 메서드 테스트") + class GetUploadUrlTest { - // when & then - assertThatThrownBy(() -> imageManager.getUploadUrl(userId, fileName, targetId)) - .isInstanceOf(RuntimeException.class); - } + @Test + @DisplayName("성공: 주어진 ObjectKey로 Presigned URL을 정상적으로 생성한다") + void should_ReturnPresignedUrl_When_ObjectKeyIsValid() { + // given + String objectKey = "test-path/user1/some-uuid.jpg"; + String expectedUrl = "https://example.com/presigned-url"; + int expireTime = fileUploadProperties.presignedUrlExpireTime(); - // 테스트를 위한 구체 클래스 - static class TestImageManager extends AbstractImageManager { - public TestImageManager(ObjectStorage objectStorage) { - super(objectStorage); - } + when(objectStorage.createPresignedPutUrl(anyString(), anyInt())).thenReturn(expectedUrl); - @Override - protected String generateObjectKeyDetail(String targetId, String ext) { - return "key"; - } + // when + String actualUrl = imageManager.getUploadUrl(objectKey); - @Override - public ImageType getFileType() { - return ImageType.PROFILE_IMAGE; + // then + assertThat(actualUrl).isEqualTo(expectedUrl); + verify(objectStorage).createPresignedPutUrl(objectKey, expireTime); } } } diff --git a/src/test/java/com/studypals/global/file/restDocsTest/ImageFileControllerRestDocsTest.java b/src/test/java/com/studypals/global/file/restDocsTest/ImageFileControllerRestDocsTest.java index 2bb66620..cb26dd06 100644 --- a/src/test/java/com/studypals/global/file/restDocsTest/ImageFileControllerRestDocsTest.java +++ b/src/test/java/com/studypals/global/file/restDocsTest/ImageFileControllerRestDocsTest.java @@ -40,9 +40,10 @@ class ImageFileControllerRestDocsTest extends RestDocsSupport { void getProfileUploadUrl_success() throws Exception { // given ProfilePresignedUrlReq request = new ProfilePresignedUrlReq("my-profile.jpeg"); + Long imageId = 1L; String presignedUrl = "https://s3-presigned-url.com/for/profile/my-profile.jpeg?signature=..."; - PresignedUrlRes response = new PresignedUrlRes(presignedUrl); + PresignedUrlRes response = new PresignedUrlRes(imageId, presignedUrl); given(imageFileService.getProfileUploadUrl(any(ProfilePresignedUrlReq.class), any())) .willReturn(response); @@ -67,6 +68,7 @@ void getProfileUploadUrl_success() throws Exception { fieldWithPath("code").description("응답 코드 (I01-01)"), fieldWithPath("status").description("응답 상태"), fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.id").description("이미지 파일의 식별 ID"), fieldWithPath("data.url").description("생성된 Presigned URL")))); } @@ -77,7 +79,8 @@ void getChatUploadUrl_success() throws Exception { // given ChatPresignedUrlReq request = new ChatPresignedUrlReq("chat-image.png", "chat-room-123"); String presignedUrl = "https://s3-presigned-url.com/for/chat/chat-image.png?signature=..."; - PresignedUrlRes response = new PresignedUrlRes(presignedUrl); + Long imageId = 1L; + PresignedUrlRes response = new PresignedUrlRes(imageId, presignedUrl); given(imageFileService.getChatUploadUrl(any(ChatPresignedUrlReq.class), any())) .willReturn(response); @@ -107,6 +110,7 @@ void getChatUploadUrl_success() throws Exception { fieldWithPath("code").description("응답 코드 (I01-01)"), fieldWithPath("status").description("응답 상태"), fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.id").description("이미지 파일의 식별 ID"), fieldWithPath("data.url").description("생성된 Presigned URL")))); } } diff --git a/src/test/java/com/studypals/global/file/service/ImageFileServiceImplTest.java b/src/test/java/com/studypals/global/file/service/ImageFileServiceImplTest.java index 08ff1d93..02b22693 100644 --- a/src/test/java/com/studypals/global/file/service/ImageFileServiceImplTest.java +++ b/src/test/java/com/studypals/global/file/service/ImageFileServiceImplTest.java @@ -2,6 +2,7 @@ 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.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -15,7 +16,14 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.studypals.global.file.dao.AbstractImageManager; +import com.studypals.domain.chatManage.entity.ChatRoom; +import com.studypals.domain.chatManage.worker.ChatImageManager; +import com.studypals.domain.chatManage.worker.ChatImageWriter; +import com.studypals.domain.chatManage.worker.ChatRoomReader; +import com.studypals.domain.memberManage.entity.Member; +import com.studypals.domain.memberManage.worker.MemberProfileImageManager; +import com.studypals.domain.memberManage.worker.MemberProfileImageWriter; +import com.studypals.domain.memberManage.worker.MemberReader; import com.studypals.global.file.dto.ChatPresignedUrlReq; import com.studypals.global.file.dto.PresignedUrlRes; import com.studypals.global.file.dto.ProfilePresignedUrlReq; @@ -28,17 +36,34 @@ class ImageFileServiceImplTest { private ImageFileService imageFileService; @Mock - private AbstractImageManager mockProfileImageManager; + private MemberProfileImageManager mockProfileImageManager; @Mock - private AbstractImageManager mockChatImageManager; + private ChatImageManager mockChatImageManager; + + @Mock + private MemberReader memberReader; + + @Mock + private ChatRoomReader chatRoomReader; + + @Mock + private MemberProfileImageWriter memberProfileImageWriter; + + @Mock + private ChatImageWriter chatImageWriter; @BeforeEach void setUp() { when(mockProfileImageManager.getFileType()).thenReturn(ImageType.PROFILE_IMAGE); when(mockChatImageManager.getFileType()).thenReturn(ImageType.CHAT_IMAGE); - imageFileService = new ImageFileServiceImpl(List.of(mockProfileImageManager, mockChatImageManager)); + imageFileService = new ImageFileServiceImpl( + List.of(mockProfileImageManager, mockChatImageManager), + memberReader, + chatRoomReader, + memberProfileImageWriter, + chatImageWriter); } @Test @@ -47,18 +72,31 @@ void getProfileUploadUrl_shouldCallCorrectManager() { // given Long userId = 1L; ProfilePresignedUrlReq request = new ProfilePresignedUrlReq("profile.jpg"); + String expectedObjectKey = "profile/1/some-uuid.jpg"; + Long expectedImageId = 99L; String expectedUrl = "http://s3.com/profile-upload-url"; - when(mockProfileImageManager.getUploadUrl(userId, "profile.jpg", "1")).thenReturn(expectedUrl); + // 서비스가 호출할 Mock 객체의 동작을 모두 정의합니다. + when(mockProfileImageManager.createObjectKey(userId, request.fileName(), String.valueOf(userId))) + .thenReturn(expectedObjectKey); + when(memberReader.getRef(userId)).thenReturn(Member.builder().id(userId).build()); + when(memberProfileImageWriter.save(any(Member.class), eq(expectedObjectKey), eq(request.fileName()))) + .thenReturn(expectedImageId); + when(mockProfileImageManager.getUploadUrl(expectedObjectKey)).thenReturn(expectedUrl); // when PresignedUrlRes actualResult = imageFileService.getProfileUploadUrl(request, userId); // then assertThat(actualResult).isNotNull(); + assertThat(actualResult.id()).isEqualTo(expectedImageId); assertThat(actualResult.url()).isEqualTo(expectedUrl); - verify(mockProfileImageManager).getUploadUrl(userId, "profile.jpg", "1"); - verify(mockChatImageManager, never()).getUploadUrl(any(), any(), any()); + + // 올바른 메서드가 올바른 인자와 함께 호출되었는지 검증합니다. + verify(mockProfileImageManager).createObjectKey(userId, request.fileName(), String.valueOf(userId)); + verify(memberProfileImageWriter).save(any(Member.class), eq(expectedObjectKey), eq(request.fileName())); + verify(mockProfileImageManager).getUploadUrl(expectedObjectKey); + verify(mockChatImageManager, never()).getUploadUrl(any()); } @Test @@ -67,18 +105,31 @@ void getChatUploadUrl_shouldCallCorrectManager() { // given Long userId = 1L; ChatPresignedUrlReq request = new ChatPresignedUrlReq("chat-image.png", "chat-room-123"); + String expectedObjectKey = "chat/chat-room-123/some-uuid.png"; + Long expectedImageId = 100L; String expectedUrl = "http://s3.com/chat-upload-url"; - when(mockChatImageManager.getUploadUrl(userId, "chat-image.png", "chat-room-123")) - .thenReturn(expectedUrl); + // 서비스가 호출할 Mock 객체의 동작을 모두 정의합니다. + when(mockChatImageManager.createObjectKey(userId, request.fileName(), request.chatRoomId())) + .thenReturn(expectedObjectKey); + when(chatRoomReader.getById(request.chatRoomId())) + .thenReturn(ChatRoom.builder().id(request.chatRoomId()).build()); + when(chatImageWriter.save(any(ChatRoom.class), eq(expectedObjectKey), eq(request.fileName()))) + .thenReturn(expectedImageId); + when(mockChatImageManager.getUploadUrl(expectedObjectKey)).thenReturn(expectedUrl); // when PresignedUrlRes actualResult = imageFileService.getChatUploadUrl(request, userId); // then assertThat(actualResult).isNotNull(); + assertThat(actualResult.id()).isEqualTo(expectedImageId); assertThat(actualResult.url()).isEqualTo(expectedUrl); - verify(mockChatImageManager).getUploadUrl(userId, "chat-image.png", "chat-room-123"); - verify(mockProfileImageManager, never()).getUploadUrl(any(), any(), any()); + + // 올바른 메서드가 올바른 인자와 함께 호출되었는지 검증합니다. + verify(mockChatImageManager).createObjectKey(userId, request.fileName(), request.chatRoomId()); + verify(chatImageWriter).save(any(ChatRoom.class), eq(expectedObjectKey), eq(request.fileName())); + verify(mockChatImageManager).getUploadUrl(expectedObjectKey); + verify(mockProfileImageManager, never()).getUploadUrl(any()); } } diff --git a/src/test/java/com/studypals/testModules/testUtils/CleanUp.java b/src/test/java/com/studypals/testModules/testUtils/CleanUp.java index 248b2350..f5eff81e 100644 --- a/src/test/java/com/studypals/testModules/testUtils/CleanUp.java +++ b/src/test/java/com/studypals/testModules/testUtils/CleanUp.java @@ -46,7 +46,9 @@ public void all() { jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0"); for (String table : tableNames) { - jdbcTemplate.execute("TRUNCATE TABLE " + table.toLowerCase()); + if (isTableExists(table)) { + jdbcTemplate.execute("TRUNCATE TABLE " + table.toLowerCase()); + } } jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1"); @@ -55,4 +57,17 @@ public void all() { .serverCommands() .flushAll(); } + + /** + * 실제 DB에 테이블이 존재하는지 쿼리 + */ + private boolean isTableExists(String tableName) { + try { + // MySQL/H2 공용: 테이블 정보 조회 시 에러가 없으면 존재하는 것으로 간주 + jdbcTemplate.execute("SELECT 1 FROM " + tableName + " LIMIT 1"); + return true; + } catch (Exception e) { + return false; + } + } } From 8790f858edea6b86b0b46d1ac2eb767d6d89a1c3 Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Sat, 17 Jan 2026 01:47:09 +0900 Subject: [PATCH 12/25] =?UTF-8?q?Fix:=20=EC=9B=90=EB=B3=B8=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=EB=8A=94=20origin=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EC=97=90=20=EC=A0=80=EC=9E=A5=EB=90=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../studypals/domain/chatManage/worker/ChatImageManager.java | 2 +- .../domain/memberManage/worker/MemberProfileImageManager.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java index 5eceb3c2..20f3a442 100644 --- a/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java +++ b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java @@ -29,7 +29,7 @@ */ @Component public class ChatImageManager extends AbstractImageManager { - private static final String CHAT_IMAGE_PATH = "chat"; + private static final String CHAT_IMAGE_PATH = "origin/chat"; private final ChatRoomReader chatRoomReader; public ChatImageManager(ObjectStorage objectStorage, FileProperties properties, ChatRoomReader chatRoomReader) { diff --git a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageManager.java b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageManager.java index 8c3fdadd..2c7b77c4 100644 --- a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageManager.java +++ b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageManager.java @@ -28,7 +28,7 @@ @Component public class MemberProfileImageManager extends AbstractImageManager { - private static final String PROFILE_PATH = "profile"; + private static final String PROFILE_PATH = "origin/profile"; public MemberProfileImageManager(ObjectStorage objectStorage, FileProperties properties) { super(objectStorage, properties); From 419e4f66809ee6eaf6444b5b0ff751e084385385 Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Sat, 17 Jan 2026 13:04:31 +0900 Subject: [PATCH 13/25] =?UTF-8?q?Feat:=20index=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/studypals/domain/chatManage/entity/ChatImage.java | 5 +++++ .../domain/memberManage/entity/MemberProfileImage.java | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/main/java/com/studypals/domain/chatManage/entity/ChatImage.java b/src/main/java/com/studypals/domain/chatManage/entity/ChatImage.java index efa72199..ac085aa5 100644 --- a/src/main/java/com/studypals/domain/chatManage/entity/ChatImage.java +++ b/src/main/java/com/studypals/domain/chatManage/entity/ChatImage.java @@ -2,8 +2,10 @@ import jakarta.persistence.Entity; import jakarta.persistence.FetchType; +import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; @@ -16,6 +18,9 @@ @Getter @SuperBuilder @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "chat_image", + indexes = @Index(name = "idx_chat_image_status_created_at", columnList = "imageStatus, createdAt")) public class ChatImage extends ImageFile { @ManyToOne(fetch = FetchType.LAZY) diff --git a/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java b/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java index 459805c6..c1a01108 100644 --- a/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java +++ b/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java @@ -2,8 +2,10 @@ import jakarta.persistence.Entity; import jakarta.persistence.FetchType; +import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; @@ -16,6 +18,9 @@ @Getter @SuperBuilder @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "member_profile_image", + indexes = @Index(name = "idx_member_profile_image_status_created_at", columnList = "imageStatus, createdAt")) public class MemberProfileImage extends ImageFile { @OneToOne(fetch = FetchType.LAZY) From 24b649309e4cfe36e358a01b8a1aa7322ef78002 Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Sat, 17 Jan 2026 20:08:54 +0900 Subject: [PATCH 14/25] =?UTF-8?q?Docs:=20=EC=A3=BC=EC=84=9D=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 --- .../chatManage/worker/ChatImageWriter.java | 23 +++++ .../worker/MemberProfileImageWriter.java | 23 +++++ .../studypals/global/file/FileProperties.java | 11 +++ .../com/studypals/global/file/FileType.java | 19 +++- .../com/studypals/global/file/FileUtils.java | 28 +++++- .../studypals/global/file/ObjectStorage.java | 53 ++++++++-- .../global/file/dao/AbstractFileManager.java | 38 +++++-- .../global/file/dao/AbstractImageManager.java | 99 +++++++++++++------ .../global/file/dto/ChatPresignedUrlReq.java | 9 ++ .../global/file/dto/PresignedUrlRes.java | 14 +++ .../file/dto/ProfilePresignedUrlReq.java | 8 ++ .../global/file/entity/ImageStatus.java | 29 +++++- .../global/file/entity/ImageType.java | 18 +++- .../global/file/entity/ImageVariantKey.java | 30 +++++- .../file/service/ImageFileServiceImpl.java | 74 +++++++++++++- 15 files changed, 412 insertions(+), 64 deletions(-) diff --git a/src/main/java/com/studypals/domain/chatManage/worker/ChatImageWriter.java b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageWriter.java index 8aae3aea..d8737215 100644 --- a/src/main/java/com/studypals/domain/chatManage/worker/ChatImageWriter.java +++ b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageWriter.java @@ -10,11 +10,34 @@ import com.studypals.global.annotations.Worker; import com.studypals.global.file.FileUtils; +/** + * 채팅 이미지의 메타데이터를 데이터베이스에 저장하는 역할을 전담하는 Worker 클래스입니다. + *

+ * 이 클래스는 CQRS(Command Query Responsibility Segregation) 패턴의 'Command' 측면을 담당하며, + * 시스템의 상태를 변경하는 '쓰기(Write)' 작업에만 집중합니다. + * {@link Transactional} 어노테이션을 통해 데이터 저장 작업의 원자성을 보장합니다. + * + * @author sleepyhoon + * @since 2024-01-15 + * @see ChatImage + * @see ChatImageRepository + */ @Worker @RequiredArgsConstructor public class ChatImageWriter { private final ChatImageRepository chatImageRepository; + /** + * 채팅 이미지의 메타데이터를 생성하고 데이터베이스에 저장합니다. + *

+ * 이 메서드는 클라이언트가 Presigned URL을 통해 스토리지에 파일을 업로드하기 에 호출됩니다. + * 파일이 실제로 업로드되기 전에 데이터베이스에 해당 파일의 존재를 미리 기록하는 역할을 합니다. + * + * @param chatRoom 이미지가 속한 채팅방 엔티티 + * @param objectKey 스토리지에 저장될 파일의 고유 객체 키 + * @param fileName 원본 파일의 이름 + * @return 데이터베이스에 저장된 {@link ChatImage}의 고유 ID + */ @Transactional public Long save(ChatRoom chatRoom, String objectKey, String fileName) { String extension = FileUtils.extractExtension(fileName); diff --git a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java index 9a33e6e9..7e0cd2a3 100644 --- a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java +++ b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java @@ -10,11 +10,34 @@ import com.studypals.global.annotations.Worker; import com.studypals.global.file.FileUtils; +/** + * 회원 프로필 이미지의 메타데이터를 데이터베이스에 저장하는 역할을 전담하는 Worker 클래스입니다. + *

+ * 이 클래스는 CQRS(Command Query Responsibility Segregation) 패턴의 'Command' 측면을 담당하며, + * 시스템의 상태를 변경하는 '쓰기(Write)' 작업에만 집중합니다. + * {@link Transactional} 어노테이션을 통해 데이터 저장 작업의 원자성을 보장합니다. + * + * @author sleepyhoon + * @since 2024-01-15 + * @see MemberProfileImage + * @see MemberProfileImageRepository + */ @Worker @RequiredArgsConstructor public class MemberProfileImageWriter { private final MemberProfileImageRepository memberProfileImageRepository; + /** + * 회원 프로필 이미지의 메타데이터를 생성하고 데이터베이스에 저장합니다. + *

+ * 이 메서드는 클라이언트가 Presigned URL을 통해 스토리지에 파일을 업로드하기 에 호출됩니다. + * 파일이 실제로 업로드되기 전에 데이터베이스에 해당 파일의 존재를 미리 기록하는 역할을 합니다. + * + * @param member 프로필 이미지가 속한 회원 엔티티 + * @param objectKey 스토리지에 저장될 파일의 고유 객체 키 + * @param fileName 원본 파일의 이름 + * @return 데이터베이스에 저장된 {@link MemberProfileImage}의 고유 ID + */ @Transactional public Long save(Member member, String objectKey, String fileName) { String extension = FileUtils.extractExtension(fileName); diff --git a/src/main/java/com/studypals/global/file/FileProperties.java b/src/main/java/com/studypals/global/file/FileProperties.java index e10d88c5..24978488 100644 --- a/src/main/java/com/studypals/global/file/FileProperties.java +++ b/src/main/java/com/studypals/global/file/FileProperties.java @@ -8,6 +8,17 @@ import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.validation.annotation.Validated; +/** + * 파일 업로드 관련 설정을 담는 {@link ConfigurationProperties} 클래스입니다. + *

+ * 이 클래스는 {@code application.yml} 파일의 {@code file.upload} 접두사를 가진 프로퍼티들을 + * 타입 안전하게 바인딩합니다. + * + * @param extensions 허용되는 파일 확장자 목록. {@code @NotEmpty} 제약조건이 적용되어, 최소 하나 이상의 확장자가 설정되어야 합니다. + * @param presignedUrlExpireTime Presigned URL의 만료 시간(초 단위). {@code @Positive} 제약조건이 적용되어, 반드시 양수여야 합니다. + * @author sleepyhoon + * @since 2024-01-10 + */ @Validated @ConfigurationProperties(prefix = "file.upload") public record FileProperties(@NotEmpty List extensions, @Positive int presignedUrlExpireTime) {} diff --git a/src/main/java/com/studypals/global/file/FileType.java b/src/main/java/com/studypals/global/file/FileType.java index 7a3c2315..37e2df20 100644 --- a/src/main/java/com/studypals/global/file/FileType.java +++ b/src/main/java/com/studypals/global/file/FileType.java @@ -1,10 +1,25 @@ package com.studypals.global.file; +import com.studypals.global.file.entity.ImageType; + /** - * 파일의 종류를 나타내는 최상위 마커 인터페이스입니다.

- * 모든 파일 타입 enum은 이 인터페이스를 구현해야 합니다. + * 시스템에서 다루는 모든 파일의 종류를 나타내기 위한 최상위 마커(Marker) 인터페이스입니다. + *

+ * 이 인터페이스는 내부에 메서드를 가지지 않으며, 오직 타입을 그룹화하는 용도로만 사용됩니다. + * {@link ImageType}과 같이 파일을 종류별로 구분하는 모든 열거형(Enum)은 이 인터페이스를 구현해야 합니다. + *

+ * 설계 의도: + *

    + *
  • 타입 안전성 및 다형성: 서로 다른 파일 타입 Enum들을 공통된 {@code FileType}으로 다룰 수 있게 합니다.
  • + *
  • 확장성: 향후 'LogType' 등 새로운 파일 종류가 추가되더라도, + * 이 인터페이스를 구현함으로써 기존의 파일 관리 메커니즘(예: 전략 패턴)에 쉽게 통합될 수 있습니다.
  • + *
+ * 예를 들어, {@code ImageFileServiceImpl}에서는 {@code Map} 구조를 사용하여 + * 파일 타입에 따라 적절한 Manager를 동적으로 선택합니다. * * @author sleepyhoon * @since 2026-01-10 + * @see ImageType + * @see com.studypals.global.file.dao.AbstractFileManager */ public interface FileType {} diff --git a/src/main/java/com/studypals/global/file/FileUtils.java b/src/main/java/com/studypals/global/file/FileUtils.java index 60c505db..bf810bb7 100644 --- a/src/main/java/com/studypals/global/file/FileUtils.java +++ b/src/main/java/com/studypals/global/file/FileUtils.java @@ -1,10 +1,32 @@ package com.studypals.global.file; -public class FileUtils { +/** + * 파일 관련 유틸리티 메서드를 제공하는 클래스입니다. + *

+ * 이 클래스의 모든 메서드는 상태를 가지지 않는 순수 함수 형태의 static 메서드입니다. + * 따라서 별도의 상태 관리나 의존성 주입이 필요 없어 Spring의 빈(Bean)으로 등록하지 않습니다. + * 외부에서 인스턴스화되는 것을 방지하기 위해 private 생성자를 가집니다. + * + * @author sleepyhoon + * @since 2024-01-10 + */ +public final class FileUtils { + + /** + * {@link FileUtils}는 유틸리티 클래스이므로 인스턴스화할 수 없습니다. + */ + private FileUtils() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + /** * 파일 이름에서 확장자를 추출합니다. - * @param fileName 파일 이름 - * @return 추출한 확장자 이름 + *

+ * 파일 이름에 점(.)이 없거나 마지막 문자가 점인 경우, 빈 문자열을 반환합니다. + * 추출된 확장자는 모두 소문자로 변환됩니다. + * + * @param fileName 파일 이름 (예: "image.JPG", "document.pdf") + * @return 추출된 소문자 확장자 (예: "jpg", "pdf") 또는 확장자가 없는 경우 빈 문자열 */ public static String extractExtension(String fileName) { int lastDotIndex = fileName.lastIndexOf("."); diff --git a/src/main/java/com/studypals/global/file/ObjectStorage.java b/src/main/java/com/studypals/global/file/ObjectStorage.java index 947ea39c..cd2ed39e 100644 --- a/src/main/java/com/studypals/global/file/ObjectStorage.java +++ b/src/main/java/com/studypals/global/file/ObjectStorage.java @@ -1,23 +1,60 @@ package com.studypals.global.file; /** - * Object Storage 의 인터페이스입니다. 메서드를 정의합니다. + * Object Storage와의 상호작용을 위한 표준 인터페이스를 정의합니다. + *

+ * 이 인터페이스는 파일(객체)의 삭제, 경로 분석, Presigned URL 생성 등 + * 객체 스토리지에서 수행해야 하는 핵심 기능들을 추상화합니다. + * 실제 구현체(예: {@code MinioStorage})는 이 인터페이스를 구현하여 + * 특정 스토리지 기술(MinIO, AWS S3 등)에 대한 구체적인 로직을 제공합니다. + *

+ * 이를 통해 서비스 로직은 실제 스토리지 구현에 대한 의존성을 낮추고, + * 향후 다른 스토리지 시스템으로 유연하게 교체할 수 있습니다. * - *

확장성을 고려해 스토리지 관련 메서드를 인터페이스로 분리했습니다. - * - *

상속 정보:
- * MinioStorage의 부모 인터페이스입니다. - * - * @author s0o0bn + * @author s0o0bn, sleepyhoon * @since 2025-04-11 */ public interface ObjectStorage { - void delete(String destination); + /** + * 스토리지에서 지정된 객체(파일)를 삭제합니다. + * + * @param objectKey 삭제할 객체의 고유 키 (예: "profile/images/user1.jpg") + */ + void delete(String objectKey); + /** + * 전체 파일 URL에서 객체 키(Object Key) 부분만 추출합니다. + *

+ * 예를 들어, "https://storage.example.com/bucket-name/path/to/object.jpg" 라는 URL이 주어졌을 때, + * "path/to/object.jpg" 부분을 반환합니다. + * + * @param url 전체 파일 URL + * @return 추출된 객체 키 + */ String parsePath(String url); + /** + * 객체 조회를 위한 Presigned URL을 생성합니다. + *

+ * 이 URL은 제한된 시간 동안만 유효하며, private 객체에 대한 임시적인 접근 권한을 부여합니다. + * 클라이언트는 이 URL을 사용하여 파일을 다운로드하거나 이미지 뷰어에 표시할 수 있습니다. + * + * @param objectKey Presigned URL을 생성할 객체의 고유 키 + * @param expirySeconds URL의 만료 시간 (초 단위) + * @return 생성된 Presigned GET URL + */ String createPresignedGetUrl(String objectKey, int expirySeconds); + /** + * 객체 업로드를 위한 Presigned URL을 생성합니다. + *

+ * 클라이언트는 이 URL을 사용하여 서버를 거치지 않고 스토리지에 직접 파일을 업로드할 수 있습니다. + * 이는 서버의 부하를 줄이고 업로드 속도를 향상시키는 효과적인 방법입니다. + * + * @param objectKey 업로드될 객체에 부여할 고유 키 + * @param expirySeconds URL의 만료 시간 (초 단위) + * @return 생성된 Presigned PUT URL + */ String createPresignedPutUrl(String objectKey, int expirySeconds); } diff --git a/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java b/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java index 4ed3e3b4..a3f4f670 100644 --- a/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java +++ b/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java @@ -6,23 +6,41 @@ import com.studypals.global.file.ObjectStorage; /** - * 파일을 처리하는데 사용하는 최상위 추상 클래스입니다. - * 파일을 다루며 Minio/S3 에 접근하는 클래스를 만들 경우, 해당 클래스를 상속해야 합니다. - * + * 모든 파일 관리자(Manager)의 최상위 추상 클래스입니다. + *

+ * 이 클래스는 파일 관리에 필요한 공통 기능과 기본 계약을 정의합니다. + * 특정 도메인의 파일을 관리하는 모든 구체적인 Manager 클래스(예: {@link AbstractImageManager})는 + * 이 클래스를 상속받아야 합니다. *

- * 파일 종류와 상관 없이 파일 삭제, 파일 타입 반환, 파일 확장자 반환이 가능합니다. + * 주요 역할: + *

    + *
  • {@link ObjectStorage}에 대한 의존성을 가지며, 이를 통해 실제 스토리지 작업을 수행합니다.
  • + *
  • 파일 URL을 이용한 공통 삭제 로직({@link #delete})을 제공합니다.
  • + *
  • 하위 클래스가 어떤 종류의 파일을 처리하는지 명시하도록 {@link #getFileType} 메서드를 강제합니다. + * 이는 다양한 파일 타입의 Manager를 동적으로 선택하는 전략 패턴의 기반이 됩니다.
  • + *
* * @author sleepyhoon * @since 2026-01-14 + * @see ObjectStorage + * @see AbstractImageManager */ @RequiredArgsConstructor public abstract class AbstractFileManager { + + /** + * 실제 객체 스토리지와의 상호작용을 담당하는 구현체입니다. + * 하위 클래스에서 스토리지 기능에 접근할 수 있도록 {@code protected}로 선언되었습니다. + */ protected final ObjectStorage objectStorage; /** - * 파일을 삭제합니다. + * 스토리지에 저장된 파일을 삭제합니다. + *

+ * 전체 파일 URL을 입력받아 내부적으로 객체 키(Object Key)를 추출한 후, + * {@link ObjectStorage#delete}를 호출하여 실제 파일을 삭제합니다. * - * @param url 삭제할 파일 URL + * @param url 삭제할 파일의 전체 URL */ public void delete(String url) { String destination = objectStorage.parsePath(url); @@ -30,8 +48,12 @@ public void delete(String url) { } /** - * 클래스가 담당하는 파일 타입을 반환합니다. - * @return 파일 타입 + * 이 Manager가 담당하는 파일의 종류({@link FileType})를 반환합니다. + *

+ * 이 추상 메서드는 하위 클래스에서 반드시 구현해야 합니다. + * 반환된 값은 {@code ImageFileServiceImpl} 등에서 적절한 Manager를 찾는 키로 사용됩니다. + * + * @return 이 Manager가 처리하는 {@link FileType} */ public abstract FileType getFileType(); } diff --git a/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java b/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java index ec617884..717693f6 100644 --- a/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java +++ b/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java @@ -10,14 +10,19 @@ import com.studypals.global.file.entity.ImageVariantKey; /** - * * 파일을 처리하는데 사용하는 추상 클래스입니다. - * * - * *

- * * 파일을 업로드하기 위한 UploadUrl을 반환합니다. - * * 파일 조회(다운로드)의 경우 일부 도메인은 Public URL을 사용하고, 일부는 Presigned URL을 사용합니다. - * * 파일 삭제 시, URL에서 경로를 추출하여 스토리지에서 삭제합니다. + * 다양한 종류의 이미지 파일을 일관된 방식으로 처리하기 위한 추상 클래스입니다. + *

+ * 이 클래스는 템플릿 메서드 패턴을 사용하여 이미지 파일 관리의 전체적인 로직 흐름을 정의합니다. + * {@link #createObjectKey}와 {@link #getUploadUrl} 같은 final 메서드가 템플릿 역할을 하며, + * 세부적인 구현이 필요한 {@link #generateObjectKeyDetail}은 하위 클래스에서 구현하도록 강제합니다. + *

+ * 이를 통해 프로필 이미지, 채팅 이미지 등 각기 다른 도메인의 이미지 관리 로직을 + * 표준화된 프로세스에 따라 처리하면서도, 도메인별 경로 생성 정책 등은 유연하게 확장할 수 있습니다. * * @author sleepyhoon + * @see AbstractFileManager + * @see com.studypals.domain.memberManage.worker.MemberProfileImageManager + * @see com.studypals.domain.chatManage.worker.ChatImageManager * @since 2026-01-13 */ public abstract class AbstractImageManager extends AbstractFileManager { @@ -25,6 +30,12 @@ public abstract class AbstractImageManager extends AbstractFileManager { private final List acceptableExtensions; private final int presignedUrlExpireTime; + /** + * {@code AbstractImageManager}의 생성자입니다. + * + * @param objectStorage 스토리지 상호작용을 위한 인터페이스 구현체 + * @param properties 파일 관련 설정값 (허용 확장자, Presigned URL 만료 시간 등) + */ public AbstractImageManager(ObjectStorage objectStorage, FileProperties properties) { super(objectStorage); this.acceptableExtensions = properties.extensions(); @@ -32,18 +43,33 @@ public AbstractImageManager(ObjectStorage objectStorage, FileProperties properti } /** - * 파일 업로드를 위한 Presigned URL을 발급합니다. - * 내부적으로 파일 이름 검증과 타겟 ID 검증을 수행합니다. - * 해당 메서드는 재정의할 수 없습니다. - * @param userId 업로드 요청한 사용자 ID - * @param fileName 업로드할 파일 이름 - * @param targetId 업로드 대상 식별자 (예: userId, groupId, chatRoomId) - * @return 업로드 가능한 Presigned URL + * 파일 업로드를 위한 Presigned URL을 생성하여 반환합니다. + *

+ * 이 메서드는 템플릿의 일부로, 모든 하위 클래스에서 동일한 방식으로 동작해야 하므로 {@code final}로 선언되었습니다. + * 실제 URL 생성은 {@link ObjectStorage} 구현체에 위임합니다. + * + * @param objectKey 스토리지에 저장될 객체의 고유 키 + * @return 업로드 전용 Presigned URL */ public final String getUploadUrl(String objectKey) { return objectStorage.createPresignedPutUrl(objectKey, presignedUrlExpireTime); } + /** + * 스토리지에 저장될 고유한 객체 키(Object Key)를 생성하는 템플릿 메서드입니다. + *

+ * 이 메서드는 {@code final}로 선언되어 있으며, 다음과 같은 정해진 순서로 동작합니다. + *

    + *
  1. {@link #validateFileName}: 파일 이름과 확장자를 검증합니다.
  2. + *
  3. {@link #validateTargetId}: 하위 클래스에서 재정의 가능한 대상 ID 유효성을 검증합니다 (Hook).
  4. + *
  5. {@link #generateObjectKey}: 실제 객체 키를 생성합니다.
  6. + *
+ * + * @param userId 업로드를 요청한 사용자 ID + * @param fileName 원본 파일 이름 + * @param targetId 업로드 대상의 식별자 (예: 사용자 ID, 채팅방 ID 등) + * @return 생성된 고유 객체 키 + */ public final String createObjectKey(Long userId, String fileName, String targetId) { validateFileName(fileName); validateTargetId(userId, targetId); @@ -51,10 +77,12 @@ public final String createObjectKey(Long userId, String fileName, String targetI } /** - * MinIO/S3 에 저장할 경로(ObjectKey)를 생성합니다. - * @param fileName 이미지 이름 - * @param targetId 업로드 대상 식별자 (예: userId, groupId, chatRoomId) - * @return 생성된 ObjectKey + * 객체 키 생성을 위한 내부 헬퍼 메서드입니다. + * 파일 확장자를 추출한 뒤, 하위 클래스에서 구현된 {@link #generateObjectKeyDetail}을 호출하여 최종 키를 완성합니다. + * + * @param fileName 원본 파일 이름 + * @param targetId 업로드 대상 식별자 + * @return 생성된 객체 키 */ private String generateObjectKey(String fileName, String targetId) { String ext = FileUtils.extractExtension(fileName); @@ -62,35 +90,46 @@ private String generateObjectKey(String fileName, String targetId) { } /** - * 프로필, 채팅 이미지의 경로(ObjectKey)가 다르기 때문에 구체 클래스에서 구현해야 합니다. - * @param targetId 업로드 대상 식별자 (예: userId, groupId, chatRoomId) + * 객체 키의 상세 경로를 생성하는 추상 메서드입니다. + *

+ * 이 메서드는 하위 클래스에서 반드시 구현해야 합니다. + * 이미지의 종류(프로필, 채팅 등)에 따라 달라지는 저장 경로 구조를 정의하는 역할을 합니다. + * + * @param targetId 업로드 대상 식별자 (예: 사용자 ID, 채팅방 ID) * @param ext 파일 확장자 - * @return 생성된 ObjectKey + * @return 도메인에 특화된 경로가 포함된 최종 객체 키 */ protected abstract String generateObjectKeyDetail(String targetId, String ext); /** - * targetId의 유효성을 검증합니다. - * 기본적으로는 아무런 검증도 수행하지 않으며(Hook Method), - * 검증이 필요한 구체 클래스에서 이 메서드를 오버라이드하여 구현합니다. + * 대상 식별자(targetId)의 유효성을 검증하는 Hook 메서드입니다. + *

+ * 기본적으로는 아무런 검증을 수행하지 않습니다. + * 특정 도메인에서 추가적인 검증(예: 채팅방 멤버 여부 확인)이 필요한 경우, + * 하위 클래스에서 이 메서드를 재정의(Override)하여 사용합니다. * - * @param userId 검증할 사용자 ID + * @param userId 검증을 요청한 사용자 ID * @param targetId 검증할 대상 식별자 - * @throws IllegalArgumentException 유효하지 않은 targetId인 경우 + * @throws RuntimeException 유효성 검증에 실패할 경우 적절한 예외를 발생시킬 수 있습니다. */ protected void validateTargetId(Long userId, String targetId) { - // 기본 구현: 검증 없음 + // 기본 구현은 비어 있으며, 하위 클래스에서 필요에 따라 재정의합니다. } /** - * 구현체에서 다루는 이미지 사이즈를 반환합니다. - * @return ImageVariantKey 리스트 + * 이 Manager가 처리하는 이미지의 다양한 크기 버전(Variant) 정보를 반환합니다. + * 하위 클래스는 이 메서드를 구현하여 원본, 썸네일 등 필요한 이미지 종류를 정의해야 합니다. + * + * @return {@link ImageVariantKey} 리스트 */ protected abstract List variants(); /** - * 사전에 정해둔 파일 확장자를 가지는지 확인합니다. - * @param fileName 확인할 파일 이름 + * 파일 이름의 유효성과 확장자를 검증합니다. + * 파일 이름이 null이거나 '.'을 포함하지 않는 경우, 또는 허용되지 않은 확장자인 경우 예외를 발생시킵니다. + * + * @param fileName 검증할 파일 이름 + * @throws FileException 유효하지 않은 파일 이름 또는 지원하지 않는 확장자인 경우 */ private void validateFileName(String fileName) { if (fileName == null || !fileName.contains(".")) { diff --git a/src/main/java/com/studypals/global/file/dto/ChatPresignedUrlReq.java b/src/main/java/com/studypals/global/file/dto/ChatPresignedUrlReq.java index be7c76d9..6fa6c488 100644 --- a/src/main/java/com/studypals/global/file/dto/ChatPresignedUrlReq.java +++ b/src/main/java/com/studypals/global/file/dto/ChatPresignedUrlReq.java @@ -3,6 +3,15 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +/** + * 채팅 사진 업로드에 필요한 정보를 가집니다. + * + * @param fileName 업로드할 파일의 이름. {@code @NotNull}과 {@code @NotBlank} 제약조건이 적용됩니다. + * @param chatRoomId 업로드할 파일이 속한 채팅방 id. {@code @NotNull}과 {@code @NotBlank} 제약조건이 적용됩니다. + * + * @author sleepyhoon + * @since 2026-01-10 + */ public record ChatPresignedUrlReq( @NotNull(message = "파일 이름은 필수입니다.") @NotBlank(message = "파일 이름은 공백일 수 없습니다.") String fileName, @NotNull(message = "채팅방 ID는 필수입니다.") @NotBlank(message = "채팅방 ID는 공백일 수 없습니다.") String chatRoomId) {} diff --git a/src/main/java/com/studypals/global/file/dto/PresignedUrlRes.java b/src/main/java/com/studypals/global/file/dto/PresignedUrlRes.java index f816d9a7..5bf1dc43 100644 --- a/src/main/java/com/studypals/global/file/dto/PresignedUrlRes.java +++ b/src/main/java/com/studypals/global/file/dto/PresignedUrlRes.java @@ -1,3 +1,17 @@ package com.studypals.global.file.dto; +/** + * Presigned URL 생성 요청에 대한 응답 데이터를 담는 DTO(Data Transfer Object)입니다. + *

+ * 이 객체는 클라이언트가 파일을 스토리지에 직접 업로드하는 데 필요한 정보들을 제공합니다. + * 클라이언트는 이 응답을 받아 {@code url}에 파일을 업로드한 후, 필요에 따라 {@code id}를 통해 + * 서버에 업로드 완료를 통지하여 이미지 상태를 변경할 수 있습니다. + * + * @param id 데이터베이스에 미리 저장된 이미지 메타데이터의 고유 ID. + * 파일 업로드 완료 후 서버에 상태 변경(예: PENDING -> COMPLETE)을 알리는 데 사용됩니다. + * @param url 파일을 업로드할 수 있는, 유효 기간이 제한된 Presigned URL (일반적으로 HTTP PUT). + * 클라이언트는 이 URL 주소로 파일 데이터를 직접 전송해야 합니다. + * @author sleepyhoon + * @since 2024-01-15 + */ public record PresignedUrlRes(Long id, String url) {} diff --git a/src/main/java/com/studypals/global/file/dto/ProfilePresignedUrlReq.java b/src/main/java/com/studypals/global/file/dto/ProfilePresignedUrlReq.java index 159c67ba..6c0cb2c7 100644 --- a/src/main/java/com/studypals/global/file/dto/ProfilePresignedUrlReq.java +++ b/src/main/java/com/studypals/global/file/dto/ProfilePresignedUrlReq.java @@ -3,4 +3,12 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +/** + * 프로필 사진 업로드에 필요한 정보를 가집니다. + * + * @param fileName 업로드할 파일의 이름. {@code @NotNull}과 {@code @NotBlank} 제약조건이 적용됩니다. + * + * @author sleepyhoon + * @since 2026-01-10 + */ public record ProfilePresignedUrlReq(@NotNull @NotBlank String fileName) {} diff --git a/src/main/java/com/studypals/global/file/entity/ImageStatus.java b/src/main/java/com/studypals/global/file/entity/ImageStatus.java index 9b81fa86..a6931449 100644 --- a/src/main/java/com/studypals/global/file/entity/ImageStatus.java +++ b/src/main/java/com/studypals/global/file/entity/ImageStatus.java @@ -1,7 +1,30 @@ package com.studypals.global.file.entity; +/** + * 이미지 파일의 업로드 상태를 나타내는 열거형(Enum) 클래스입니다. + *

+ * 클라이언트가 Presigned URL을 발급받은 시점부터 실제 파일 업로드가 완료되거나 실패하기까지의 + * 이미지 파일 라이프사이클을 관리하는 데 사용됩니다. + * + * @author sleepyhoon + * @since 2024-01-15 + */ public enum ImageStatus { - PENDING, // 대기 중 - COMPLETE, // 완료 - EXPIRED // 실패 + /** + * Presigned URL이 발급되었으나, 아직 파일이 스토리지에 최종적으로 업로드되지 않은 대기 상태입니다. + * 이미지 메타데이터가 DB에 처음 저장될 때의 기본 상태입니다. + */ + PENDING, + + /** + * 파일이 스토리지에 성공적으로 업로드되고, 서버가 이를 확인한 완료 상태입니다. + * 이 상태의 이미지만 사용자에게 정상적으로 노출됩니다. + */ + COMPLETE, + + /** + * Presigned URL의 유효 기간이 만료되거나 다른 이유로 파일 업로드가 실패한 상태입니다. + * 스케줄링된 배치 작업을 통해 이 상태의 이미지 메타데이터는 주기적으로 정리될 수 있습니다. + */ + EXPIRED } diff --git a/src/main/java/com/studypals/global/file/entity/ImageType.java b/src/main/java/com/studypals/global/file/entity/ImageType.java index a05fece9..a43fd799 100644 --- a/src/main/java/com/studypals/global/file/entity/ImageType.java +++ b/src/main/java/com/studypals/global/file/entity/ImageType.java @@ -3,13 +3,27 @@ import com.studypals.global.file.FileType; /** - * 파일 이미지 타입을 정의합니다.

- * 현재 프로필, 채팅 이미지만 고려합니다. + * 시스템에서 다루는 이미지의 종류를 정의하는 열거형(Enum) 클래스입니다. + *

+ * 이 Enum은 {@link FileType} 인터페이스를 구현하며, 각 상수는 특정 도메인에서 사용되는 + * 이미지의 유형을 나타냅니다. (예: 사용자 프로필, 채팅 메시지) + *

+ * {@code ImageFileServiceImpl}에서는 이 타입을 키로 사용하여 + * 적절한 {@code AbstractImageManager}를 동적으로 선택하는 전략 패턴을 구현합니다. * * @author sleepyhoon * @since 2026-01-10 + * @see FileType + * @see com.studypals.global.file.service.ImageFileServiceImpl */ public enum ImageType implements FileType { + /** + * 사용자 프로필 이미지를 나타냅니다. + */ PROFILE_IMAGE, + + /** + * 채팅 메시지에서 사용되는 이미지를 나타냅니다. + */ CHAT_IMAGE } diff --git a/src/main/java/com/studypals/global/file/entity/ImageVariantKey.java b/src/main/java/com/studypals/global/file/entity/ImageVariantKey.java index 58f6501f..3d7bac76 100644 --- a/src/main/java/com/studypals/global/file/entity/ImageVariantKey.java +++ b/src/main/java/com/studypals/global/file/entity/ImageVariantKey.java @@ -4,21 +4,41 @@ import lombok.RequiredArgsConstructor; /** - * 이미지 사이즈에 관한 enum입니다. - * - *

외부 모듈:
- * 필요 시 외부 모듈에 대한 내용을 적습니다. + * 이미지의 다양한 크기 버전(Variant)을 정의하는 열거형(Enum) 클래스입니다. + *

+ * 원본 이미지를 스토리지에 업로드한 후, 썸네일, 중간 크기 이미지 등 다양한 크기의 + * 파생 이미지를 생성하고 관리하는 데 사용될 수 있습니다. + * 각 상수는 특정 크기(픽셀 단위)를 정의합니다. + *

+ * 예를 들어, {@code AbstractImageManager}의 하위 클래스에서 이 Enum을 사용하여 + * 어떤 크기의 이미지들을 생성하고 관리할지 명시할 수 있습니다. * * @author sleepyhoon - * @see * @since 2026-01-16 + * @see com.studypals.global.file.dao.AbstractImageManager */ @Getter @RequiredArgsConstructor public enum ImageVariantKey { + + /** + * 작은 크기 (256px). 주로 썸네일이나 목록 뷰에 사용하기에 적합합니다. + */ SMALL(256), + + /** + * 중간 크기 (512px). 일반적인 콘텐츠 뷰에 사용하기에 적합합니다. + */ MEDIUM(512), + + /** + * 큰 크기 (1024px). 상세 보기나 전체 화면 표시에 사용하기에 적합합니다. + */ LARGE(1024); + /** + * 해당 이미지 크기 버전의 한 변의 길이(픽셀 단위)입니다. + * 일반적으로 정사각형 이미지의 가로/세로 크기를 의미합니다. + */ private final int size; } diff --git a/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java b/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java index c9432231..6fb3f988 100644 --- a/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java +++ b/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java @@ -26,11 +26,25 @@ import com.studypals.global.file.entity.ImageType; /** - * 파일을 처리하는 로직을 정의한 구현 클래스입니다. - * 파일 업로드를 위한 presigned url을 발급을 진행합니다. + * 이미지 파일 관련 비즈니스 로직을 처리하는 서비스 구현체입니다. + *

+ * 이 서비스는 클라이언트가 파일을 스토리지에 직접 업로드하는 데 필요한 Presigned URL을 생성하는 역할을 담당합니다. + * {@link ImageType}에 따라 적절한 {@link AbstractImageManager}를 동적으로 선택하여 로직을 위임하는 전략 패턴을 사용합니다. + * 이를 통해 새로운 이미지 타입이 추가되더라도 서비스 코드의 변경 없이 유연하게 확장할 수 있습니다. + * + *

주요 흐름: + *

    + *
  1. 클라이언트로부터 이미지 타입에 맞는 Presigned URL 생성 요청을 받습니다.
  2. + *
  3. 요청 타입에 맞는 Manager를 조회합니다. (예: 프로필 사진, 채팅 사진)
  4. + *
  5. Manager를 통해 스토리지에 저장될 고유한 Object Key를 생성합니다.
  6. + *
  7. 파일이 실제로 업로드되기 전에, 해당 Object Key와 파일 메타데이터를 데이터베이스에 먼저 저장합니다.
  8. + *
  9. 생성된 Object Key를 기반으로 스토리지에 업로드할 수 있는 Presigned URL을 발급하여 클라이언트에 반환합니다.
  10. + *
* * @author sleepyhoon * @since 2026-01-10 + * @see ImageFileService + * @see AbstractImageManager */ @Service public class ImageFileServiceImpl implements ImageFileService { @@ -41,6 +55,21 @@ public class ImageFileServiceImpl implements ImageFileService { private final MemberProfileImageWriter profileImageWriter; private final ChatImageWriter chatImageWriter; + /** + * 의존성 주입을 위한 생성자입니다. + *

+ * Spring 컨텍스트에 등록된 모든 {@link AbstractImageManager} 타입의 빈을 리스트로 주입받아, + * 각 Manager가 처리하는 {@link FileType}을 키로 하는 맵을 구성합니다. + * 만약 서로 다른 Manager가 동일한 FileType을 처리하려고 할 경우, 애플리케이션 구동 시점에 + * {@link IllegalStateException}을 발생시켜 설정 오류를 방지합니다. + * + * @param managers Spring 컨텍스트에 의해 주입되는 {@code AbstractImageManager}의 모든 구현체 리스트 + * @param memberReader 회원 정보를 조회하는 워커 + * @param chatRoomReader 채팅방 정보를 조회하는 워커 + * @param profileImageWriter 프로필 이미지 메타데이터를 저장하는 워커 + * @param chatImageWriter 채팅 이미지 메타데이터를 저장하는 워커 + * @throws IllegalStateException 동일한 FileType을 처리하는 Manager가 두 개 이상 존재할 경우 발생 + */ public ImageFileServiceImpl( List managers, MemberReader memberReader, @@ -51,7 +80,7 @@ public ImageFileServiceImpl( .collect(Collectors.toMap( AbstractFileManager::getFileType, Function.identity(), (existing, duplicate) -> { throw new IllegalStateException(String.format( - "ImageType 중복 등록 오류. '%s' 타입이 '%s'와 '%s' 클래스에서 중복으로 처리됩니다.", + "FileType 중복 등록 오류. '%s' 타입이 '%s'와 '%s' 클래스에서 중복으로 처리됩니다.", existing.getFileType(), existing.getClass().getName(), duplicate.getClass().getName())); @@ -62,6 +91,20 @@ public ImageFileServiceImpl( this.chatImageWriter = chatImageWriter; } + /** + * 사용자 프로필 이미지 업로드를 위한 Presigned URL을 생성합니다. + *

+ * {@link ImageType#PROFILE_IMAGE} 타입에 맞는 Manager를 찾아 다음을 수행합니다: + *

    + *
  1. 사용자 ID와 파일명을 기반으로 Object Key를 생성합니다.
  2. + *
  3. 생성된 Object Key를 포함한 이미지 정보를 DB에 미리 저장하고, 이미지 ID를 발급받습니다.
  4. + *
  5. Object Key를 사용하여 스토리지에 업로드할 수 있는 Presigned URL을 생성합니다.
  6. + *
+ * + * @param request 파일 이름이 담긴 요청 DTO + * @param userId Presigned URL을 요청한 사용자의 ID + * @return 생성된 이미지 ID와 Presigned URL이 포함된 응답 DTO + */ @Override public PresignedUrlRes getProfileUploadUrl(ProfilePresignedUrlReq request, Long userId) { MemberProfileImageManager manager = getManager(ImageType.PROFILE_IMAGE, MemberProfileImageManager.class); @@ -77,6 +120,20 @@ public PresignedUrlRes getProfileUploadUrl(ProfilePresignedUrlReq request, Long return new PresignedUrlRes(imageId, uploadUrl); } + /** + * 채팅방 내 이미지 업로드를 위한 Presigned URL을 생성합니다. + *

+ * {@link ImageType#CHAT_IMAGE} 타입에 맞는 Manager를 찾아 다음을 수행합니다: + *

    + *
  1. 채팅방 ID, 사용자 ID, 파일명을 기반으로 Object Key를 생성합니다.
  2. + *
  3. 생성된 Object Key를 포함한 이미지 정보를 DB에 미리 저장하고, 이미지 ID를 발급받습니다.
  4. + *
  5. Object Key를 사용하여 스토리지에 업로드할 수 있는 Presigned URL을 생성합니다.
  6. + *
+ * + * @param request 채팅방 ID와 파일 이름이 담긴 요청 DTO + * @param userId Presigned URL을 요청한 사용자의 ID + * @return 생성된 이미지 ID와 Presigned URL이 포함된 응답 DTO + */ @Override public PresignedUrlRes getChatUploadUrl(ChatPresignedUrlReq request, Long userId) { ChatImageManager manager = getManager(ImageType.CHAT_IMAGE, ChatImageManager.class); @@ -92,6 +149,17 @@ public PresignedUrlRes getChatUploadUrl(ChatPresignedUrlReq request, Long userId return new PresignedUrlRes(imageId, uploadUrl); } + /** + * 지정된 {@link FileType}에 해당하는 {@link AbstractImageManager}의 구현체를 타입 안전하게 조회합니다. + * + * @param fileType 조회할 파일 타입 (예: {@code ImageType.PROFILE_IMAGE}) + * @param managerClass 반환받고자 하는 Manager의 클래스 타입 + * @param {@code AbstractImageManager}를 상속하는 특정 Manager 타입 + * @return 요청된 타입의 Manager 인스턴스 + * @throws FileException 해당 {@code fileType}을 처리하는 Manager가 등록되어 있지 않을 경우 발생 + * @throws IllegalStateException 조회된 Manager가 요청된 {@code managerClass} 타입과 일치하지 않을 경우 발생. + * 이는 심각한 설정 오류를 의미합니다. + */ private T getManager(FileType fileType, Class managerClass) { AbstractImageManager manager = managerMap.get(fileType); if (manager == null) { From 14e679e0efdb0edb75fec54a94210e3c6cbb2530 Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Sat, 17 Jan 2026 20:14:03 +0900 Subject: [PATCH 15/25] =?UTF-8?q?Docs:=20=EB=AC=B8=EC=84=9C=ED=99=94=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/asciidoc/api/file.adoc | 52 ++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/src/asciidoc/api/file.adoc b/src/asciidoc/api/file.adoc index 1f5ac738..e9931d7e 100644 --- a/src/asciidoc/api/file.adoc +++ b/src/asciidoc/api/file.adoc @@ -1,4 +1,4 @@ -= 👥 file API += 📁 File API :doctype: book :icons: font :source-highlighter: highlightjs @@ -6,64 +6,60 @@ :toclevels: 3 :sectlinks: -== 👥 그룹 관련 응답 코드 (I01) +[[overview]] +== 개요: Presigned URL을 이용한 파일 업로드 + +StudyPals의 파일 업로드는 서버의 부하를 줄이고 효율성을 높이기 위해 **Presigned URL** 방식을 사용합니다. 전체적인 프로세스는 다음과 같습니다. + +. *Presigned URL 요청*: 클라이언트가 업로드할 파일의 정보(파일명 등)를 서버에 보내 Presigned URL을 요청합니다. +. *Presigned URL 응답*: 서버는 요청을 검증한 후, 지정된 시간 동안만 유효한 업로드 전용 URL을 생성하여 클라이언트에 반환합니다. 이때 DB에는 파일 메타데이터가 `PENDING` 상태로 미리 저장됩니다. +. *파일 업로드*: 클라이언트는 발급받은 Presigned URL로 스토리지(MinIO)에 직접 파일 데이터를 `HTTP PUT` 요청으로 전송합니다. +. *업로드 완료 처리 (향후 구현)*: 클라이언트는 업로드 성공 후, 서버에 파일의 상태를 `COMPLETE`로 변경해달라고 요청할 수 있습니다. + +[[response-codes]] +== 📄 파일 관련 응답 코드 (F01) |=== | 기능 코드 | 설명 -| 00 | 프로필 이미지 업로드 Presigned URL 조회 -| 01 | 채팅 이미지 업로드 Presigned URL 조회 +| `FILE_IMAGE_UPLOAD` | 이미지 업로드를 위한 Presigned URL 발급 성공 |=== == ✨ API 문서 -=== 파일 API +=== 1. 프로필 이미지 업로드 URL 요청 '''' - -==== 1. 프로필 이미지 업로드 URL 조회 - *Description* + -''' - -프로필 이미지 업로드를 위한 Presigned URL을 조회합니다. 해당 Presigned URL을 통해 MinIO에 직접 요청하여 이미지를 업로드해야 합니다. +사용자 프로필 이미지 업로드를 위한 Presigned URL을 발급받습니다. +- *HTTP Method*: `POST` +- *Endpoint*: `/files/image/profile` *REQUEST* + - -''' - include::{snippets}/image-file-controller-rest-docs-test/get-profile-upload-url_success/http-request.adoc[] include::{snippets}/image-file-controller-rest-docs-test/get-profile-upload-url_success/request-fields.adoc[] *RESPONSE* + - -''' - include::{snippets}/image-file-controller-rest-docs-test/get-profile-upload-url_success/response-fields.adoc[] include::{snippets}/image-file-controller-rest-docs-test/get-profile-upload-url_success/http-response.adoc[] '''' -==== 2. 채팅 이미지 업로드 URL 조회 - +=== 2. 채팅 이미지 업로드 URL 요청 +'''' *Description* + -''' - -채팅 이미지 업로드를 위한 Presigned URL을 조회합니다. 해당 Presigned URL을 통해 MinIO에 직접 요청하여 이미지를 업로드해야 합니다. +채팅방에서 사용할 이미지 업로드를 위한 Presigned URL을 발급받습니다. +- *HTTP Method*: `POST` +- *Endpoint*: `/files/image/chat` +- *Requires Authentication*: Yes *REQUEST* + - -''' - include::{snippets}/image-file-controller-rest-docs-test/get-chat-upload-url_success/http-request.adoc[] include::{snippets}/image-file-controller-rest-docs-test/get-chat-upload-url_success/request-fields.adoc[] *RESPONSE* + - -''' - include::{snippets}/image-file-controller-rest-docs-test/get-chat-upload-url_success/response-fields.adoc[] include::{snippets}/image-file-controller-rest-docs-test/get-chat-upload-url_success/http-response.adoc[] From 90c92c4e402a16a369d9bdca014d8410950f3c68 Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Wed, 21 Jan 2026 02:01:01 +0900 Subject: [PATCH 16/25] =?UTF-8?q?Fix:=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=8B=9C=20presigned=20url=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B3=A0=20File=20=EC=A7=81=EC=A0=91=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=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 --- .../domain/memberManage/entity/Member.java | 6 +- .../entity/MemberProfileImage.java | 1 + .../service/MemberServiceImpl.java | 1 + .../worker/MemberProfileImageWriter.java | 4 ++ .../studypals/global/file/ObjectStorage.java | 24 +++---- .../global/file/api/ImageFileController.java | 33 ++++----- .../global/file/dao/AbstractFileManager.java | 13 ++++ .../global/file/dao/AbstractImageManager.java | 70 +++++++++---------- .../global/file/dto/ImageUploadRes.java | 18 +++++ .../global/file/dto/PresignedUrlRes.java | 17 ----- .../global/file/entity/ImageFile.java | 25 +++++-- .../global/file/entity/ImageStatus.java | 17 +++-- .../global/file/service/ImageFileService.java | 25 ++++--- .../file/service/ImageFileServiceImpl.java | 68 ++++++++++-------- .../studypals/global/minio/MinioStorage.java | 40 +++++++---- .../file/dao/AbstractImageManagerTest.java | 2 +- .../service/ImageFileServiceImplTest.java | 12 ++-- 17 files changed, 219 insertions(+), 157 deletions(-) create mode 100644 src/main/java/com/studypals/global/file/dto/ImageUploadRes.java delete mode 100644 src/main/java/com/studypals/global/file/dto/PresignedUrlRes.java diff --git a/src/main/java/com/studypals/domain/memberManage/entity/Member.java b/src/main/java/com/studypals/domain/memberManage/entity/Member.java index 610fe2cc..0c1cb0ef 100644 --- a/src/main/java/com/studypals/domain/memberManage/entity/Member.java +++ b/src/main/java/com/studypals/domain/memberManage/entity/Member.java @@ -51,6 +51,10 @@ public class Member { @Column(name = "image_url", nullable = true, length = 255) private String imageUrl; + @OneToOne(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) + @Setter + private MemberProfileImage profileImage; + @Column(name = "created_at") @CreatedDate private LocalDate createdDate; @@ -73,6 +77,6 @@ public Member(String username, String password, String nickname) { public void updateProfile(LocalDate birthday, String position, String imageUrl) { this.birthday = birthday; this.position = position; - this.imageUrl = imageUrl; + this.imageUrl = imageUrl; // TODO : 수정 필요 } } diff --git a/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java b/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java index c1a01108..ed7a6f8f 100644 --- a/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java +++ b/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java @@ -14,6 +14,7 @@ import com.studypals.global.file.entity.ImageFile; +// 주석 추가 @Entity @Getter @SuperBuilder diff --git a/src/main/java/com/studypals/domain/memberManage/service/MemberServiceImpl.java b/src/main/java/com/studypals/domain/memberManage/service/MemberServiceImpl.java index b44422ff..ccfb35d9 100644 --- a/src/main/java/com/studypals/domain/memberManage/service/MemberServiceImpl.java +++ b/src/main/java/com/studypals/domain/memberManage/service/MemberServiceImpl.java @@ -64,6 +64,7 @@ public Long getMemberIdByUsername(String username) { public Long updateProfile(Long userId, UpdateProfileReq dto) { Member member = memberReader.get(userId); + // TODO: 이미지를 직접 받도록 변경 후, update 로직을 수정해야 함. member.updateProfile(dto.birthday(), dto.position(), dto.imageUrl()); memberWriter.save(member); diff --git a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java index 7e0cd2a3..10fbcb53 100644 --- a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java +++ b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java @@ -9,6 +9,7 @@ import com.studypals.domain.memberManage.entity.MemberProfileImage; import com.studypals.global.annotations.Worker; import com.studypals.global.file.FileUtils; +import com.studypals.global.file.entity.ImageStatus; /** * 회원 프로필 이미지의 메타데이터를 데이터베이스에 저장하는 역할을 전담하는 Worker 클래스입니다. @@ -47,8 +48,11 @@ public Long save(Member member, String objectKey, String fileName) { .objectKey(objectKey) .originalFileName(fileName) .mimeType(extension) + .imageStatus(ImageStatus.PENDING) // 비동기 리사이징 대기 상태로 저장 .build()); + member.setProfileImage(savedImage); + return savedImage.getId(); } } diff --git a/src/main/java/com/studypals/global/file/ObjectStorage.java b/src/main/java/com/studypals/global/file/ObjectStorage.java index cd2ed39e..2a8542a9 100644 --- a/src/main/java/com/studypals/global/file/ObjectStorage.java +++ b/src/main/java/com/studypals/global/file/ObjectStorage.java @@ -1,5 +1,7 @@ package com.studypals.global.file; +import org.springframework.web.multipart.MultipartFile; + /** * Object Storage와의 상호작용을 위한 표준 인터페이스를 정의합니다. *

@@ -16,10 +18,18 @@ */ public interface ObjectStorage { + /** + * 스토리지에 파일을 저장합니다. + * + * @param file 저장할 파일 + * @param objectKey 파일을 저장할 경로 + */ + String upload(MultipartFile file, String objectKey); + /** * 스토리지에서 지정된 객체(파일)를 삭제합니다. * - * @param objectKey 삭제할 객체의 고유 키 (예: "profile/images/user1.jpg") + * @param objectKey 삭제할 객체의 경로 */ void delete(String objectKey); @@ -45,16 +55,4 @@ public interface ObjectStorage { * @return 생성된 Presigned GET URL */ String createPresignedGetUrl(String objectKey, int expirySeconds); - - /** - * 객체 업로드를 위한 Presigned URL을 생성합니다. - *

- * 클라이언트는 이 URL을 사용하여 서버를 거치지 않고 스토리지에 직접 파일을 업로드할 수 있습니다. - * 이는 서버의 부하를 줄이고 업로드 속도를 향상시키는 효과적인 방법입니다. - * - * @param objectKey 업로드될 객체에 부여할 고유 키 - * @param expirySeconds URL의 만료 시간 (초 단위) - * @return 생성된 Presigned PUT URL - */ - String createPresignedPutUrl(String objectKey, int expirySeconds); } diff --git a/src/main/java/com/studypals/global/file/api/ImageFileController.java b/src/main/java/com/studypals/global/file/api/ImageFileController.java index ad7d1a7e..3e466451 100644 --- a/src/main/java/com/studypals/global/file/api/ImageFileController.java +++ b/src/main/java/com/studypals/global/file/api/ImageFileController.java @@ -1,19 +1,18 @@ package com.studypals.global.file.api; -import jakarta.validation.Valid; - +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; import lombok.RequiredArgsConstructor; -import com.studypals.global.file.dto.ChatPresignedUrlReq; -import com.studypals.global.file.dto.PresignedUrlRes; -import com.studypals.global.file.dto.ProfilePresignedUrlReq; +import com.studypals.global.file.dto.ImageUploadRes; import com.studypals.global.file.service.ImageFileService; import com.studypals.global.responses.CommonResponse; import com.studypals.global.responses.Response; @@ -21,11 +20,11 @@ /** * 파일 관련 로직을 처리하는 컨트롤러입니다. - * 파일 업로드는 서버 측에서 presigned url을 발급하고 클라이언트 측에서 진행합니다. + * 클라이언트로부터 파일을 직접 받아 서버에서 스토리지로 업로드를 진행합니다. * *

  *     - POST /files/image/profile : 프로필 사진 업로드를 위한 URL 발급
- *     - POST /files/image/chat : 채팅 사진 업로드를 위한 URL 발급
+ *     - POST /files/image/chat : 채팅 사진 업로드
  * 
* * @author sleepyhoon @@ -37,17 +36,19 @@ public class ImageFileController { private final ImageFileService imageFileService; - @PostMapping("/profile") - public ResponseEntity> getUploadUrl( - @Valid @RequestBody ProfilePresignedUrlReq request, @AuthenticationPrincipal Long userId) { - PresignedUrlRes response = imageFileService.getProfileUploadUrl(request, userId); + @PostMapping(value = "/profile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> uploadProfileImage( + @RequestPart("file") MultipartFile file, @AuthenticationPrincipal Long userId) { + ImageUploadRes response = imageFileService.uploadProfileImage(file, userId); return ResponseEntity.ok(CommonResponse.success(ResponseCode.FILE_IMAGE_UPLOAD, response)); } - @PostMapping("/chat") - public ResponseEntity> getUploadUrl( - @Valid @RequestBody ChatPresignedUrlReq request, @AuthenticationPrincipal Long userId) { - PresignedUrlRes response = imageFileService.getChatUploadUrl(request, userId); + @PostMapping(value = "/chat", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> uploadChatImage( + @RequestPart("file") MultipartFile file, + @RequestParam("chatRoomId") String chatRoomId, + @AuthenticationPrincipal Long userId) { + ImageUploadRes response = imageFileService.uploadChatImage(file, chatRoomId, userId); return ResponseEntity.ok(CommonResponse.success(ResponseCode.FILE_IMAGE_UPLOAD, response)); } } diff --git a/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java b/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java index a3f4f670..fa23a16d 100644 --- a/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java +++ b/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java @@ -1,5 +1,7 @@ package com.studypals.global.file.dao; +import org.springframework.web.multipart.MultipartFile; + import lombok.RequiredArgsConstructor; import com.studypals.global.file.FileType; @@ -34,6 +36,17 @@ public abstract class AbstractFileManager { */ protected final ObjectStorage objectStorage; + /** + * 파일을 스토리지에 업로드합니다. + * + * @param file 업로드할 파일 + * @param objectKey 스토리지에 저장될 키 + * @return 업로드된 파일의 접근 URL + */ + public String upload(MultipartFile file, String objectKey) { + return objectStorage.upload(file, objectKey); + } + /** * 스토리지에 저장된 파일을 삭제합니다. *

diff --git a/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java b/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java index 717693f6..b0250374 100644 --- a/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java +++ b/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java @@ -13,7 +13,7 @@ * 다양한 종류의 이미지 파일을 일관된 방식으로 처리하기 위한 추상 클래스입니다. *

* 이 클래스는 템플릿 메서드 패턴을 사용하여 이미지 파일 관리의 전체적인 로직 흐름을 정의합니다. - * {@link #createObjectKey}와 {@link #getUploadUrl} 같은 final 메서드가 템플릿 역할을 하며, + * {@link #createObjectKey}와 {@link #getPresignedGetUrl} 같은 final 메서드가 템플릿 역할을 하며, * 세부적인 구현이 필요한 {@link #generateObjectKeyDetail}은 하위 클래스에서 구현하도록 강제합니다. *

* 이를 통해 프로필 이미지, 채팅 이미지 등 각기 다른 도메인의 이미지 관리 로직을 @@ -51,8 +51,8 @@ public AbstractImageManager(ObjectStorage objectStorage, FileProperties properti * @param objectKey 스토리지에 저장될 객체의 고유 키 * @return 업로드 전용 Presigned URL */ - public final String getUploadUrl(String objectKey) { - return objectStorage.createPresignedPutUrl(objectKey, presignedUrlExpireTime); + public final String getPresignedGetUrl(String objectKey) { + return objectStorage.createPresignedGetUrl(objectKey, presignedUrlExpireTime); } /** @@ -76,6 +76,38 @@ public final String createObjectKey(Long userId, String fileName, String targetI return generateObjectKey(fileName, targetId); } + /** + * 파일 이름의 유효성과 확장자를 검증합니다. + * 파일 이름이 null이거나 '.'을 포함하지 않는 경우, 또는 허용되지 않은 확장자인 경우 예외를 발생시킵니다. + * + * @param fileName 검증할 파일 이름 + * @throws FileException 유효하지 않은 파일 이름 또는 지원하지 않는 확장자인 경우 + */ + private void validateFileName(String fileName) { + if (fileName == null || !fileName.contains(".")) { + throw new FileException(FileErrorCode.INVALID_FILE_NAME); + } + String extension = FileUtils.extractExtension(fileName); + if (!acceptableExtensions.contains(extension)) { + throw new FileException(FileErrorCode.UNSUPPORTED_FILE_IMAGE_EXTENSION); + } + } + + /** + * 대상 식별자(targetId)의 유효성을 검증하는 Hook 메서드입니다. + *

+ * 기본적으로는 아무런 검증을 수행하지 않습니다. + * 특정 도메인에서 추가적인 검증(예: 채팅방 멤버 여부 확인)이 필요한 경우, + * 하위 클래스에서 이 메서드를 재정의(Override)하여 사용합니다. + * + * @param userId 검증을 요청한 사용자 ID + * @param targetId 검증할 대상 식별자 + * @throws RuntimeException 유효성 검증에 실패할 경우 적절한 예외를 발생시킬 수 있습니다. + */ + protected void validateTargetId(Long userId, String targetId) { + // 기본 구현은 비어 있으며, 하위 클래스에서 필요에 따라 재정의합니다. + } + /** * 객체 키 생성을 위한 내부 헬퍼 메서드입니다. * 파일 확장자를 추출한 뒤, 하위 클래스에서 구현된 {@link #generateObjectKeyDetail}을 호출하여 최종 키를 완성합니다. @@ -101,21 +133,6 @@ private String generateObjectKey(String fileName, String targetId) { */ protected abstract String generateObjectKeyDetail(String targetId, String ext); - /** - * 대상 식별자(targetId)의 유효성을 검증하는 Hook 메서드입니다. - *

- * 기본적으로는 아무런 검증을 수행하지 않습니다. - * 특정 도메인에서 추가적인 검증(예: 채팅방 멤버 여부 확인)이 필요한 경우, - * 하위 클래스에서 이 메서드를 재정의(Override)하여 사용합니다. - * - * @param userId 검증을 요청한 사용자 ID - * @param targetId 검증할 대상 식별자 - * @throws RuntimeException 유효성 검증에 실패할 경우 적절한 예외를 발생시킬 수 있습니다. - */ - protected void validateTargetId(Long userId, String targetId) { - // 기본 구현은 비어 있으며, 하위 클래스에서 필요에 따라 재정의합니다. - } - /** * 이 Manager가 처리하는 이미지의 다양한 크기 버전(Variant) 정보를 반환합니다. * 하위 클래스는 이 메서드를 구현하여 원본, 썸네일 등 필요한 이미지 종류를 정의해야 합니다. @@ -123,21 +140,4 @@ protected void validateTargetId(Long userId, String targetId) { * @return {@link ImageVariantKey} 리스트 */ protected abstract List variants(); - - /** - * 파일 이름의 유효성과 확장자를 검증합니다. - * 파일 이름이 null이거나 '.'을 포함하지 않는 경우, 또는 허용되지 않은 확장자인 경우 예외를 발생시킵니다. - * - * @param fileName 검증할 파일 이름 - * @throws FileException 유효하지 않은 파일 이름 또는 지원하지 않는 확장자인 경우 - */ - private void validateFileName(String fileName) { - if (fileName == null || !fileName.contains(".")) { - throw new FileException(FileErrorCode.INVALID_FILE_NAME); - } - String extension = FileUtils.extractExtension(fileName); - if (!acceptableExtensions.contains(extension)) { - throw new FileException(FileErrorCode.UNSUPPORTED_FILE_IMAGE_EXTENSION); - } - } } diff --git a/src/main/java/com/studypals/global/file/dto/ImageUploadRes.java b/src/main/java/com/studypals/global/file/dto/ImageUploadRes.java new file mode 100644 index 00000000..37abc486 --- /dev/null +++ b/src/main/java/com/studypals/global/file/dto/ImageUploadRes.java @@ -0,0 +1,18 @@ +package com.studypals.global.file.dto; + +/** + * 이미지 업로드 완료 후 반환되는 응답 DTO입니다. + * + * @param id 데이터베이스에 저장된 이미지의 고유 ID (PK). + * 추후 비즈니스 로직(예: 회원 정보 수정, 채팅 메시지 전송)에서 이 ID를 참조합니다. + * @param imageUrl 업로드된 이미지를 즉시 조회할 수 있는 URL. + *

+ * 스토리지 설정에 따라 다음 중 하나가 반환됩니다: + *

    + *
  • Public 버킷인 경우: 영구적인 정적 URL
  • + *
  • Private 버킷인 경우: 일정 시간 동안 유효한 GET Presigned URL
  • + *
+ * @author sleepyhoon + * @since 2026-01-15 + */ +public record ImageUploadRes(Long id, String imageUrl) {} diff --git a/src/main/java/com/studypals/global/file/dto/PresignedUrlRes.java b/src/main/java/com/studypals/global/file/dto/PresignedUrlRes.java deleted file mode 100644 index 5bf1dc43..00000000 --- a/src/main/java/com/studypals/global/file/dto/PresignedUrlRes.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.studypals.global.file.dto; - -/** - * Presigned URL 생성 요청에 대한 응답 데이터를 담는 DTO(Data Transfer Object)입니다. - *

- * 이 객체는 클라이언트가 파일을 스토리지에 직접 업로드하는 데 필요한 정보들을 제공합니다. - * 클라이언트는 이 응답을 받아 {@code url}에 파일을 업로드한 후, 필요에 따라 {@code id}를 통해 - * 서버에 업로드 완료를 통지하여 이미지 상태를 변경할 수 있습니다. - * - * @param id 데이터베이스에 미리 저장된 이미지 메타데이터의 고유 ID. - * 파일 업로드 완료 후 서버에 상태 변경(예: PENDING -> COMPLETE)을 알리는 데 사용됩니다. - * @param url 파일을 업로드할 수 있는, 유효 기간이 제한된 Presigned URL (일반적으로 HTTP PUT). - * 클라이언트는 이 URL 주소로 파일 데이터를 직접 전송해야 합니다. - * @author sleepyhoon - * @since 2024-01-15 - */ -public record PresignedUrlRes(Long id, String url) {} diff --git a/src/main/java/com/studypals/global/file/entity/ImageFile.java b/src/main/java/com/studypals/global/file/entity/ImageFile.java index 6dabc5c2..85a6980d 100644 --- a/src/main/java/com/studypals/global/file/entity/ImageFile.java +++ b/src/main/java/com/studypals/global/file/entity/ImageFile.java @@ -35,8 +35,10 @@ @EntityListeners(AuditingEntityListener.class) @NoArgsConstructor(access = AccessLevel.PROTECTED) public abstract class ImageFile { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "image_file_id") private Long id; /** @@ -61,10 +63,11 @@ public abstract class ImageFile { private String mimeType; /** - * 이미지 상태입니다. - * Presigned URL 발급하면 PENDING - * 발급 후 성공 API를 호출하면 COMPLETE - * 발급 후 일정 시간 이내 성공 API를 호출하지 않으면 EXPIRED + * 이미지의 처리 상태입니다. + *

+ * - PENDING: 처리 대기 중 (리사이징 전) + * - COMPLETE: 처리 완료 (리사이징 완료) + * - FAILED: 처리 실패 (재시도 필요) */ @Column(nullable = false) @Enumerated(EnumType.STRING) @@ -77,4 +80,18 @@ public abstract class ImageFile { @CreatedDate @Column(updatable = false, nullable = false) private LocalDateTime createdAt; + + /** + * 이미지 처리가 완료되었음을 표시합니다. + */ + public void complete() { + this.imageStatus = ImageStatus.COMPLETE; + } + + /** + * 이미지 처리 중 오류가 발생했음을 표시합니다. + */ + public void fail() { + this.imageStatus = ImageStatus.FAILED; + } } diff --git a/src/main/java/com/studypals/global/file/entity/ImageStatus.java b/src/main/java/com/studypals/global/file/entity/ImageStatus.java index a6931449..ca6da238 100644 --- a/src/main/java/com/studypals/global/file/entity/ImageStatus.java +++ b/src/main/java/com/studypals/global/file/entity/ImageStatus.java @@ -3,28 +3,27 @@ /** * 이미지 파일의 업로드 상태를 나타내는 열거형(Enum) 클래스입니다. *

- * 클라이언트가 Presigned URL을 발급받은 시점부터 실제 파일 업로드가 완료되거나 실패하기까지의 - * 이미지 파일 라이프사이클을 관리하는 데 사용됩니다. + * 서버 직접 업로드 방식에서 이미지의 리사이징 및 저장 처리 상태를 관리하는 데 사용됩니다. + * 특히 리사이징 실패 시 재시도 로직을 위한 상태 구분에 활용됩니다. * * @author sleepyhoon * @since 2024-01-15 */ public enum ImageStatus { /** - * Presigned URL이 발급되었으나, 아직 파일이 스토리지에 최종적으로 업로드되지 않은 대기 상태입니다. - * 이미지 메타데이터가 DB에 처음 저장될 때의 기본 상태입니다. + * 이미지 메타데이터가 생성되었으나, 아직 리사이징 등 후속 처리가 완료되지 않은 대기 상태입니다. */ PENDING, /** - * 파일이 스토리지에 성공적으로 업로드되고, 서버가 이를 확인한 완료 상태입니다. - * 이 상태의 이미지만 사용자에게 정상적으로 노출됩니다. + * 리사이징 및 스토리지 저장이 성공적으로 완료된 상태입니다. + * 서비스에서 정상적으로 조회 가능한 상태입니다. */ COMPLETE, /** - * Presigned URL의 유효 기간이 만료되거나 다른 이유로 파일 업로드가 실패한 상태입니다. - * 스케줄링된 배치 작업을 통해 이 상태의 이미지 메타데이터는 주기적으로 정리될 수 있습니다. + * 리사이징이나 업로드 과정에서 오류가 발생한 상태입니다. + * 추후 배치 작업 등을 통해 재시도를 수행할 수 있습니다. */ - EXPIRED + FAILED } diff --git a/src/main/java/com/studypals/global/file/service/ImageFileService.java b/src/main/java/com/studypals/global/file/service/ImageFileService.java index f8cd24a4..d0603167 100644 --- a/src/main/java/com/studypals/global/file/service/ImageFileService.java +++ b/src/main/java/com/studypals/global/file/service/ImageFileService.java @@ -1,8 +1,8 @@ package com.studypals.global.file.service; -import com.studypals.global.file.dto.ChatPresignedUrlReq; -import com.studypals.global.file.dto.PresignedUrlRes; -import com.studypals.global.file.dto.ProfilePresignedUrlReq; +import org.springframework.web.multipart.MultipartFile; + +import com.studypals.global.file.dto.ImageUploadRes; /** * 파일을 처리하는 로직을 정의한 인터페이스입니다. @@ -16,16 +16,19 @@ */ public interface ImageFileService { /** - * 프로필 이미지 업로드를 위한 URL을 발급합니다. - * @param request 프로필 파일 이름 정보가 담긴 요청 DTO - * @return 업로드 가능한 URL + * 프로필 이미지를 스토리지에 업로드합니다. + * @param file 업로드할 이미지 파일 + * @param userId 요청한 사용자 ID + * @return 업로드된 파일 정보 (ID, Access URL) */ - PresignedUrlRes getProfileUploadUrl(ProfilePresignedUrlReq request, Long userId); + ImageUploadRes uploadProfileImage(MultipartFile file, Long userId); /** - * 채팅 이미지 업로드를 위한 URL을 발급합니다. - * @param request 채팅 파일 이름과 타겟 ID 정보가 담긴 요청 DTO - * @return 업로드 가능한 URL + * 채팅 이미지를 스토리지에 업로드합니다. + * @param file 업로드할 이미지 파일 + * @param chatRoomId 채팅방 ID + * @param userId 요청한 사용자 ID + * @return 업로드된 파일 정보 (ID, Access URL) */ - PresignedUrlRes getChatUploadUrl(ChatPresignedUrlReq request, Long userId); + ImageUploadRes uploadChatImage(MultipartFile file, String chatRoomId, Long userId); } diff --git a/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java b/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java index 6fb3f988..11136be5 100644 --- a/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java +++ b/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java @@ -6,6 +6,7 @@ import java.util.stream.Collectors; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; import com.studypals.domain.chatManage.entity.ChatRoom; import com.studypals.domain.chatManage.worker.ChatImageManager; @@ -20,25 +21,25 @@ import com.studypals.global.file.FileType; import com.studypals.global.file.dao.AbstractFileManager; import com.studypals.global.file.dao.AbstractImageManager; -import com.studypals.global.file.dto.ChatPresignedUrlReq; -import com.studypals.global.file.dto.PresignedUrlRes; -import com.studypals.global.file.dto.ProfilePresignedUrlReq; +import com.studypals.global.file.dto.ImageUploadRes; import com.studypals.global.file.entity.ImageType; /** * 이미지 파일 관련 비즈니스 로직을 처리하는 서비스 구현체입니다. *

- * 이 서비스는 클라이언트가 파일을 스토리지에 직접 업로드하는 데 필요한 Presigned URL을 생성하는 역할을 담당합니다. + * 이 서비스는 클라이언트로부터 받은 파일을 스토리지에 직접 업로드하는 역할을 담당합니다. * {@link ImageType}에 따라 적절한 {@link AbstractImageManager}를 동적으로 선택하여 로직을 위임하는 전략 패턴을 사용합니다. * 이를 통해 새로운 이미지 타입이 추가되더라도 서비스 코드의 변경 없이 유연하게 확장할 수 있습니다. * *

주요 흐름: *

    - *
  1. 클라이언트로부터 이미지 타입에 맞는 Presigned URL 생성 요청을 받습니다.
  2. - *
  3. 요청 타입에 맞는 Manager를 조회합니다. (예: 프로필 사진, 채팅 사진)
  4. + *
  5. 클라이언트로부터 이미지 파일과 메타데이터를 받습니다.
  6. + *
  7. 요청 타입에 맞는 Manager를 조회합니다.
  8. *
  9. Manager를 통해 스토리지에 저장될 고유한 Object Key를 생성합니다.
  10. - *
  11. 파일이 실제로 업로드되기 전에, 해당 Object Key와 파일 메타데이터를 데이터베이스에 먼저 저장합니다.
  12. - *
  13. 생성된 Object Key를 기반으로 스토리지에 업로드할 수 있는 Presigned URL을 발급하여 클라이언트에 반환합니다.
  14. + *
  15. 이미지 리사이징 로직을 수행합니다.
  16. + *
  17. ObjectStorage를 통해 파일을 스토리지에 업로드합니다.
  18. + *
  19. 업로드된 파일 정보와 메타데이터를 데이터베이스에 저장합니다.
  20. + *
  21. 저장된 이미지 ID와 접근 가능한 URL을 반환합니다.
  22. *
* * @author sleepyhoon @@ -92,61 +93,68 @@ public ImageFileServiceImpl( } /** - * 사용자 프로필 이미지 업로드를 위한 Presigned URL을 생성합니다. + * 사용자 프로필 이미지를 업로드합니다. *

* {@link ImageType#PROFILE_IMAGE} 타입에 맞는 Manager를 찾아 다음을 수행합니다: *

    *
  1. 사용자 ID와 파일명을 기반으로 Object Key를 생성합니다.
  2. - *
  3. 생성된 Object Key를 포함한 이미지 정보를 DB에 미리 저장하고, 이미지 ID를 발급받습니다.
  4. - *
  5. Object Key를 사용하여 스토리지에 업로드할 수 있는 Presigned URL을 생성합니다.
  6. + *
  7. 스토리지에 파일을 업로드합니다.
  8. + *
  9. 이미지 정보를 DB에 저장합니다.
  10. *
* - * @param request 파일 이름이 담긴 요청 DTO + * @param file 업로드할 이미지 파일 * @param userId Presigned URL을 요청한 사용자의 ID - * @return 생성된 이미지 ID와 Presigned URL이 포함된 응답 DTO + * @return 생성된 이미지 ID와 접근 URL이 포함된 응답 DTO */ @Override - public PresignedUrlRes getProfileUploadUrl(ProfilePresignedUrlReq request, Long userId) { + public ImageUploadRes uploadProfileImage(MultipartFile file, Long userId) { MemberProfileImageManager manager = getManager(ImageType.PROFILE_IMAGE, MemberProfileImageManager.class); + Member member = memberReader.get(userId); - String objectKey = manager.createObjectKey(userId, request.fileName(), String.valueOf(userId)); + // 기존에 프로필이 존재하는 경우, minio 에서 삭제해야 함. + if (member.getProfileImage() != null) { + manager.delete(member.getProfileImage().getObjectKey()); + } - Member member = memberReader.getRef(userId); + String objectKey = manager.createObjectKey(userId, file.getOriginalFilename(), String.valueOf(userId)); - Long imageId = profileImageWriter.save(member, objectKey, request.fileName()); + // Manager에게 업로드 위임 (리사이징 로직 등은 Manager 내부에서 처리 가능) + String fileUrl = manager.upload(file, objectKey); - String uploadUrl = manager.getUploadUrl(objectKey); + Long imageId = profileImageWriter.save(member, objectKey, file.getOriginalFilename()); - return new PresignedUrlRes(imageId, uploadUrl); + return new ImageUploadRes(imageId, fileUrl); } /** - * 채팅방 내 이미지 업로드를 위한 Presigned URL을 생성합니다. + * 채팅방 내 이미지를 업로드합니다. *

* {@link ImageType#CHAT_IMAGE} 타입에 맞는 Manager를 찾아 다음을 수행합니다: *

    *
  1. 채팅방 ID, 사용자 ID, 파일명을 기반으로 Object Key를 생성합니다.
  2. - *
  3. 생성된 Object Key를 포함한 이미지 정보를 DB에 미리 저장하고, 이미지 ID를 발급받습니다.
  4. - *
  5. Object Key를 사용하여 스토리지에 업로드할 수 있는 Presigned URL을 생성합니다.
  6. + *
  7. 스토리지에 파일을 업로드합니다.
  8. + *
  9. 이미지 정보를 DB에 저장합니다.
  10. *
* - * @param request 채팅방 ID와 파일 이름이 담긴 요청 DTO + * @param file 업로드할 이미지 파일 + * @param chatRoomId 채팅방 ID * @param userId Presigned URL을 요청한 사용자의 ID - * @return 생성된 이미지 ID와 Presigned URL이 포함된 응답 DTO + * @return 생성된 이미지 ID와 접근 URL이 포함된 응답 DTO */ @Override - public PresignedUrlRes getChatUploadUrl(ChatPresignedUrlReq request, Long userId) { + public ImageUploadRes uploadChatImage(MultipartFile file, String chatRoomId, Long userId) { ChatImageManager manager = getManager(ImageType.CHAT_IMAGE, ChatImageManager.class); - String objectKey = manager.createObjectKey(userId, request.fileName(), request.chatRoomId()); + String objectKey = manager.createObjectKey(userId, file.getOriginalFilename(), chatRoomId); - ChatRoom chatRoom = chatRoomReader.getById(request.chatRoomId()); + // Manager에게 업로드 위임 + String fileUrl = manager.upload(file, objectKey); - Long imageId = chatImageWriter.save(chatRoom, objectKey, request.fileName()); + ChatRoom chatRoom = chatRoomReader.getById(chatRoomId); - String uploadUrl = manager.getUploadUrl(objectKey); + Long imageId = chatImageWriter.save(chatRoom, objectKey, file.getOriginalFilename()); - return new PresignedUrlRes(imageId, uploadUrl); + return new ImageUploadRes(imageId, fileUrl); } /** diff --git a/src/main/java/com/studypals/global/minio/MinioStorage.java b/src/main/java/com/studypals/global/minio/MinioStorage.java index ca61e339..d22e1eac 100644 --- a/src/main/java/com/studypals/global/minio/MinioStorage.java +++ b/src/main/java/com/studypals/global/minio/MinioStorage.java @@ -1,9 +1,12 @@ package com.studypals.global.minio; +import java.io.InputStream; + import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; import lombok.RequiredArgsConstructor; @@ -45,6 +48,29 @@ public void init() { validateBucket(); } + /** + * MultipartFile 형태의 파일을 업로드합니다. + * + * @param file 업로드할 파일 + * @param objectKey 저장할 파일 경로 + * @return 저장된 minio URL + */ + @Override + public String upload(MultipartFile file, String objectKey) { + try { + InputStream inputStream = file.getInputStream(); + + minioClient.putObject( + PutObjectArgs.builder().bucket(bucket).object(objectKey).stream(inputStream, file.getSize(), -1) + .contentType(file.getContentType()) + .build()); + + return endpoint + "/" + bucket + "/" + objectKey; + } catch (Exception e) { + throw new RuntimeException(e.getMessage()); + } + } + /** * path 경로에 저장된 파일을 삭제합니다. * @@ -88,20 +114,6 @@ public String createPresignedGetUrl(String objectKey, int expirySeconds) { } } - @Override - public String createPresignedPutUrl(String objectKey, int expirySeconds) { - try { - return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() - .method(Method.PUT) - .bucket(bucket) - .object(objectKey) - .expiry(expirySeconds) - .build()); - } catch (Exception e) { - throw new RuntimeException("Presigned PUT URL 생성에 실패했습니다.", e); - } - } - /** * MinIO 버킷이 유효한지 확인합니다. 유효하지 않다면, 해당 이름으로 버킷을 생성합니다. */ diff --git a/src/test/java/com/studypals/global/file/dao/AbstractImageManagerTest.java b/src/test/java/com/studypals/global/file/dao/AbstractImageManagerTest.java index e9774175..6a7a90ab 100644 --- a/src/test/java/com/studypals/global/file/dao/AbstractImageManagerTest.java +++ b/src/test/java/com/studypals/global/file/dao/AbstractImageManagerTest.java @@ -152,7 +152,7 @@ void should_ReturnPresignedUrl_When_ObjectKeyIsValid() { when(objectStorage.createPresignedPutUrl(anyString(), anyInt())).thenReturn(expectedUrl); // when - String actualUrl = imageManager.getUploadUrl(objectKey); + String actualUrl = imageManager.getPresignedGetUrl(objectKey); // then assertThat(actualUrl).isEqualTo(expectedUrl); diff --git a/src/test/java/com/studypals/global/file/service/ImageFileServiceImplTest.java b/src/test/java/com/studypals/global/file/service/ImageFileServiceImplTest.java index 02b22693..d4dad5b7 100644 --- a/src/test/java/com/studypals/global/file/service/ImageFileServiceImplTest.java +++ b/src/test/java/com/studypals/global/file/service/ImageFileServiceImplTest.java @@ -82,7 +82,7 @@ void getProfileUploadUrl_shouldCallCorrectManager() { when(memberReader.getRef(userId)).thenReturn(Member.builder().id(userId).build()); when(memberProfileImageWriter.save(any(Member.class), eq(expectedObjectKey), eq(request.fileName()))) .thenReturn(expectedImageId); - when(mockProfileImageManager.getUploadUrl(expectedObjectKey)).thenReturn(expectedUrl); + when(mockProfileImageManager.getPresignedGetUrl(expectedObjectKey)).thenReturn(expectedUrl); // when PresignedUrlRes actualResult = imageFileService.getProfileUploadUrl(request, userId); @@ -95,8 +95,8 @@ void getProfileUploadUrl_shouldCallCorrectManager() { // 올바른 메서드가 올바른 인자와 함께 호출되었는지 검증합니다. verify(mockProfileImageManager).createObjectKey(userId, request.fileName(), String.valueOf(userId)); verify(memberProfileImageWriter).save(any(Member.class), eq(expectedObjectKey), eq(request.fileName())); - verify(mockProfileImageManager).getUploadUrl(expectedObjectKey); - verify(mockChatImageManager, never()).getUploadUrl(any()); + verify(mockProfileImageManager).getPresignedGetUrl(expectedObjectKey); + verify(mockChatImageManager, never()).getPresignedGetUrl(any()); } @Test @@ -116,7 +116,7 @@ void getChatUploadUrl_shouldCallCorrectManager() { .thenReturn(ChatRoom.builder().id(request.chatRoomId()).build()); when(chatImageWriter.save(any(ChatRoom.class), eq(expectedObjectKey), eq(request.fileName()))) .thenReturn(expectedImageId); - when(mockChatImageManager.getUploadUrl(expectedObjectKey)).thenReturn(expectedUrl); + when(mockChatImageManager.getPresignedGetUrl(expectedObjectKey)).thenReturn(expectedUrl); // when PresignedUrlRes actualResult = imageFileService.getChatUploadUrl(request, userId); @@ -129,7 +129,7 @@ void getChatUploadUrl_shouldCallCorrectManager() { // 올바른 메서드가 올바른 인자와 함께 호출되었는지 검증합니다. verify(mockChatImageManager).createObjectKey(userId, request.fileName(), request.chatRoomId()); verify(chatImageWriter).save(any(ChatRoom.class), eq(expectedObjectKey), eq(request.fileName())); - verify(mockChatImageManager).getUploadUrl(expectedObjectKey); - verify(mockProfileImageManager, never()).getUploadUrl(any()); + verify(mockChatImageManager).getPresignedGetUrl(expectedObjectKey); + verify(mockProfileImageManager, never()).getPresignedGetUrl(any()); } } From cc32e41f4e78adf60a2f54f3f2ec875a30bd3b57 Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Wed, 21 Jan 2026 19:39:16 +0900 Subject: [PATCH 17/25] =?UTF-8?q?Fix:=20=EC=82=AC=EC=A7=84=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EB=B0=98=EC=98=81=ED=95=9C=20=EC=B6=94?= =?UTF-8?q?=EC=83=81=ED=99=94=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatManage/worker/ChatImageManager.java | 15 +++++++++++ .../worker/MemberProfileImageManager.java | 14 ++++++++++ .../worker/MemberProfileImageWriter.java | 2 +- .../global/file/dao/AbstractFileManager.java | 23 ++++++++-------- .../global/file/dao/AbstractImageManager.java | 27 +++++++++---------- .../global/file/dto/ImageUploadDto.java | 3 +++ .../file/service/ImageFileServiceImpl.java | 19 +++++-------- .../studypals/global/minio/MinioStorage.java | 6 ++--- 8 files changed, 67 insertions(+), 42 deletions(-) create mode 100644 src/main/java/com/studypals/global/file/dto/ImageUploadDto.java diff --git a/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java index 20f3a442..c14ef53b 100644 --- a/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java +++ b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java @@ -4,12 +4,14 @@ import java.util.UUID; import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; import com.studypals.global.exceptions.errorCode.ChatErrorCode; import com.studypals.global.exceptions.exception.ChatException; import com.studypals.global.file.FileProperties; import com.studypals.global.file.ObjectStorage; import com.studypals.global.file.dao.AbstractImageManager; +import com.studypals.global.file.dto.ImageUploadDto; import com.studypals.global.file.entity.ImageType; import com.studypals.global.file.entity.ImageVariantKey; @@ -67,4 +69,17 @@ protected List variants() { public ImageType getFileType() { return ImageType.CHAT_IMAGE; } + + /** + * 채팅 이미지를 업로드하고, 생성된 ObjectKey와 파일 URL을 반환합니다. + * + * @param file 업로드할 파일 + * @param chatRoomId 채팅방 ID + * @param userId 사용자 ID + * @return ObjectKey와 파일 URL을 담은 ImageUploadDto + */ + public ImageUploadDto upload(MultipartFile file, String chatRoomId, Long userId) { + // 공통 업로드 로직을 수행하는 부모 클래스의 템플릿 메서드를 호출합니다. + return performUpload(file, userId, chatRoomId); + } } diff --git a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageManager.java b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageManager.java index 2c7b77c4..c0520507 100644 --- a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageManager.java +++ b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageManager.java @@ -4,10 +4,12 @@ import java.util.UUID; import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; import com.studypals.global.file.FileProperties; import com.studypals.global.file.ObjectStorage; import com.studypals.global.file.dao.AbstractImageManager; +import com.studypals.global.file.dto.ImageUploadDto; import com.studypals.global.file.entity.ImageType; import com.studypals.global.file.entity.ImageVariantKey; @@ -56,4 +58,16 @@ protected List variants() { public ImageType getFileType() { return ImageType.PROFILE_IMAGE; } + + /** + * 프로필 이미지를 업로드하고, 생성된 ObjectKey와 파일 URL을 반환합니다. + * + * @param file 업로드할 파일 + * @param userId 사용자 ID + * @return ObjectKey와 파일 URL을 담은 ImageUploadDto + */ + public ImageUploadDto upload(MultipartFile file, Long userId) { + // 공통 업로드 로직을 수행하는 부모 클래스의 템플릿 메서드를 호출합니다. + return performUpload(file, userId, String.valueOf(userId)); + } } diff --git a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java index 10fbcb53..204436a9 100644 --- a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java +++ b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java @@ -48,7 +48,7 @@ public Long save(Member member, String objectKey, String fileName) { .objectKey(objectKey) .originalFileName(fileName) .mimeType(extension) - .imageStatus(ImageStatus.PENDING) // 비동기 리사이징 대기 상태로 저장 + .imageStatus(ImageStatus.PENDING) .build()); member.setProfileImage(savedImage); diff --git a/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java b/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java index fa23a16d..016208e2 100644 --- a/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java +++ b/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java @@ -36,17 +36,6 @@ public abstract class AbstractFileManager { */ protected final ObjectStorage objectStorage; - /** - * 파일을 스토리지에 업로드합니다. - * - * @param file 업로드할 파일 - * @param objectKey 스토리지에 저장될 키 - * @return 업로드된 파일의 접근 URL - */ - public String upload(MultipartFile file, String objectKey) { - return objectStorage.upload(file, objectKey); - } - /** * 스토리지에 저장된 파일을 삭제합니다. *

@@ -69,4 +58,16 @@ public void delete(String url) { * @return 이 Manager가 처리하는 {@link FileType} */ public abstract FileType getFileType(); + + /** + * 파일을 스토리지에 업로드합니다. + *

+ * 이 추상 메서드는 하위 클래스에서 반드시 구현해야 합니다. + * @param file 업로드할 파일 + * @param objectKey 스토리지에 저장될 키 + * @return 업로드된 파일의 접근 URL + */ + public String upload(MultipartFile file, String objectKey) { + return objectStorage.upload(file, objectKey); + } } diff --git a/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java b/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java index b0250374..2aa9c379 100644 --- a/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java +++ b/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java @@ -2,11 +2,14 @@ import java.util.List; +import org.springframework.web.multipart.MultipartFile; + import com.studypals.global.exceptions.errorCode.FileErrorCode; import com.studypals.global.exceptions.exception.FileException; import com.studypals.global.file.FileProperties; import com.studypals.global.file.FileUtils; import com.studypals.global.file.ObjectStorage; +import com.studypals.global.file.dto.ImageUploadDto; import com.studypals.global.file.entity.ImageVariantKey; /** @@ -55,6 +58,13 @@ public final String getPresignedGetUrl(String objectKey) { return objectStorage.createPresignedGetUrl(objectKey, presignedUrlExpireTime); } + // 주석 필요 + public final ImageUploadDto upload(MultipartFile file, Long userId) { + String objectKey = createObjectKey(userId, file.getOriginalFilename(), String.valueOf(userId)); + String imageUrl = objectStorage.upload(file, objectKey); + return new ImageUploadDto(imageUrl, objectKey); + } + /** * 스토리지에 저장될 고유한 객체 키(Object Key)를 생성하는 템플릿 메서드입니다. *

@@ -62,7 +72,6 @@ public final String getPresignedGetUrl(String objectKey) { *

    *
  1. {@link #validateFileName}: 파일 이름과 확장자를 검증합니다.
  2. *
  3. {@link #validateTargetId}: 하위 클래스에서 재정의 가능한 대상 ID 유효성을 검증합니다 (Hook).
  4. - *
  5. {@link #generateObjectKey}: 실제 객체 키를 생성합니다.
  6. *
* * @param userId 업로드를 요청한 사용자 ID @@ -73,7 +82,8 @@ public final String getPresignedGetUrl(String objectKey) { public final String createObjectKey(Long userId, String fileName, String targetId) { validateFileName(fileName); validateTargetId(userId, targetId); - return generateObjectKey(fileName, targetId); + String extension = FileUtils.extractExtension(fileName); + return generateObjectKeyDetail(fileName, extension); } /** @@ -108,19 +118,6 @@ protected void validateTargetId(Long userId, String targetId) { // 기본 구현은 비어 있으며, 하위 클래스에서 필요에 따라 재정의합니다. } - /** - * 객체 키 생성을 위한 내부 헬퍼 메서드입니다. - * 파일 확장자를 추출한 뒤, 하위 클래스에서 구현된 {@link #generateObjectKeyDetail}을 호출하여 최종 키를 완성합니다. - * - * @param fileName 원본 파일 이름 - * @param targetId 업로드 대상 식별자 - * @return 생성된 객체 키 - */ - private String generateObjectKey(String fileName, String targetId) { - String ext = FileUtils.extractExtension(fileName); - return generateObjectKeyDetail(targetId, ext); - } - /** * 객체 키의 상세 경로를 생성하는 추상 메서드입니다. *

diff --git a/src/main/java/com/studypals/global/file/dto/ImageUploadDto.java b/src/main/java/com/studypals/global/file/dto/ImageUploadDto.java new file mode 100644 index 00000000..3b6e98ff --- /dev/null +++ b/src/main/java/com/studypals/global/file/dto/ImageUploadDto.java @@ -0,0 +1,3 @@ +package com.studypals.global.file.dto; + +public record ImageUploadDto(String objectKey, String imageUrl) {} diff --git a/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java b/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java index 11136be5..39417cc0 100644 --- a/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java +++ b/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java @@ -21,6 +21,7 @@ import com.studypals.global.file.FileType; import com.studypals.global.file.dao.AbstractFileManager; import com.studypals.global.file.dao.AbstractImageManager; +import com.studypals.global.file.dto.ImageUploadDto; import com.studypals.global.file.dto.ImageUploadRes; import com.studypals.global.file.entity.ImageType; @@ -116,14 +117,11 @@ public ImageUploadRes uploadProfileImage(MultipartFile file, Long userId) { manager.delete(member.getProfileImage().getObjectKey()); } - String objectKey = manager.createObjectKey(userId, file.getOriginalFilename(), String.valueOf(userId)); + ImageUploadDto uploadDto = manager.upload(file, userId); - // Manager에게 업로드 위임 (리사이징 로직 등은 Manager 내부에서 처리 가능) - String fileUrl = manager.upload(file, objectKey); + Long imageId = profileImageWriter.save(member, uploadDto.objectKey(), file.getOriginalFilename()); - Long imageId = profileImageWriter.save(member, objectKey, file.getOriginalFilename()); - - return new ImageUploadRes(imageId, fileUrl); + return new ImageUploadRes(imageId, uploadDto.imageUrl()); } /** @@ -145,16 +143,13 @@ public ImageUploadRes uploadProfileImage(MultipartFile file, Long userId) { public ImageUploadRes uploadChatImage(MultipartFile file, String chatRoomId, Long userId) { ChatImageManager manager = getManager(ImageType.CHAT_IMAGE, ChatImageManager.class); - String objectKey = manager.createObjectKey(userId, file.getOriginalFilename(), chatRoomId); - - // Manager에게 업로드 위임 - String fileUrl = manager.upload(file, objectKey); + ImageUploadDto uploadDto = manager.upload(file, chatRoomId, userId); ChatRoom chatRoom = chatRoomReader.getById(chatRoomId); - Long imageId = chatImageWriter.save(chatRoom, objectKey, file.getOriginalFilename()); + Long imageId = chatImageWriter.save(chatRoom, uploadDto.objectKey(), file.getOriginalFilename()); - return new ImageUploadRes(imageId, fileUrl); + return new ImageUploadRes(imageId, uploadDto.imageUrl()); } /** diff --git a/src/main/java/com/studypals/global/minio/MinioStorage.java b/src/main/java/com/studypals/global/minio/MinioStorage.java index d22e1eac..7cbc458e 100644 --- a/src/main/java/com/studypals/global/minio/MinioStorage.java +++ b/src/main/java/com/studypals/global/minio/MinioStorage.java @@ -67,7 +67,7 @@ public String upload(MultipartFile file, String objectKey) { return endpoint + "/" + bucket + "/" + objectKey; } catch (Exception e) { - throw new RuntimeException(e.getMessage()); + throw new RuntimeException("MinIO 파일 업로드에 실패했습니다. ObjectKey: " + objectKey, e); } } @@ -84,7 +84,7 @@ public void delete(String destination) { .object(destination) .build()); } catch (Exception e) { - throw new RuntimeException(e.getMessage()); + throw new RuntimeException("MinIO 파일 삭제에 실패했습니다. ObjectKey: " + destination, e); } } @@ -129,7 +129,7 @@ private void validateBucket() { .config("public") .build()); } catch (Exception e) { - throw new RuntimeException(e.getMessage()); + throw new RuntimeException("MinIO 버킷 초기화에 실패했습니다. Bucket: " + bucket, e); } } } From 64a3da21b422b6d0ff33975a87f1adcdf4befb91 Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Fri, 23 Jan 2026 18:11:30 +0900 Subject: [PATCH 18/25] =?UTF-8?q?Fix:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=82=AC=EC=A7=84=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=99=84=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chatManage/entity/ChatImage.java | 22 ++++++++ .../chatManage/worker/ChatImageManager.java | 2 +- .../entity/MemberProfileImage.java | 50 ++++++++++++++++++- .../worker/MemberProfileImageWriter.java | 5 ++ .../com/studypals/global/file/FileUtils.java | 6 +++ .../global/file/dao/AbstractFileManager.java | 10 ++-- .../global/file/dao/AbstractImageManager.java | 10 ++-- .../global/file/entity/ImageFile.java | 7 +++ .../file/service/ImageFileServiceImpl.java | 46 ++++++++++++----- 9 files changed, 131 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/studypals/domain/chatManage/entity/ChatImage.java b/src/main/java/com/studypals/domain/chatManage/entity/ChatImage.java index ac085aa5..6b86850c 100644 --- a/src/main/java/com/studypals/domain/chatManage/entity/ChatImage.java +++ b/src/main/java/com/studypals/domain/chatManage/entity/ChatImage.java @@ -14,6 +14,28 @@ import com.studypals.global.file.entity.ImageFile; +/** + * 채팅방(ChatRoom) 내에서 전송된 이미지의 메타데이터를 관리하는 엔티티입니다. + *

+ * 이 엔티티는 {@link ImageFile}을 상속받아 이미지 파일의 공통 속성을 관리하며, + * {@link ChatRoom}과 다대일(Many-to-One) 관계를 맺습니다. + * 채팅 이미지는 한 번 생성되면 수정되지 않는 불변(Immutable)의 특성을 가집니다. + * + *

주요 특징: + *

    + *
  • 상속 관계: {@link ImageFile}의 모든 속성을 상속받습니다.
  • + *
  • 연관 관계: 여러 개의 채팅 이미지가 하나의 {@link ChatRoom}에 속합니다.
  • + *
  • 불변성: 생성 후 상태가 변경되지 않습니다. (수정 기능 없음)
  • + *
  • 인덱싱: 이미지 처리 상태({@code imageStatus})와 생성일({@code createdAt})에 복합 인덱스가 설정되어 있어, + * 리사이징 등 비동기 처리 대상 조회 시 성능을 향상시킵니다.
  • + *
+ * + * @author sleepyhoon + * @since 2024-01-15 + * @see ImageFile + * @see ChatRoom + * @see com.studypals.domain.chatManage.worker.ChatImageManager + */ @Entity @Getter @SuperBuilder diff --git a/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java index c14ef53b..bf96be4c 100644 --- a/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java +++ b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java @@ -78,7 +78,7 @@ public ImageType getFileType() { * @param userId 사용자 ID * @return ObjectKey와 파일 URL을 담은 ImageUploadDto */ - public ImageUploadDto upload(MultipartFile file, String chatRoomId, Long userId) { + public ImageUploadDto upload(MultipartFile file, Long userId, String chatRoomId) { // 공통 업로드 로직을 수행하는 부모 클래스의 템플릿 메서드를 호출합니다. return performUpload(file, userId, chatRoomId); } diff --git a/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java b/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java index ed7a6f8f..cc004902 100644 --- a/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java +++ b/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java @@ -1,5 +1,8 @@ package com.studypals.domain.memberManage.entity; +import java.time.LocalDateTime; + +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.Index; @@ -7,14 +10,38 @@ import jakarta.persistence.OneToOne; import jakarta.persistence.Table; +import org.springframework.data.annotation.LastModifiedDate; + import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.experimental.SuperBuilder; import com.studypals.global.file.entity.ImageFile; +import com.studypals.global.file.entity.ImageStatus; -// 주석 추가 +/** + * 회원(Member)의 프로필 이미지 정보를 관리하는 엔티티입니다. + *

+ * 이 엔티티는 {@link ImageFile}을 상속받아 이미지 파일의 공통 메타데이터(objectKey, fileName 등)를 관리하며, + * {@link Member}와 일대일(One-to-One) 관계를 맺습니다. + * 프로필 이미지는 수정(update)이 가능하며, 이 때 기존 레코드를 재활용하여 새로운 이미지 정보로 갱신합니다. + * + *

주요 특징: + *

    + *
  • 상속 관계: {@link ImageFile}의 모든 속성을 상속받습니다.
  • + *
  • 연관 관계: {@link Member}와 1:1 관계를 맺습니다.
  • + *
  • 수정 기능: {@link #update} 메서드를 통해 기존 프로필 이미지를 새로운 이미지로 교체할 수 있습니다.
  • + *
  • 인덱싱: 이미지 처리 상태({@code imageStatus})와 생성일({@code createdAt})에 복합 인덱스가 설정되어 있어, + * 리사이징 등 비동기 처리 대상 조회 시 성능을 향상시킵니다.
  • + *
+ * + * @author sleepyhoon + * @since 2024-01-15 + * @see ImageFile + * @see Member + * @see com.studypals.global.file.service.ImageFileServiceImpl + */ @Entity @Getter @SuperBuilder @@ -27,4 +54,25 @@ public class MemberProfileImage extends ImageFile { @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private Member member; + + @LastModifiedDate + @Column(nullable = false) + private LocalDateTime updatedAt; + + /** + * 프로필 이미지 정보를 새로운 파일 정보로 업데이트합니다. + *

+ * 이 메서드는 엔티티의 상태를 변경하는 역할을 하며, 전달되는 값들은 + * 이미 서비스 레이어에서 유효성 검증이 완료되었다고 가정합니다. + * + * @param newObjectKey 새로 업로드된 파일의 Object Key + * @param newOriginalFileName 새로 업로드된 파일의 원본 이름 + * @param newMimeType 새로 업로드된 파일의 확장자 + */ + public void update(String newObjectKey, String newOriginalFileName, String newMimeType) { + this.setObjectKey(newObjectKey); + this.setOriginalFileName(newOriginalFileName); + this.setMimeType(newMimeType); + this.setImageStatus(ImageStatus.PENDING); // 새 파일이므로 리사이징을 위해 PENDING으로 상태 변경 + } } diff --git a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java index 204436a9..95ecfc37 100644 --- a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java +++ b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java @@ -55,4 +55,9 @@ public Long save(Member member, String objectKey, String fileName) { return savedImage.getId(); } + + @Transactional + public Long saveEntity(MemberProfileImage entity) { + return memberProfileImageRepository.save(entity).getId(); + } } diff --git a/src/main/java/com/studypals/global/file/FileUtils.java b/src/main/java/com/studypals/global/file/FileUtils.java index bf810bb7..1691f92d 100644 --- a/src/main/java/com/studypals/global/file/FileUtils.java +++ b/src/main/java/com/studypals/global/file/FileUtils.java @@ -1,5 +1,8 @@ package com.studypals.global.file; +import com.studypals.global.exceptions.errorCode.FileErrorCode; +import com.studypals.global.exceptions.exception.FileException; + /** * 파일 관련 유틸리티 메서드를 제공하는 클래스입니다. *

@@ -29,6 +32,9 @@ private FileUtils() { * @return 추출된 소문자 확장자 (예: "jpg", "pdf") 또는 확장자가 없는 경우 빈 문자열 */ public static String extractExtension(String fileName) { + if (fileName == null || fileName.isBlank()) { + throw new FileException(FileErrorCode.INVALID_FILE_NAME); + } int lastDotIndex = fileName.lastIndexOf("."); if (lastDotIndex == -1 || lastDotIndex == fileName.length() - 1) { return ""; // 확장자가 없는 경우 처리 diff --git a/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java b/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java index 016208e2..fffe67b2 100644 --- a/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java +++ b/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java @@ -38,15 +38,11 @@ public abstract class AbstractFileManager { /** * 스토리지에 저장된 파일을 삭제합니다. - *

- * 전체 파일 URL을 입력받아 내부적으로 객체 키(Object Key)를 추출한 후, - * {@link ObjectStorage#delete}를 호출하여 실제 파일을 삭제합니다. * - * @param url 삭제할 파일의 전체 URL + * @param objectKey 삭제할 파일의 객체 키(Object Key) */ - public void delete(String url) { - String destination = objectStorage.parsePath(url); - objectStorage.delete(destination); + public void delete(String objectKey) { + objectStorage.delete(objectKey); } /** diff --git a/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java b/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java index 2aa9c379..2f295c75 100644 --- a/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java +++ b/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java @@ -59,10 +59,10 @@ public final String getPresignedGetUrl(String objectKey) { } // 주석 필요 - public final ImageUploadDto upload(MultipartFile file, Long userId) { - String objectKey = createObjectKey(userId, file.getOriginalFilename(), String.valueOf(userId)); - String imageUrl = objectStorage.upload(file, objectKey); - return new ImageUploadDto(imageUrl, objectKey); + protected final ImageUploadDto performUpload(MultipartFile file, Long userId, String targetId) { + String objectKey = createObjectKey(userId, file.getOriginalFilename(), targetId); + String imageUrl = super.upload(file, objectKey); + return new ImageUploadDto(objectKey, imageUrl); } /** @@ -83,7 +83,7 @@ public final String createObjectKey(Long userId, String fileName, String targetI validateFileName(fileName); validateTargetId(userId, targetId); String extension = FileUtils.extractExtension(fileName); - return generateObjectKeyDetail(fileName, extension); + return generateObjectKeyDetail(targetId, extension); } /** diff --git a/src/main/java/com/studypals/global/file/entity/ImageFile.java b/src/main/java/com/studypals/global/file/entity/ImageFile.java index 85a6980d..72345027 100644 --- a/src/main/java/com/studypals/global/file/entity/ImageFile.java +++ b/src/main/java/com/studypals/global/file/entity/ImageFile.java @@ -19,12 +19,15 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; import lombok.experimental.SuperBuilder; /** * 객체 스토리지에 저장된 이미지 파일의 메타데이터를 관리하는 엔티티의 공통 속성을 정의하는 추상 클래스입니다. * {@code @MappedSuperclass}를 사용하여 이 클래스를 상속하는 엔티티들은 아래 필드들을 자신의 컬럼으로 포함하게 됩니다. * + * objectKey, originalFileName, mimeType, imageStatus의 경우 protected setter를 가집니다. 이는 오직 수정 가능한 이미지 한정으로 사용합니다. + * * @author sleepyhoon * @since 2026-01-13 */ @@ -46,6 +49,7 @@ public abstract class ImageFile { * 예: "profile/1/uuid.jpg" */ @Column(nullable = false, unique = true) + @Setter(AccessLevel.PROTECTED) private String objectKey; /** @@ -53,6 +57,7 @@ public abstract class ImageFile { * 예: "my_vacation_photo.jpg" */ @Column(nullable = false) + @Setter(AccessLevel.PROTECTED) private String originalFileName; /** @@ -60,6 +65,7 @@ public abstract class ImageFile { * 예: "jpg" */ @Column(nullable = false) + @Setter(AccessLevel.PROTECTED) private String mimeType; /** @@ -72,6 +78,7 @@ public abstract class ImageFile { @Column(nullable = false) @Enumerated(EnumType.STRING) @Builder.Default + @Setter(AccessLevel.PROTECTED) private ImageStatus imageStatus = ImageStatus.PENDING; /** diff --git a/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java b/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java index 39417cc0..087106f8 100644 --- a/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java +++ b/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java @@ -13,12 +13,14 @@ import com.studypals.domain.chatManage.worker.ChatImageWriter; import com.studypals.domain.chatManage.worker.ChatRoomReader; import com.studypals.domain.memberManage.entity.Member; +import com.studypals.domain.memberManage.entity.MemberProfileImage; import com.studypals.domain.memberManage.worker.MemberProfileImageManager; import com.studypals.domain.memberManage.worker.MemberProfileImageWriter; import com.studypals.domain.memberManage.worker.MemberReader; import com.studypals.global.exceptions.errorCode.FileErrorCode; import com.studypals.global.exceptions.exception.FileException; import com.studypals.global.file.FileType; +import com.studypals.global.file.FileUtils; import com.studypals.global.file.dao.AbstractFileManager; import com.studypals.global.file.dao.AbstractImageManager; import com.studypals.global.file.dto.ImageUploadDto; @@ -65,12 +67,17 @@ public class ImageFileServiceImpl implements ImageFileService { * 만약 서로 다른 Manager가 동일한 FileType을 처리하려고 할 경우, 애플리케이션 구동 시점에 * {@link IllegalStateException}을 발생시켜 설정 오류를 방지합니다. * + *

+ * managerMap 안에는 아래와 같이 AbstractFileManager을 상속한 스프링 빈이 저장됩니다. + *

+     * {
+     *   ImageType.PROFILE_IMAGE : (MemberProfileImageManager 인스턴스),
+     *   ImageType.CHAT_IMAGE    : (ChatImageManager 인스턴스)
+     * }
+     * 
+ * 이를 통해 이후의 서비스 메서드에서는 파일 타입만으로 적절한 Manager를 즉시 찾아 사용할 수 있습니다. + * * @param managers Spring 컨텍스트에 의해 주입되는 {@code AbstractImageManager}의 모든 구현체 리스트 - * @param memberReader 회원 정보를 조회하는 워커 - * @param chatRoomReader 채팅방 정보를 조회하는 워커 - * @param profileImageWriter 프로필 이미지 메타데이터를 저장하는 워커 - * @param chatImageWriter 채팅 이미지 메타데이터를 저장하는 워커 - * @throws IllegalStateException 동일한 FileType을 처리하는 Manager가 두 개 이상 존재할 경우 발생 */ public ImageFileServiceImpl( List managers, @@ -110,16 +117,29 @@ public ImageFileServiceImpl( @Override public ImageUploadRes uploadProfileImage(MultipartFile file, Long userId) { MemberProfileImageManager manager = getManager(ImageType.PROFILE_IMAGE, MemberProfileImageManager.class); - Member member = memberReader.get(userId); - - // 기존에 프로필이 존재하는 경우, minio 에서 삭제해야 함. - if (member.getProfileImage() != null) { - manager.delete(member.getProfileImage().getObjectKey()); - } + // 1. [MinIO] 프로필 사진 업로드, 업로드가 성공하면 file 정보는 전부 유효합니다. ImageUploadDto uploadDto = manager.upload(file, userId); - Long imageId = profileImageWriter.save(member, uploadDto.objectKey(), file.getOriginalFilename()); + // DB 업데이트를 위한 준비 + Member member = memberReader.get(userId); + String extension = FileUtils.extractExtension(file.getOriginalFilename()); + MemberProfileImage currentProfile = member.getProfileImage(); + Long imageId; + + if (currentProfile != null) { + // 2. [DB] 기존 정보가 있으면 -> DB 먼저 업데이트 + String oldObjectKey = currentProfile.getObjectKey(); // 삭제할 키 미리 백업 + + currentProfile.update(uploadDto.objectKey(), file.getOriginalFilename(), extension); + imageId = profileImageWriter.saveEntity(currentProfile); // 더티 체킹을 하지 않으므로 명시적 저장 + + // 3. [MinIO] 모든 처리가 끝난 후 -> 기존 파일 삭제 + manager.delete(oldObjectKey); + } else { + // [DB] 기존 프로필이 없으면 그냥 저장 + imageId = profileImageWriter.save(member, uploadDto.objectKey(), file.getOriginalFilename()); + } return new ImageUploadRes(imageId, uploadDto.imageUrl()); } @@ -143,7 +163,7 @@ public ImageUploadRes uploadProfileImage(MultipartFile file, Long userId) { public ImageUploadRes uploadChatImage(MultipartFile file, String chatRoomId, Long userId) { ChatImageManager manager = getManager(ImageType.CHAT_IMAGE, ChatImageManager.class); - ImageUploadDto uploadDto = manager.upload(file, chatRoomId, userId); + ImageUploadDto uploadDto = manager.upload(file, userId, chatRoomId); ChatRoom chatRoom = chatRoomReader.getById(chatRoomId); From 12fe24cca99afbf2ba5420df770e827463fb1d60 Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Tue, 27 Jan 2026 12:43:11 +0900 Subject: [PATCH 19/25] =?UTF-8?q?Fix:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../memberManage/dto/UpdateProfileReq.java | 2 +- .../dto/mappers/MemberMapper.java | 19 +++++++++++++++---- .../domain/memberManage/entity/Member.java | 14 +++++++++----- .../entity/MemberProfileImage.java | 2 -- .../service/MemberServiceImpl.java | 8 +++++--- 5 files changed, 30 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/studypals/domain/memberManage/dto/UpdateProfileReq.java b/src/main/java/com/studypals/domain/memberManage/dto/UpdateProfileReq.java index a04ba41c..794b3471 100644 --- a/src/main/java/com/studypals/domain/memberManage/dto/UpdateProfileReq.java +++ b/src/main/java/com/studypals/domain/memberManage/dto/UpdateProfileReq.java @@ -12,4 +12,4 @@ * @author jack8 * @since 2025-12-16 */ -public record UpdateProfileReq(@PastOrPresent LocalDate birthday, String position, String imageUrl) {} +public record UpdateProfileReq(@PastOrPresent LocalDate birthday, String position) {} diff --git a/src/main/java/com/studypals/domain/memberManage/dto/mappers/MemberMapper.java b/src/main/java/com/studypals/domain/memberManage/dto/mappers/MemberMapper.java index 75932050..217c1df1 100644 --- a/src/main/java/com/studypals/domain/memberManage/dto/mappers/MemberMapper.java +++ b/src/main/java/com/studypals/domain/memberManage/dto/mappers/MemberMapper.java @@ -1,9 +1,10 @@ package com.studypals.domain.memberManage.dto.mappers; -import org.mapstruct.Mapper; +import org.springframework.stereotype.Component; import com.studypals.domain.memberManage.dto.MemberDetailsRes; import com.studypals.domain.memberManage.entity.Member; +import com.studypals.global.file.ObjectStorage; /** * Member 에 대한 mapping 클래스입니다. @@ -16,8 +17,18 @@ * @author jack8 * @since 2025-04-16 */ -@Mapper(componentModel = "spring") -public interface MemberMapper { +@Component +public class MemberMapper { - MemberDetailsRes toRes(Member member); + public MemberDetailsRes toRes(Member member, ObjectStorage objectStorage) { + return MemberDetailsRes.builder() + .id(member.getId()) + .username(member.getUsername()) + .nickname(member.getNickname()) + .birthday(member.getBirthday()) + .imageUrl(objectStorage.convertKeyToFileUrl(member.getProfileImageObjectKey())) + .createdDate(member.getCreatedDate()) + .token(member.getToken()) + .build(); + } } diff --git a/src/main/java/com/studypals/domain/memberManage/entity/Member.java b/src/main/java/com/studypals/domain/memberManage/entity/Member.java index 0c1cb0ef..be90d36a 100644 --- a/src/main/java/com/studypals/domain/memberManage/entity/Member.java +++ b/src/main/java/com/studypals/domain/memberManage/entity/Member.java @@ -48,9 +48,6 @@ public class Member { @Column(name = "position", nullable = true, length = 255) private String position; - @Column(name = "image_url", nullable = true, length = 255) - private String imageUrl; - @OneToOne(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Setter private MemberProfileImage profileImage; @@ -74,9 +71,16 @@ public Member(String username, String password, String nickname) { this.token = 0L; } - public void updateProfile(LocalDate birthday, String position, String imageUrl) { + public void updateProfile(LocalDate birthday, String position) { this.birthday = birthday; this.position = position; - this.imageUrl = imageUrl; // TODO : 수정 필요 + } + + /** + * 프로필 이미지의 Object Key를 반환합니다. + * 프로필 이미지가 없는 경우 null을 반환합니다. + */ + public String getProfileImageObjectKey() { + return this.profileImage != null ? this.profileImage.getObjectKey() : null; } } diff --git a/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java b/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java index cc004902..3f550077 100644 --- a/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java +++ b/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java @@ -2,7 +2,6 @@ import java.time.LocalDateTime; -import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.Index; @@ -56,7 +55,6 @@ public class MemberProfileImage extends ImageFile { private Member member; @LastModifiedDate - @Column(nullable = false) private LocalDateTime updatedAt; /** diff --git a/src/main/java/com/studypals/domain/memberManage/service/MemberServiceImpl.java b/src/main/java/com/studypals/domain/memberManage/service/MemberServiceImpl.java index ccfb35d9..9b00058c 100644 --- a/src/main/java/com/studypals/domain/memberManage/service/MemberServiceImpl.java +++ b/src/main/java/com/studypals/domain/memberManage/service/MemberServiceImpl.java @@ -16,6 +16,7 @@ import com.studypals.domain.memberManage.worker.MemberWriter; import com.studypals.global.exceptions.errorCode.AuthErrorCode; import com.studypals.global.exceptions.exception.AuthException; +import com.studypals.global.file.ObjectStorage; /** * member service 의 구현 클래스입니다. @@ -41,6 +42,8 @@ public class MemberServiceImpl implements MemberService { private final PasswordEncoder passwordEncoder; private final MemberMapper memberMapper; + private final ObjectStorage objectStorage; + @Override @Transactional public Long createMember(CreateMemberReq dto) { @@ -64,8 +67,7 @@ public Long getMemberIdByUsername(String username) { public Long updateProfile(Long userId, UpdateProfileReq dto) { Member member = memberReader.get(userId); - // TODO: 이미지를 직접 받도록 변경 후, update 로직을 수정해야 함. - member.updateProfile(dto.birthday(), dto.position(), dto.imageUrl()); + member.updateProfile(dto.birthday(), dto.position()); memberWriter.save(member); @@ -76,7 +78,7 @@ public Long updateProfile(Long userId, UpdateProfileReq dto) { @Transactional(readOnly = true) public MemberDetailsRes getProfile(Long userId) { Member member = memberReader.get(userId); - return memberMapper.toRes(member); + return memberMapper.toRes(member, objectStorage); } @Override From 5b04a29e17a083497c968ceadba9fc6c2d4247aa Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Tue, 27 Jan 2026 12:44:01 +0900 Subject: [PATCH 20/25] =?UTF-8?q?Fix:=20objectKey=20=EB=B0=98=ED=99=98=20?= =?UTF-8?q?=EC=8B=9C=20domain=20+=20bucket=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=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 --- .../chatManage/dto/mapper/ChatRoomMapper.java | 35 +++++++++++-------- .../service/ChatRoomServiceImpl.java | 7 +++- .../dao/GroupMemberCustomRepositoryImpl.java | 13 +++++-- .../groupManage/dto/GetGroupDetailRes.java | 9 +++-- .../domain/groupManage/dto/GetGroupsRes.java | 9 +++-- .../GroupEntryRequestCustomMapper.java | 9 +++-- .../service/GroupEntryServiceImpl.java | 5 ++- .../service/GroupRankingServiceImpl.java | 6 +++- .../groupManage/service/GroupServiceImpl.java | 9 +++-- .../studypals/global/file/ObjectStorage.java | 8 +++++ .../studypals/global/minio/MinioStorage.java | 15 +++++++- 11 files changed, 95 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/studypals/domain/chatManage/dto/mapper/ChatRoomMapper.java b/src/main/java/com/studypals/domain/chatManage/dto/mapper/ChatRoomMapper.java index 43a9a795..99033552 100644 --- a/src/main/java/com/studypals/domain/chatManage/dto/mapper/ChatRoomMapper.java +++ b/src/main/java/com/studypals/domain/chatManage/dto/mapper/ChatRoomMapper.java @@ -2,29 +2,34 @@ import java.util.Map; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; +import org.springframework.stereotype.Component; import com.studypals.domain.chatManage.dto.ChatRoomInfoRes; +import com.studypals.domain.chatManage.dto.ChatRoomInfoRes.UserInfo; import com.studypals.domain.chatManage.dto.ChatRoomListRes; import com.studypals.domain.chatManage.dto.ChatroomLatestInfo; import com.studypals.domain.chatManage.entity.ChatRoomMember; +import com.studypals.global.file.ObjectStorage; /** - * ChatRoom 에 대한 mapper 클래스입니다. + * ChatRoom 도메인 관련 mapper 입니다. * - * @author jack8 - * @since 2025-05-22 + * @author sleepyhoon + * @see + * @since 2026-01-27 */ -@Mapper(componentModel = "spring") -public interface ChatRoomMapper { - /** - * 트랜잭션 내에서만 처리되어야 합니다. - */ - @Mapping(target = "userId", source = "member.id") - @Mapping(target = "imageUrl", source = "member.imageUrl") - @Mapping(target = "nickname", source = "member.nickname") - ChatRoomInfoRes.UserInfo toDto(ChatRoomMember entity); +@Component +public class ChatRoomMapper { + + public ChatRoomInfoRes.UserInfo toDto(ChatRoomMember entity, ObjectStorage objectStorage) { + return UserInfo.builder() + .userId(entity.getMember().getId()) + .nickname(entity.getMember().getNickname()) + .role(entity.getRole()) + // TODO: 채팅방 이미지도 minio로 이동해야함. 아직 구현되지 않음. + .imageUrl(objectStorage.convertKeyToFileUrl(entity.getChatRoom().getImageUrl())) + .build(); + } /** * 단일 ChatRoomMember 객체와 최신 메시지 조회 결과를 기반으로 @@ -35,7 +40,7 @@ public interface ChatRoomMapper { * @param latestInfos 채팅방별 최신 메시지 및 언리드 정보 * @return ChatRoomListRes.ChatRoomInfo 변환 결과 */ - default ChatRoomListRes.ChatRoomInfo toChatRoomInfo( + public ChatRoomListRes.ChatRoomInfo toChatRoomInfo( ChatRoomMember chatRoomMember, Map latestInfos) { String chatRoomId = chatRoomMember.getChatRoom().getId(); ChatroomLatestInfo info = latestInfos.get(chatRoomId); diff --git a/src/main/java/com/studypals/domain/chatManage/service/ChatRoomServiceImpl.java b/src/main/java/com/studypals/domain/chatManage/service/ChatRoomServiceImpl.java index d0e79b2a..29a7ae20 100644 --- a/src/main/java/com/studypals/domain/chatManage/service/ChatRoomServiceImpl.java +++ b/src/main/java/com/studypals/domain/chatManage/service/ChatRoomServiceImpl.java @@ -19,6 +19,7 @@ import com.studypals.domain.memberManage.worker.MemberReader; import com.studypals.global.exceptions.errorCode.ChatErrorCode; import com.studypals.global.exceptions.exception.ChatException; +import com.studypals.global.file.ObjectStorage; /** * 채팅방 진입 시 필요한 정보를 조회하는 서비스 구현 클래스입니다. @@ -53,6 +54,8 @@ public class ChatRoomServiceImpl implements ChatRoomService { private final ChatMessageReader chatMessageReader; private final MemberReader memberReader; + private final ObjectStorage objectStorage; + /** * 특정 유저가 특정 채팅방에 입장할 때 필요한 전체 정보를 조회합니다. *

@@ -112,7 +115,9 @@ public ChatRoomInfoRes getChatRoomInfo(Long userId, String chatRoomId, String ch return ChatRoomInfoRes.builder() .roomId(chatRoomId) .name(chatRoom.getName()) - .userInfos(members.stream().map(chatRoomMapper::toDto).toList()) + .userInfos(members.stream() + .map(m -> chatRoomMapper.toDto(m, objectStorage)) + .toList()) .cursor(chatCursorRes) .logs(logs) .build(); diff --git a/src/main/java/com/studypals/domain/groupManage/dao/GroupMemberCustomRepositoryImpl.java b/src/main/java/com/studypals/domain/groupManage/dao/GroupMemberCustomRepositoryImpl.java index 3a391a40..1c231be5 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/GroupMemberCustomRepositoryImpl.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/GroupMemberCustomRepositoryImpl.java @@ -38,10 +38,15 @@ public class GroupMemberCustomRepositoryImpl implements GroupMemberCustomReposit public List findTopNMemberByJoinedAt(Long groupId, int limit) { return queryFactory .select(Projections.constructor( - GroupMemberProfileDto.class, member.id, member.nickname, member.imageUrl, groupMember.role)) + GroupMemberProfileDto.class, + member.id, + member.nickname, + member.profileImage.objectKey, + groupMember.role)) .from(groupMember) .join(member) .on(groupMember.member.id.eq(member.id)) + .leftJoin(member.profileImage) // 프로필 이미지가 없는 멤버도 조회되도록 Left Join 추가 .where(groupMember.group.id.eq(groupId)) .orderBy(orderByLeaderPriority(), groupMember.joinedAt.desc()) .limit(limit) @@ -52,10 +57,14 @@ public List findTopNMemberByJoinedAt(Long groupId, int li public List findTopNMemberInGroupIds(List groupIds, int limit) { return queryFactory .select(Projections.constructor( - GroupMemberProfileMappingDto.class, groupMember.group.id, member.imageUrl, groupMember.role)) + GroupMemberProfileMappingDto.class, + groupMember.group.id, + member.profileImage.objectKey, + groupMember.role)) .from(groupMember) .join(member) .on(groupMember.member.id.eq(member.id)) + .leftJoin(member.profileImage) // 프로필 이미지가 없는 멤버도 조회되도록 Left Join 추가 .where(groupMember.group.id.in(groupIds)) .orderBy(orderByLeaderPriority(), groupMember.joinedAt.desc()) .limit(limit) diff --git a/src/main/java/com/studypals/domain/groupManage/dto/GetGroupDetailRes.java b/src/main/java/com/studypals/domain/groupManage/dto/GetGroupDetailRes.java index ee7dcf70..765ed900 100644 --- a/src/main/java/com/studypals/domain/groupManage/dto/GetGroupDetailRes.java +++ b/src/main/java/com/studypals/domain/groupManage/dto/GetGroupDetailRes.java @@ -5,6 +5,7 @@ import com.studypals.domain.groupManage.entity.Group; import com.studypals.domain.groupManage.entity.GroupMember; import com.studypals.domain.memberManage.entity.Member; +import com.studypals.global.file.ObjectStorage; public record GetGroupDetailRes( Long id, @@ -15,7 +16,8 @@ public record GetGroupDetailRes( int currentMemberCount, List profiles, GroupTotalGoalDto groupGoals) { - public static GetGroupDetailRes of(Group group, List groupMembers, GroupTotalGoalDto goals) { + public static GetGroupDetailRes of( + Group group, List groupMembers, GroupTotalGoalDto goals, ObjectStorage objectStorage) { return new GetGroupDetailRes( group.getId(), group.getName(), @@ -27,7 +29,10 @@ public static GetGroupDetailRes of(Group group, List groupMembers, .map(gm -> { Member member = gm.getMember(); return new GroupMemberProfileDto( - member.getId(), member.getNickname(), member.getImageUrl(), gm.getRole()); + member.getId(), + member.getNickname(), + objectStorage.convertKeyToFileUrl(member.getProfileImageObjectKey()), + gm.getRole()); }) .toList(), goals); diff --git a/src/main/java/com/studypals/domain/groupManage/dto/GetGroupsRes.java b/src/main/java/com/studypals/domain/groupManage/dto/GetGroupsRes.java index e892933f..356dfbe9 100644 --- a/src/main/java/com/studypals/domain/groupManage/dto/GetGroupsRes.java +++ b/src/main/java/com/studypals/domain/groupManage/dto/GetGroupsRes.java @@ -4,6 +4,7 @@ import java.util.List; import com.studypals.domain.studyManage.dto.GroupCategoryDto; +import com.studypals.global.file.ObjectStorage; public record GetGroupsRes( Long groupId, @@ -17,7 +18,10 @@ public record GetGroupsRes( List profiles, List categoryIds) { public static GetGroupsRes of( - GroupSummaryDto dto, List rawProfiles, List categoryIds) { + GroupSummaryDto dto, + List rawProfiles, + List categoryIds, + ObjectStorage objectStorage) { return new GetGroupsRes( dto.id(), dto.name(), @@ -28,7 +32,8 @@ public static GetGroupsRes of( dto.approvalRequired(), dto.createdDate(), rawProfiles.stream() - .map(rp -> new GroupMemberProfileImageDto(rp.imageUrl(), rp.role())) + .map(rp -> new GroupMemberProfileImageDto( + objectStorage.convertKeyToFileUrl(rp.imageUrl()), rp.role())) .toList(), categoryIds.stream().map(GroupCategoryDto::categoryId).toList()); } diff --git a/src/main/java/com/studypals/domain/groupManage/dto/mappers/GroupEntryRequestCustomMapper.java b/src/main/java/com/studypals/domain/groupManage/dto/mappers/GroupEntryRequestCustomMapper.java index 238d9ae8..5cad28a9 100644 --- a/src/main/java/com/studypals/domain/groupManage/dto/mappers/GroupEntryRequestCustomMapper.java +++ b/src/main/java/com/studypals/domain/groupManage/dto/mappers/GroupEntryRequestCustomMapper.java @@ -10,6 +10,7 @@ import com.studypals.domain.groupManage.entity.GroupEntryRequest; import com.studypals.domain.memberManage.dto.MemberProfileDto; import com.studypals.domain.memberManage.entity.Member; +import com.studypals.global.file.ObjectStorage; /** * mapstruct 가 아닌 별도의 DTO 매핑 로직을 담당하는 유틸성 클래스입니다. @@ -26,7 +27,8 @@ public class GroupEntryRequestCustomMapper { * @param members * @return */ - public static List map(List requests, List members) { + public static List map( + List requests, List members, ObjectStorage objectStorage) { Map memberMap = members.stream().collect(Collectors.toMap(Member::getId, Function.identity())); return requests.stream() @@ -37,7 +39,10 @@ public static List map(List requests, L new IllegalStateException("Member not found for request ID: " + request.getId())); return new GroupEntryRequestDto( request.getId(), - new MemberProfileDto(member.getId(), member.getNickname(), member.getImageUrl()), + new MemberProfileDto( + member.getId(), + member.getNickname(), + objectStorage.convertKeyToFileUrl(member.getProfileImageObjectKey())), request.getCreatedDate()); }) .toList(); diff --git a/src/main/java/com/studypals/domain/groupManage/service/GroupEntryServiceImpl.java b/src/main/java/com/studypals/domain/groupManage/service/GroupEntryServiceImpl.java index f24e465e..78e59cc0 100644 --- a/src/main/java/com/studypals/domain/groupManage/service/GroupEntryServiceImpl.java +++ b/src/main/java/com/studypals/domain/groupManage/service/GroupEntryServiceImpl.java @@ -19,6 +19,7 @@ import com.studypals.domain.memberManage.worker.MemberReader; import com.studypals.global.exceptions.errorCode.GroupErrorCode; import com.studypals.global.exceptions.exception.GroupException; +import com.studypals.global.file.ObjectStorage; import com.studypals.global.request.Cursor; import com.studypals.global.responses.CursorResponse; @@ -52,6 +53,8 @@ public class GroupEntryServiceImpl implements GroupEntryService { private final ChatRoomWriter chatRoomWriter; + private final ObjectStorage objectStorage; + @Override @Transactional(readOnly = true) public GroupEntryCodeRes generateEntryCode(Long userId, Long groupId) { @@ -111,7 +114,7 @@ public CursorResponse.Content getEntryRequests(Long userId .map(r -> r.getMember().getId()) .toList()); List content = - GroupEntryRequestCustomMapper.map(entryRequests.getContent(), requestedMembers); + GroupEntryRequestCustomMapper.map(entryRequests.getContent(), requestedMembers, objectStorage); return new CursorResponse.Content<>( content, content.get(content.size() - 1).requestId(), entryRequests.hasNext()); diff --git a/src/main/java/com/studypals/domain/groupManage/service/GroupRankingServiceImpl.java b/src/main/java/com/studypals/domain/groupManage/service/GroupRankingServiceImpl.java index f8c984d8..af3b7831 100644 --- a/src/main/java/com/studypals/domain/groupManage/service/GroupRankingServiceImpl.java +++ b/src/main/java/com/studypals/domain/groupManage/service/GroupRankingServiceImpl.java @@ -13,6 +13,7 @@ import com.studypals.domain.groupManage.worker.GroupAuthorityValidator; import com.studypals.domain.groupManage.worker.GroupMemberReader; import com.studypals.domain.groupManage.worker.GroupRankingWorker; +import com.studypals.global.file.ObjectStorage; /** * groupRankingService 구현 클래스입니다. @@ -36,6 +37,8 @@ public class GroupRankingServiceImpl implements GroupRankingService { private final GroupMemberReader groupMemberReader; private final GroupAuthorityValidator validator; + private final ObjectStorage objectStorage; + @Override public List getGroupRanking(Long userId, Long groupId, GroupRankingPeriod period) { // 해당 유저가 속한 그룹인가? @@ -54,7 +57,8 @@ public List getGroupRanking(Long userId, Long groupId, Gr return new GroupMemberRankingDto( groupMember.getMember().getId(), groupMember.getMember().getNickname(), - groupMember.getMember().getImageUrl(), + objectStorage.convertKeyToFileUrl( + groupMember.getMember().getProfileImageObjectKey()), studySeconds, groupMember.getRole()); }) diff --git a/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java b/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java index c6f7d138..15390b72 100644 --- a/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java +++ b/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java @@ -25,6 +25,7 @@ import com.studypals.domain.studyManage.dto.GroupCategoryDto; import com.studypals.domain.studyManage.entity.StudyType; import com.studypals.domain.studyManage.worker.StudyCategoryReader; +import com.studypals.global.file.ObjectStorage; import com.studypals.global.retry.RetryTx; /** @@ -53,13 +54,14 @@ public class GroupServiceImpl implements GroupService { private final GroupAuthorityValidator validator; private final GroupMapper groupMapper; private final GroupGoalCalculator groupGoalCalculator; - private final GroupHashTagWorker groupHashTagWorker; // chat room worker class private final ChatRoomWriter chatRoomWriter; private final StudyCategoryReader studyCategoryReader; + private final ObjectStorage objectStorage; + @Override public List getGroupTags() { return groupReader.getGroupTags().stream().map(groupMapper::toTagDto).toList(); @@ -114,7 +116,8 @@ public List getGroups(Long userId) { .map(group -> GetGroupsRes.of( group, membersMap.getOrDefault(group.id(), Collections.emptyList()), - categoriesMap.getOrDefault(group.id(), Collections.emptyList()))) + categoriesMap.getOrDefault(group.id(), Collections.emptyList()), + objectStorage)) .toList(); } @@ -131,6 +134,6 @@ public GetGroupDetailRes getGroupDetails(Long userId, Long groupId) { // 그룹에 속한 유저들의 목표 달성률 계산 GroupTotalGoalDto userGoals = groupGoalCalculator.calculateGroupGoals(groupId, groupMembers); - return GetGroupDetailRes.of(group, groupMembers, userGoals); + return GetGroupDetailRes.of(group, groupMembers, userGoals, objectStorage); } } diff --git a/src/main/java/com/studypals/global/file/ObjectStorage.java b/src/main/java/com/studypals/global/file/ObjectStorage.java index 2a8542a9..36410dee 100644 --- a/src/main/java/com/studypals/global/file/ObjectStorage.java +++ b/src/main/java/com/studypals/global/file/ObjectStorage.java @@ -18,6 +18,14 @@ */ public interface ObjectStorage { + /** + * objectKey에서 fileUrl로 변환해줍니다. + * + * @param objectKey + * @return 클라이언트에서 바로 접근할 수 있는 파일 경로 + */ + String convertKeyToFileUrl(String objectKey); + /** * 스토리지에 파일을 저장합니다. * diff --git a/src/main/java/com/studypals/global/minio/MinioStorage.java b/src/main/java/com/studypals/global/minio/MinioStorage.java index 7cbc458e..d029a0bf 100644 --- a/src/main/java/com/studypals/global/minio/MinioStorage.java +++ b/src/main/java/com/studypals/global/minio/MinioStorage.java @@ -48,6 +48,19 @@ public void init() { validateBucket(); } + /** + * objectKey에서 fileUrl로 변환해줍니다. objectKey가 null 이거나 비어있다면 null을 반환합니다. + * @param objectKey minio 내 파일 경로 + * @return 클라이언트에서 바로 접근할 수 있는 파일 경로 + */ + @Override + public String convertKeyToFileUrl(String objectKey) { + if (objectKey == null || objectKey.isBlank()) { + return null; + } + return endpoint + "/" + bucket + "/" + objectKey; + } + /** * MultipartFile 형태의 파일을 업로드합니다. * @@ -65,7 +78,7 @@ public String upload(MultipartFile file, String objectKey) { .contentType(file.getContentType()) .build()); - return endpoint + "/" + bucket + "/" + objectKey; + return convertKeyToFileUrl(objectKey); } catch (Exception e) { throw new RuntimeException("MinIO 파일 업로드에 실패했습니다. ObjectKey: " + objectKey, e); } From 4d775dc0d7050ad68338cfa605280bc433c3e2f3 Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Tue, 27 Jan 2026 15:08:17 +0900 Subject: [PATCH 21/25] =?UTF-8?q?Test:=20Test=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/file/dto/ChatPresignedUrlReq.java | 17 -- .../global/file/dto/ImageUploadRes.java | 4 +- .../file/dto/ProfilePresignedUrlReq.java | 14 -- .../global/file/entity/ImageFile.java | 4 +- .../service/ChatRoomServiceTest.java | 10 +- .../dao/GroupMemberRepositoryTest.java | 8 +- .../service/GroupEntryServiceTest.java | 4 + .../service/GroupRankingServiceTest.java | 10 +- .../groupManage/service/GroupServiceTest.java | 10 +- .../worker/GroupGoalCalculatorTest.java | 6 +- .../MemberControllerRestDocsTest.java | 7 +- .../service/MemberServiceTest.java | 12 +- .../file/dao/AbstractFileManagerTest.java | 6 +- .../file/dao/AbstractImageManagerTest.java | 6 +- .../ImageFileControllerRestDocsTest.java | 72 +++---- .../service/ImageFileServiceImplTest.java | 185 ++++++++++++------ .../global/minio/MinioStorageTest.java | 51 ++--- .../testSupport/DataJpaSupport.java | 17 +- 18 files changed, 245 insertions(+), 198 deletions(-) delete mode 100644 src/main/java/com/studypals/global/file/dto/ChatPresignedUrlReq.java delete mode 100644 src/main/java/com/studypals/global/file/dto/ProfilePresignedUrlReq.java diff --git a/src/main/java/com/studypals/global/file/dto/ChatPresignedUrlReq.java b/src/main/java/com/studypals/global/file/dto/ChatPresignedUrlReq.java deleted file mode 100644 index 6fa6c488..00000000 --- a/src/main/java/com/studypals/global/file/dto/ChatPresignedUrlReq.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.studypals.global.file.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; - -/** - * 채팅 사진 업로드에 필요한 정보를 가집니다. - * - * @param fileName 업로드할 파일의 이름. {@code @NotNull}과 {@code @NotBlank} 제약조건이 적용됩니다. - * @param chatRoomId 업로드할 파일이 속한 채팅방 id. {@code @NotNull}과 {@code @NotBlank} 제약조건이 적용됩니다. - * - * @author sleepyhoon - * @since 2026-01-10 - */ -public record ChatPresignedUrlReq( - @NotNull(message = "파일 이름은 필수입니다.") @NotBlank(message = "파일 이름은 공백일 수 없습니다.") String fileName, - @NotNull(message = "채팅방 ID는 필수입니다.") @NotBlank(message = "채팅방 ID는 공백일 수 없습니다.") String chatRoomId) {} diff --git a/src/main/java/com/studypals/global/file/dto/ImageUploadRes.java b/src/main/java/com/studypals/global/file/dto/ImageUploadRes.java index 37abc486..d3021875 100644 --- a/src/main/java/com/studypals/global/file/dto/ImageUploadRes.java +++ b/src/main/java/com/studypals/global/file/dto/ImageUploadRes.java @@ -3,7 +3,7 @@ /** * 이미지 업로드 완료 후 반환되는 응답 DTO입니다. * - * @param id 데이터베이스에 저장된 이미지의 고유 ID (PK). + * @param imageId 데이터베이스에 저장된 이미지의 고유 ID (PK). * 추후 비즈니스 로직(예: 회원 정보 수정, 채팅 메시지 전송)에서 이 ID를 참조합니다. * @param imageUrl 업로드된 이미지를 즉시 조회할 수 있는 URL. *

@@ -15,4 +15,4 @@ * @author sleepyhoon * @since 2026-01-15 */ -public record ImageUploadRes(Long id, String imageUrl) {} +public record ImageUploadRes(Long imageId, String imageUrl) {} diff --git a/src/main/java/com/studypals/global/file/dto/ProfilePresignedUrlReq.java b/src/main/java/com/studypals/global/file/dto/ProfilePresignedUrlReq.java deleted file mode 100644 index 6c0cb2c7..00000000 --- a/src/main/java/com/studypals/global/file/dto/ProfilePresignedUrlReq.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.studypals.global.file.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; - -/** - * 프로필 사진 업로드에 필요한 정보를 가집니다. - * - * @param fileName 업로드할 파일의 이름. {@code @NotNull}과 {@code @NotBlank} 제약조건이 적용됩니다. - * - * @author sleepyhoon - * @since 2026-01-10 - */ -public record ProfilePresignedUrlReq(@NotNull @NotBlank String fileName) {} diff --git a/src/main/java/com/studypals/global/file/entity/ImageFile.java b/src/main/java/com/studypals/global/file/entity/ImageFile.java index 72345027..cf443aee 100644 --- a/src/main/java/com/studypals/global/file/entity/ImageFile.java +++ b/src/main/java/com/studypals/global/file/entity/ImageFile.java @@ -41,7 +41,7 @@ public abstract class ImageFile { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "image_file_id") + @Column(name = "id", nullable = false, unique = true) private Long id; /** @@ -85,7 +85,7 @@ public abstract class ImageFile { * 이미지가 업로드된 시간입니다. */ @CreatedDate - @Column(updatable = false, nullable = false) + @Column(updatable = false) private LocalDateTime createdAt; /** diff --git a/src/test/java/com/studypals/domain/chatManage/service/ChatRoomServiceTest.java b/src/test/java/com/studypals/domain/chatManage/service/ChatRoomServiceTest.java index 997e92f4..8a7a911b 100644 --- a/src/test/java/com/studypals/domain/chatManage/service/ChatRoomServiceTest.java +++ b/src/test/java/com/studypals/domain/chatManage/service/ChatRoomServiceTest.java @@ -15,6 +15,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mapstruct.factory.Mappers; import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -29,6 +30,7 @@ import com.studypals.domain.chatManage.worker.ChatRoomReader; import com.studypals.domain.memberManage.entity.Member; import com.studypals.domain.memberManage.worker.MemberReader; +import com.studypals.global.file.ObjectStorage; /** * {@link ChatRoomService} 에 대한 테스트코드 @@ -66,6 +68,10 @@ class ChatRoomServiceTest { @Mock private MemberReader memberReader; + @Mock + private ObjectStorage objectStorage; + + @InjectMocks private ChatRoomServiceImpl chatRoomService; private final ChatMessageMapper chatMessageMapper = Mappers.getMapper(ChatMessageMapper.class); @@ -73,7 +79,7 @@ class ChatRoomServiceTest { @BeforeEach void setup() { chatRoomService = new ChatRoomServiceImpl( - chatRoomReader, chatRoomMapper, chatMessageMapper, chatMessageReader, memberReader); + chatRoomReader, chatRoomMapper, chatMessageMapper, chatMessageReader, memberReader, objectStorage); } @Test @@ -91,7 +97,7 @@ void getChatRoomInfo_success() { given(mockMember1.getId()).willReturn(userId); given(mockMember1.getId()).willReturn(2L); - given(chatRoomMapper.toDto(any())) + given(chatRoomMapper.toDto(any(), any())) .willReturn(new ChatRoomInfoRes.UserInfo(userId, "nickname", ChatRoomRole.MEMBER, "image")); given(mockMember1.getId()).willReturn(userId); diff --git a/src/test/java/com/studypals/domain/groupManage/dao/GroupMemberRepositoryTest.java b/src/test/java/com/studypals/domain/groupManage/dao/GroupMemberRepositoryTest.java index 48d7c17e..23569cc0 100644 --- a/src/test/java/com/studypals/domain/groupManage/dao/GroupMemberRepositoryTest.java +++ b/src/test/java/com/studypals/domain/groupManage/dao/GroupMemberRepositoryTest.java @@ -60,9 +60,9 @@ void findTopNMembers_success() { List expected = List.of( new GroupMemberProfileDto( - member1.getId(), member1.getNickname(), member1.getImageUrl(), leader.getRole()), + member1.getId(), member1.getNickname(), member1.getProfileImageObjectKey(), leader.getRole()), new GroupMemberProfileDto( - member2.getId(), member2.getNickname(), member2.getImageUrl(), member.getRole())); + member2.getId(), member2.getNickname(), member2.getProfileImageObjectKey(), member.getRole())); // when List actual = @@ -121,13 +121,13 @@ void findAllMembersInGroupIds_success() { .findFirst() .get(); assertThat(mapping1.groupId()).isEqualTo(g1.getId()); - assertThat(mapping1.imageUrl()).isEqualTo("imageUrl-url"); + assertThat(mapping1.imageUrl()).isEqualTo(m1.getProfileImageObjectKey()); GroupMemberProfileMappingDto mapping2 = result.stream() .filter(r -> r.groupId().equals(g2.getId())) .findFirst() .get(); assertThat(mapping2.groupId()).isEqualTo(g2.getId()); - assertThat(mapping2.imageUrl()).isEqualTo("imageUrl-url"); + assertThat(mapping2.imageUrl()).isEqualTo(m2.getProfileImageObjectKey()); } } diff --git a/src/test/java/com/studypals/domain/groupManage/service/GroupEntryServiceTest.java b/src/test/java/com/studypals/domain/groupManage/service/GroupEntryServiceTest.java index cffd8a68..cdf4891c 100644 --- a/src/test/java/com/studypals/domain/groupManage/service/GroupEntryServiceTest.java +++ b/src/test/java/com/studypals/domain/groupManage/service/GroupEntryServiceTest.java @@ -28,6 +28,7 @@ import com.studypals.domain.memberManage.worker.MemberReader; import com.studypals.global.exceptions.errorCode.GroupErrorCode; import com.studypals.global.exceptions.exception.GroupException; +import com.studypals.global.file.ObjectStorage; import com.studypals.global.request.Cursor; import com.studypals.global.request.DateSortType; import com.studypals.global.responses.CursorResponse; @@ -74,6 +75,9 @@ public class GroupEntryServiceTest { @Mock private GroupEntryRequest mockEntryRequest; + @Mock + private ObjectStorage objectStorage; + @InjectMocks private GroupEntryServiceImpl groupEntryService; diff --git a/src/test/java/com/studypals/domain/groupManage/service/GroupRankingServiceTest.java b/src/test/java/com/studypals/domain/groupManage/service/GroupRankingServiceTest.java index a0c1157c..6f4ed391 100644 --- a/src/test/java/com/studypals/domain/groupManage/service/GroupRankingServiceTest.java +++ b/src/test/java/com/studypals/domain/groupManage/service/GroupRankingServiceTest.java @@ -23,6 +23,7 @@ import com.studypals.domain.groupManage.worker.GroupMemberReader; import com.studypals.domain.groupManage.worker.GroupRankingWorker; import com.studypals.domain.memberManage.entity.Member; +import com.studypals.global.file.ObjectStorage; @ExtendWith(MockitoExtension.class) class GroupRankingServiceTest { @@ -36,6 +37,9 @@ class GroupRankingServiceTest { @Mock private GroupAuthorityValidator validator; + @Mock + private ObjectStorage objectStorage; + @InjectMocks private GroupRankingServiceImpl groupRankingService; @@ -82,11 +86,7 @@ private List createMockGroupMembers(Long groupId) { } private GroupMember createMember(Long id, String nick, String img, Group group, GroupRole role) { - Member member = Member.builder() - .id(id) - .nickname(nick) - .imageUrl("https://example.com/" + img) - .build(); + Member member = Member.builder().id(id).nickname(nick).build(); return GroupMember.builder() .id(id + 1000L) // GroupMember 자체의 ID diff --git a/src/test/java/com/studypals/domain/groupManage/service/GroupServiceTest.java b/src/test/java/com/studypals/domain/groupManage/service/GroupServiceTest.java index 1b14ef82..23dc66b9 100644 --- a/src/test/java/com/studypals/domain/groupManage/service/GroupServiceTest.java +++ b/src/test/java/com/studypals/domain/groupManage/service/GroupServiceTest.java @@ -30,6 +30,7 @@ import com.studypals.domain.studyManage.worker.StudyCategoryReader; import com.studypals.global.exceptions.errorCode.GroupErrorCode; import com.studypals.global.exceptions.exception.GroupException; +import com.studypals.global.file.ObjectStorage; /** * {@link GroupService} 에 대한 단위 테스트입니다. @@ -88,6 +89,9 @@ public class GroupServiceTest { @Mock private GroupHashTagWorker groupHashTagWorker; + @Mock + private ObjectStorage objectStorage; + @InjectMocks private GroupServiceImpl groupService; @@ -278,11 +282,7 @@ private List createMockGroupMembers(Long groupId) { } private GroupMember createMember(Long id, String nick, String img, Group group, GroupRole role) { - Member member = Member.builder() - .id(id) - .nickname(nick) - .imageUrl("https://example.com/" + img) - .build(); + Member member = Member.builder().id(id).nickname(nick).build(); return GroupMember.builder() .id(id + 1000L) // GroupMember 자체의 ID diff --git a/src/test/java/com/studypals/domain/groupManage/worker/GroupGoalCalculatorTest.java b/src/test/java/com/studypals/domain/groupManage/worker/GroupGoalCalculatorTest.java index 4433f79f..5fab7676 100644 --- a/src/test/java/com/studypals/domain/groupManage/worker/GroupGoalCalculatorTest.java +++ b/src/test/java/com/studypals/domain/groupManage/worker/GroupGoalCalculatorTest.java @@ -249,11 +249,7 @@ private List createMockGroupMembers(Long groupId) { } private Member createMemberEntity(Long id, String nick, String img) { - return Member.builder() - .id(id) - .nickname(nick) - .imageUrl("https://example.com/" + img) - .build(); + return Member.builder().id(id).nickname(nick).build(); } private GroupMember createGroupMember(Member member, Group group, GroupRole role) { diff --git a/src/test/java/com/studypals/domain/memberManage/restDocsTest/MemberControllerRestDocsTest.java b/src/test/java/com/studypals/domain/memberManage/restDocsTest/MemberControllerRestDocsTest.java index a4e90280..c4e12510 100644 --- a/src/test/java/com/studypals/domain/memberManage/restDocsTest/MemberControllerRestDocsTest.java +++ b/src/test/java/com/studypals/domain/memberManage/restDocsTest/MemberControllerRestDocsTest.java @@ -153,7 +153,7 @@ void getProfile_success() throws Exception { @WithMockUser void updateProfile_success() throws Exception { // given - UpdateProfileReq req = new UpdateProfileReq(LocalDate.of(1999, 8, 20), "학생", "exmaple.image.com"); + UpdateProfileReq req = new UpdateProfileReq(LocalDate.of(1999, 8, 20), "학생"); given(memberService.updateProfile(anyLong(), any(UpdateProfileReq.class))) .willReturn(1L); @@ -169,10 +169,7 @@ void updateProfile_success() throws Exception { httpResponse(), requestFields( fieldWithPath("birthday").description("생일").optional(), - fieldWithPath("position").description("직무/포지션").optional(), - fieldWithPath("imageUrl") - .description("프로필 이미지 URL") - .optional()), + fieldWithPath("position").description("직무/포지션").optional()), responseFields( fieldWithPath("code").description("응답 코드 (U01-02)"), fieldWithPath("status").description("응답 상태"), diff --git a/src/test/java/com/studypals/domain/memberManage/service/MemberServiceTest.java b/src/test/java/com/studypals/domain/memberManage/service/MemberServiceTest.java index 6b4cc721..6782db72 100644 --- a/src/test/java/com/studypals/domain/memberManage/service/MemberServiceTest.java +++ b/src/test/java/com/studypals/domain/memberManage/service/MemberServiceTest.java @@ -24,6 +24,7 @@ import com.studypals.domain.memberManage.worker.MemberWriter; import com.studypals.global.exceptions.errorCode.AuthErrorCode; import com.studypals.global.exceptions.exception.AuthException; +import com.studypals.global.file.ObjectStorage; /** * {@link MemberService} 에 대한 단위 테스트입니다. @@ -50,6 +51,9 @@ class MemberServiceTest { @Mock private Member mockMember; + @Mock + private ObjectStorage objectStorage; + @InjectMocks private MemberServiceImpl memberService; @@ -129,7 +133,7 @@ void updateProfile_success() { // given Long userId = 1L; - UpdateProfileReq req = new UpdateProfileReq(LocalDate.of(1999, 8, 20), "학생", "example.image.com"); + UpdateProfileReq req = new UpdateProfileReq(LocalDate.of(1999, 8, 20), "학생"); given(memberReader.get(userId)).willReturn(mockMember); given(mockMember.getId()).willReturn(1L); @@ -141,7 +145,7 @@ void updateProfile_success() { assertThat(result).isEqualTo(1L); then(memberReader).should().get(userId); - then(mockMember).should().updateProfile(LocalDate.of(1999, 8, 20), "학생", "example.image.com"); + then(mockMember).should().updateProfile(LocalDate.of(1999, 8, 20), "학생"); then(memberWriter).should().save(mockMember); } @@ -162,7 +166,7 @@ void getProfile_success() { .build(); given(memberReader.get(userId)).willReturn(mockMember); - given(mapper.toRes(mockMember)).willReturn(res); + given(mapper.toRes(mockMember, objectStorage)).willReturn(res); // when MemberDetailsRes result = memberService.getProfile(userId); @@ -171,6 +175,6 @@ void getProfile_success() { assertThat(result).isSameAs(res); then(memberReader).should().get(userId); - then(mapper).should().toRes(mockMember); + then(mapper).should().toRes(mockMember, objectStorage); } } diff --git a/src/test/java/com/studypals/global/file/dao/AbstractFileManagerTest.java b/src/test/java/com/studypals/global/file/dao/AbstractFileManagerTest.java index d66be842..7fa9434a 100644 --- a/src/test/java/com/studypals/global/file/dao/AbstractFileManagerTest.java +++ b/src/test/java/com/studypals/global/file/dao/AbstractFileManagerTest.java @@ -1,7 +1,6 @@ package com.studypals.global.file.dao; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.times; @@ -42,13 +41,10 @@ void setUp() { @DisplayName("파일 삭제 성공") void delete_success() { // given - String url = "https://example.com/test/image.jpg"; String objectKey = "test/image.jpg"; - given(objectStorage.parsePath(url)).willReturn(objectKey); - // when - fileRepository.delete(url); + fileRepository.delete(objectKey); // then then(objectStorage).should(times(1)).delete(objectKey); diff --git a/src/test/java/com/studypals/global/file/dao/AbstractImageManagerTest.java b/src/test/java/com/studypals/global/file/dao/AbstractImageManagerTest.java index 6a7a90ab..c5909866 100644 --- a/src/test/java/com/studypals/global/file/dao/AbstractImageManagerTest.java +++ b/src/test/java/com/studypals/global/file/dao/AbstractImageManagerTest.java @@ -2,8 +2,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -149,14 +147,14 @@ void should_ReturnPresignedUrl_When_ObjectKeyIsValid() { String expectedUrl = "https://example.com/presigned-url"; int expireTime = fileUploadProperties.presignedUrlExpireTime(); - when(objectStorage.createPresignedPutUrl(anyString(), anyInt())).thenReturn(expectedUrl); + when(objectStorage.createPresignedGetUrl(objectKey, expireTime)).thenReturn(expectedUrl); // when String actualUrl = imageManager.getPresignedGetUrl(objectKey); // then assertThat(actualUrl).isEqualTo(expectedUrl); - verify(objectStorage).createPresignedPutUrl(objectKey, expireTime); + verify(objectStorage).createPresignedGetUrl(objectKey, expireTime); } } } diff --git a/src/test/java/com/studypals/global/file/restDocsTest/ImageFileControllerRestDocsTest.java b/src/test/java/com/studypals/global/file/restDocsTest/ImageFileControllerRestDocsTest.java index cb26dd06..76a9d173 100644 --- a/src/test/java/com/studypals/global/file/restDocsTest/ImageFileControllerRestDocsTest.java +++ b/src/test/java/com/studypals/global/file/restDocsTest/ImageFileControllerRestDocsTest.java @@ -7,6 +7,10 @@ import static org.springframework.restdocs.http.HttpDocumentation.httpResponse; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -14,14 +18,14 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.ResultActions; +import org.springframework.web.multipart.MultipartFile; import com.studypals.global.file.api.ImageFileController; -import com.studypals.global.file.dto.ChatPresignedUrlReq; -import com.studypals.global.file.dto.PresignedUrlRes; -import com.studypals.global.file.dto.ProfilePresignedUrlReq; +import com.studypals.global.file.dto.ImageUploadRes; import com.studypals.global.file.service.ImageFileService; import com.studypals.global.responses.CommonResponse; import com.studypals.global.responses.Response; @@ -34,25 +38,30 @@ class ImageFileControllerRestDocsTest extends RestDocsSupport { @MockitoBean private ImageFileService imageFileService; + private final MockMultipartFile mockMultipartFile = new MockMultipartFile( + "file", // 컨트롤러가 받는 파라미터 변수명 (필수 확인!) + "chat-image.png", // 업로드할 파일명 + "image/png", // 파일 타입 + "fake-image-content".getBytes() // 파일 내용 (더미) + ); + @Test @WithMockUser - @DisplayName("프로필 이미지 업로드용 Presigned URL 요청") + @DisplayName("프로필 이미지 업로드 성공") void getProfileUploadUrl_success() throws Exception { // given - ProfilePresignedUrlReq request = new ProfilePresignedUrlReq("my-profile.jpeg"); Long imageId = 1L; - String presignedUrl = "https://s3-presigned-url.com/for/profile/my-profile.jpeg?signature=..."; + String imageUrl = "http://example.com/image.jpg"; - PresignedUrlRes response = new PresignedUrlRes(imageId, presignedUrl); - given(imageFileService.getProfileUploadUrl(any(ProfilePresignedUrlReq.class), any())) + ImageUploadRes response = new ImageUploadRes(imageId, imageUrl); + given(imageFileService.uploadProfileImage(any(MultipartFile.class), any())) .willReturn(response); - Response expected = CommonResponse.success(ResponseCode.FILE_IMAGE_UPLOAD, response); + Response expected = CommonResponse.success(ResponseCode.FILE_IMAGE_UPLOAD, response); // when - ResultActions result = mockMvc.perform(post("/files/image/profile") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); + ResultActions result = mockMvc.perform( + multipart("/files/image/profile").file(mockMultipartFile).contentType(MediaType.MULTIPART_FORM_DATA)); // then result.andExpect(status().isOk()) @@ -61,36 +70,34 @@ void getProfileUploadUrl_success() throws Exception { .andDo(restDocs.document( httpRequest(), httpResponse(), - requestFields(fieldWithPath("fileName") - .description("업로드할 파일 이름 (확장자 포함)") - .attributes(constraints("not null, not blank"))), + requestParts(partWithName("file").description("업로드할 이미지 파일 (MultipartFile)")), responseFields( fieldWithPath("code").description("응답 코드 (I01-01)"), fieldWithPath("status").description("응답 상태"), fieldWithPath("message").description("응답 메시지"), - fieldWithPath("data.id").description("이미지 파일의 식별 ID"), - fieldWithPath("data.url").description("생성된 Presigned URL")))); + fieldWithPath("data.imageId").description("이미지 파일의 식별 ID"), + fieldWithPath("data.imageUrl").description("저장된 이미지 주소")))); } @Test @WithMockUser - @DisplayName("채팅 이미지 업로드용 Presigned URL 요청") + @DisplayName("채팅 이미지 업로드 성공") void getChatUploadUrl_success() throws Exception { // given - ChatPresignedUrlReq request = new ChatPresignedUrlReq("chat-image.png", "chat-room-123"); - String presignedUrl = "https://s3-presigned-url.com/for/chat/chat-image.png?signature=..."; Long imageId = 1L; - PresignedUrlRes response = new PresignedUrlRes(imageId, presignedUrl); + String imageUrl = "http://example.com/image.jpg"; + ImageUploadRes response = new ImageUploadRes(imageId, imageUrl); - given(imageFileService.getChatUploadUrl(any(ChatPresignedUrlReq.class), any())) + given(imageFileService.uploadChatImage(any(MultipartFile.class), any(), any())) .willReturn(response); - Response expected = CommonResponse.success(ResponseCode.FILE_IMAGE_UPLOAD, response); + Response expected = CommonResponse.success(ResponseCode.FILE_IMAGE_UPLOAD, response); // when - ResultActions result = mockMvc.perform(post("/files/image/chat") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); + ResultActions result = mockMvc.perform(multipart("/files/image/chat") + .file(mockMultipartFile) + .queryParam("chatRoomId", "chatRoomId-123-456") + .contentType(MediaType.MULTIPART_FORM_DATA)); // then result.andExpect(status().isOk()) @@ -99,18 +106,13 @@ void getChatUploadUrl_success() throws Exception { .andDo(restDocs.document( httpRequest(), httpResponse(), - requestFields( - fieldWithPath("fileName") - .description("업로드할 파일 이름 (확장자 포함)") - .attributes(constraints("not null, not blank")), - fieldWithPath("chatRoomId") - .description("업로드 대상 채팅방 ID") - .attributes(constraints("not null, not blank"))), + queryParameters(parameterWithName("chatRoomId").description("채팅방 식별자 ID")), + requestParts(partWithName("file").description("업로드할 이미지 파일 (MultipartFile)")), responseFields( fieldWithPath("code").description("응답 코드 (I01-01)"), fieldWithPath("status").description("응답 상태"), fieldWithPath("message").description("응답 메시지"), - fieldWithPath("data.id").description("이미지 파일의 식별 ID"), - fieldWithPath("data.url").description("생성된 Presigned URL")))); + fieldWithPath("data.imageId").description("이미지 파일의 식별 ID"), + fieldWithPath("data.imageUrl").description("저장된 이미지 주소")))); } } diff --git a/src/test/java/com/studypals/global/file/service/ImageFileServiceImplTest.java b/src/test/java/com/studypals/global/file/service/ImageFileServiceImplTest.java index d4dad5b7..89d94712 100644 --- a/src/test/java/com/studypals/global/file/service/ImageFileServiceImplTest.java +++ b/src/test/java/com/studypals/global/file/service/ImageFileServiceImplTest.java @@ -1,11 +1,13 @@ package com.studypals.global.file.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; import java.util.List; @@ -15,31 +17,32 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.multipart.MultipartFile; import com.studypals.domain.chatManage.entity.ChatRoom; import com.studypals.domain.chatManage.worker.ChatImageManager; import com.studypals.domain.chatManage.worker.ChatImageWriter; import com.studypals.domain.chatManage.worker.ChatRoomReader; import com.studypals.domain.memberManage.entity.Member; +import com.studypals.domain.memberManage.entity.MemberProfileImage; import com.studypals.domain.memberManage.worker.MemberProfileImageManager; import com.studypals.domain.memberManage.worker.MemberProfileImageWriter; import com.studypals.domain.memberManage.worker.MemberReader; -import com.studypals.global.file.dto.ChatPresignedUrlReq; -import com.studypals.global.file.dto.PresignedUrlRes; -import com.studypals.global.file.dto.ProfilePresignedUrlReq; +import com.studypals.global.file.dao.AbstractImageManager; +import com.studypals.global.file.dto.ImageUploadDto; +import com.studypals.global.file.dto.ImageUploadRes; import com.studypals.global.file.entity.ImageType; @ExtendWith(MockitoExtension.class) class ImageFileServiceImplTest { - // Service under test private ImageFileService imageFileService; @Mock - private MemberProfileImageManager mockProfileImageManager; + private MemberProfileImageManager profileImageManager; @Mock - private ChatImageManager mockChatImageManager; + private ChatImageManager chatImageManager; @Mock private MemberReader memberReader; @@ -48,88 +51,146 @@ class ImageFileServiceImplTest { private ChatRoomReader chatRoomReader; @Mock - private MemberProfileImageWriter memberProfileImageWriter; + private MemberProfileImageWriter profileImageWriter; @Mock private ChatImageWriter chatImageWriter; + @Mock + private MultipartFile multipartFile; + @BeforeEach void setUp() { - when(mockProfileImageManager.getFileType()).thenReturn(ImageType.PROFILE_IMAGE); - when(mockChatImageManager.getFileType()).thenReturn(ImageType.CHAT_IMAGE); + given(profileImageManager.getFileType()).willReturn(ImageType.PROFILE_IMAGE); + given(chatImageManager.getFileType()).willReturn(ImageType.CHAT_IMAGE); imageFileService = new ImageFileServiceImpl( - List.of(mockProfileImageManager, mockChatImageManager), + List.of(profileImageManager, chatImageManager), memberReader, chatRoomReader, - memberProfileImageWriter, + profileImageWriter, chatImageWriter); } @Test - @DisplayName("getProfileUploadUrl 호출 시 ProfileImageManager의 getUploadUrl을 호출해야 한다") - void getProfileUploadUrl_shouldCallCorrectManager() { + @DisplayName("프로필 이미지 업로드 - 성공 (기존 프로필 없음)") + void uploadProfileImage_Success_NoExistingProfile() { // given Long userId = 1L; - ProfilePresignedUrlReq request = new ProfilePresignedUrlReq("profile.jpg"); - String expectedObjectKey = "profile/1/some-uuid.jpg"; - Long expectedImageId = 99L; - String expectedUrl = "http://s3.com/profile-upload-url"; - - // 서비스가 호출할 Mock 객체의 동작을 모두 정의합니다. - when(mockProfileImageManager.createObjectKey(userId, request.fileName(), String.valueOf(userId))) - .thenReturn(expectedObjectKey); - when(memberReader.getRef(userId)).thenReturn(Member.builder().id(userId).build()); - when(memberProfileImageWriter.save(any(Member.class), eq(expectedObjectKey), eq(request.fileName()))) - .thenReturn(expectedImageId); - when(mockProfileImageManager.getPresignedGetUrl(expectedObjectKey)).thenReturn(expectedUrl); + String originalFilename = "test.jpg"; + String objectKey = "profile/1/uuid.jpg"; + String imageUrl = "http://test.com/profile/1/uuid.jpg"; + + given(multipartFile.getOriginalFilename()).willReturn(originalFilename); + + ImageUploadDto uploadDto = new ImageUploadDto(objectKey, imageUrl); + given(profileImageManager.upload(multipartFile, userId)).willReturn(uploadDto); + + Member member = mock(Member.class); + given(memberReader.get(userId)).willReturn(member); + given(member.getProfileImage()).willReturn(null); + + Long savedImageId = 10L; + given(profileImageWriter.save(eq(member), eq(objectKey), eq(originalFilename))) + .willReturn(savedImageId); + + // when + ImageUploadRes res = imageFileService.uploadProfileImage(multipartFile, userId); + + // then + assertThat(res.imageId()).isEqualTo(savedImageId); + assertThat(res.imageUrl()).isEqualTo(imageUrl); + + verify(profileImageManager).upload(multipartFile, userId); + verify(profileImageWriter).save(eq(member), eq(objectKey), eq(originalFilename)); + verify(profileImageManager, never()).delete(anyString()); + } + + @Test + @DisplayName("프로필 이미지 업로드 - 성공 (기존 프로필 존재 -> 업데이트 및 기존 파일 삭제)") + void uploadProfileImage_Success_ExistingProfile() { + // given + Long userId = 1L; + String originalFilename = "new.jpg"; + String newObjectKey = "profile/1/new.jpg"; + String newImageUrl = "http://test.com/profile/1/new.jpg"; + String oldObjectKey = "profile/1/old.jpg"; + + given(multipartFile.getOriginalFilename()).willReturn(originalFilename); + + ImageUploadDto uploadDto = new ImageUploadDto(newObjectKey, newImageUrl); + given(profileImageManager.upload(multipartFile, userId)).willReturn(uploadDto); + + Member member = mock(Member.class); + MemberProfileImage existingProfile = mock(MemberProfileImage.class); + + given(memberReader.get(userId)).willReturn(member); + given(member.getProfileImage()).willReturn(existingProfile); + given(existingProfile.getObjectKey()).willReturn(oldObjectKey); + + Long updatedImageId = 10L; + given(profileImageWriter.saveEntity(existingProfile)).willReturn(updatedImageId); // when - PresignedUrlRes actualResult = imageFileService.getProfileUploadUrl(request, userId); + ImageUploadRes res = imageFileService.uploadProfileImage(multipartFile, userId); // then - assertThat(actualResult).isNotNull(); - assertThat(actualResult.id()).isEqualTo(expectedImageId); - assertThat(actualResult.url()).isEqualTo(expectedUrl); - - // 올바른 메서드가 올바른 인자와 함께 호출되었는지 검증합니다. - verify(mockProfileImageManager).createObjectKey(userId, request.fileName(), String.valueOf(userId)); - verify(memberProfileImageWriter).save(any(Member.class), eq(expectedObjectKey), eq(request.fileName())); - verify(mockProfileImageManager).getPresignedGetUrl(expectedObjectKey); - verify(mockChatImageManager, never()).getPresignedGetUrl(any()); + assertThat(res.imageId()).isEqualTo(updatedImageId); + assertThat(res.imageUrl()).isEqualTo(newImageUrl); + + verify(existingProfile).update(eq(newObjectKey), eq(originalFilename), anyString()); + verify(profileImageWriter).saveEntity(existingProfile); + verify(profileImageManager).delete(oldObjectKey); } @Test - @DisplayName("getChatUploadUrl 호출 시 ChatImageManager의 getUploadUrl을 호출해야 한다") - void getChatUploadUrl_shouldCallCorrectManager() { + @DisplayName("채팅방 이미지 업로드 - 성공") + void uploadChatImage_Success() { // given Long userId = 1L; - ChatPresignedUrlReq request = new ChatPresignedUrlReq("chat-image.png", "chat-room-123"); - String expectedObjectKey = "chat/chat-room-123/some-uuid.png"; - Long expectedImageId = 100L; - String expectedUrl = "http://s3.com/chat-upload-url"; - - // 서비스가 호출할 Mock 객체의 동작을 모두 정의합니다. - when(mockChatImageManager.createObjectKey(userId, request.fileName(), request.chatRoomId())) - .thenReturn(expectedObjectKey); - when(chatRoomReader.getById(request.chatRoomId())) - .thenReturn(ChatRoom.builder().id(request.chatRoomId()).build()); - when(chatImageWriter.save(any(ChatRoom.class), eq(expectedObjectKey), eq(request.fileName()))) - .thenReturn(expectedImageId); - when(mockChatImageManager.getPresignedGetUrl(expectedObjectKey)).thenReturn(expectedUrl); + String chatRoomId = "room1"; + String originalFilename = "chat.jpg"; + String objectKey = "chat/room1/uuid.jpg"; + String imageUrl = "http://test.com/chat/room1/uuid.jpg"; + + given(multipartFile.getOriginalFilename()).willReturn(originalFilename); + + ImageUploadDto uploadDto = new ImageUploadDto(objectKey, imageUrl); + given(chatImageManager.upload(multipartFile, userId, chatRoomId)).willReturn(uploadDto); + + ChatRoom chatRoom = mock(ChatRoom.class); + given(chatRoomReader.getById(chatRoomId)).willReturn(chatRoom); + + Long savedImageId = 20L; + given(chatImageWriter.save(eq(chatRoom), eq(objectKey), eq(originalFilename))) + .willReturn(savedImageId); // when - PresignedUrlRes actualResult = imageFileService.getChatUploadUrl(request, userId); + ImageUploadRes res = imageFileService.uploadChatImage(multipartFile, chatRoomId, userId); // then - assertThat(actualResult).isNotNull(); - assertThat(actualResult.id()).isEqualTo(expectedImageId); - assertThat(actualResult.url()).isEqualTo(expectedUrl); - - // 올바른 메서드가 올바른 인자와 함께 호출되었는지 검증합니다. - verify(mockChatImageManager).createObjectKey(userId, request.fileName(), request.chatRoomId()); - verify(chatImageWriter).save(any(ChatRoom.class), eq(expectedObjectKey), eq(request.fileName())); - verify(mockChatImageManager).getPresignedGetUrl(expectedObjectKey); - verify(mockProfileImageManager, never()).getPresignedGetUrl(any()); + assertThat(res.imageId()).isEqualTo(savedImageId); + assertThat(res.imageUrl()).isEqualTo(imageUrl); + + verify(chatImageManager).upload(multipartFile, userId, chatRoomId); + verify(chatImageWriter).save(eq(chatRoom), eq(objectKey), eq(originalFilename)); + } + + @Test + @DisplayName("생성자 - 중복 FileType 등록 시 예외 발생") + void constructor_DuplicateFileType() { + // given + AbstractImageManager manager1 = mock(AbstractImageManager.class); + AbstractImageManager manager2 = mock(AbstractImageManager.class); + + given(manager1.getFileType()).willReturn(ImageType.PROFILE_IMAGE); + given(manager2.getFileType()).willReturn(ImageType.PROFILE_IMAGE); // 중복 타입 + + List managers = List.of(manager1, manager2); + + // when & then + assertThatThrownBy(() -> new ImageFileServiceImpl( + managers, memberReader, chatRoomReader, profileImageWriter, chatImageWriter)) + .isInstanceOf(IllegalStateException.class); } } diff --git a/src/test/java/com/studypals/global/minio/MinioStorageTest.java b/src/test/java/com/studypals/global/minio/MinioStorageTest.java index a84b5eca..c03e801d 100644 --- a/src/test/java/com/studypals/global/minio/MinioStorageTest.java +++ b/src/test/java/com/studypals/global/minio/MinioStorageTest.java @@ -103,31 +103,32 @@ void createPresignedGetUrl_success() throws Exception { assertThat(args.expiry()).isEqualTo(expiry); } - @Test - void createPresignedPutUrl_success() throws Exception { - // given - String objectKey = "test-object"; - int expiry = 300; - String expectedUrl = "http://presigned-url"; - - given(minioClient.getPresignedObjectUrl(any(GetPresignedObjectUrlArgs.class))) - .willReturn(expectedUrl); - - // when - String actualUrl = minioStorage.createPresignedPutUrl(objectKey, expiry); - - // then - assertThat(actualUrl).isEqualTo(expectedUrl); - - ArgumentCaptor captor = ArgumentCaptor.forClass(GetPresignedObjectUrlArgs.class); - then(minioClient).should(times(1)).getPresignedObjectUrl(captor.capture()); - - GetPresignedObjectUrlArgs args = captor.getValue(); - assertThat(args.bucket()).isEqualTo(TEST_BUCKET); - assertThat(args.object()).isEqualTo(objectKey); - assertThat(args.method()).isEqualTo(Method.PUT); - assertThat(args.expiry()).isEqualTo(expiry); - } + // @Test + // void createPresignedPutUrl_success() throws Exception { + // // given + // String objectKey = "test-object"; + // int expiry = 300; + // String expectedUrl = "http://presigned-url"; + // + // given(minioClient.getPresignedObjectUrl(any(GetPresignedObjectUrlArgs.class))) + // .willReturn(expectedUrl); + // + // // when + // String actualUrl = minioStorage.createPresignedPutUrl(objectKey, expiry); + // + // // then + // assertThat(actualUrl).isEqualTo(expectedUrl); + // + // ArgumentCaptor captor = + // ArgumentCaptor.forClass(GetPresignedObjectUrlArgs.class); + // then(minioClient).should(times(1)).getPresignedObjectUrl(captor.capture()); + // + // GetPresignedObjectUrlArgs args = captor.getValue(); + // assertThat(args.bucket()).isEqualTo(TEST_BUCKET); + // assertThat(args.object()).isEqualTo(objectKey); + // assertThat(args.method()).isEqualTo(Method.PUT); + // assertThat(args.expiry()).isEqualTo(expiry); + // } private String getStoragePath() { return TEST_ENDPOINT + "/" + TEST_BUCKET + "/"; diff --git a/src/test/java/com/studypals/testModules/testSupport/DataJpaSupport.java b/src/test/java/com/studypals/testModules/testSupport/DataJpaSupport.java index 85898b9e..74f97f79 100644 --- a/src/test/java/com/studypals/testModules/testSupport/DataJpaSupport.java +++ b/src/test/java/com/studypals/testModules/testSupport/DataJpaSupport.java @@ -7,6 +7,7 @@ import org.springframework.context.annotation.Import; import com.studypals.domain.memberManage.entity.Member; +import com.studypals.domain.memberManage.entity.MemberProfileImage; import com.studypals.global.config.QueryDslTestConfig; @DataJpaTest @@ -25,11 +26,23 @@ protected Member insertMember() { } protected Member insertMember(String username, String nickname) { - return em.persist(Member.builder() + Member member = em.persist(Member.builder() .username(username) .password("password") .nickname(nickname) - .imageUrl("imageUrl-url") + .build()); + + member.setProfileImage(insertMemberProfileImage(member)); + + return member; + } + + protected MemberProfileImage insertMemberProfileImage(Member member) { + return em.persist(MemberProfileImage.builder() + .member(member) + .objectKey("profile/default" + member.getId() + ".jpg") + .originalFileName("default.jpg") + .mimeType("jpg") .build()); } } From 0722fb59b2dbae9da9789d3a289bdf535b1296d1 Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Tue, 27 Jan 2026 15:08:34 +0900 Subject: [PATCH 22/25] =?UTF-8?q?Docs:=20=EB=AC=B8=EC=84=9C=ED=99=94=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/asciidoc/api/file.adoc | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/asciidoc/api/file.adoc b/src/asciidoc/api/file.adoc index e9931d7e..ce97eed2 100644 --- a/src/asciidoc/api/file.adoc +++ b/src/asciidoc/api/file.adoc @@ -7,14 +7,14 @@ :sectlinks: [[overview]] -== 개요: Presigned URL을 이용한 파일 업로드 +== 개요: 이미지 파일 업로드 -StudyPals의 파일 업로드는 서버의 부하를 줄이고 효율성을 높이기 위해 **Presigned URL** 방식을 사용합니다. 전체적인 프로세스는 다음과 같습니다. +StudyPals는 `multipart/form-data` 형식을 사용하여 이미지 파일을 서버로 직접 업로드합니다. 전체적인 프로세스는 다음과 같습니다. -. *Presigned URL 요청*: 클라이언트가 업로드할 파일의 정보(파일명 등)를 서버에 보내 Presigned URL을 요청합니다. -. *Presigned URL 응답*: 서버는 요청을 검증한 후, 지정된 시간 동안만 유효한 업로드 전용 URL을 생성하여 클라이언트에 반환합니다. 이때 DB에는 파일 메타데이터가 `PENDING` 상태로 미리 저장됩니다. -. *파일 업로드*: 클라이언트는 발급받은 Presigned URL로 스토리지(MinIO)에 직접 파일 데이터를 `HTTP PUT` 요청으로 전송합니다. -. *업로드 완료 처리 (향후 구현)*: 클라이언트는 업로드 성공 후, 서버에 파일의 상태를 `COMPLETE`로 변경해달라고 요청할 수 있습니다. +. *파일 전송*: 클라이언트는 `multipart/form-data` 요청을 통해 이미지 파일과 필요한 메타데이터(채팅방 ID 등)를 서버로 전송합니다. +. *서버 처리*: 서버는 수신한 파일을 검증하고, 스토리지(MinIO)에 업로드합니다. +. *데이터 저장*: 업로드가 성공하면 파일의 메타데이터(경로, 원본 이름 등)를 데이터베이스에 저장합니다. +. *응답 반환*: 저장된 이미지의 식별자(ID)와 접근 가능한 URL을 클라이언트에 반환합니다. [[response-codes]] == 📄 파일 관련 응답 코드 (F01) @@ -22,23 +22,24 @@ StudyPals의 파일 업로드는 서버의 부하를 줄이고 효율성을 높 |=== | 기능 코드 | 설명 -| `FILE_IMAGE_UPLOAD` | 이미지 업로드를 위한 Presigned URL 발급 성공 +| `FILE_IMAGE_UPLOAD` | 이미지 파일 업로드 성공 |=== == ✨ API 문서 -=== 1. 프로필 이미지 업로드 URL 요청 +=== 1. 프로필 이미지 업로드 '''' *Description* + -사용자 프로필 이미지 업로드를 위한 Presigned URL을 발급받습니다. +사용자의 프로필 이미지를 업로드합니다. 기존 프로필 이미지가 존재할 경우, 새로운 이미지로 교체됩니다. - *HTTP Method*: `POST` - *Endpoint*: `/files/image/profile` +- *Content-Type*: `multipart/form-data` *REQUEST* + include::{snippets}/image-file-controller-rest-docs-test/get-profile-upload-url_success/http-request.adoc[] -include::{snippets}/image-file-controller-rest-docs-test/get-profile-upload-url_success/request-fields.adoc[] +include::{snippets}/image-file-controller-rest-docs-test/get-profile-upload-url_success/request-parts.adoc[] *RESPONSE* + include::{snippets}/image-file-controller-rest-docs-test/get-profile-upload-url_success/response-fields.adoc[] @@ -46,19 +47,21 @@ include::{snippets}/image-file-controller-rest-docs-test/get-profile-upload-url_ '''' -=== 2. 채팅 이미지 업로드 URL 요청 +=== 2. 채팅 이미지 업로드 '''' *Description* + -채팅방에서 사용할 이미지 업로드를 위한 Presigned URL을 발급받습니다. +채팅방에서 사용할 이미지를 업로드합니다. - *HTTP Method*: `POST` - *Endpoint*: `/files/image/chat` +- *Content-Type*: `multipart/form-data` - *Requires Authentication*: Yes *REQUEST* + include::{snippets}/image-file-controller-rest-docs-test/get-chat-upload-url_success/http-request.adoc[] -include::{snippets}/image-file-controller-rest-docs-test/get-chat-upload-url_success/request-fields.adoc[] +include::{snippets}/image-file-controller-rest-docs-test/get-chat-upload-url_success/query-parameters.adoc[] +include::{snippets}/image-file-controller-rest-docs-test/get-chat-upload-url_success/request-parts.adoc[] *RESPONSE* + include::{snippets}/image-file-controller-rest-docs-test/get-chat-upload-url_success/response-fields.adoc[] From b37518bba9426a75b5fbf25cc37ff1a2f5aad3c4 Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Tue, 27 Jan 2026 15:12:38 +0900 Subject: [PATCH 23/25] =?UTF-8?q?Docs:=20=EC=A3=BC=EC=84=9D=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/file/api/ImageFileController.java | 2 +- .../global/file/dao/AbstractFileManager.java | 8 -------- .../global/file/dao/AbstractImageManager.java | 16 +++++++++++++++- .../global/file/dto/ImageUploadDto.java | 9 +++++++++ 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/studypals/global/file/api/ImageFileController.java b/src/main/java/com/studypals/global/file/api/ImageFileController.java index 3e466451..00b6f4fd 100644 --- a/src/main/java/com/studypals/global/file/api/ImageFileController.java +++ b/src/main/java/com/studypals/global/file/api/ImageFileController.java @@ -23,7 +23,7 @@ * 클라이언트로부터 파일을 직접 받아 서버에서 스토리지로 업로드를 진행합니다. * *

- *     - POST /files/image/profile : 프로필 사진 업로드를 위한 URL 발급
+ *     - POST /files/image/profile : 프로필 사진 업로드
  *     - POST /files/image/chat : 채팅 사진 업로드
  * 
* diff --git a/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java b/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java index fffe67b2..0ab293db 100644 --- a/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java +++ b/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java @@ -13,14 +13,6 @@ * 이 클래스는 파일 관리에 필요한 공통 기능과 기본 계약을 정의합니다. * 특정 도메인의 파일을 관리하는 모든 구체적인 Manager 클래스(예: {@link AbstractImageManager})는 * 이 클래스를 상속받아야 합니다. - *

- * 주요 역할: - *

    - *
  • {@link ObjectStorage}에 대한 의존성을 가지며, 이를 통해 실제 스토리지 작업을 수행합니다.
  • - *
  • 파일 URL을 이용한 공통 삭제 로직({@link #delete})을 제공합니다.
  • - *
  • 하위 클래스가 어떤 종류의 파일을 처리하는지 명시하도록 {@link #getFileType} 메서드를 강제합니다. - * 이는 다양한 파일 타입의 Manager를 동적으로 선택하는 전략 패턴의 기반이 됩니다.
  • - *
* * @author sleepyhoon * @since 2026-01-14 diff --git a/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java b/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java index 2f295c75..db069af3 100644 --- a/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java +++ b/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java @@ -58,7 +58,21 @@ public final String getPresignedGetUrl(String objectKey) { return objectStorage.createPresignedGetUrl(objectKey, presignedUrlExpireTime); } - // 주석 필요 + /** + * 실제 파일 업로드를 수행하는 공통 메서드입니다. + *

+ * 하위 클래스의 {@code upload} 메서드에서 호출되며, 다음 과정을 수행합니다: + *

    + *
  1. {@link #createObjectKey}를 호출하여 저장 경로(Object Key)를 생성합니다.
  2. + *
  3. 부모 클래스의 {@link AbstractFileManager#upload}를 호출하여 실제 스토리지 업로드를 수행합니다.
  4. + *
  5. 업로드된 결과(Key, URL)를 DTO로 변환하여 반환합니다.
  6. + *
+ * + * @param file 업로드할 멀티파트 파일 + * @param userId 요청한 사용자 ID + * @param targetId 업로드 대상 식별자 (예: 사용자 ID, 채팅방 ID) + * @return 업로드된 파일의 키와 URL 정보를 담은 DTO + */ protected final ImageUploadDto performUpload(MultipartFile file, Long userId, String targetId) { String objectKey = createObjectKey(userId, file.getOriginalFilename(), targetId); String imageUrl = super.upload(file, objectKey); diff --git a/src/main/java/com/studypals/global/file/dto/ImageUploadDto.java b/src/main/java/com/studypals/global/file/dto/ImageUploadDto.java index 3b6e98ff..a0fd6c13 100644 --- a/src/main/java/com/studypals/global/file/dto/ImageUploadDto.java +++ b/src/main/java/com/studypals/global/file/dto/ImageUploadDto.java @@ -1,3 +1,12 @@ package com.studypals.global.file.dto; +/** + * 이미지 파일 업로드 완료 후 반환되는 데이터 전송 객체(DTO)입니다. + *

+ * 스토리지에 저장된 파일의 고유 식별자(Object Key)와 + * 클라이언트가 접근 가능한 URL 정보를 포함합니다. + * + * @param objectKey 스토리지에 저장된 객체의 고유 키 (예: "profile/1/uuid.jpg") + * @param imageUrl 이미지에 접근할 수 있는 전체 URL (예: "http://cdn.example.com/profile/1/uuid.jpg") + */ public record ImageUploadDto(String objectKey, String imageUrl) {} From f8615bff4edeb13d7789704c3008523c0827e5db Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Tue, 27 Jan 2026 16:43:16 +0900 Subject: [PATCH 24/25] =?UTF-8?q?Fix:=20copilot=20PR=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chatManage/entity/ChatImage.java | 2 +- .../chatManage/worker/ChatImageManager.java | 5 ++ .../chatManage/worker/ChatImageWriter.java | 6 +- .../entity/MemberProfileImage.java | 2 +- .../worker/MemberProfileImageManager.java | 6 +- .../worker/MemberProfileImageWriter.java | 19 ++--- .../studypals/global/file/FileProperties.java | 2 +- .../com/studypals/global/file/FileUtils.java | 2 +- .../studypals/global/file/ObjectStorage.java | 1 + .../global/file/dao/AbstractImageManager.java | 14 +++- .../global/file/entity/ImageStatus.java | 2 +- .../file/service/ImageFileServiceImpl.java | 26 +++++-- src/main/resources/application.properties | 3 + .../worker/ChatImageWriterTest.java | 67 +++++++++++++++++ .../worker/MemberProfileImageWriterTest.java | 71 +++++++++++++++++++ .../file/dao/AbstractImageManagerTest.java | 5 ++ .../service/ImageFileServiceImplTest.java | 36 +++++++--- .../testModules/testUtils/CleanUp.java | 3 + 18 files changed, 234 insertions(+), 38 deletions(-) create mode 100644 src/test/java/com/studypals/domain/chatManage/worker/ChatImageWriterTest.java create mode 100644 src/test/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriterTest.java diff --git a/src/main/java/com/studypals/domain/chatManage/entity/ChatImage.java b/src/main/java/com/studypals/domain/chatManage/entity/ChatImage.java index 6b86850c..1d92646f 100644 --- a/src/main/java/com/studypals/domain/chatManage/entity/ChatImage.java +++ b/src/main/java/com/studypals/domain/chatManage/entity/ChatImage.java @@ -31,7 +31,7 @@ * * * @author sleepyhoon - * @since 2024-01-15 + * @since 2026-01-15 * @see ImageFile * @see ChatRoom * @see com.studypals.domain.chatManage.worker.ChatImageManager diff --git a/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java index bf96be4c..321234bd 100644 --- a/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java +++ b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java @@ -82,4 +82,9 @@ public ImageUploadDto upload(MultipartFile file, Long userId, String chatRoomId) // 공통 업로드 로직을 수행하는 부모 클래스의 템플릿 메서드를 호출합니다. return performUpload(file, userId, chatRoomId); } + + @Override + protected boolean usePresignedUrl() { + return true; + } } diff --git a/src/main/java/com/studypals/domain/chatManage/worker/ChatImageWriter.java b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageWriter.java index d8737215..940255c1 100644 --- a/src/main/java/com/studypals/domain/chatManage/worker/ChatImageWriter.java +++ b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageWriter.java @@ -18,7 +18,7 @@ * {@link Transactional} 어노테이션을 통해 데이터 저장 작업의 원자성을 보장합니다. * * @author sleepyhoon - * @since 2024-01-15 + * @since 2026-01-15 * @see ChatImage * @see ChatImageRepository */ @@ -30,8 +30,8 @@ public class ChatImageWriter { /** * 채팅 이미지의 메타데이터를 생성하고 데이터베이스에 저장합니다. *

- * 이 메서드는 클라이언트가 Presigned URL을 통해 스토리지에 파일을 업로드하기 에 호출됩니다. - * 파일이 실제로 업로드되기 전에 데이터베이스에 해당 파일의 존재를 미리 기록하는 역할을 합니다. + * 이 메서드는 클라이언트가 서버를 통해 이미지를 업로드할 때 호출되며, + * 서버가 파일을 처리하고 스토리지에 저장하는 흐름 속에서 해당 파일의 메타데이터를 데이터베이스에 기록합니다. * * @param chatRoom 이미지가 속한 채팅방 엔티티 * @param objectKey 스토리지에 저장될 파일의 고유 객체 키 diff --git a/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java b/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java index 3f550077..27a97742 100644 --- a/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java +++ b/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java @@ -36,7 +36,7 @@ * * * @author sleepyhoon - * @since 2024-01-15 + * @since 2026-01-15 * @see ImageFile * @see Member * @see com.studypals.global.file.service.ImageFileServiceImpl diff --git a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageManager.java b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageManager.java index c0520507..6b8a1201 100644 --- a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageManager.java +++ b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageManager.java @@ -67,7 +67,11 @@ public ImageType getFileType() { * @return ObjectKey와 파일 URL을 담은 ImageUploadDto */ public ImageUploadDto upload(MultipartFile file, Long userId) { - // 공통 업로드 로직을 수행하는 부모 클래스의 템플릿 메서드를 호출합니다. return performUpload(file, userId, String.valueOf(userId)); } + + @Override + protected boolean usePresignedUrl() { + return false; + } } diff --git a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java index 95ecfc37..b408bb84 100644 --- a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java +++ b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java @@ -19,7 +19,7 @@ * {@link Transactional} 어노테이션을 통해 데이터 저장 작업의 원자성을 보장합니다. * * @author sleepyhoon - * @since 2024-01-15 + * @since 2026-01-15 * @see MemberProfileImage * @see MemberProfileImageRepository */ @@ -31,8 +31,8 @@ public class MemberProfileImageWriter { /** * 회원 프로필 이미지의 메타데이터를 생성하고 데이터베이스에 저장합니다. *

- * 이 메서드는 클라이언트가 Presigned URL을 통해 스토리지에 파일을 업로드하기 에 호출됩니다. - * 파일이 실제로 업로드되기 전에 데이터베이스에 해당 파일의 존재를 미리 기록하는 역할을 합니다. + * 이 메서드는 서버가 클라이언트로부터 전달받은 파일을 스토리지에 업로드할 때 호출됩니다. + * 서버가 파일을 업로드하는 과정에서 해당 파일의 메타데이터를 데이터베이스에 저장하는 역할을 합니다. * * @param member 프로필 이미지가 속한 회원 엔티티 * @param objectKey 스토리지에 저장될 파일의 고유 객체 키 @@ -40,24 +40,15 @@ public class MemberProfileImageWriter { * @return 데이터베이스에 저장된 {@link MemberProfileImage}의 고유 ID */ @Transactional - public Long save(Member member, String objectKey, String fileName) { + public MemberProfileImage save(Member member, String objectKey, String fileName) { String extension = FileUtils.extractExtension(fileName); - MemberProfileImage savedImage = memberProfileImageRepository.save(MemberProfileImage.builder() + return memberProfileImageRepository.save(MemberProfileImage.builder() .member(member) .objectKey(objectKey) .originalFileName(fileName) .mimeType(extension) .imageStatus(ImageStatus.PENDING) .build()); - - member.setProfileImage(savedImage); - - return savedImage.getId(); - } - - @Transactional - public Long saveEntity(MemberProfileImage entity) { - return memberProfileImageRepository.save(entity).getId(); } } diff --git a/src/main/java/com/studypals/global/file/FileProperties.java b/src/main/java/com/studypals/global/file/FileProperties.java index 24978488..da98480d 100644 --- a/src/main/java/com/studypals/global/file/FileProperties.java +++ b/src/main/java/com/studypals/global/file/FileProperties.java @@ -17,7 +17,7 @@ * @param extensions 허용되는 파일 확장자 목록. {@code @NotEmpty} 제약조건이 적용되어, 최소 하나 이상의 확장자가 설정되어야 합니다. * @param presignedUrlExpireTime Presigned URL의 만료 시간(초 단위). {@code @Positive} 제약조건이 적용되어, 반드시 양수여야 합니다. * @author sleepyhoon - * @since 2024-01-10 + * @since 2026-01-10 */ @Validated @ConfigurationProperties(prefix = "file.upload") diff --git a/src/main/java/com/studypals/global/file/FileUtils.java b/src/main/java/com/studypals/global/file/FileUtils.java index 1691f92d..2d30eb7b 100644 --- a/src/main/java/com/studypals/global/file/FileUtils.java +++ b/src/main/java/com/studypals/global/file/FileUtils.java @@ -11,7 +11,7 @@ * 외부에서 인스턴스화되는 것을 방지하기 위해 private 생성자를 가집니다. * * @author sleepyhoon - * @since 2024-01-10 + * @since 2026-01-10 */ public final class FileUtils { diff --git a/src/main/java/com/studypals/global/file/ObjectStorage.java b/src/main/java/com/studypals/global/file/ObjectStorage.java index 36410dee..9aa3b367 100644 --- a/src/main/java/com/studypals/global/file/ObjectStorage.java +++ b/src/main/java/com/studypals/global/file/ObjectStorage.java @@ -31,6 +31,7 @@ public interface ObjectStorage { * * @param file 저장할 파일 * @param objectKey 파일을 저장할 경로 + * @return 업로드된 파일에 접근할 수 있는 전체 URL */ String upload(MultipartFile file, String objectKey); diff --git a/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java b/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java index db069af3..c7cbcef7 100644 --- a/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java +++ b/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java @@ -76,6 +76,11 @@ public final String getPresignedGetUrl(String objectKey) { protected final ImageUploadDto performUpload(MultipartFile file, Long userId, String targetId) { String objectKey = createObjectKey(userId, file.getOriginalFilename(), targetId); String imageUrl = super.upload(file, objectKey); + + if (usePresignedUrl()) { + imageUrl = getPresignedGetUrl(objectKey); + } + return new ImageUploadDto(objectKey, imageUrl); } @@ -103,7 +108,7 @@ public final String createObjectKey(Long userId, String fileName, String targetI /** * 파일 이름의 유효성과 확장자를 검증합니다. * 파일 이름이 null이거나 '.'을 포함하지 않는 경우, 또는 허용되지 않은 확장자인 경우 예외를 발생시킵니다. - * + * TODO: 강도높은 유효성 검증을 위해 Tika, ImageIO을 추가로 도입할 수 있습니다. * @param fileName 검증할 파일 이름 * @throws FileException 유효하지 않은 파일 이름 또는 지원하지 않는 확장자인 경우 */ @@ -151,4 +156,11 @@ protected void validateTargetId(Long userId, String targetId) { * @return {@link ImageVariantKey} 리스트 */ protected abstract List variants(); + + /** + * 업로드 완료 후 반환할 URL을 Presigned URL로 할지 여부를 결정합니다. + * + * @return true면 Presigned URL 반환, false면 업로드 시 반환된 URL(Public) 사용 + */ + protected abstract boolean usePresignedUrl(); } diff --git a/src/main/java/com/studypals/global/file/entity/ImageStatus.java b/src/main/java/com/studypals/global/file/entity/ImageStatus.java index ca6da238..bf2aa4e5 100644 --- a/src/main/java/com/studypals/global/file/entity/ImageStatus.java +++ b/src/main/java/com/studypals/global/file/entity/ImageStatus.java @@ -7,7 +7,7 @@ * 특히 리사이징 실패 시 재시도 로직을 위한 상태 구분에 활용됩니다. * * @author sleepyhoon - * @since 2024-01-15 + * @since 2026-01-15 */ public enum ImageStatus { /** diff --git a/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java b/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java index 087106f8..d6b2ad9c 100644 --- a/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java +++ b/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java @@ -6,6 +6,9 @@ import java.util.stream.Collectors; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.multipart.MultipartFile; import com.studypals.domain.chatManage.entity.ChatRoom; @@ -115,6 +118,7 @@ public ImageFileServiceImpl( * @return 생성된 이미지 ID와 접근 URL이 포함된 응답 DTO */ @Override + @Transactional public ImageUploadRes uploadProfileImage(MultipartFile file, Long userId) { MemberProfileImageManager manager = getManager(ImageType.PROFILE_IMAGE, MemberProfileImageManager.class); @@ -132,13 +136,26 @@ public ImageUploadRes uploadProfileImage(MultipartFile file, Long userId) { String oldObjectKey = currentProfile.getObjectKey(); // 삭제할 키 미리 백업 currentProfile.update(uploadDto.objectKey(), file.getOriginalFilename(), extension); - imageId = profileImageWriter.saveEntity(currentProfile); // 더티 체킹을 하지 않으므로 명시적 저장 - // 3. [MinIO] 모든 처리가 끝난 후 -> 기존 파일 삭제 - manager.delete(oldObjectKey); + imageId = currentProfile.getId(); + + // 성공적으로 수정이 되었을 때만 minio에서 기존 프로필을 delete + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + manager.delete(oldObjectKey); + } + }); + } } else { // [DB] 기존 프로필이 없으면 그냥 저장 - imageId = profileImageWriter.save(member, uploadDto.objectKey(), file.getOriginalFilename()); + MemberProfileImage savedImage = + profileImageWriter.save(member, uploadDto.objectKey(), file.getOriginalFilename()); + + imageId = savedImage.getId(); + + member.setProfileImage(savedImage); } return new ImageUploadRes(imageId, uploadDto.imageUrl()); @@ -160,6 +177,7 @@ public ImageUploadRes uploadProfileImage(MultipartFile file, Long userId) { * @return 생성된 이미지 ID와 접근 URL이 포함된 응답 DTO */ @Override + @Transactional public ImageUploadRes uploadChatImage(MultipartFile file, String chatRoomId, Long userId) { ChatImageManager manager = getManager(ImageType.CHAT_IMAGE, ChatImageManager.class); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index aa6a9b53..a20353bb 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -75,3 +75,6 @@ chat.subscribe.address.default=/sub/chat/room/ # =============================== file.upload.extensions=jpg,jpeg,png,bmp,webp file.upload.presigned-url-expire-time=600 + +spring.servlet.multipart.max-file-size=10MB +spring.servlet.multipart.max-request-size=12MB \ No newline at end of file diff --git a/src/test/java/com/studypals/domain/chatManage/worker/ChatImageWriterTest.java b/src/test/java/com/studypals/domain/chatManage/worker/ChatImageWriterTest.java new file mode 100644 index 00000000..cae7e5ff --- /dev/null +++ b/src/test/java/com/studypals/domain/chatManage/worker/ChatImageWriterTest.java @@ -0,0 +1,67 @@ +package com.studypals.domain.chatManage.worker; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.studypals.domain.chatManage.dao.ChatImageRepository; +import com.studypals.domain.chatManage.entity.ChatImage; +import com.studypals.domain.chatManage.entity.ChatRoom; +import com.studypals.global.file.entity.ImageStatus; + +@ExtendWith(MockitoExtension.class) +class ChatImageWriterTest { + + @InjectMocks + private ChatImageWriter chatImageWriter; + + @Mock + private ChatImageRepository chatImageRepository; + + @Test + @DisplayName("채팅 이미지 메타데이터 저장에 성공한다") + void save_Success() { + // given + ChatRoom chatRoom = mock(ChatRoom.class); + String objectKey = "chat/room1/uuid.jpg"; + String fileName = "chat-image.jpg"; + Long expectedImageId = 100L; + + ChatImage savedImage = ChatImage.builder() + .id(expectedImageId) + .chatRoom(chatRoom) + .objectKey(objectKey) + .originalFileName(fileName) + .mimeType("jpg") + .imageStatus(ImageStatus.PENDING) + .build(); + + given(chatImageRepository.save(any(ChatImage.class))).willReturn(savedImage); + + // when + Long actualImageId = chatImageWriter.save(chatRoom, objectKey, fileName); + + // then + assertThat(actualImageId).isEqualTo(expectedImageId); + + ArgumentCaptor imageCaptor = ArgumentCaptor.forClass(ChatImage.class); + verify(chatImageRepository).save(imageCaptor.capture()); + + ChatImage capturedImage = imageCaptor.getValue(); + assertThat(capturedImage.getChatRoom()).isEqualTo(chatRoom); + assertThat(capturedImage.getObjectKey()).isEqualTo(objectKey); + assertThat(capturedImage.getOriginalFileName()).isEqualTo(fileName); + assertThat(capturedImage.getMimeType()).isEqualTo("jpg"); + assertThat(capturedImage.getImageStatus()).isEqualTo(ImageStatus.PENDING); + } +} diff --git a/src/test/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriterTest.java b/src/test/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriterTest.java new file mode 100644 index 00000000..a3443487 --- /dev/null +++ b/src/test/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriterTest.java @@ -0,0 +1,71 @@ +package com.studypals.domain.memberManage.worker; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.studypals.domain.memberManage.dao.MemberProfileImageRepository; +import com.studypals.domain.memberManage.entity.Member; +import com.studypals.domain.memberManage.entity.MemberProfileImage; +import com.studypals.global.file.entity.ImageStatus; + +@ExtendWith(MockitoExtension.class) +class MemberProfileImageWriterTest { + + @InjectMocks + private MemberProfileImageWriter memberProfileImageWriter; + + @Mock + private MemberProfileImageRepository memberProfileImageRepository; + + @Test + @DisplayName("프로필 이미지 메타데이터 저장에 성공하고, Member 객체에 연관관계를 설정한다") + void save_Success() { + // given + Member member = Member.builder().id(1L).build(); + String objectKey = "profile/1/uuid.jpg"; + String fileName = "my-profile.jpg"; + Long expectedImageId = 99L; + + MemberProfileImage expectedImage = MemberProfileImage.builder() + .id(expectedImageId) + .member(member) + .objectKey(objectKey) + .originalFileName(fileName) + .mimeType("jpg") + .imageStatus(ImageStatus.PENDING) + .build(); + + given(memberProfileImageRepository.save(any(MemberProfileImage.class))).willReturn(expectedImage); + + // when + MemberProfileImage savedImage = memberProfileImageWriter.save(member, objectKey, fileName); + + // then + assertThat(savedImage.getMember()).isEqualTo(expectedImage.getMember()); + assertThat(savedImage.getObjectKey()).isEqualTo(expectedImage.getObjectKey()); + assertThat(savedImage.getOriginalFileName()).isEqualTo(expectedImage.getOriginalFileName()); + assertThat(savedImage.getMimeType()).isEqualTo(expectedImage.getMimeType()); + assertThat(savedImage.getImageStatus()).isEqualTo(expectedImage.getImageStatus()); + + // repository.save()에 전달된 인자 캡처 및 검증 + ArgumentCaptor imageCaptor = ArgumentCaptor.forClass(MemberProfileImage.class); + verify(memberProfileImageRepository).save(imageCaptor.capture()); + MemberProfileImage capturedImage = imageCaptor.getValue(); + + assertThat(capturedImage.getMember()).isEqualTo(member); + assertThat(capturedImage.getObjectKey()).isEqualTo(objectKey); + assertThat(capturedImage.getOriginalFileName()).isEqualTo(fileName); + assertThat(capturedImage.getMimeType()).isEqualTo("jpg"); + assertThat(capturedImage.getImageStatus()).isEqualTo(ImageStatus.PENDING); + } +} diff --git a/src/test/java/com/studypals/global/file/dao/AbstractImageManagerTest.java b/src/test/java/com/studypals/global/file/dao/AbstractImageManagerTest.java index c5909866..546b943a 100644 --- a/src/test/java/com/studypals/global/file/dao/AbstractImageManagerTest.java +++ b/src/test/java/com/studypals/global/file/dao/AbstractImageManagerTest.java @@ -51,6 +51,11 @@ protected List variants() { public ImageType getFileType() { return ImageType.PROFILE_IMAGE; } + + @Override + public boolean usePresignedUrl() { + return false; + } } @BeforeEach diff --git a/src/test/java/com/studypals/global/file/service/ImageFileServiceImplTest.java b/src/test/java/com/studypals/global/file/service/ImageFileServiceImplTest.java index 89d94712..0a08fa52 100644 --- a/src/test/java/com/studypals/global/file/service/ImageFileServiceImplTest.java +++ b/src/test/java/com/studypals/global/file/service/ImageFileServiceImplTest.java @@ -11,12 +11,15 @@ import java.util.List; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.multipart.MultipartFile; import com.studypals.domain.chatManage.entity.ChatRoom; @@ -31,6 +34,7 @@ import com.studypals.global.file.dao.AbstractImageManager; import com.studypals.global.file.dto.ImageUploadDto; import com.studypals.global.file.dto.ImageUploadRes; +import com.studypals.global.file.entity.ImageStatus; import com.studypals.global.file.entity.ImageType; @ExtendWith(MockitoExtension.class) @@ -61,6 +65,7 @@ class ImageFileServiceImplTest { @BeforeEach void setUp() { + TransactionSynchronizationManager.initSynchronization(); given(profileImageManager.getFileType()).willReturn(ImageType.PROFILE_IMAGE); given(chatImageManager.getFileType()).willReturn(ImageType.CHAT_IMAGE); @@ -72,6 +77,11 @@ void setUp() { chatImageWriter); } + @AfterEach + void tearDown() { + TransactionSynchronizationManager.clear(); + } + @Test @DisplayName("프로필 이미지 업로드 - 성공 (기존 프로필 없음)") void uploadProfileImage_Success_NoExistingProfile() { @@ -80,25 +90,33 @@ void uploadProfileImage_Success_NoExistingProfile() { String originalFilename = "test.jpg"; String objectKey = "profile/1/uuid.jpg"; String imageUrl = "http://test.com/profile/1/uuid.jpg"; + Member member = Member.builder().id(1L).build(); + + Long expectedImageId = 99L; + MemberProfileImage expectedImage = MemberProfileImage.builder() + .id(expectedImageId) + .member(member) + .objectKey(objectKey) + .originalFileName(originalFilename) + .mimeType("jpg") + .imageStatus(ImageStatus.PENDING) + .build(); given(multipartFile.getOriginalFilename()).willReturn(originalFilename); ImageUploadDto uploadDto = new ImageUploadDto(objectKey, imageUrl); given(profileImageManager.upload(multipartFile, userId)).willReturn(uploadDto); - Member member = mock(Member.class); given(memberReader.get(userId)).willReturn(member); - given(member.getProfileImage()).willReturn(null); - Long savedImageId = 10L; given(profileImageWriter.save(eq(member), eq(objectKey), eq(originalFilename))) - .willReturn(savedImageId); + .willReturn(expectedImage); // when ImageUploadRes res = imageFileService.uploadProfileImage(multipartFile, userId); // then - assertThat(res.imageId()).isEqualTo(savedImageId); + assertThat(res.imageId()).isEqualTo(expectedImageId); assertThat(res.imageUrl()).isEqualTo(imageUrl); verify(profileImageManager).upload(multipartFile, userId); @@ -128,18 +146,16 @@ void uploadProfileImage_Success_ExistingProfile() { given(member.getProfileImage()).willReturn(existingProfile); given(existingProfile.getObjectKey()).willReturn(oldObjectKey); - Long updatedImageId = 10L; - given(profileImageWriter.saveEntity(existingProfile)).willReturn(updatedImageId); - // when ImageUploadRes res = imageFileService.uploadProfileImage(multipartFile, userId); + // 트랜잭션 커밋 후 동작(파일 삭제)을 검증하기 위해 수동으로 트리거 + TransactionSynchronizationManager.getSynchronizations().forEach(TransactionSynchronization::afterCommit); + // then - assertThat(res.imageId()).isEqualTo(updatedImageId); assertThat(res.imageUrl()).isEqualTo(newImageUrl); verify(existingProfile).update(eq(newObjectKey), eq(originalFilename), anyString()); - verify(profileImageWriter).saveEntity(existingProfile); verify(profileImageManager).delete(oldObjectKey); } diff --git a/src/test/java/com/studypals/testModules/testUtils/CleanUp.java b/src/test/java/com/studypals/testModules/testUtils/CleanUp.java index f5eff81e..47e88721 100644 --- a/src/test/java/com/studypals/testModules/testUtils/CleanUp.java +++ b/src/test/java/com/studypals/testModules/testUtils/CleanUp.java @@ -4,6 +4,8 @@ import java.util.Set; import java.util.stream.Collectors; +import javax.sql.DataSource; + import jakarta.persistence.Entity; import jakarta.persistence.EntityManager; import jakarta.persistence.Table; @@ -27,6 +29,7 @@ public class CleanUp { private final JdbcTemplate jdbcTemplate; private final EntityManager entityManager; private final StringRedisTemplate stringRedisTemplate; + private final DataSource dataSource; @Transactional public void all() { From 643f05f18ff15cbe35d9a6053ad4b7342f69a98e Mon Sep 17 00:00:00 2001 From: sleepyhoon Date: Tue, 27 Jan 2026 16:59:40 +0900 Subject: [PATCH 25/25] =?UTF-8?q?Fix:=20=EB=AC=B8=EC=84=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 --- .../file/restDocsTest/ImageFileControllerRestDocsTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/studypals/global/file/restDocsTest/ImageFileControllerRestDocsTest.java b/src/test/java/com/studypals/global/file/restDocsTest/ImageFileControllerRestDocsTest.java index 76a9d173..cb86ae0f 100644 --- a/src/test/java/com/studypals/global/file/restDocsTest/ImageFileControllerRestDocsTest.java +++ b/src/test/java/com/studypals/global/file/restDocsTest/ImageFileControllerRestDocsTest.java @@ -85,7 +85,7 @@ void getProfileUploadUrl_success() throws Exception { void getChatUploadUrl_success() throws Exception { // given Long imageId = 1L; - String imageUrl = "http://example.com/image.jpg"; + String imageUrl = "http://example.com/presigned-url-image.jpg"; ImageUploadRes response = new ImageUploadRes(imageId, imageUrl); given(imageFileService.uploadChatImage(any(MultipartFile.class), any(), any())) @@ -113,6 +113,6 @@ void getChatUploadUrl_success() throws Exception { fieldWithPath("status").description("응답 상태"), fieldWithPath("message").description("응답 메시지"), fieldWithPath("data.imageId").description("이미지 파일의 식별 ID"), - fieldWithPath("data.imageUrl").description("저장된 이미지 주소")))); + fieldWithPath("data.imageUrl").description("저장된 이미지를 조회할 presigned url")))); } }