latestInfos) {
String chatRoomId = chatRoomMember.getChatRoom().getId();
ChatroomLatestInfo info = latestInfos.get(chatRoomId);
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..1d92646f
--- /dev/null
+++ b/src/main/java/com/studypals/domain/chatManage/entity/ChatImage.java
@@ -0,0 +1,51 @@
+package com.studypals.domain.chatManage.entity;
+
+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;
+import lombok.NoArgsConstructor;
+import lombok.experimental.SuperBuilder;
+
+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 2026-01-15
+ * @see ImageFile
+ * @see ChatRoom
+ * @see com.studypals.domain.chatManage.worker.ChatImageManager
+ */
+@Entity
+@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)
+ @JoinColumn(name = "chat_room_id", nullable = false)
+ private ChatRoom chatRoom;
+}
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/chatManage/worker/ChatImageManager.java b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java
index f30640c0..92eab5b1 100644
--- a/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java
+++ b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java
@@ -1,14 +1,19 @@
package com.studypals.domain.chatManage.worker;
+import java.util.List;
import java.util.UUID;
import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+import com.studypals.domain.chatManage.entity.ChatRoom;
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;
+import com.studypals.global.file.entity.ImageVariantKey;
/**
* 파일 중 채팅 이미지를 처리하는데 사용하는 구체 클래스입니다.
@@ -21,17 +26,23 @@
* {@link AbstractImageManager}
*
* @author sleepyhoon
- * @See AbstractImageManager
+ * @see AbstractImageManager
* @since 2026-01-13
*/
@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;
+ private final ChatImageWriter chatImageWriter;
- public ChatImageManager(ObjectStorage objectStorage, ChatRoomReader chatRoomReader) {
- super(objectStorage);
+ public ChatImageManager(
+ ObjectStorage objectStorage,
+ FileProperties properties,
+ ChatRoomReader chatRoomReader,
+ ChatImageWriter chatImageWriter) {
+ super(objectStorage, properties);
this.chatRoomReader = chatRoomReader;
+ this.chatImageWriter = chatImageWriter;
}
/**
@@ -51,12 +62,30 @@ protected String generateObjectKeyDetail(String chatRoomId, String ext) {
return CHAT_IMAGE_PATH + "/" + chatRoomId + "/" + UUID.randomUUID() + "." + ext;
}
- /**
- * 이 클래스는 채팅 이미지를 처리합니다.
- * @return 처리하는 이미지 종류
- */
@Override
- public ImageType getFileType() {
+ @Transactional
+ protected Long saveImage(Long userId, String chatRoomId, String objectKey, String originalFileName) {
+ ChatRoom chatRoom = chatRoomReader.getById(chatRoomId);
+ return chatImageWriter.save(chatRoom, objectKey, originalFileName);
+ }
+
+ @Override
+ protected List variants() {
+ return List.of(ImageVariantKey.SMALL, ImageVariantKey.MEDIUM, ImageVariantKey.LARGE);
+ }
+
+ @Override
+ public ImageType getType() {
return ImageType.CHAT_IMAGE;
}
+
+ @Override
+ public boolean supports(ImageType type) {
+ return type == getType();
+ }
+
+ @Override
+ protected boolean usePresignedUrl() {
+ return true; // 채팅 이미지는 Pre-Signed URL 사용
+ }
}
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..f079711b
--- /dev/null
+++ b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageWriter.java
@@ -0,0 +1,43 @@
+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 클래스입니다.
+ *
+ * 이 클래스는 CQRS(Command Query Responsibility Segregation) 패턴의 'Command' 측면을 담당하며,
+ * 시스템의 상태를 변경하는 '쓰기(Write)' 작업에만 집중합니다.
+ * {@link Transactional} 어노테이션을 통해 데이터 저장 작업의 원자성을 보장합니다.
+ *
+ * @author sleepyhoon
+ * @since 2026-01-15
+ * @see ChatImage
+ * @see ChatImageRepository
+ */
+@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();
+ }
+}
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/domain/memberManage/api/MemberController.java b/src/main/java/com/studypals/domain/memberManage/api/MemberController.java
index 9bff73f6..1f44abb0 100644
--- a/src/main/java/com/studypals/domain/memberManage/api/MemberController.java
+++ b/src/main/java/com/studypals/domain/memberManage/api/MemberController.java
@@ -2,14 +2,18 @@
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.*;
+import org.springframework.web.multipart.MultipartFile;
import lombok.RequiredArgsConstructor;
import com.studypals.domain.memberManage.dto.*;
import com.studypals.domain.memberManage.service.MemberService;
+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;
import com.studypals.global.responses.ResponseCode;
@@ -21,6 +25,8 @@
* - POST /register : 회원가입({@link CreateMemberReq})
* - GET /profile : 프로필 조회
* - PUT /profile : 프로필 수정 ({@link UpdateProfileReq})
+ * - POST /profile : 프로필 이미지 업로드
+ * - GET /register/check : 중복 체크
*
*
*
@@ -31,6 +37,7 @@
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
+ private final ImageFileService imageFileService;
@PostMapping("/register")
public ResponseEntity> register(@Valid @RequestBody CreateMemberReq req) {
@@ -54,6 +61,13 @@ public ResponseEntity> updateProfile(
return ResponseEntity.ok(CommonResponse.success(ResponseCode.USER_UPDATE, id, "프로필 갱신을 성공하였습니다."));
}
+ @PostMapping(value = "/profile/image", 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));
+ }
+
@GetMapping("/register/check")
public ResponseEntity> checkAvailability(
@RequestParam(required = false) String username, @RequestParam(required = false) String nickname) {
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/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 610fe2cc..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,8 +48,9 @@ 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;
@Column(name = "created_at")
@CreatedDate
@@ -70,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;
+ }
+
+ /**
+ * 프로필 이미지의 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
new file mode 100644
index 00000000..27a97742
--- /dev/null
+++ b/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java
@@ -0,0 +1,76 @@
+package com.studypals.domain.memberManage.entity;
+
+import java.time.LocalDateTime;
+
+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 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 2026-01-15
+ * @see ImageFile
+ * @see Member
+ * @see com.studypals.global.file.service.ImageFileServiceImpl
+ */
+@Entity
+@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)
+ @JoinColumn(name = "user_id", nullable = false)
+ private Member member;
+
+ @LastModifiedDate
+ 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/service/MemberServiceImpl.java b/src/main/java/com/studypals/domain/memberManage/service/MemberServiceImpl.java
index b44422ff..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,7 +67,7 @@ public Long getMemberIdByUsername(String username) {
public Long updateProfile(Long userId, UpdateProfileReq dto) {
Member member = memberReader.get(userId);
- member.updateProfile(dto.birthday(), dto.position(), dto.imageUrl());
+ member.updateProfile(dto.birthday(), dto.position());
memberWriter.save(member);
@@ -75,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
diff --git a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageManager.java b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageManager.java
new file mode 100644
index 00000000..8a372fd5
--- /dev/null
+++ b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageManager.java
@@ -0,0 +1,109 @@
+package com.studypals.domain.memberManage.worker;
+
+import java.util.List;
+import java.util.UUID;
+
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import com.studypals.domain.memberManage.entity.Member;
+import com.studypals.domain.memberManage.entity.MemberProfileImage;
+import com.studypals.global.file.FileProperties;
+import com.studypals.global.file.FileUtils;
+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;
+
+/**
+ * 파일 중 프로필 이미지를 처리하는데 사용하는 구체 클래스입니다.
+ *
+ *
+ * - 프로필 이미지 업로드를 위해 Presigned URL을 사용합니다.
+ * - 프로필 이미지 조회를 위해 Public URL을 사용합니다.
+ *
+ *
상속 구조
+ * {@link AbstractImageManager}
+ *
+ * @author sleepyhoon
+ * @See AbstractImageManager
+ * @since 2026-01-13
+ */
+@Component
+public class MemberProfileImageManager extends AbstractImageManager {
+
+ private static final String PROFILE_IMAGE_PATH = "origin/profile";
+
+ private final MemberReader memberReader;
+ private final MemberProfileImageWriter memberProfileImageWriter;
+
+ public MemberProfileImageManager(
+ ObjectStorage objectStorage,
+ FileProperties properties,
+ MemberReader memberReader,
+ MemberProfileImageWriter memberProfileImageWriter) {
+ super(objectStorage, properties);
+ this.memberReader = memberReader;
+ this.memberProfileImageWriter = memberProfileImageWriter;
+ }
+
+ @Override
+ protected String generateObjectKeyDetail(String targetId, String ext) {
+ // targetId는 userId
+ return PROFILE_IMAGE_PATH + "/" + targetId + "/" + UUID.randomUUID() + "." + ext;
+ }
+
+ @Override
+ @Transactional
+ protected Long saveImage(Long userId, String targetId, String objectKey, String originalFileName) {
+ Member member = memberReader.get(userId);
+ MemberProfileImage currentProfile = member.getProfileImage();
+
+ if (currentProfile != null) {
+ // 기존 정보가 있으면 -> DB 업데이트 및 기존 파일 삭제 예약
+ String oldObjectKey = currentProfile.getObjectKey();
+ String extension = FileUtils.extractExtension(originalFileName);
+
+ currentProfile.update(objectKey, originalFileName, extension);
+
+ // 트랜잭션 커밋 성공 시 스토리지에서 구버전 파일 삭제
+ if (TransactionSynchronizationManager.isSynchronizationActive()) {
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+ @Override
+ public void afterCommit() {
+ delete(oldObjectKey);
+ }
+ });
+ }
+ return currentProfile.getId();
+ } else {
+ // 기존 프로필이 없으면 새로 저장
+ MemberProfileImage savedImage = memberProfileImageWriter.save(member, objectKey, originalFileName);
+
+ member.setProfileImage(savedImage);
+ return savedImage.getId();
+ }
+ }
+
+ @Override
+ protected List variants() {
+ return List.of(ImageVariantKey.SMALL, ImageVariantKey.MEDIUM);
+ }
+
+ @Override
+ public ImageType getType() {
+ return ImageType.PROFILE_IMAGE;
+ }
+
+ @Override
+ public boolean supports(ImageType type) {
+ return type == getType();
+ }
+
+ @Override
+ protected boolean usePresignedUrl() {
+ return false; // 프로필은 Public URL 사용
+ }
+}
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..b408bb84
--- /dev/null
+++ b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java
@@ -0,0 +1,54 @@
+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;
+import com.studypals.global.file.entity.ImageStatus;
+
+/**
+ * 회원 프로필 이미지의 메타데이터를 데이터베이스에 저장하는 역할을 전담하는 Worker 클래스입니다.
+ *
+ * 이 클래스는 CQRS(Command Query Responsibility Segregation) 패턴의 'Command' 측면을 담당하며,
+ * 시스템의 상태를 변경하는 '쓰기(Write)' 작업에만 집중합니다.
+ * {@link Transactional} 어노테이션을 통해 데이터 저장 작업의 원자성을 보장합니다.
+ *
+ * @author sleepyhoon
+ * @since 2026-01-15
+ * @see MemberProfileImage
+ * @see MemberProfileImageRepository
+ */
+@Worker
+@RequiredArgsConstructor
+public class MemberProfileImageWriter {
+ private final MemberProfileImageRepository memberProfileImageRepository;
+
+ /**
+ * 회원 프로필 이미지의 메타데이터를 생성하고 데이터베이스에 저장합니다.
+ *
+ * 이 메서드는 서버가 클라이언트로부터 전달받은 파일을 스토리지에 업로드할 때 호출됩니다.
+ * 서버가 파일을 업로드하는 과정에서 해당 파일의 메타데이터를 데이터베이스에 저장하는 역할을 합니다.
+ *
+ * @param member 프로필 이미지가 속한 회원 엔티티
+ * @param objectKey 스토리지에 저장될 파일의 고유 객체 키
+ * @param fileName 원본 파일의 이름
+ * @return 데이터베이스에 저장된 {@link MemberProfileImage}의 고유 ID
+ */
+ @Transactional
+ public MemberProfileImage save(Member member, String objectKey, String fileName) {
+ String extension = FileUtils.extractExtension(fileName);
+
+ return memberProfileImageRepository.save(MemberProfileImage.builder()
+ .member(member)
+ .objectKey(objectKey)
+ .originalFileName(fileName)
+ .mimeType(extension)
+ .imageStatus(ImageStatus.PENDING)
+ .build());
+ }
+}
diff --git a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileManager.java b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileManager.java
deleted file mode 100644
index d0cad9e8..00000000
--- a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileManager.java
+++ /dev/null
@@ -1,51 +0,0 @@
-package com.studypals.domain.memberManage.worker;
-
-import java.util.UUID;
-
-import org.springframework.stereotype.Component;
-
-import com.studypals.global.file.ObjectStorage;
-import com.studypals.global.file.dao.AbstractImageManager;
-import com.studypals.global.file.entity.ImageType;
-
-/**
- * 파일 중 프로필 이미지를 처리하는데 사용하는 구체 클래스입니다.
- *
- *
- * - 프로필 이미지 업로드를 위해 Presigned URL을 사용합니다.
- * - 프로필 이미지 조회를 위해 Public URL을 사용합니다.
- *
- *
상속 구조
- * {@link AbstractImageManager}
- *
- * @author sleepyhoon
- * @See AbstractImageManager
- * @since 2026-01-13
- */
-@Component
-public class MemberProfileManager extends AbstractImageManager {
-
- private static final String PROFILE_PATH = "profile";
-
- public MemberProfileManager(ObjectStorage objectStorage) {
- super(objectStorage);
- }
-
- /**
- * 프로필 사진을 저장할 경로(objectKey) 지정합니다.
- * @return 프로필 사진 조회에 필요한 경로(objectKey) 반환
- */
- @Override
- protected String generateObjectKeyDetail(String targetId, String ext) {
- return PROFILE_PATH + "/" + targetId + "/" + UUID.randomUUID() + "." + ext;
- }
-
- /**
- * 이 클래스는 프로필 이미지를 처리합니다.
- * @return 처리하는 이미지 종류
- */
- @Override
- public ImageType getFileType() {
- return ImageType.PROFILE_IMAGE;
- }
-}
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..da98480d
--- /dev/null
+++ b/src/main/java/com/studypals/global/file/FileProperties.java
@@ -0,0 +1,24 @@
+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;
+
+/**
+ * 파일 업로드 관련 설정을 담는 {@link ConfigurationProperties} 클래스입니다.
+ *
+ * 이 클래스는 {@code application.yml} 파일의 {@code file.upload} 접두사를 가진 프로퍼티들을
+ * 타입 안전하게 바인딩합니다.
+ *
+ * @param extensions 허용되는 파일 확장자 목록. {@code @NotEmpty} 제약조건이 적용되어, 최소 하나 이상의 확장자가 설정되어야 합니다.
+ * @param presignedUrlExpireTime Presigned URL의 만료 시간(초 단위). {@code @Positive} 제약조건이 적용되어, 반드시 양수여야 합니다.
+ * @author sleepyhoon
+ * @since 2026-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
new file mode 100644
index 00000000..37e2df20
--- /dev/null
+++ b/src/main/java/com/studypals/global/file/FileType.java
@@ -0,0 +1,25 @@
+package com.studypals.global.file;
+
+import com.studypals.global.file.entity.ImageType;
+
+/**
+ * 시스템에서 다루는 모든 파일의 종류를 나타내기 위한 최상위 마커(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
new file mode 100644
index 00000000..2d30eb7b
--- /dev/null
+++ b/src/main/java/com/studypals/global/file/FileUtils.java
@@ -0,0 +1,44 @@
+package com.studypals.global.file;
+
+import com.studypals.global.exceptions.errorCode.FileErrorCode;
+import com.studypals.global.exceptions.exception.FileException;
+
+/**
+ * 파일 관련 유틸리티 메서드를 제공하는 클래스입니다.
+ *
+ * 이 클래스의 모든 메서드는 상태를 가지지 않는 순수 함수 형태의 static 메서드입니다.
+ * 따라서 별도의 상태 관리나 의존성 주입이 필요 없어 Spring의 빈(Bean)으로 등록하지 않습니다.
+ * 외부에서 인스턴스화되는 것을 방지하기 위해 private 생성자를 가집니다.
+ *
+ * @author sleepyhoon
+ * @since 2026-01-10
+ */
+public final class FileUtils {
+
+ /**
+ * {@link FileUtils}는 유틸리티 클래스이므로 인스턴스화할 수 없습니다.
+ */
+ private FileUtils() {
+ throw new UnsupportedOperationException("This is a utility class and cannot be instantiated");
+ }
+
+ /**
+ * 파일 이름에서 확장자를 추출합니다.
+ *
+ * 파일 이름에 점(.)이 없거나 마지막 문자가 점인 경우, 빈 문자열을 반환합니다.
+ * 추출된 확장자는 모두 소문자로 변환됩니다.
+ *
+ * @param fileName 파일 이름 (예: "image.JPG", "document.pdf")
+ * @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 ""; // 확장자가 없는 경우 처리
+ }
+ return fileName.substring(lastDotIndex + 1).toLowerCase();
+ }
+}
diff --git a/src/main/java/com/studypals/global/file/ObjectStorage.java b/src/main/java/com/studypals/global/file/ObjectStorage.java
index 947ea39c..9aa3b367 100644
--- a/src/main/java/com/studypals/global/file/ObjectStorage.java
+++ b/src/main/java/com/studypals/global/file/ObjectStorage.java
@@ -1,23 +1,67 @@
package com.studypals.global.file;
+import org.springframework.web.multipart.MultipartFile;
+
/**
- * Object Storage 의 인터페이스입니다. 메서드를 정의합니다.
- *
- *
확장성을 고려해 스토리지 관련 메서드를 인터페이스로 분리했습니다.
- *
- *
상속 정보:
- * MinioStorage의 부모 인터페이스입니다.
+ * Object Storage와의 상호작용을 위한 표준 인터페이스를 정의합니다.
+ *
+ * 이 인터페이스는 파일(객체)의 삭제, 경로 분석, Presigned URL 생성 등
+ * 객체 스토리지에서 수행해야 하는 핵심 기능들을 추상화합니다.
+ * 실제 구현체(예: {@code MinioStorage})는 이 인터페이스를 구현하여
+ * 특정 스토리지 기술(MinIO, AWS S3 등)에 대한 구체적인 로직을 제공합니다.
+ *
+ * 이를 통해 서비스 로직은 실제 스토리지 구현에 대한 의존성을 낮추고,
+ * 향후 다른 스토리지 시스템으로 유연하게 교체할 수 있습니다.
*
- * @author s0o0bn
+ * @author s0o0bn, sleepyhoon
* @since 2025-04-11
*/
public interface ObjectStorage {
- void delete(String destination);
+ /**
+ * objectKey에서 fileUrl로 변환해줍니다.
+ *
+ * @param objectKey
+ * @return 클라이언트에서 바로 접근할 수 있는 파일 경로
+ */
+ String convertKeyToFileUrl(String objectKey);
+ /**
+ * 스토리지에 파일을 저장합니다.
+ *
+ * @param file 저장할 파일
+ * @param objectKey 파일을 저장할 경로
+ * @return 업로드된 파일에 접근할 수 있는 전체 URL
+ */
+ String upload(MultipartFile file, String objectKey);
+
+ /**
+ * 스토리지에서 지정된 객체(파일)를 삭제합니다.
+ *
+ * @param objectKey 삭제할 객체의 경로
+ */
+ 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);
-
- 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
deleted file mode 100644
index ad7d1a7e..00000000
--- a/src/main/java/com/studypals/global/file/api/ImageFileController.java
+++ /dev/null
@@ -1,53 +0,0 @@
-package com.studypals.global.file.api;
-
-import jakarta.validation.Valid;
-
-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.RestController;
-
-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.service.ImageFileService;
-import com.studypals.global.responses.CommonResponse;
-import com.studypals.global.responses.Response;
-import com.studypals.global.responses.ResponseCode;
-
-/**
- * 파일 관련 로직을 처리하는 컨트롤러입니다.
- * 파일 업로드는 서버 측에서 presigned url을 발급하고 클라이언트 측에서 진행합니다.
- *
- *
- * - POST /files/image/profile : 프로필 사진 업로드를 위한 URL 발급
- * - POST /files/image/chat : 채팅 사진 업로드를 위한 URL 발급
- *
- *
- * @author sleepyhoon
- * @since 2026-01-10
- */
-@RestController
-@RequestMapping("/files/image")
-@RequiredArgsConstructor
-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);
- 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);
- 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 56944649..cee3d1e0 100644
--- a/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java
+++ b/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java
@@ -1,50 +1,61 @@
package com.studypals.global.file.dao;
+import org.springframework.web.multipart.MultipartFile;
+
import lombok.RequiredArgsConstructor;
+import com.studypals.global.file.FileType;
import com.studypals.global.file.ObjectStorage;
-import com.studypals.global.file.entity.FileType;
/**
- * 파일을 처리하는데 사용하는 최상위 추상 클래스입니다.
- * 파일을 다루며 Minio/S3 에 접근하는 클래스를 만들 경우, 해당 클래스를 상속해야 합니다.
- *
+ * 모든 파일 관리자(Manager)의 최상위 추상 클래스입니다.
*
- * 파일 종류와 상관 없이 파일 삭제, 파일 타입 반환, 파일 확장자 반환이 가능합니다.
+ * 이 클래스는 파일 관리에 필요한 공통 기능과 기본 계약을 정의합니다.
+ * 특정 도메인의 파일을 관리하는 모든 구체적인 Manager 클래스(예: {@link AbstractImageManager})는
+ * 이 클래스를 상속받아야 합니다.
*
* @author sleepyhoon
* @since 2026-01-14
+ * @see ObjectStorage
+ * @see AbstractImageManager
*/
@RequiredArgsConstructor
public abstract class AbstractFileManager {
+
+ /**
+ * 실제 객체 스토리지와의 상호작용을 담당하는 구현체입니다.
+ * 하위 클래스에서 스토리지 기능에 접근할 수 있도록 {@code protected}로 선언되었습니다.
+ */
protected final ObjectStorage objectStorage;
/**
- * 파일을 삭제합니다.
+ * 스토리지에 저장된 파일을 삭제합니다.
*
- * @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);
}
/**
- * 클래스가 담당하는 파일 타입을 반환합니다.
- * @return 파일 타입
+ * 이 Manager가 담당하는 파일의 종류({@link FileType})를 반환합니다.
+ *
+ * 이 추상 메서드는 하위 클래스에서 반드시 구현해야 합니다.
+ * 반환된 값은 {@code ImageFileServiceImpl} 등에서 적절한 Manager를 찾는 키로 사용됩니다.
+ *
+ * @return 이 Manager가 처리하는 {@link FileType}
*/
- public abstract FileType getFileType();
+ public abstract FileType getType();
/**
- * 파일 이름에서 확장자를 추출합니다.
- * @param fileName 파일 이름
- * @return 추출한 확장자 이름
+ * 파일을 스토리지에 업로드합니다.
+ *
+ * 이 추상 메서드는 하위 클래스에서 반드시 구현해야 합니다.
+ * @param file 업로드할 파일
+ * @param objectKey 스토리지에 저장될 키
+ * @return 업로드된 파일의 접근 URL
*/
- protected String extractExtension(String fileName) {
- int lastDotIndex = fileName.lastIndexOf(".");
- if (lastDotIndex == -1 || lastDotIndex == fileName.length() - 1) {
- return ""; // 확장자가 없는 경우 처리
- }
- return fileName.substring(lastDotIndex + 1).toLowerCase();
+ protected 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 0ae6bbae..2c49a127 100644
--- a/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java
+++ b/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java
@@ -2,94 +2,199 @@
import java.util.List;
-import org.springframework.beans.factory.annotation.Value;
+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.dto.ImageUploadRes;
+import com.studypals.global.file.entity.ImageType;
+import com.studypals.global.file.entity.ImageVariantKey;
/**
- * * 파일을 처리하는데 사용하는 추상 클래스입니다.
- * *
- * *
- * * 파일을 업로드하기 위한 UploadUrl을 반환합니다.
- * * 파일 조회(다운로드)의 경우 일부 도메인은 Public URL을 사용하고, 일부는 Presigned URL을 사용합니다.
- * * 파일 삭제 시, URL에서 경로를 추출하여 스토리지에서 삭제합니다.
+ * 다양한 종류의 이미지 파일을 일관된 방식으로 처리하기 위한 추상 클래스입니다.
+ *
+ * 이를 통해 프로필 이미지, 채팅 이미지 등 각기 다른 도메인의 이미지 관리 로직을
+ * 표준화된 프로세스에 따라 처리하면서도, 도메인별 경로 생성 정책 등은 유연하게 확장할 수 있습니다.
*
* @author sleepyhoon
* @since 2026-01-13
*/
public abstract class AbstractImageManager extends AbstractFileManager {
- @Value("${file.upload.extensions}")
- private List acceptableExtensions;
+ private final List acceptableExtensions;
+ private final int presignedUrlExpireTime;
- @Value("${file.upload.presigned-url-expire-time}")
- private int presignedUrlExpireTime;
-
- public AbstractImageManager(ObjectStorage objectStorage) {
+ /**
+ * {@code AbstractImageManager}의 생성자입니다.
+ *
+ * @param objectStorage 스토리지 상호작용을 위한 인터페이스 구현체
+ * @param properties 파일 관련 설정값 (허용 확장자, Presigned URL 만료 시간 등)
+ */
+ public AbstractImageManager(ObjectStorage objectStorage, FileProperties properties) {
super(objectStorage);
+ this.acceptableExtensions = properties.extensions();
+ this.presignedUrlExpireTime = properties.presignedUrlExpireTime();
}
/**
- * 파일 업로드를 위한 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(Long userId, String fileName, String targetId) {
- validateFileName(fileName);
- validateTargetId(userId, targetId);
- String objectKey = generateObjectKey(fileName, targetId);
- return objectStorage.createPresignedPutUrl(objectKey, presignedUrlExpireTime);
+ public String getPresignedGetUrl(String objectKey) {
+ return objectStorage.createPresignedGetUrl(objectKey, presignedUrlExpireTime);
}
/**
- * MinIO/S3 에 저장할 경로(ObjectKey)를 생성합니다.
- * @param fileName 이미지 이름
- * @param targetId 업로드 대상 식별자 (예: userId, groupId, chatRoomId)
- * @return 생성된 ObjectKey
+ * 이미지 업로드 및 메타데이터 저장을 수행하는 템플릿 메서드입니다.
+ *
+ * 1. 스토리지 업로드 ({@link #uploadImage})
+ * 2. DB 메타데이터 저장 ({@link #saveImage})
+ * 과정을 순차적으로 실행합니다.
+ *
+ * @param file 업로드할 파일
+ * @param userId 요청한 사용자 ID
+ * @param targetId 대상 식별자 (프로필인 경우 userId, 채팅인 경우 chatRoomId)
+ * @return 업로드된 이미지 정보 (ID, URL)
*/
- private String generateObjectKey(String fileName, String targetId) {
- String ext = extractExtension(fileName);
- return generateObjectKeyDetail(targetId, ext);
+ public ImageUploadRes upload(MultipartFile file, Long userId, String targetId) {
+ ImageUploadDto uploadDto = uploadImage(file, userId, targetId);
+ Long imageId = saveImage(userId, targetId, uploadDto.objectKey(), file.getOriginalFilename());
+ return new ImageUploadRes(imageId, uploadDto.imageUrl());
}
/**
- * 프로필, 채팅 이미지의 경로(ObjectKey)가 다르기 때문에 구체 클래스에서 구현해야 합니다.
- * @param targetId 업로드 대상 식별자 (예: userId, groupId, chatRoomId)
- * @param ext 파일 확장자
- * @return 생성된 ObjectKey
+ * 실제 파일 업로드를 수행하는 공통 메서드입니다.
+ *
+ * 하위 클래스의 {@code upload} 메서드에서 호출되며, 다음 과정을 수행합니다:
+ *
+ * - {@link #createObjectKey}를 호출하여 저장 경로(Object Key)를 생성합니다.
+ * - 부모 클래스의 {@link AbstractFileManager#upload}를 호출하여 실제 스토리지 업로드를 수행합니다.
+ * - 업로드된 결과(Key, URL)를 DTO로 변환하여 반환합니다.
+ *
+ *
+ * @param file 업로드할 멀티파트 파일
+ * @param userId 요청한 사용자 ID
+ * @param targetId 업로드 대상 식별자 (예: 사용자 ID, 채팅방 ID)
+ * @return 업로드된 파일의 키와 URL 정보를 담은 DTO
*/
- protected abstract String generateObjectKeyDetail(String targetId, String ext);
+ protected ImageUploadDto uploadImage(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);
+ }
/**
- * targetId의 유효성을 검증합니다.
- * 기본적으로는 아무런 검증도 수행하지 않으며(Hook Method),
- * 검증이 필요한 구체 클래스에서 이 메서드를 오버라이드하여 구현합니다.
+ * 스토리지에 저장될 고유한 객체 키(Object Key)를 생성하는 템플릿 메서드입니다.
+ *
+ * 이 메서드는 {@code final}로 선언되어 있으며, 다음과 같은 정해진 순서로 동작합니다.
+ *
+ * - {@link #validateFileName}: 파일 이름과 확장자를 검증합니다.
+ * - {@link #validateTargetId}: 하위 클래스에서 재정의 가능한 대상 ID 유효성을 검증합니다 (Hook).
+ *
*
- * @param userId 검증할 사용자 ID
- * @param targetId 검증할 대상 식별자
- * @throws IllegalArgumentException 유효하지 않은 targetId인 경우
+ * @param userId 업로드를 요청한 사용자 ID
+ * @param fileName 원본 파일 이름
+ * @param targetId 업로드 대상의 식별자 (예: 사용자 ID, 채팅방 ID 등)
+ * @return 생성된 고유 객체 키
*/
- protected void validateTargetId(Long userId, String targetId) {
- // 기본 구현: 검증 없음
+ public String createObjectKey(Long userId, String fileName, String targetId) {
+ // 1. 검증과 동시에 정규화된 확장자를 받아옵니다.
+ String normalizedExtension = validateAndGetExtension(fileName);
+
+ validateTargetId(userId, targetId);
+
+ // 2. 하위 클래스에 넘겨줍니다.
+ return generateObjectKeyDetail(targetId, normalizedExtension);
}
/**
- * 사전에 정해둔 파일 확장자를 가지는지 확인합니다.
- * @param fileName 확인할 파일 이름
+ * 파일 이름의 유효성과 확장자를 검증합니다.
+ * 파일 이름이 null이거나 '.'을 포함하지 않는 경우, 또는 허용되지 않은 확장자인 경우 예외를 발생시킵니다.
+ * TODO: 강도높은 유효성 검증을 위해 Tika, ImageIO을 추가로 도입할 수 있습니다.
+ * @param fileName 검증할 파일 이름
+ * @throws FileException 유효하지 않은 파일 이름 또는 지원하지 않는 확장자인 경우
+ * @return 유효한 파일 확장자값
*/
- private void validateFileName(String fileName) {
+ private String validateAndGetExtension(String fileName) {
if (fileName == null || !fileName.contains(".")) {
throw new FileException(FileErrorCode.INVALID_FILE_NAME);
}
- String extension = extractExtension(fileName);
+
+ // 확장자 추출 및 소문자 변환 (NPE 방지 및 대소문자 문제 해결)
+ String extension = FileUtils.extractExtension(fileName).toLowerCase();
+
if (!acceptableExtensions.contains(extension)) {
throw new FileException(FileErrorCode.UNSUPPORTED_FILE_IMAGE_EXTENSION);
}
+ return extension;
}
+
+ /**
+ * 대상 식별자(targetId)의 유효성을 검증하는 Hook 메서드입니다.
+ *
+ * 기본적으로는 아무런 검증을 수행하지 않습니다.
+ * 특정 도메인에서 추가적인 검증(예: 채팅방 멤버 여부 확인)이 필요한 경우,
+ * 하위 클래스에서 이 메서드를 재정의(Override)하여 사용합니다.
+ *
+ * @param userId 검증을 요청한 사용자 ID
+ * @param targetId 검증할 대상 식별자
+ * @throws RuntimeException 유효성 검증에 실패할 경우 적절한 예외를 발생시킬 수 있습니다.
+ */
+ protected void validateTargetId(Long userId, String targetId) {
+ // 기본 구현은 비어 있으며, 하위 클래스에서 필요에 따라 재정의합니다.
+ }
+
+ /**
+ * 객체 키의 상세 경로를 생성하는 추상 메서드입니다.
+ *
+ * 이 메서드는 하위 클래스에서 반드시 구현해야 합니다.
+ * 이미지의 종류(프로필, 채팅 등)에 따라 달라지는 저장 경로 구조를 정의하는 역할을 합니다.
+ *
+ * @param targetId 업로드 대상 식별자 (예: 사용자 ID, 채팅방 ID)
+ * @param ext 파일 확장자
+ * @return 도메인에 특화된 경로가 포함된 최종 객체 키
+ */
+ protected abstract String generateObjectKeyDetail(String targetId, String ext);
+
+ /**
+ * 업로드된 이미지 정보를 데이터베이스에 저장합니다.
+ *
+ * @param userId 요청 사용자 ID
+ * @param targetId 대상 식별자
+ * @param objectKey 이미지 저장 경로
+ * @param originalFileName 원본 파일명
+ * @return 저장된 이미지의 PK (ID)
+ */
+ protected abstract Long saveImage(Long userId, String targetId, String objectKey, String originalFileName);
+
+ /**
+ * 이 Manager가 처리하는 이미지의 다양한 크기 버전(Variant) 정보를 반환합니다.
+ * 하위 클래스는 이 메서드를 구현하여 원본, 썸네일 등 필요한 이미지 종류를 정의해야 합니다.
+ *
+ * @return {@link ImageVariantKey} 리스트
+ */
+ protected abstract List variants();
+
+ /**
+ * 업로드 완료 후 반환할 URL을 Presigned URL로 할지 여부를 결정합니다.
+ *
+ * @return true면 Presigned URL 반환, false면 업로드 시 반환된 URL(Public) 사용
+ */
+ protected abstract boolean usePresignedUrl();
+
+ public abstract boolean supports(ImageType fileType);
}
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 be7c76d9..00000000
--- a/src/main/java/com/studypals/global/file/dto/ChatPresignedUrlReq.java
+++ /dev/null
@@ -1,8 +0,0 @@
-package com.studypals.global.file.dto;
-
-import jakarta.validation.constraints.NotBlank;
-import jakarta.validation.constraints.NotNull;
-
-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/ImageUploadDto.java b/src/main/java/com/studypals/global/file/dto/ImageUploadDto.java
new file mode 100644
index 00000000..a0fd6c13
--- /dev/null
+++ b/src/main/java/com/studypals/global/file/dto/ImageUploadDto.java
@@ -0,0 +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) {}
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..d3021875
--- /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 imageId 데이터베이스에 저장된 이미지의 고유 ID (PK).
+ * 추후 비즈니스 로직(예: 회원 정보 수정, 채팅 메시지 전송)에서 이 ID를 참조합니다.
+ * @param imageUrl 업로드된 이미지를 즉시 조회할 수 있는 URL.
+ *
+ * 스토리지 설정에 따라 다음 중 하나가 반환됩니다:
+ *
+ * - Public 버킷인 경우: 영구적인 정적 URL
+ * - Private 버킷인 경우: 일정 시간 동안 유효한 GET Presigned URL
+ *
+ * @author sleepyhoon
+ * @since 2026-01-15
+ */
+public record ImageUploadRes(Long imageId, 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 7bbf6e8e..00000000
--- a/src/main/java/com/studypals/global/file/dto/PresignedUrlRes.java
+++ /dev/null
@@ -1,3 +0,0 @@
-package com.studypals.global.file.dto;
-
-public record PresignedUrlRes(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
deleted file mode 100644
index 159c67ba..00000000
--- a/src/main/java/com/studypals/global/file/dto/ProfilePresignedUrlReq.java
+++ /dev/null
@@ -1,6 +0,0 @@
-package com.studypals.global.file.dto;
-
-import jakarta.validation.constraints.NotBlank;
-import jakarta.validation.constraints.NotNull;
-
-public record ProfilePresignedUrlReq(@NotNull @NotBlank String fileName) {}
diff --git a/src/main/java/com/studypals/global/file/entity/FileType.java b/src/main/java/com/studypals/global/file/entity/FileType.java
deleted file mode 100644
index af88d389..00000000
--- a/src/main/java/com/studypals/global/file/entity/FileType.java
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.studypals.global.file.entity;
-
-/**
- * 파일의 종류를 나타내는 최상위 마커 인터페이스입니다.
- * 모든 파일 타입 enum은 이 인터페이스를 구현해야 합니다.
- *
- * @author sleepyhoon
- * @since 2026-01-10
- */
-public interface FileType {}
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..cf443aee
--- /dev/null
+++ b/src/main/java/com/studypals/global/file/entity/ImageFile.java
@@ -0,0 +1,104 @@
+package com.studypals.global.file.entity;
+
+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.Setter;
+import lombok.experimental.SuperBuilder;
+
+/**
+ * 객체 스토리지에 저장된 이미지 파일의 메타데이터를 관리하는 엔티티의 공통 속성을 정의하는 추상 클래스입니다.
+ * {@code @MappedSuperclass}를 사용하여 이 클래스를 상속하는 엔티티들은 아래 필드들을 자신의 컬럼으로 포함하게 됩니다.
+ *
+ * objectKey, originalFileName, mimeType, imageStatus의 경우 protected setter를 가집니다. 이는 오직 수정 가능한 이미지 한정으로 사용합니다.
+ *
+ * @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)
+ @Column(name = "id", nullable = false, unique = true)
+ private Long id;
+
+ /**
+ * 객체 스토리지(예: MinIO, S3) 내에서 파일을 식별하는 고유한 키입니다.
+ * 예: "profile/1/uuid.jpg"
+ */
+ @Column(nullable = false, unique = true)
+ @Setter(AccessLevel.PROTECTED)
+ private String objectKey;
+
+ /**
+ * 사용자가 업로드한 원본 파일의 이름입니다.
+ * 예: "my_vacation_photo.jpg"
+ */
+ @Column(nullable = false)
+ @Setter(AccessLevel.PROTECTED)
+ private String originalFileName;
+
+ /**
+ * 파일의 MIME 타입입니다.
+ * 예: "jpg"
+ */
+ @Column(nullable = false)
+ @Setter(AccessLevel.PROTECTED)
+ private String mimeType;
+
+ /**
+ * 이미지의 처리 상태입니다.
+ *
+ * - PENDING: 처리 대기 중 (리사이징 전)
+ * - COMPLETE: 처리 완료 (리사이징 완료)
+ * - FAILED: 처리 실패 (재시도 필요)
+ */
+ @Column(nullable = false)
+ @Enumerated(EnumType.STRING)
+ @Builder.Default
+ @Setter(AccessLevel.PROTECTED)
+ private ImageStatus imageStatus = ImageStatus.PENDING;
+
+ /**
+ * 이미지가 업로드된 시간입니다.
+ */
+ @CreatedDate
+ @Column(updatable = 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
new file mode 100644
index 00000000..bf2aa4e5
--- /dev/null
+++ b/src/main/java/com/studypals/global/file/entity/ImageStatus.java
@@ -0,0 +1,29 @@
+package com.studypals.global.file.entity;
+
+/**
+ * 이미지 파일의 업로드 상태를 나타내는 열거형(Enum) 클래스입니다.
+ *
+ * 서버 직접 업로드 방식에서 이미지의 리사이징 및 저장 처리 상태를 관리하는 데 사용됩니다.
+ * 특히 리사이징 실패 시 재시도 로직을 위한 상태 구분에 활용됩니다.
+ *
+ * @author sleepyhoon
+ * @since 2026-01-15
+ */
+public enum ImageStatus {
+ /**
+ * 이미지 메타데이터가 생성되었으나, 아직 리사이징 등 후속 처리가 완료되지 않은 대기 상태입니다.
+ */
+ PENDING,
+
+ /**
+ * 리사이징 및 스토리지 저장이 성공적으로 완료된 상태입니다.
+ * 서비스에서 정상적으로 조회 가능한 상태입니다.
+ */
+ COMPLETE,
+
+ /**
+ * 리사이징이나 업로드 과정에서 오류가 발생한 상태입니다.
+ * 추후 배치 작업 등을 통해 재시도를 수행할 수 있습니다.
+ */
+ FAILED
+}
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..a43fd799 100644
--- a/src/main/java/com/studypals/global/file/entity/ImageType.java
+++ b/src/main/java/com/studypals/global/file/entity/ImageType.java
@@ -1,13 +1,29 @@
package com.studypals.global.file.entity;
+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
new file mode 100644
index 00000000..3d7bac76
--- /dev/null
+++ b/src/main/java/com/studypals/global/file/entity/ImageVariantKey.java
@@ -0,0 +1,44 @@
+package com.studypals.global.file.entity;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * 이미지의 다양한 크기 버전(Variant)을 정의하는 열거형(Enum) 클래스입니다.
+ *
+ * 원본 이미지를 스토리지에 업로드한 후, 썸네일, 중간 크기 이미지 등 다양한 크기의
+ * 파생 이미지를 생성하고 관리하는 데 사용될 수 있습니다.
+ * 각 상수는 특정 크기(픽셀 단위)를 정의합니다.
+ *
+ * 예를 들어, {@code AbstractImageManager}의 하위 클래스에서 이 Enum을 사용하여
+ * 어떤 크기의 이미지들을 생성하고 관리할지 명시할 수 있습니다.
+ *
+ * @author sleepyhoon
+ * @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/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 6d50baa7..739638f3 100644
--- a/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java
+++ b/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java
@@ -1,71 +1,58 @@
package com.studypals.global.file.service;
-import java.util.List;
-import java.util.Map;
-import java.util.function.Function;
-import java.util.stream.Collectors;
-
import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+
+import lombok.RequiredArgsConstructor;
-import com.studypals.global.exceptions.errorCode.FileErrorCode;
-import com.studypals.global.exceptions.exception.FileException;
-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.dto.ImageUploadRes;
import com.studypals.global.file.entity.ImageType;
+import com.studypals.global.file.worker.ImageManagerFactory;
/**
- * 파일을 처리하는 로직을 정의한 구현 클래스입니다.
- * 파일 업로드를 위한 presigned url을 발급을 진행합니다.
+ * 이미지 파일 관련 비즈니스 로직을 처리하는 서비스 구현체입니다.
+ *
+ * 이 서비스는 클라이언트로부터 받은 파일을 스토리지에 직접 업로드하는 역할을 담당합니다.
+ * {@link ImageType}에 따라 적절한 {@link AbstractImageManager}를 동적으로 선택하여 로직을 위임하는 전략 패턴을 사용합니다.
+ * 이를 통해 새로운 이미지 타입이 추가되더라도 서비스 코드의 변경 없이 유연하게 확장할 수 있습니다.
*
* @author sleepyhoon
* @since 2026-01-10
+ * @see ImageFileService
+ * @see AbstractImageManager
*/
@Service
+@RequiredArgsConstructor
public class ImageFileServiceImpl implements ImageFileService {
- private final Map managerMap;
-
- public ImageFileServiceImpl(List managers) {
- this.managerMap = managers.stream()
- .collect(Collectors.toMap(
- AbstractFileManager::getFileType, Function.identity(), (existing, duplicate) -> {
- throw new IllegalStateException(String.format(
- "ImageType 중복 등록 오류. '%s' 타입이 '%s'와 '%s' 클래스에서 중복으로 처리됩니다.",
- existing.getFileType(),
- existing.getClass().getName(),
- duplicate.getClass().getName()));
- }));
- }
+ private final ImageManagerFactory imageManagerFactory;
+ /**
+ * 사용자 프로필 이미지를 업로드합니다.
+ *
+ * @param file 업로드할 이미지 파일
+ * @param userId Presigned URL을 요청한 사용자의 ID
+ * @return 생성된 이미지 ID와 접근 URL이 포함된 응답 DTO
+ */
@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);
+ public ImageUploadRes uploadProfileImage(MultipartFile file, Long userId) {
+ AbstractImageManager manager = imageManagerFactory.getManager(ImageType.PROFILE_IMAGE);
+ // 프로필 이미지의 targetId는 userId와 동일하게 취급
+ return manager.upload(file, userId, String.valueOf(userId));
}
+ /**
+ * 채팅방 내 이미지를 업로드합니다.
+ *
+ * @param file 업로드할 이미지 파일
+ * @param chatRoomId 채팅방 ID
+ * @param userId Presigned URL을 요청한 사용자의 ID
+ * @return 생성된 이미지 ID와 접근 URL이 포함된 응답 DTO
+ */
@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);
- }
-
- private T getManager(FileType fileType, Class managerClass) {
- AbstractFileManager manager = managerMap.get(fileType);
- if (manager == null) {
- throw new FileException(FileErrorCode.UNSUPPORTED_FILE_IMAGE_TYPE);
- }
- if (!managerClass.isInstance(manager)) {
- // 잘못된 타입의 Manager가 매핑된 경우, 이는 심각한 설정 오류입니다.
- throw new IllegalStateException(String.format(
- "요청된 FileType '%s'에 대한 Manager 타입이 일치하지 않습니다. 기대값: %s, 실제값: %s",
- fileType, managerClass.getName(), manager.getClass().getName()));
- }
- return managerClass.cast(manager);
+ public ImageUploadRes uploadChatImage(MultipartFile file, String chatRoomId, Long userId) {
+ AbstractImageManager manager = imageManagerFactory.getManager(ImageType.CHAT_IMAGE);
+ return manager.upload(file, userId, chatRoomId);
}
}
diff --git a/src/main/java/com/studypals/global/file/worker/ImageManagerFactory.java b/src/main/java/com/studypals/global/file/worker/ImageManagerFactory.java
new file mode 100644
index 00000000..97bdced7
--- /dev/null
+++ b/src/main/java/com/studypals/global/file/worker/ImageManagerFactory.java
@@ -0,0 +1,49 @@
+package com.studypals.global.file.worker;
+
+import java.util.List;
+
+import org.springframework.stereotype.Component;
+
+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.AbstractImageManager;
+import com.studypals.global.file.entity.ImageType;
+
+/**
+ * 이미지 파일 처리를 담당하는 매니저({@link AbstractImageManager})들을 관리하고 제공하는 팩토리 클래스입니다.
+ *
+ * 이 클래스는 애플리케이션 구동 시 Spring Context에 등록된 모든 {@link AbstractImageManager} 구현체를 수집하여
+ * {@link FileType}을 키로 하는 Map으로 초기화합니다.
+ * 이후 비즈니스 로직에서 특정 이미지 타입에 대한 처리가 필요할 때, 적절한 구현체를 찾아 반환하는 역할을 수행합니다.
+ *
+ * 이를 통해 전략 패턴(Strategy Pattern)을 지원하며, 새로운 이미지 타입이 추가되더라도
+ * 클라이언트 코드(Service 등)의 변경 없이 기능을 확장할 수 있습니다.
+ *
+ * @author sleepyhoon
+ * @see AbstractImageManager
+ * @see FileType
+ * @since 2026-02-07
+ */
+@Component
+public class ImageManagerFactory {
+ private final List managers;
+
+ public ImageManagerFactory(List managers) {
+ this.managers = managers;
+ }
+
+ /**
+ * 지정된 {@link FileType}에 해당하는 {@link AbstractImageManager}의 구현체를 타입 안전하게 조회합니다.
+ *
+ * @param fileType 조회할 파일 타입 (예: {@code ImageType.PROFILE_IMAGE})
+ * @return 요청된 타입의 Manager 인스턴스
+ * @throws FileException 해당 {@code fileType}을 처리하는 Manager가 등록되어 있지 않을 경우 발생
+ */
+ public AbstractImageManager getManager(ImageType imageType) {
+ return managers.stream()
+ .filter(s -> s.supports(imageType))
+ .findFirst()
+ .orElseThrow(() -> new FileException(FileErrorCode.UNSUPPORTED_FILE_IMAGE_TYPE));
+ }
+}
diff --git a/src/main/java/com/studypals/global/minio/MinioStorage.java b/src/main/java/com/studypals/global/minio/MinioStorage.java
index ca61e339..d029a0bf 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,42 @@ 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 형태의 파일을 업로드합니다.
+ *
+ * @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 convertKeyToFileUrl(objectKey);
+ } catch (Exception e) {
+ throw new RuntimeException("MinIO 파일 업로드에 실패했습니다. ObjectKey: " + objectKey, e);
+ }
+ }
+
/**
* path 경로에 저장된 파일을 삭제합니다.
*
@@ -58,7 +97,7 @@ public void delete(String destination) {
.object(destination)
.build());
} catch (Exception e) {
- throw new RuntimeException(e.getMessage());
+ throw new RuntimeException("MinIO 파일 삭제에 실패했습니다. ObjectKey: " + destination, e);
}
}
@@ -88,20 +127,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 버킷이 유효한지 확인합니다. 유효하지 않다면, 해당 이름으로 버킷을 생성합니다.
*/
@@ -117,7 +142,7 @@ private void validateBucket() {
.config("public")
.build());
} catch (Exception e) {
- throw new RuntimeException(e.getMessage());
+ throw new RuntimeException("MinIO 버킷 초기화에 실패했습니다. Bucket: " + bucket, e);
}
}
}
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/restDocsTest/ChatRoomControllerRestDocsTest.java b/src/test/java/com/studypals/domain/chatManage/restDocsTest/ChatRoomControllerRestDocsTest.java
index 3df360a7..a45c77ff 100644
--- a/src/test/java/com/studypals/domain/chatManage/restDocsTest/ChatRoomControllerRestDocsTest.java
+++ b/src/test/java/com/studypals/domain/chatManage/restDocsTest/ChatRoomControllerRestDocsTest.java
@@ -6,24 +6,34 @@
import static org.springframework.restdocs.http.HttpDocumentation.httpRequest;
import static org.springframework.restdocs.http.HttpDocumentation.httpResponse;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get;
+import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
+import static org.springframework.restdocs.request.RequestDocumentation.partWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
+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.status;
import java.util.List;
+import org.junit.jupiter.api.DisplayName;
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.domain.chatManage.api.ChatRoomController;
import com.studypals.domain.chatManage.dto.*;
import com.studypals.domain.chatManage.entity.ChatRoomRole;
import com.studypals.domain.chatManage.service.ChatRoomService;
+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;
import com.studypals.global.responses.ResponseCode;
@@ -41,6 +51,16 @@ class ChatRoomControllerRestDocsTest extends RestDocsSupport {
@MockitoBean
private ChatRoomService chatRoomService;
+ @MockitoBean
+ private ImageFileService imageFileService;
+
+ private final MockMultipartFile mockMultipartFile = new MockMultipartFile(
+ "file", // 컨트롤러가 받는 파라미터 변수명 (필수 확인!)
+ "chat-image.png", // 업로드할 파일명
+ "image/png", // 파일 타입
+ "fake-image-content".getBytes() // 파일 내용 (더미)
+ );
+
@Test
@WithMockUser
void getChatRoomInfo_success() throws Exception {
@@ -120,4 +140,41 @@ void getChatRoomInfo_success() throws Exception {
fieldWithPath("data.logs[].content").description("채팅 메시지 내용"),
fieldWithPath("data.logs[].sender").description("메시지 보낸 유저 ID"))));
}
+
+ @Test
+ @WithMockUser
+ @DisplayName("채팅 이미지 업로드 성공")
+ void upLoadChatImage_success() throws Exception {
+ // given
+ Long imageId = 1L;
+ String imageUrl = "http://example.com/presigned-url-image.jpg";
+ ImageUploadRes response = new ImageUploadRes(imageId, imageUrl);
+
+ given(imageFileService.uploadChatImage(any(MultipartFile.class), any(), any()))
+ .willReturn(response);
+
+ Response expected = CommonResponse.success(ResponseCode.FILE_IMAGE_UPLOAD, response);
+
+ // when
+ ResultActions result =
+ mockMvc.perform(multipart("/chat/room/{chatRoomId}/image", "chatRoomId-123-456") // 1. URL 템플릿 사용
+ .file(mockMultipartFile)
+ .contentType(MediaType.MULTIPART_FORM_DATA));
+
+ // then
+ result.andExpect(status().isOk())
+ .andExpect(hasKey(expected))
+ .andDo(print())
+ .andDo(restDocs.document(
+ httpRequest(),
+ httpResponse(),
+ pathParameters(parameterWithName("chatRoomId").description("채팅방 ID")),
+ requestParts(partWithName("file").description("업로드할 이미지 파일 (MultipartFile)")),
+ responseFields(
+ fieldWithPath("code").description("응답 코드 (I01-01)"),
+ fieldWithPath("status").description("응답 상태"),
+ fieldWithPath("message").description("응답 메시지"),
+ fieldWithPath("data.imageId").description("이미지 파일의 식별 ID"),
+ fieldWithPath("data.imageUrl").description("저장된 이미지를 조회할 presigned url"))));
+ }
}
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/chatManage/worker/ChatImageManagerTest.java b/src/test/java/com/studypals/domain/chatManage/worker/ChatImageManagerTest.java
index 56173927..5f75f99d 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;
@@ -31,57 +24,22 @@ class ChatImageManagerTest {
@Mock
private ChatRoomReader chatRoomReader;
+ @Mock
+ private ChatImageWriter chatImageWriter;
+
private ChatImageManager chatImageManager;
@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, chatImageWriter);
}
@Test
@DisplayName("파일 타입 반환 확인")
- void getFileType() {
+ void getType() {
// when
- ImageType type = chatImageManager.getFileType();
+ ImageType type = chatImageManager.getType();
// then
assertThat(type).isEqualTo(ImageType.CHAT_IMAGE);
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..c2c6548f 100644
--- a/src/test/java/com/studypals/domain/memberManage/restDocsTest/MemberControllerRestDocsTest.java
+++ b/src/test/java/com/studypals/domain/memberManage/restDocsTest/MemberControllerRestDocsTest.java
@@ -10,17 +10,23 @@
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.status;
import java.time.LocalDate;
+import org.junit.jupiter.api.DisplayName;
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.domain.memberManage.api.MemberController;
import com.studypals.domain.memberManage.dto.CheckDuplicateDto;
@@ -30,6 +36,8 @@
import com.studypals.domain.memberManage.service.MemberService;
import com.studypals.global.exceptions.errorCode.AuthErrorCode;
import com.studypals.global.exceptions.exception.AuthException;
+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;
import com.studypals.global.responses.ResponseCode;
@@ -49,6 +57,16 @@ public class MemberControllerRestDocsTest extends RestDocsSupport {
@MockitoBean
private MemberService memberService;
+ @MockitoBean
+ private ImageFileService imageFileService;
+
+ private final MockMultipartFile mockMultipartFile = new MockMultipartFile(
+ "file", // 컨트롤러가 받는 파라미터 변수명 (필수 확인!)
+ "chat-image.png", // 업로드할 파일명
+ "image/png", // 파일 타입
+ "fake-image-content".getBytes() // 파일 내용 (더미)
+ );
+
@Test
void register_success() throws Exception {
@@ -153,7 +171,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 +187,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("응답 상태"),
@@ -180,6 +195,40 @@ void updateProfile_success() throws Exception {
fieldWithPath("message").description("응답 메시지"))));
}
+ @Test
+ @WithMockUser
+ @DisplayName("프로필 이미지 업로드 성공")
+ void upLoadProfileImage_success() throws Exception {
+ // given
+ Long imageId = 1L;
+ String imageUrl = "http://example.com/image.jpg";
+
+ ImageUploadRes response = new ImageUploadRes(imageId, imageUrl);
+ given(imageFileService.uploadProfileImage(any(MultipartFile.class), any()))
+ .willReturn(response);
+
+ Response expected = CommonResponse.success(ResponseCode.FILE_IMAGE_UPLOAD, response);
+
+ // when
+ ResultActions result = mockMvc.perform(
+ multipart("/profile/image").file(mockMultipartFile).contentType(MediaType.MULTIPART_FORM_DATA));
+
+ // then
+ result.andExpect(status().isOk())
+ .andExpect(hasKey(expected))
+ .andDo(print())
+ .andDo(restDocs.document(
+ httpRequest(),
+ httpResponse(),
+ requestParts(partWithName("file").description("업로드할 이미지 파일 (MultipartFile)")),
+ responseFields(
+ fieldWithPath("code").description("응답 코드 (I01-01)"),
+ fieldWithPath("status").description("응답 상태"),
+ fieldWithPath("message").description("응답 메시지"),
+ fieldWithPath("data.imageId").description("이미지 파일의 식별 ID"),
+ fieldWithPath("data.imageUrl").description("저장된 이미지 주소"))));
+ }
+
@Test
@WithMockUser
void checkAvailability_fail_when_both_username_and_nickname_present() throws Exception {
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/domain/memberManage/worker/MemberProfileImageManagerTest.java b/src/test/java/com/studypals/domain/memberManage/worker/MemberProfileImageManagerTest.java
new file mode 100644
index 00000000..5e40701d
--- /dev/null
+++ b/src/test/java/com/studypals/domain/memberManage/worker/MemberProfileImageManagerTest.java
@@ -0,0 +1,48 @@
+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;
+
+ @Mock
+ private MemberReader memberReader;
+
+ @Mock
+ private MemberProfileImageWriter memberProfileImageWriter;
+
+ private MemberProfileImageManager memberProfileImageManager;
+
+ @BeforeEach
+ void setUp() {
+ FileProperties fileUploadProperties = new FileProperties(List.of("jpg", "png"), 600);
+ memberProfileImageManager = new MemberProfileImageManager(
+ objectStorage, fileUploadProperties, memberReader, memberProfileImageWriter);
+ }
+
+ @Test
+ @DisplayName("파일 타입 반환 확인")
+ void getType() {
+ // when
+ ImageType type = memberProfileImageManager.getType();
+
+ // 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..43d19e82 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;
@@ -12,6 +11,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;
@@ -41,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);
@@ -112,12 +109,12 @@ public TestFileManager(ObjectStorage objectStorage) {
}
@Override
- public ImageType getFileType() {
+ public ImageType getType() {
return ImageType.PROFILE_IMAGE;
}
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..b2b76e17 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,26 @@
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.mockito.ArgumentMatchers.anyInt;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.BDDMockito.given;
+import static org.assertj.core.api.Assertions.assertThatCode;
+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 +28,149 @@ 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);
- }
-
- @Test
- @DisplayName("업로드 URL 발급 성공 - 파일 이름 검증 통과")
- void getUploadUrl_success() {
- // given
- Long userId = 1L;
- String fileName = "image.jpg";
- String targetId = "user1";
- String expectedUrl = "https://example.com/presigned-url";
+ // 테스트를 위한 구체 클래스
+ static class TestImageManager extends AbstractImageManager {
- given(objectStorage.createPresignedPutUrl(anyString(), anyInt())).willReturn(expectedUrl);
+ public TestImageManager(ObjectStorage objectStorage, FileProperties properties) {
+ super(objectStorage, properties);
+ }
- // when
- String result = imageManager.getUploadUrl(userId, fileName, targetId);
+ @Override
+ protected String generateObjectKeyDetail(String targetId, String ext) {
+ return "test-path/" + targetId + "/" + UUID.randomUUID() + "." + ext;
+ }
- // then
- assertThat(result).isEqualTo(expectedUrl);
- }
+ @Override
+ protected Long saveImage(Long userId, String targetId, String objectKey, String originalFileName) {
+ return 1L; // 테스트용 더미 ID 반환
+ }
- @Test
- @DisplayName("업로드 URL 발급 실패 - 대문자 확장자도 허용")
- void getUploadUrl_upperCase() {
- // given
- Long userId = 1L;
- String fileName = "image.PNG";
- String targetId = "user1";
- String expectedUrl = "https://example.com/presigned-url";
+ @Override
+ protected List variants() {
+ return List.of();
+ }
- given(objectStorage.createPresignedPutUrl(anyString(), anyInt())).willReturn(expectedUrl);
+ @Override
+ protected boolean usePresignedUrl() {
+ return true;
+ }
- // when
- String result = imageManager.getUploadUrl(userId, fileName, targetId);
+ @Override
+ public boolean supports(ImageType fileType) {
+ return true;
+ }
- // then
- assertThat(result).isEqualTo(expectedUrl);
+ @Override
+ public ImageType getType() {
+ return ImageType.PROFILE_IMAGE;
+ }
}
- @Test
- @DisplayName("업로드 URL 발급 실패 - 지원하지 않는 확장자")
- void getUploadUrl_invalidExtension() {
+ @BeforeEach
+ void setUp() {
// given
- Long userId = 1L;
- String fileName = "document.txt";
- String targetId = "user1";
-
- // when & then
- assertThatThrownBy(() -> imageManager.getUploadUrl(userId, fileName, targetId))
- .isInstanceOf(RuntimeException.class); // FileException이 RuntimeException을 상속한다고 가정
+ fileUploadProperties = new FileProperties(List.of("jpg", "jpeg", "png", "bmp", "webp"), 600);
+ imageManager = new TestImageManager(objectStorage, fileUploadProperties);
}
- @Test
- @DisplayName("업로드 URL 발급 실패 - 확장자 없음")
- void getUploadUrl_noExtension() {
- // given
- Long userId = 1L;
- String fileName = "image";
- String targetId = "user1";
+ @Nested
+ @DisplayName("createObjectKey 메서드 테스트")
+ class CreateObjectKeyTest {
- // when & then
- assertThatThrownBy(() -> imageManager.getUploadUrl(userId, fileName, targetId))
- .isInstanceOf(RuntimeException.class);
- }
+ @Test
+ @DisplayName("성공: 유효한 요청 시 ObjectKey를 정상적으로 생성한다")
+ void should_CreateObjectKey_When_RequestIsValid() {
+ // given
+ Long userId = 1L;
+ String fileName = "image.jpg";
+ String targetId = "user1";
- @Test
- @DisplayName("업로드 URL 발급 실패 - null 파일 이름")
- void getUploadUrl_null() {
- // given
- Long userId = 1L;
- String fileName = null;
- String targetId = "user1";
+ // when
+ String objectKey = imageManager.createObjectKey(userId, fileName, targetId);
- // when & then
- assertThatThrownBy(() -> imageManager.getUploadUrl(userId, fileName, targetId))
- .isInstanceOf(RuntimeException.class);
- }
+ // then
+ assertThat(objectKey).contains("test-path/user1/");
+ assertThat(objectKey).endsWith(".jpg");
+ }
- // 테스트를 위한 구체 클래스
- static class TestImageManager extends AbstractImageManager {
- public TestImageManager(ObjectStorage objectStorage) {
- super(objectStorage);
+ @Test
+ @DisplayName("성공: 대문자 확장자도 허용하여 ObjectKey를 생성한다")
+ void should_CreateObjectKey_When_ExtensionIsUpperCase() {
+ // given
+ Long userId = 1L;
+ String fileName = "image.PNG";
+ String targetId = "user1";
+
+ // when
+ String objectKey = imageManager.createObjectKey(userId, fileName, targetId);
+
+ // then
+ assertThat(objectKey).contains("test-path/user1/");
+ assertThat(objectKey).endsWith(".png");
}
- @Override
- protected String generateObjectKeyDetail(String targetId, String ext) {
- return "key";
+ @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);
}
- @Override
- public ImageType getFileType() {
- return ImageType.PROFILE_IMAGE;
+ @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("실패: 파일 이름이 null이면 FileException 던진다")
+ void should_ThrowException_When_FileNameIsNull() {
+ // given
+ Long userId = 1L;
+ String targetId = "user1";
+
+ // when & then
+ assertThatCode(() -> imageManager.createObjectKey(userId, null, targetId))
+ .isInstanceOf(FileException.class);
+ }
+ }
+
+ @Nested
+ @DisplayName("getUploadUrl(objectKey) 메서드 테스트")
+ class GetUploadUrlTest {
+
+ @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();
+
+ when(objectStorage.createPresignedGetUrl(objectKey, expireTime)).thenReturn(expectedUrl);
+
+ // when
+ String actualUrl = imageManager.getPresignedGetUrl(objectKey);
+
+ // then
+ assertThat(actualUrl).isEqualTo(expectedUrl);
+ 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
deleted file mode 100644
index 2bb66620..00000000
--- a/src/test/java/com/studypals/global/file/restDocsTest/ImageFileControllerRestDocsTest.java
+++ /dev/null
@@ -1,112 +0,0 @@
-package com.studypals.global.file.restDocsTest;
-
-import static com.studypals.testModules.testUtils.JsonFieldResultMatcher.*;
-import static org.mockito.ArgumentMatchers.*;
-import static org.mockito.BDDMockito.*;
-import static org.springframework.restdocs.http.HttpDocumentation.httpRequest;
-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.test.web.servlet.result.MockMvcResultHandlers.print;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
-
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
-import org.springframework.http.MediaType;
-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 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.service.ImageFileService;
-import com.studypals.global.responses.CommonResponse;
-import com.studypals.global.responses.Response;
-import com.studypals.global.responses.ResponseCode;
-import com.studypals.testModules.testSupport.RestDocsSupport;
-
-@WebMvcTest(ImageFileController.class)
-class ImageFileControllerRestDocsTest extends RestDocsSupport {
-
- @MockitoBean
- private ImageFileService imageFileService;
-
- @Test
- @WithMockUser
- @DisplayName("프로필 이미지 업로드용 Presigned URL 요청")
- void getProfileUploadUrl_success() throws Exception {
- // given
- ProfilePresignedUrlReq request = new ProfilePresignedUrlReq("my-profile.jpeg");
- String presignedUrl = "https://s3-presigned-url.com/for/profile/my-profile.jpeg?signature=...";
-
- PresignedUrlRes response = new PresignedUrlRes(presignedUrl);
- given(imageFileService.getProfileUploadUrl(any(ProfilePresignedUrlReq.class), any()))
- .willReturn(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)));
-
- // then
- result.andExpect(status().isOk())
- .andExpect(hasKey(expected))
- .andDo(print())
- .andDo(restDocs.document(
- httpRequest(),
- httpResponse(),
- requestFields(fieldWithPath("fileName")
- .description("업로드할 파일 이름 (확장자 포함)")
- .attributes(constraints("not null, not blank"))),
- responseFields(
- fieldWithPath("code").description("응답 코드 (I01-01)"),
- fieldWithPath("status").description("응답 상태"),
- fieldWithPath("message").description("응답 메시지"),
- fieldWithPath("data.url").description("생성된 Presigned URL"))));
- }
-
- @Test
- @WithMockUser
- @DisplayName("채팅 이미지 업로드용 Presigned URL 요청")
- 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);
-
- given(imageFileService.getChatUploadUrl(any(ChatPresignedUrlReq.class), any()))
- .willReturn(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)));
-
- // then
- result.andExpect(status().isOk())
- .andExpect(hasKey(expected))
- .andDo(print())
- .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"))),
- responseFields(
- fieldWithPath("code").description("응답 코드 (I01-01)"),
- fieldWithPath("status").description("응답 상태"),
- fieldWithPath("message").description("응답 메시지"),
- 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..8248115d 100644
--- a/src/test/java/com/studypals/global/file/service/ImageFileServiceImplTest.java
+++ b/src/test/java/com/studypals/global/file/service/ImageFileServiceImplTest.java
@@ -1,84 +1,74 @@
package com.studypals.global.file.service;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.never;
+import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-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.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.web.multipart.MultipartFile;
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;
+import com.studypals.global.file.worker.ImageManagerFactory;
@ExtendWith(MockitoExtension.class)
class ImageFileServiceImplTest {
- // Service under test
- private ImageFileService imageFileService;
+ @InjectMocks
+ private ImageFileServiceImpl imageFileService;
@Mock
- private AbstractImageManager mockProfileImageManager;
+ private ImageManagerFactory imageManagerFactory;
@Mock
- private AbstractImageManager mockChatImageManager;
-
- @BeforeEach
- void setUp() {
- when(mockProfileImageManager.getFileType()).thenReturn(ImageType.PROFILE_IMAGE);
- when(mockChatImageManager.getFileType()).thenReturn(ImageType.CHAT_IMAGE);
+ private AbstractImageManager imageManager;
- imageFileService = new ImageFileServiceImpl(List.of(mockProfileImageManager, mockChatImageManager));
- }
+ @Mock
+ private MultipartFile multipartFile;
@Test
- @DisplayName("getProfileUploadUrl 호출 시 ProfileImageManager의 getUploadUrl을 호출해야 한다")
- void getProfileUploadUrl_shouldCallCorrectManager() {
+ @DisplayName("프로필 이미지 업로드 - 성공")
+ void uploadProfileImage_Success() {
// given
Long userId = 1L;
- ProfilePresignedUrlReq request = new ProfilePresignedUrlReq("profile.jpg");
- String expectedUrl = "http://s3.com/profile-upload-url";
+ String targetId = String.valueOf(userId);
+ ImageUploadRes expectedRes = new ImageUploadRes(1L, "http://url");
- when(mockProfileImageManager.getUploadUrl(userId, "profile.jpg", "1")).thenReturn(expectedUrl);
+ given(imageManagerFactory.getManager(ImageType.PROFILE_IMAGE)).willReturn(imageManager);
+ given(imageManager.upload(multipartFile, userId, targetId)).willReturn(expectedRes);
// when
- PresignedUrlRes actualResult = imageFileService.getProfileUploadUrl(request, userId);
+ ImageUploadRes res = imageFileService.uploadProfileImage(multipartFile, userId);
// then
- assertThat(actualResult).isNotNull();
- assertThat(actualResult.url()).isEqualTo(expectedUrl);
- verify(mockProfileImageManager).getUploadUrl(userId, "profile.jpg", "1");
- verify(mockChatImageManager, never()).getUploadUrl(any(), any(), any());
+ assertThat(res).isEqualTo(expectedRes);
+ verify(imageManagerFactory).getManager(ImageType.PROFILE_IMAGE);
+ verify(imageManager).upload(multipartFile, userId, targetId);
}
@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 expectedUrl = "http://s3.com/chat-upload-url";
+ String chatRoomId = "room1";
+ ImageUploadRes expectedRes = new ImageUploadRes(2L, "http://chat-url");
- when(mockChatImageManager.getUploadUrl(userId, "chat-image.png", "chat-room-123"))
- .thenReturn(expectedUrl);
+ given(imageManagerFactory.getManager(ImageType.CHAT_IMAGE)).willReturn(imageManager);
+ given(imageManager.upload(multipartFile, userId, chatRoomId)).willReturn(expectedRes);
// when
- PresignedUrlRes actualResult = imageFileService.getChatUploadUrl(request, userId);
+ ImageUploadRes res = imageFileService.uploadChatImage(multipartFile, chatRoomId, userId);
// then
- assertThat(actualResult).isNotNull();
- assertThat(actualResult.url()).isEqualTo(expectedUrl);
- verify(mockChatImageManager).getUploadUrl(userId, "chat-image.png", "chat-room-123");
- verify(mockProfileImageManager, never()).getUploadUrl(any(), any(), any());
+ assertThat(res).isEqualTo(expectedRes);
+ verify(imageManagerFactory).getManager(ImageType.CHAT_IMAGE);
+ verify(imageManager).upload(multipartFile, userId, chatRoomId);
}
}
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());
}
}
diff --git a/src/test/java/com/studypals/testModules/testUtils/CleanUp.java b/src/test/java/com/studypals/testModules/testUtils/CleanUp.java
index 248b2350..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() {
@@ -46,7 +49,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 +60,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;
+ }
+ }
}