Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/#464 이미지 s3 도입 #479

Merged
merged 26 commits into from
Oct 23, 2023
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1d873ec
feat: 이미지를 저장할 수 있는 ImageService 및 ImageClient 생성
hectick Oct 6, 2023
5a0865b
refactor: 로컬 환경에서 이미지를 저장, 삭제할 때 ImageFileUploader 대신 LocalImageClien…
hectick Oct 6, 2023
cddb4c9
refactor: 게시글, 댓글 조회시 이미지 url을 Domain을 통해서가 아니라 ImageService를 통해서 알아내…
hectick Oct 6, 2023
2e7fa41
refactor: properties 파일의 domain 변수명을 image.domain으로 구체화 및 테스트 코드 추가
hectick Oct 6, 2023
7b00a10
fix: 이미지 삭제 버그 수정
hectick Oct 6, 2023
8a2247c
refactor: 메서드명 수정
hectick Oct 6, 2023
947af09
feat: prod 환경에서 s3에 이미지를 저장하는 기능 구현
hectick Oct 10, 2023
36efbbc
Merge remote-tracking branch 'origin/dev' into feat/#464_이미지_s3_도입
hectick Oct 10, 2023
970d320
refactor: application-prod.properties 다듬기
hectick Oct 10, 2023
9fb67b8
fix: S3Client configuration
hectick Oct 10, 2023
2743167
fix: final 제거
hectick Oct 10, 2023
da9f56f
fix: contentType 지정
hectick Oct 10, 2023
39a5b0f
refactor: 주석 제거
hectick Oct 10, 2023
ea1f06b
feat: 이미지 s3로 이주하는 api 생성
hectick Oct 13, 2023
69b8b73
Merge remote-tracking branch 'origin/dev' into feat/#464_이미지_s3_도입
hectick Oct 18, 2023
ebe422d
fix: 머지 후 애플리케이션 동작하도록 코드 수정
hectick Oct 18, 2023
55e18df
refactor:
hectick Oct 18, 2023
9e46be3
Merge remote-tracking branch 'origin/dev' into feat/#464_이미지_s3_도입
hectick Oct 18, 2023
2e05fb6
refactor: 어노테이션 순서 변경
hectick Oct 18, 2023
e37c0fe
docs: 회원정보수정 문서화 수정
hectick Oct 18, 2023
ed721e0
refactor: 변수명 변경
hectick Oct 18, 2023
bef925f
refactor: 캡슐화
hectick Oct 18, 2023
1880dea
refactor: ImageInfoFactory 클래스 분리 및 image 관련 클래스들 패키지 변경
hectick Oct 18, 2023
de6231a
refactor: 오래된 파일 경로 원상복구
hectick Oct 18, 2023
b435108
feat: 프로필 자동 삭제 기능 도입
hectick Oct 18, 2023
d7cda0b
Merge remote-tracking branch 'origin/dev' into feat/#464_이미지_s3_도입
hectick Oct 23, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ dependencies {
// flyway 추가
implementation 'org.flywaydb:flyway-mysql'
implementation 'org.flywaydb:flyway-core'

//s3
implementation platform('software.amazon.awssdk:bom:2.20.56')
implementation 'software.amazon.awssdk:s3:'

}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
import edonymyeon.backend.comment.repository.CommentRepository;
import edonymyeon.backend.global.exception.EdonymyeonException;
import edonymyeon.backend.global.exception.ExceptionInformation;
import edonymyeon.backend.image.ImageFileUploader;
import edonymyeon.backend.image.application.ImageService;
import edonymyeon.backend.image.application.ImageType;
import edonymyeon.backend.image.commentimage.domain.CommentImageInfo;
import edonymyeon.backend.image.commentimage.repository.CommentImageInfoRepository;
import edonymyeon.backend.image.domain.Domain;
import edonymyeon.backend.member.application.dto.MemberId;
import edonymyeon.backend.member.domain.Member;
import edonymyeon.backend.member.repository.MemberRepository;
Expand All @@ -38,12 +38,10 @@ public class CommentService {

private final CommentImageInfoRepository commentImageInfoRepository;

private final ImageFileUploader imageFileUploader;
private final ImageService imageService;

private final ApplicationEventPublisher publisher;

private final Domain domain;

@Transactional
public long createComment(final MemberId memberId, final Long postId, final CommentRequest commentRequest) {
final Post post = postRepository.findById(postId)
Expand All @@ -68,7 +66,7 @@ private CommentImageInfo extractCommentImageInfo(final MultipartFile image) {
if (image == null || image.isEmpty()) {
return null;
}
final CommentImageInfo commentImageInfo = CommentImageInfo.from(imageFileUploader.uploadFile(image));
final CommentImageInfo commentImageInfo = CommentImageInfo.from(imageService.save(image, ImageType.COMMENT));
commentImageInfoRepository.save(commentImageInfo);
return commentImageInfo;
}
Expand Down Expand Up @@ -102,6 +100,6 @@ private String convertToImageUrl(final CommentImageInfo commentImageInfo) {
return null;
}
final String imageFileName = commentImageInfo.getStoreName();
return domain.convertToImageUrl(imageFileName);
return imageService.convertToImageUrl(imageFileName, ImageType.COMMENT);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package edonymyeon.backend.global.config;

import static software.amazon.awssdk.regions.Region.AP_NORTHEAST_2;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider;
import software.amazon.awssdk.services.s3.S3Client;

@Configuration
public class S3Config {

@Bean
public S3Client s3Client() {
return S3Client.builder()
.credentialsProvider(InstanceProfileCredentialsProvider.builder().build())
.region(AP_NORTHEAST_2)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public enum ExceptionInformation {
REQUEST_FILE_SIZE_TOO_LARGE(2, "첨부 파일의 용량이 제한을 초과하였습니다."),
CACHE_NOT_FOUND(100, "캐싱된 값이 없습니다."),
INVALID_SETTING_MANAGER_ASSIGNED(201, "잘못된 설정 매니저가 매핑되었습니다"),
UNSUPPORTED_METHOD_CALL(202, "현재 지원하지 않는 기능입니다"),

// 클래스이름_필드명_틀린내용
// 1___: 인증 관련
Expand Down Expand Up @@ -59,6 +60,10 @@ public enum ExceptionInformation {
IMAGE_EXTENSION_INVALID(5000, "등록할 수 없는 이미지 확장자입니다."),
IMAGE_DOMAIN_INVALID(5001, "이미지의 url 경로가 잘못되었습니다."),
IMAGE_STORE_NAME_INVALID(5002, "유효하지 않은 이미지 이름이 포함되어 있습니다."),
IMAGE_GET_BYTE_FAILED(5003, "이미지 파일을 byte로 변환할 수 없습니다."),
IMAGE_ORIGINAL_DIRECTORY_INVALID(5004, "기존 이미지 파일 디렉터리를 읽을 수 없습니다."),
IMAGE_CONTENT_TYPE_FAIL(5005, "기존 이미지의 content type을 알 수 없습니"),
IMAGE_DELETION_FAIL(5006, "이미지 삭제를 실패하였습니다"),

// 6___: 소비, 절약 관련
CONSUMPTION_POST_ID_ALREADY_EXIST(6000, "이미 소비, 절약 여부가 확정된 게시글입니다."),
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package edonymyeon.backend.image.application;

import org.springframework.web.multipart.MultipartFile;

public interface ImageClient {

void upload(final MultipartFile image, final String directory, final String storeName);

boolean supportsDeletion();

void delete(final String imagePath);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package edonymyeon.backend.image.application;

import java.io.File;

public interface ImageMigrationClient {

void migrate(final File image, final String directory, final String storeName);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package edonymyeon.backend.image.application;

import static edonymyeon.backend.global.exception.ExceptionInformation.IMAGE_ORIGINAL_DIRECTORY_INVALID;
import static java.util.Objects.isNull;

import edonymyeon.backend.global.exception.BusinessLogicException;
import edonymyeon.backend.image.commentimage.domain.CommentImageInfo;
import edonymyeon.backend.image.commentimage.repository.CommentImageInfoRepository;
import edonymyeon.backend.image.postimage.domain.PostImageInfo;
import edonymyeon.backend.image.postimage.repository.PostImageInfoRepository;
import java.io.File;
import java.io.FilenameFilter;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
public class ImageMigrationService {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 서비스는 migration 끝나면 제거할건가유?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

핫픽스 해야되지 않을까욤


private final PostImageInfoRepository postImageInfoRepository;

private final CommentImageInfoRepository commentImageInfoRepository;

private final ImageMigrationClient imageMigrationClient;

@Value("${image.root-dir}")
private String newDir;

@Value("${file.dir}")
private String originalDir;

public void migrate() {
File[] files = new File(originalDir).listFiles(getFilter());
if (isNull(files)) {
throw new BusinessLogicException(IMAGE_ORIGINAL_DIRECTORY_INVALID);
}

final List<PostImageInfo> postImages = postImageInfoRepository.findAllImages();
final List<CommentImageInfo> commentImages = commentImageInfoRepository.findAllImages();
//todo: 프로필 이미지 꺼내오기

for (File file : files) {
final String name = file.getName();
// 게시글 이미지인지 판단
if (isPostImage(postImages, name)) {
imageMigrationClient.migrate(file, newDir + ImageType.POST.getSaveDirectory(), name);
}
// 댓글 이미지인지 판단
if (isCommentImage(commentImages, name)) {
imageMigrationClient.migrate(file, newDir + ImageType.COMMENT.getSaveDirectory(), name);
}
// todo: 프로필 이미지라면 -> cloudfront의 profile 폴더로 이동
// todo: 어디에도 해당되지 않는 경우(db에 정보가 없는!) -> 삭제
}
}

@NotNull
private FilenameFilter getFilter() {
return new FilenameFilter() {
@Override
public boolean accept(final File dir, final String name) {
return !name.equals("edonymyeon-firebase.json");
}
};
}

private boolean isPostImage(final List<PostImageInfo> postImages, final String name) {
return postImages.stream().anyMatch(each -> each.getStoreName().equals(name));
}

private boolean isCommentImage(final List<CommentImageInfo> commentImages, final String name) {
return commentImages.stream().anyMatch(each -> each.getStoreName().equals(name));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package edonymyeon.backend.image.application;

import static edonymyeon.backend.global.exception.ExceptionInformation.IMAGE_EXTENSION_INVALID;

import edonymyeon.backend.global.exception.EdonymyeonException;
import edonymyeon.backend.image.ImageExtension;
import edonymyeon.backend.image.ImageFileNameStrategy;
import edonymyeon.backend.image.domain.Domain;
import edonymyeon.backend.image.domain.ImageInfo;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

@Service
@RequiredArgsConstructor
This2sho marked this conversation as resolved.
Show resolved Hide resolved
public class ImageService {
This2sho marked this conversation as resolved.
Show resolved Hide resolved

private final ImageClient imageClient;

private final ImageFileNameStrategy imageFileNameStrategy;

private final Domain domain;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이미지 서비스가 생기면서 각각 서비스에서 사용하던
도메인을 한곳에서 관리하니 편하네여!!!!


@Value("${image.root-dir}")
private String rootDirectory; //todo: 어디다 두는게 좋을까?

public ImageInfo save(final MultipartFile image, final ImageType imageType) {
final String originalFileName = image.getOriginalFilename();
validateExtension(originalFileName);
final String storeName = imageFileNameStrategy.createName(originalFileName);
final ImageInfo imageInfo = new ImageInfo(storeName); //todo: imageType에 따라 imageInfo 변환
imageClient.upload(
image,
rootDirectory + imageType.getSaveDirectory(),
storeName
);
return imageInfo; //todo: 생성된 imageInfo 저장 및 반환
}

public List<ImageInfo> saveAll(final List<MultipartFile> images, final ImageType imageType) {
return images.stream()
.map(each -> save(each, imageType))
.toList();
}

private void validateExtension(final String originalFileName) {
final String ext = ImageExtension.extractExt(originalFileName);
if (ImageExtension.contains(ext)) {
return;
}
throw new EdonymyeonException(IMAGE_EXTENSION_INVALID);
}

/**
* @param storeName 이미지의 이름
* @param imageType 이미지 사용처
* @return 리소스가 저장된 실제 경로
*/
private String findResourcePath(final String storeName, final ImageType imageType) {
return rootDirectory + imageType.getSaveDirectory() + storeName;
}

/**
* @param fileName 예를 들면 post/name.png 처럼 이미지 타입까지 같이 들어온다
* @return 리소스가 저장된 실제 경로
*/
public String findResourcePath(final String fileName) {
return rootDirectory + fileName;
}

/**
* @param imageType 이미지 사용처
* @return 사용자에게 보여줄 이미지 도메인 주소(단 이미지 파일 이름은 제외, 예를 들어 https://localhost:8080/images/post/)
*/
public String findBaseUrl(final ImageType imageType) {
return domain.getDomain() + imageType.getSaveDirectory();
}

public void removeImage(final ImageInfo imageInfo, final ImageType imageType) {
if(!imageClient.supportsDeletion()){
return;
}
//todo: 이미지 삭제?
imageClient.delete(findResourcePath(imageInfo.getStoreName(), imageType));
}

public String convertToImageUrl(final String fileName, final ImageType imageType) {
return domain.convertToImageUrl(imageType.getSaveDirectory() + fileName);
}

public List<String> removeDomainFromUrl(final List<String> originalImageUrls, final ImageType imageType) {
return domain.removeDomainFromUrl(originalImageUrls, imageType.getSaveDirectory());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package edonymyeon.backend.image.application;

public enum ImageType {

POST("post/"),
COMMENT("comment/"),
PROFILE("profile/");

private final String saveDirectory;

ImageType(final String saveDirectory) {
this.saveDirectory = saveDirectory;
}

public String getSaveDirectory() {
return saveDirectory;
}
}
Loading