Skip to content

Commit

Permalink
MATE-132 : [FEAT] 썸네일 이미지 생성 및 저장, 삭제 기능 구현 (#123)
Browse files Browse the repository at this point in the history
* MATE-132 : [FEAT] 썸네일레이터 의존성 추가

* MATE-132 : [FEAT] 썸네일 이미지 생성 및 저장, 삭제 기능 구현

* MATE-132 : [FEAT] 굿즈거래 게시물 썸네일 이미지 업로드 로직 추가

* MATE-132 : [FEAT] 이미지 Url 반환 기능 구현 및 File 관련 Util 클래스 생성

* MATE-132 : [REFACTOR] 이미지 Url 반환 로직 변경에 따른 DTO 클래스 리팩토링

* MATE-132 : [TEST] 이미지 업로드 및 Url 반환 로직 변경에 따른 테스트 코드 수정

* MATE-132 : [REFACTOR] 회원 및 메이트 도메인 썸네일 로직 추가

* MATE-132 : [TEST] 회원 및 메이트 테스트 코드 수정

* MATE-132 : [REFACTOR] 회원 및 메이트 기본 이미지 생성 로직 수정

* MATE-132 : [TEST] ProfileServiceTest 테스트 코드 수정
  • Loading branch information
hongjeZZ authored Jan 3, 2025
1 parent fd850c3 commit 4cc99f8
Show file tree
Hide file tree
Showing 29 changed files with 257 additions and 126 deletions.
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ dependencies {

// AWS S3
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

// thumbnailator
implementation group: 'net.coobird', name: 'thumbnailator', version: '0.4.20'
}

tasks.named('test') {
Expand Down
105 changes: 73 additions & 32 deletions src/main/java/com/example/mate/domain/file/FileService.java
Original file line number Diff line number Diff line change
@@ -1,39 +1,52 @@
package com.example.mate.domain.file;

import static com.example.mate.domain.file.FileUtils.getFileName;

import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.example.mate.common.error.CustomException;
import com.example.mate.common.error.ErrorCode;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import javax.imageio.ImageIO;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.coobird.thumbnailator.Thumbnails;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.util.UUID;

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class FileService {

// 파일명에서 허용하지 않는 문자들에 대한 정규식
private static final String FILE_NAME_REGEX = "[^a-zA-Z0-9.\\-_]";
private static final String FILE_NAME_REPLACEMENT = "_";
private static final double THUMBNAIL_RATIO = 2;
private static final String THUMBNAIL_PREFIX = "t_";

private final AmazonS3 amazonS3;

@Getter
@Value("${cloud.aws.s3.bucket}")
private String bucket;

// 파일 업로드
/**
* 이미지 파일을 S3 버킷에 업로드하는 메서드
*
* @param file 업로드할 MultipartFile
* @return 업로드된 파일의 이름
*/
public String uploadFile(MultipartFile file) {
// UUID 를 포함한 새로운 파일명 반환
String fileName = getFileName(file);

// 메타데이터 설정
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(file.getSize());
metadata.setContentType(file.getContentType());
Expand All @@ -43,47 +56,75 @@ public String uploadFile(MultipartFile file) {
} catch (Exception e) {
throw new CustomException(ErrorCode.FILE_UPLOAD_ERROR);
}
return amazonS3.getUrl(bucket, fileName).toString(); // 파일 저장된 URL 반환
return fileName;
}

// 파일 삭제
public void deleteFile(String imageUrl) {
String key = extractKeyFromUrl(imageUrl);
/**
* 원본 이미지와 썸네일 이미지를 S3 버킷에 업로드하는 메서드
*
* @param file 업로드할 이미지 파일
* @return 업로드된 원본 파일의 이름
*/
public String uploadImageWithThumbnail(MultipartFile file) {
// 원본 파일 S3 업로드
String originalFileName = uploadFile(file);

// 썸네일 생성
ByteArrayInputStream thumbnailStream = createThumbnailStream(file);

// 썸네일 메타데이터 설정
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(thumbnailStream.available());
metadata.setContentType(file.getContentType());

// 썸네일 이미지 업로드
try {
amazonS3.deleteObject(bucket, key); // 파일 삭제
amazonS3.putObject(bucket, THUMBNAIL_PREFIX + originalFileName, thumbnailStream, metadata);
} catch (Exception e) {
throw new CustomException(ErrorCode.FILE_DELETE_ERROR);
throw new CustomException(ErrorCode.FILE_UPLOAD_ERROR);
}
return originalFileName;
}

/**
* S3 URL에서 객체 키를 추출
* 파일의 썸네일 이미지를 생성하는 메서드
*
* @param imageUrl S3 URL
* @return 추출된 객체 키
* @param file 원본 이미지 파일
* @return 썸네일 이미지의 ByteArrayInputStream
*/
private String extractKeyFromUrl(String imageUrl) {
return imageUrl.replace("https://" + bucket + ".s3.ap-northeast-2.amazonaws.com/", "");
}
private ByteArrayInputStream createThumbnailStream(MultipartFile file) {
// try-with-resources 문으로 auto close
try (ByteArrayOutputStream thumbnailOutputStream = new ByteArrayOutputStream();
InputStream fileInputStream = file.getInputStream()) {

/**
* 파일명에서 허용되지 않는 문자를 제거하고, UUID 를 추가한 새로운 파일명을 생성
*
* @param file 업로드할 파일
* @return UUID 를 포함한 새로운 파일명
*/
private static String getFileName(MultipartFile file) {
String uuid = UUID.randomUUID().toString();
return uuid + FILE_NAME_REPLACEMENT + cleanFileName(file.getOriginalFilename());
// BufferedImage 를 이용해 이미지를 메모리에서 로드
BufferedImage originalImage = ImageIO.read(fileInputStream);
int width = (int) (originalImage.getWidth() / THUMBNAIL_RATIO);
int height = (int) (originalImage.getHeight() / THUMBNAIL_RATIO);

// 썸네일 생성
Thumbnails.of(originalImage)
.size(width, height)
.outputFormat("jpg")
.toOutputStream(thumbnailOutputStream);
return new ByteArrayInputStream(thumbnailOutputStream.toByteArray());
} catch (IOException e) {
throw new CustomException(ErrorCode.FILE_UPLOAD_ERROR);
}
}

/**
* 파일 이름에서 허용되지 않는 문자를 대체 문자로 변경합니다.
* S3에 업로드된 파일과 해당 파일의 썸네일을 삭제하는 메서드
* - 썸네일 이미지가 없으면 무시
*
* @param fileName 원본 파일명
* @return 대체된 파일명
* @param fileName 삭제할 파일 이름
*/
private static String cleanFileName(String fileName) {
return fileName.replaceAll(FILE_NAME_REGEX, FILE_NAME_REPLACEMENT);
public void deleteFile(String fileName) {
try {
amazonS3.deleteObject(bucket, fileName);
amazonS3.deleteObject(bucket, THUMBNAIL_PREFIX + fileName);
} catch (Exception e) {
throw new CustomException(ErrorCode.FILE_DELETE_ERROR);
}
}
}
50 changes: 50 additions & 0 deletions src/main/java/com/example/mate/domain/file/FileUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.example.mate.domain.file;

import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

@Component
public class FileUtils {

private static final String FILE_NAME_REGEX = "[^a-zA-Z0-9.\\-_]";
private static final String FILE_NAME_REPLACEMENT = "_";
private static final String THUMBNAIL_PREFIX = "t_";

private static String AWS_BUCKET_URL;

@Value("${cloud.aws.s3.bucket}")
public void setAwsBucketUrl(String bucket) {
AWS_BUCKET_URL = "https://" + bucket + ".s3.ap-northeast-2.amazonaws.com/";
}

public static String getImageUrl(String fileName) {
return AWS_BUCKET_URL + fileName;
}

public static String getThumbnailImageUrl(String fileName) {
return AWS_BUCKET_URL + THUMBNAIL_PREFIX + fileName;
}

/**
* 파일명에서 허용되지 않는 문자를 제거하고, UUID 를 추가한 새로운 파일명을 생성
*
* @param file 업로드할 파일
* @return UUID 를 포함한 새로운 파일명
*/
public static String getFileName(MultipartFile file) {
String uuid = UUID.randomUUID().toString();
return uuid + FILE_NAME_REPLACEMENT + cleanFileName(file.getOriginalFilename());
}

/**
* 파일 이름에서 허용되지 않는 문자를 대체 문자로 변경합니다.
*
* @param fileName 원본 파일명
* @return 대체된 파일명
*/
private static String cleanFileName(String fileName) {
return fileName.replaceAll(FILE_NAME_REGEX, FILE_NAME_REPLACEMENT);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.example.mate.domain.goodsChat.dto.response;

import com.example.mate.domain.file.FileUtils;
import com.example.mate.domain.goodsChat.entity.GoodsChatMessage;
import com.example.mate.domain.goodsChat.entity.GoodsChatPart;
import com.example.mate.domain.member.entity.Member;
Expand Down Expand Up @@ -31,7 +32,7 @@ public static GoodsChatMessageResponse of(GoodsChatMessage chatMessage) {
.roomId(goodsChatPart.getGoodsChatRoom().getId())
.senderId(sender.getId())
.senderNickname(sender.getNickname())
.senderImageUrl(sender.getImageUrl())
.senderImageUrl(FileUtils.getThumbnailImageUrl(sender.getImageUrl()))
.message(chatMessage.getContent())
.messageType(chatMessage.getMessageType().getValue())
.sentAt(chatMessage.getSentAt())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.example.mate.common.response.PageResponse;
import com.example.mate.domain.constant.TeamInfo;
import com.example.mate.domain.file.FileUtils;
import com.example.mate.domain.goodsPost.entity.GoodsPost;
import com.example.mate.domain.goodsChat.entity.GoodsChatRoom;
import lombok.Builder;
Expand Down Expand Up @@ -38,7 +39,7 @@ public static GoodsChatRoomResponse of(GoodsChatRoom chatRoom, PageResponse<Good
.title(goodsPost.getTitle())
.category(goodsPost.getCategory().getValue())
.price(goodsPost.getPrice())
.imageUrl(mainImageUrl)
.imageUrl(FileUtils.getThumbnailImageUrl(mainImageUrl))
.postStatus(goodsPost.getStatus().getValue())
.chatRoomStatus(chatRoom.getIsActive().toString())
.initialMessages(initialMessages)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.example.mate.domain.goodsChat.dto.response;

import com.example.mate.domain.file.FileUtils;
import com.example.mate.domain.goodsPost.entity.GoodsPost;
import com.example.mate.domain.goodsChat.entity.GoodsChatRoom;
import com.example.mate.domain.member.entity.Member;
Expand Down Expand Up @@ -30,8 +31,8 @@ public static GoodsChatRoomSummaryResponse of(GoodsChatRoom chatRoom, Member opp
.lastChatContent(chatRoom.getLastChatContent())
.lastChatSentAt(chatRoom.getLastChatSentAt())
.placeName(goodsPost.getLocation().getPlaceName())
.goodsMainImageUrl(goodsPost.getMainImageUrl())
.opponentImageUrl(opponent.getImageUrl())
.goodsMainImageUrl(FileUtils.getThumbnailImageUrl(goodsPost.getMainImageUrl()))
.opponentImageUrl(FileUtils.getThumbnailImageUrl(opponent.getImageUrl()))
.build();
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package com.example.mate.domain.goodsPost.dto.response;

import com.example.mate.domain.constant.TeamInfo;
import com.example.mate.domain.file.FileUtils;
import com.example.mate.domain.goodsPost.entity.GoodsPost;
import com.example.mate.domain.goodsPost.entity.GoodsPostImage;
import com.example.mate.domain.goodsPost.entity.Role;
import java.util.List;
import lombok.Builder;
Expand All @@ -29,6 +29,11 @@ public class GoodsPostResponse {
public static GoodsPostResponse of(GoodsPost goodsPost) {
String teamName = goodsPost.getTeamId() == null ? null : TeamInfo.getById(goodsPost.getTeamId()).shortName;

List<String> imageUrls = goodsPost.getGoodsPostImages()
.stream()
.map(image -> FileUtils.getImageUrl(image.getImageUrl()))
.toList();

return GoodsPostResponse.builder()
.id(goodsPost.getId())
.seller(MemberInfo.from(goodsPost.getSeller(), Role.SELLER))
Expand All @@ -38,7 +43,7 @@ public static GoodsPostResponse of(GoodsPost goodsPost) {
.price(goodsPost.getPrice())
.content(goodsPost.getContent())
.location(LocationInfo.from(goodsPost.getLocation()))
.imageUrls(goodsPost.getGoodsPostImages().stream().map(GoodsPostImage::getImageUrl).toList())
.imageUrls(imageUrls)
.status(goodsPost.getStatus().getValue())
.build();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.example.mate.domain.goodsPost.dto.response;

import com.example.mate.domain.constant.TeamInfo;
import com.example.mate.domain.file.FileUtils;
import com.example.mate.domain.goodsPost.entity.GoodsPost;
import lombok.Builder;
import lombok.Getter;
Expand All @@ -18,7 +19,7 @@ public class GoodsPostSummaryResponse {
private final Integer price;
private final String imageUrl;

public static GoodsPostSummaryResponse of(GoodsPost goodsPost, String mainImageUrl) {
public static GoodsPostSummaryResponse of(GoodsPost goodsPost, String imageFileName) {
String teamName = goodsPost.getTeamId() == null ? null : TeamInfo.getById(goodsPost.getTeamId()).shortName;

return GoodsPostSummaryResponse.builder()
Expand All @@ -27,7 +28,7 @@ public static GoodsPostSummaryResponse of(GoodsPost goodsPost, String mainImageU
.title(goodsPost.getTitle())
.category(goodsPost.getCategory().getValue())
.price(goodsPost.getPrice())
.imageUrl(mainImageUrl)
.imageUrl(FileUtils.getThumbnailImageUrl(imageFileName))
.build();
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.example.mate.domain.goodsPost.dto.response;

import com.example.mate.domain.file.FileUtils;
import com.example.mate.domain.goodsPost.entity.Role;
import com.example.mate.domain.member.entity.Member;
import lombok.AccessLevel;
Expand Down Expand Up @@ -33,7 +34,7 @@ public static MemberInfo from(Member member, Role role) {
.memberId(member.getId())
.nickname(member.getNickname())
.manner(member.getManner())
.imageUrl(member.getImageUrl())
.imageUrl(FileUtils.getThumbnailImageUrl(member.getImageUrl()))
.role(role)
.build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,17 +114,32 @@ private void attachImagesToGoodsPost(GoodsPost goodsPost, List<MultipartFile> fi
private List<GoodsPostImage> uploadImageFiles(List<MultipartFile> files, GoodsPost savedPost) {
List<GoodsPostImage> images = new ArrayList<>();

for (MultipartFile file : files) {
String uploadUrl = fileService.uploadFile(file);
GoodsPostImage image = GoodsPostImage.builder()
.imageUrl(uploadUrl)
.post(savedPost)
.build();
images.add(image);
// 첫 번째 파일은 썸네일 생성과 함께 업로드
images.add(handleImageUpload(files.get(0), savedPost, true));

// 나머지 파일들은 일반 업로드
for (int i = 1; i < files.size(); i++) {
images.add(handleImageUpload(files.get(i), savedPost, false));
}

return images;
}

private GoodsPostImage handleImageUpload(MultipartFile file, GoodsPost savedPost, boolean isMainImage) {
String uploadUrl;

if (isMainImage) {
uploadUrl = fileService.uploadImageWithThumbnail(file); // 썸네일 업로드
} else {
uploadUrl = fileService.uploadFile(file); // 일반 업로드
}

return GoodsPostImage.builder()
.imageUrl(uploadUrl)
.post(savedPost)
.build();
}

private void deleteExistingImageFiles(Long goodsPostId) {
List<String> imageUrls = imageRepository.getImageUrlsByPostId(goodsPostId);
imageUrls.forEach(fileService::deleteFile);
Expand Down
Loading

0 comments on commit 4cc99f8

Please sign in to comment.