개발일기
Sping boot AWS bucket 업로드, 수정 , 삭제하기 본문
글을 읽기 전에 해당 부분은 완벽한 글이 아님으로 참고하시길 바랍니다. 또한 프론트에서 각각 파일업로드 및 수정, 삭제 작업을 해줄 때, Controller 로 요청을 보내야 작동합니다. 이는 각각의 도메인에 파일을 기능을 완벽하게 분리하기 위하여 해당 방식으로 작업하였습니다.
또한 Cloudfront를 사용하여 path를 통하여 이미지를 접근할 수 있도록 만들어주었습니다.
우선 이미지 컨트롤러를 만들어주었다.
기존 포토카드와 디렉토리 구조는 같다. 여기서 수정해야 할 사항은.. ImageRepository가 너무 Jpa의존적이다. 그래서 ImageRepository관련해서는 바뀔 가능성이 높다.
@Slf4j
public class FileUtil {
/**
* 랜덤 파일이름 생성
* @param filename 파일이름
* @return 파일 이름 생성
*/
public static String generateFileName(String filename) {
return UUID.randomUUID() + fileExtension(filename);
}
/**
* 파일 확장자 이름 가져오기
* @param filename 파일이름
* @return 파일 확장자
*/
public static String fileExtension(String filename) {
return filename.substring(filename.lastIndexOf("."), filename.length());
}
/**
* 이미지 MimeType 확장자 체크
* @param stream 스트림
* @return 확장 체크 True, False 반환
* @throws IOException
*/
public static boolean imageExtensionCheck(InputStream stream) throws IOException {
Tika tika = new Tika();
String mimeType = tika.detect(stream);
log.info("MIME TYPE = {}", mimeType);
return mimeType.startsWith("image/png") || mimeType.startsWith("image/jpeg") || mimeType.startsWith("image/jpg");
}
public static String getMimeType(InputStream stream) throws IOException {
Tika tika = new Tika();
return tika.detect(stream);
}
public File convert(MultipartFile mfile) throws IOException {
File file = new File(mfile.getOriginalFilename());
file.createNewFile();
FileOutputStream fos = new FileOutputStream(file);
fos.write(mfile.getBytes());
fos.close();
return file;
}
가장 먼저 FileUtill 관련된 부분이다. 해당 부분은 확장자명 및, MultipartFile을 파일형식으로 바꾸기 위해 사용되는 Util이 존재한다.
각각의 기능은 주석을 달아두었습니다. 참고하시길 바랍니다.
/**
* 임시 파일 저장 Controller
* @param multipartFiles
* @return
*/
@PostMapping("/temp")
public ResponseEntity<Object> tempImageUpload(@RequestParam("files") List<MultipartFile> multipartFiles) {
List<Image> images = new ArrayList<>();
multipartFiles.forEach(file-> images.add(storageService.temporaryImageUpload(file, TEMPDIR)));
List<ImageResponse> result = new ArrayList<>();
makeImageResponse(images, result);
Map<String, List<ImageResponse>> response = new HashMap<>();
response.put("result", result);
return ResponseEntity.ok(response);
}
/**
* 실제 파일 저장 Controller
* @param request
* @return
*/
@PostMapping("")
public ResponseEntity<Object> imageUpload(@RequestBody ImageUploadRequest request) {
List<Image> images = new ArrayList<>();
request.getImagePaths().forEach(path-> images.add(storageService.imageUpload(path, request.getFeatureDir())));
List<ImageResponse> result = new ArrayList<>();
makeImageResponse(images, result);
Map<String, List<ImageResponse>> response = new HashMap<>();
response.put("result", result);
return ResponseEntity.ok(response);
}
/**
* 파일 삭제 Controller
* @param request
* @return
*/
@DeleteMapping("")
public String fileDelete(@RequestBody ImageDeleteRequest request) {
request.getImagePaths().forEach(path-> storageService.imageDelete(path, request.getFeatureDir()));
return "이미지 삭제 성공";
}
/**
* 파일 전체 공통 응답 값을 내려주기 위한 메서드
* @param images
* @param result
*/
private static void makeImageResponse(List<Image> images, List<ImageResponse> result) {
for(Image image : images) {
result.add(ImageResponse.builder()
.imageSeq(image.getImageSeq())
.imagePath(image.getImagePath())
.build());
}
}
컨트롤러입니다. 각각의 도메인 또는 엔티티는 개개인 마다 다를 것으로 해당 글에서는 제외 시키겠습니다.
package darkoverload.itzip.image.service;
import darkoverload.itzip.image.domain.Image;
import org.springframework.web.multipart.MultipartFile;
import java.awt.*;
public interface StorageService {
Image temporaryImageUpload(MultipartFile multipartFile, String featureDir);
Image imageUpload(String imagePath, String featureDir);
void imageDelete(String imagePath, String featureDir);
}
StorageService 인터페이스를 만들어 조금 더 유연한 설계를 만들어 주었습니다. 이를 통해서 StorageService 에 최대한 변경되도 로직적으로 변경이 적도록 만들어주기 위하여 구현하였습니다.
임시 저장 메소드입니다.
public Image temporaryImageUpload(MultipartFile multipartFile, String featureDir) {
if(multipartFile.isEmpty()) throw new RestApiException(ImageExceptionCode.IMAGE_NOT_FOUND);
InputStream inputStream = null;
Image result = null;
try {
inputStream = multipartFile.getInputStream();
if(!FileUtil.imageExtensionCheck(multipartFile.getInputStream())){
throw new RestApiException(ImageExceptionCode.IMAGE_FORMAT_ERROR);
}
Image originImage = Image.createImage(multipartFile, featureDir);
AWSFile awsFile = null;
try {
awsFile = awsService.upload(originImage, inputStream);
} catch (IOException e) {
throw new RestApiException(ImageExceptionCode.IMAGE_ERROR);
}
Image insertData = Image.builder()
.imageName(awsFile.getFilename())
.imagePath(awsFile.getFilePath())
.imageType(awsFile.getFileType())
.imageSize(awsFile.getSize())
.build();
result = imageService.save(insertData);
} catch (IOException e) {
throw new RestApiException(ImageExceptionCode.IMAGE_ERROR);
}
return result;
}
1. 해당 부분은 inputstream , Image 도메인을 메서드를 통해서 originImage로 던져주어 값을 받아옵니다.
2. awsFile을 이제 데이터베이스에 실질적으로 저장하기 위한 insertData로 변경하합니다.
3. InsertData를 save로 내려주고 이를 result로 받아 컨트롤러로 반환합니다.
이미지를 업로드 하기 위한 upload메소드입니다.
public AWSFile upload(Object file, InputStream inputStream) throws IOException {
AWSFile in = null;
// 이미지 파일 처리
if(file instanceof Image) {
Image image = (Image) file;
String bucketDir = bucketName + "/" + image.getFeatureDir();
amazonS3.putObject(new PutObjectRequest(bucketDir, image.getImageName(), inputStream, getObjectMetadata(image))
.withCannedAcl(CannedAccessControlList.PublicRead));
String dirUrl = filePath + image.getFeatureDir() + "/" + image.getImageName();
log.info("imageName :: {}", image.getImageName());
in = AWSFile.builder().filePath(dirUrl)
.filename(image.getImageName())
.size(image.getImageSize())
.fileType(image.getImageType())
.contentType(image.getImagePath())
.build();
}
log.info("in :: {}", in);
return in;
}
1. 이미지 파일 처리, 실질적인 docs처리를 같이 담당해야하기에 해당 instanceof를 통하여 확인해주었습니다.
2. bucketDir을 통해서 url을 만들어주었으며 해당 url로 putObject를 통해서 실질적으로 bucket에 저장되었습니다.
3. dirUrl은 이제 cloudfront로 나온 url이며 이를 통해서 실질적으로 db에 저장 시켜줄 것 입니다. 최대한 각각의 엔티티끼리 소통하지 않기위해 여러개 파일이 들어가지 않는 한 각각의 엔티티에 Path값을 저장 시킬 예정입니다. 이를 통해서 파일 전체 관리 테이블과 최대한 조인을 안하며 사용하려고 합니다.
이는 저희가 bucket을 사용한 이유이기도 합니다.
private ObjectMetadata getObjectMetadata(Image image) {
ObjectMetadata objectMetadata = new ObjectMetadata();
objectMetadata.setContentType(image.getImageType());
objectMetadata.setContentLength(image.getImageSize());
return objectMetadata;
}
AWS Metadata만드는 부분을 해당 부분 처럼 따로 메소드로 만들어주었습니다.
버켓 파일 저장 메소드
public void delete(String imageName, String featureDir){
String bucketDir = bucketName + "/" + featureDir;
String keyName = imageName;
log.info(bucketDir);
boolean isObjectExist = amazonS3.doesObjectExist(bucketDir, keyName);
if(isObjectExist) {
amazonS3.deleteObject(new DeleteObjectRequest(bucketDir, keyName));
} else {
ExceptionHandlerUtil.handleExceptionInternal(ImageExceptionCode.IMAGE_NOT_FOUND);
}
}
해당 부분은 존재 여부를 확인하여 버켓에 데이터가 존재한다면 삭제 시켜주었고 만약 없다면 전역 예외를 통해서 IMAGE_NOT_FOUND를 내려주었습니다.
실질적인 디렉토리로 파일 이동
public String moveFile(Image image,String featureDir){
String newSource = bucketName + "/" + featureDir;
String oldSource = makeOldResource(image);
String keyName = image.getImageName();
boolean isObjectExist = amazonS3.doesObjectExist(oldSource, image.getImageName());
if(!isObjectExist) ExceptionHandlerUtil.handleExceptionInternal(ImageExceptionCode.IMAGE_NOT_FOUND);
amazonS3.copyObject(oldSource, keyName, newSource, keyName);
amazonS3.deleteObject(oldSource, image.getImageName());
return filePath + featureDir + "/" + image.getImageName();
}
해당 코드는 이미 존재하는 파일이 있다면 실질 사용할 디렉토리로 파일을 이동시켜주는 메소드입니다.
이를 통해서 임시저장 파일 디렉토리에서 실질적인 파일 디렉토리로 파일을 이동시켜줍니다.
또한 마찬가지로 반환 값에 Path를 넣어줍니다.
이미지 실질적으로 업로드 하는 메소드 부분입니다.
public Image imageUpload(String imagePath, String featureDir){
Image findImage= imageService.findByImagePath(imagePath);
String moveImagePath = awsService.moveFile(findImage, featureDir);
imageService.imagePathUpdate(moveImagePath, findImage.getImageSeq());
Image result = Image.builder()
.imageSeq(findImage.getImageSeq())
.imagePath(moveImagePath)
.build();
return result;
}
makeOldResource 메서드입니다.
private String makeOldResource(Image image) {
if(!image.getImagePath().contains("temporary"))
ExceptionHandlerUtil.handleExceptionInternal(ImageExceptionCode.IMAGE_NOT_TEMP);
int index = image.getImagePath().lastIndexOf("temporary");
String tempPath = image.getImagePath().substring(index, index+9);
return bucketName + "/" + tempPath;
}
해당 부분는 temporary 디렉토리를 통해서 이전에 디렉토리로 가공해주는 역할을 합니다.
StorageService 구현체 전체 코드입니다.
package darkoverload.itzip.image.service;
import darkoverload.itzip.global.config.response.code.CommonExceptionCode;
import darkoverload.itzip.global.config.response.exception.RestApiException;
import darkoverload.itzip.global.config.response.handler.Util.ExceptionHandlerUtil;
import darkoverload.itzip.image.code.ImageExceptionCode;
import darkoverload.itzip.image.domain.Image;
import darkoverload.itzip.image.util.FileUtil;
import darkoverload.itzip.infra.bucket.domain.AWSFile;
import darkoverload.itzip.infra.bucket.service.AWSService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
@Slf4j
@Service
@RequiredArgsConstructor
public class CloudStorageService implements StorageService {
private final ImageService imageService;
private final AWSService awsService;
@Transactional
@Override
public Image temporaryImageUpload(MultipartFile multipartFile, String featureDir) {
if(multipartFile.isEmpty()) throw new RestApiException(ImageExceptionCode.IMAGE_NOT_FOUND);
InputStream inputStream = null;
Image result = null;
try {
inputStream = multipartFile.getInputStream();
if(!FileUtil.imageExtensionCheck(multipartFile.getInputStream())){
throw new RestApiException(ImageExceptionCode.IMAGE_FORMAT_ERROR);
}
Image originImage = Image.createImage(multipartFile, featureDir);
AWSFile awsFile = null;
try {
awsFile = awsService.upload(originImage, inputStream);
} catch (IOException e) {
throw new RestApiException(ImageExceptionCode.IMAGE_ERROR);
}
Image insertData = Image.builder()
.imageName(awsFile.getFilename())
.imagePath(awsFile.getFilePath())
.imageType(awsFile.getFileType())
.imageSize(awsFile.getSize())
.build();
result = imageService.save(insertData);
} catch (IOException e) {
throw new RestApiException(ImageExceptionCode.IMAGE_ERROR);
}
return result;
}
@Transactional
public Image imageUpload(String imagePath, String featureDir){
Image findImage= imageService.findByImagePath(imagePath);
String moveImagePath = awsService.moveFile(findImage, featureDir);
imageService.imagePathUpdate(moveImagePath, findImage.getImageSeq());
Image result = Image.builder()
.imageSeq(findImage.getImageSeq())
.imagePath(moveImagePath)
.build();
return result;
}
@Transactional
public void imageDelete(String imagePath, String featureDir) {
Image findImage = imageService.findByImagePath(imagePath);
imageService.delete(findImage.getImageSeq());
awsService.delete(findImage.getImageName(), featureDir);
}
}
AWSService 전체 코드입니다.
package darkoverload.itzip.infra.bucket.service;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.*;
import darkoverload.itzip.global.config.response.handler.Util.ExceptionHandlerUtil;
import darkoverload.itzip.image.code.ImageExceptionCode;
import darkoverload.itzip.image.domain.Image;
import darkoverload.itzip.infra.bucket.domain.AWSFile;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
@Service
@Slf4j
@RequiredArgsConstructor
public class AWSService {
private final AmazonS3 amazonS3;
@Value("${cloud.aws.s3.bucket}")
private String bucketName;
@Value("${file.cloudfront-path}")
private String filePath;
public AWSFile upload(Object file, InputStream inputStream) throws IOException {
AWSFile in = null;
// 이미지 파일 처리
if(file instanceof Image) {
Image image = (Image) file;
String bucketDir = bucketName + "/" + image.getFeatureDir();
amazonS3.putObject(new PutObjectRequest(bucketDir, image.getImageName(), inputStream, getObjectMetadata(image))
.withCannedAcl(CannedAccessControlList.PublicRead));
String dirUrl = filePath + image.getFeatureDir() + "/" + image.getImageName();
log.info("imageName :: {}", image.getImageName());
in = AWSFile.builder().filePath(dirUrl)
.filename(image.getImageName())
.size(image.getImageSize())
.fileType(image.getImageType())
.contentType(image.getImagePath())
.build();
}
log.info("in :: {}", in);
return in;
}
private ObjectMetadata getObjectMetadata(Image image) {
ObjectMetadata objectMetadata = new ObjectMetadata();
objectMetadata.setContentType(image.getImageType());
objectMetadata.setContentLength(image.getImageSize());
return objectMetadata;
}
public void delete(String imageName, String featureDir){
String bucketDir = bucketName + "/" + featureDir;
String keyName = imageName;
log.info(bucketDir);
boolean isObjectExist = amazonS3.doesObjectExist(bucketDir, keyName);
if(isObjectExist) {
amazonS3.deleteObject(new DeleteObjectRequest(bucketDir, keyName));
} else {
ExceptionHandlerUtil.handleExceptionInternal(ImageExceptionCode.IMAGE_NOT_FOUND);
}
}
public String moveFile(Image image,String featureDir){
String newSource = bucketName + "/" + featureDir;
String oldSource = makeOldResource(image);
String keyName = image.getImageName();
boolean isObjectExist = amazonS3.doesObjectExist(oldSource, image.getImageName());
if(!isObjectExist) ExceptionHandlerUtil.handleExceptionInternal(ImageExceptionCode.IMAGE_NOT_FOUND);
amazonS3.copyObject(oldSource, keyName, newSource, keyName);
amazonS3.deleteObject(oldSource, image.getImageName());
return filePath + featureDir + "/" + image.getImageName();
}
private String makeOldResource(Image image) {
if(!image.getImagePath().contains("temporary"))
ExceptionHandlerUtil.handleExceptionInternal(ImageExceptionCode.IMAGE_NOT_TEMP);
int index = image.getImagePath().lastIndexOf("temporary");
String tempPath = image.getImagePath().substring(index, index+9);
return bucketName + "/" + tempPath;
}
}
해당 부분은 인터페이스로 한번 더 들어가야 고민을 했으나 충분히 이미 분리되어 있다고 판단하여 인터페이스를 만들지 않았습니다.
이부분은 아직도 고민중인 코드이며 어떤 것이 정답인지 모르겠습니다.
현재 테스트 코드를 작성중이며, 기능이 생각보다 간단하여 실질적으로 CRUD기능만 있습니다. 그래서 Mockito를 달았을 때,
실질적으로 검증할 부분이 없어 고민중이나 테스트 코드를 작성하려고 합니다. 추후에 테스트 코드가 완료되면 글을 올리도록 하겠습니다.
해당 글은 request, entity, domain , repository 부분은 제외되어 있으니 참고하시길 바랍니다.
'취준생 프로젝트' 카테고리의 다른 글
docker-compose ELK 설치 (1) | 2024.08.14 |
---|---|
WebClient를 사용한 커리어넷 학교 정보 API가져오기 (0) | 2024.08.08 |
SpringBoot AWS 설정하는 방법 (0) | 2024.07.16 |
AWS CloudFront 를 통한 안전한 S3 버킷 정책 설정 (1) | 2024.07.14 |
취준생 프로젝트 ERD 구조 및 회고 (0) | 2024.06.27 |