AWS s3 이미지 업로더 분리기 (feat. @Configurration 활용)
기존 코드의 설명을 하면 ,
하단의 코드는 서비스 레이어이며 s3 이미지를 담당하는 모듈의 기능들이다.
퍼블릭 클라우드인 aws 의 객체 저장소인 s3 에 이미지 파일을 올리기 위하여 작성을 해온 코드이다.
...
@Transactional(readOnly = true)
@Service
public class AwsS3ImageService {
..
public String uploadImageOnly(MultipartFile file, String folderName) throws IOException {
..
return amazonS3.getUrl(bucketName, key).toString();
}
public List<ImageDto> uploadImagesOnly(MultipartFile[] files, String folderName) {
..
}
public ImageDto uploadSingleImage(MultipartFile file, String folderName) throws IOException {
return new ImageDto(uploadMakeS3Image(file,folderName));
}
private S3Image uploadMakeS3Image(MultipartFile file, String folderName) throws IOException {
...
return image;
}
public String cutFullFileUrlIntoNameOnly(String fileUrl) {
...
return parsedResult;
}
@Transactional
public ImageDto uploadImageFile(MultipartFile file, String folderName) throws IOException {
...
return new ImageDto(image);
}
@Transactional
public S3Image imageSave(S3Image image) {
return imageRepository.save(image);
}
@Transactional
public List<ImageDto> uploadImageFiles(MultipartFile[] files, String folderName) {
..
}
@Transactional
public void deleteImage(Long id) {
...
}
public ImageDto getImageById(Long id) {
..
}
private void createFolder(String folderName) {
...
}
private boolean doesNotExistFolder(String folderName) {
..
}
}
이미지 파일들을 다루는 서비스에서는 클라이언트는 2 가지의 방식으로 이미지 파일을 업로드 및 조회를 하였다.
방식 1 ) 2번의 api 를 호출 하는 경우
파일을 업로드하는 api 를 호출 한 후 반환 받은 문자열 "폴더/파일명"을 사용하고자 하는 도메인의 api 의 requestbody 에 담아 전송한다.
1. 파일 업로드 api 를 사용하여 서버로 멀티 파트 폼데이터로 이미지 파일과 저장할 폴더명을 전송한다.
(팀끼리 서로 약속한 aws s3 의 폴더명이 아닌 경우 s3 에 새로 폴더를 자동으로 생성되고 객체의 접근은 불가하다.)
2. 서버는 s3로 이미지를 업로드 후 조회 가능한 객체의 s3 의 주소를 제외한 "폴더명/파일명"을 반환한다.
3. 클라언트 사이드에서 api 호출로 반환 받은 "폴더명/파일명"을 서버의 특정 도메인의 Post API를 사용하여 전달한다.
4. 3에서 클라이언트로 부터 받은 "폴더명/파일명"문자열을 데이터 베이스에 저장을 한다.
5. 클라이언트는 s3 의 접근 가능한 문자열 주소를 더하여 객체에 접근한다.
방식 2) 1 번의 api 를 호출 하는경우
1. 사용하고자 하는 도메인의 api에 멀티 파트에 서버에 저장하고자 하는 모든 정보들을 담아 전송한다.
2. 서버는 파일 서비스 모듈을 사용하여 s3 에 업로드 후 도메인 모델에 "폴더명/파일명" 정보를 저장한다.
3. 클라이언트는 반환된 데이터의 s3 의 접근 가능한 문자열 주소를 더하여 객체에 접근한다.
이러한 기능을 위하여 다음과 같이 하나의 클라스에 여러가지 일들을 한다.
1. s3 에 이미지를 올리거나 삭제만 하는 기능
2. s3 에 이미지를 올림과 동시에 파일들의 정보를 데이터 베이스인 mysql 에 저장 또는 삭제를 하는 기능
3. s3 의 이미지 주소의 앞부분을 제거하고 파일 명만 반환 하는 기능
4. s3 의 폴더가 없으면 생성을 하는 기능
여라가지의 일을 하는 하는 코드들이기 때문에 다음과 같은 상황에서 문제가 발생할 수 있다.
1. aws s3 에서 gcp storage 로 전환시 코드를 수정해야하는 부분을 파악하기 어려움
2. 이미지 파일을 업로드 및 삭제만을 진행 해야하는 부분과 데이터베이스의 반영을 해야하는 코드가 같이 있으므로 유지보수의 어려움
3. 테스트의 어려움
접근 가능한 이미지 주소의 "폴더명/파일명"을 추출하는 함수를 테스트하는데 불필요한 변수가 들어가는걸 다음 과 같이 보여준다.
class AwsS3ImageServiceTest {
private AmazonS3 amazonS3;
private AwsS3ImageRepository imageRepository;
private String givenUrl = "https://tavelfeeldog.s3.ap-northeast-2.amazonaws.com/place/cccmdmmmdmdmdmdmdmdmdmdmmdmdmdm.jpg1685954555706";
@Test
void stirng_parse_test() {
int lastIndex = givenUrl.indexOf(".com/") + 5; // Adding 5 to include ".com/"
String parsedResult = "";
if (lastIndex != -1 && lastIndex < givenUrl.length()) {
parsedResult = givenUrl.substring(lastIndex);
}
String expect = "place/cccmdmmmdmdmdmdmdmdmdmdmmdmdmdm.jpg1685954555706";
assertEquals(parsedResult,expect);
}
@Test
void full_url_cut_test(){
String expect = "place/cccmdmmmdmdmdmdmdmdmdmdmmdmdmdm.jpg1685954555706";
AwsS3ImageService awsS3ImageService = new AwsS3ImageService(amazonS3,imageRepository);
assertEquals(awsS3ImageService.cutFullFileUrlIntoNameOnly(givenUrl),expect);
}
}
코드 리펙토링에 앞서 다음과 같이 객체지향의 역할 및 기능을 중점으로 생각을 해보자.
1.
- s3 파일의 업로드와 다운로드만 담당하는 역할
- s3 파일 업로드 기능
- s3 파일 삭제 기능
- s3 폴더 생성 기능
- s3 폴더 확인 기능
2.
- 파일명 관련 기능 역할
- 조회 가능한 파일 주소의 파일 명 반환 기능
-객체의 파일명에 현 서버 시간을 문자열로 더해주는 기능
3.
- 공용 이미지 파일명을 데이터 베이스에 저장 및 삭제 기능 역할 등
4.
- 데이터 베이스에 저장하지 않고 클라우드에만 파일 업로드후 파일명을 반환 받는 역할
이를 통하여 다음과 같은 제약 사항이 필요하다.
1.
클라우드 플랫폼과 독립성을 위하여 s3 와 관련된 코드만 관리를 하자.
2.
인터페이스를 추가하여 파일 업로드의 구현 관련 코드를 관리하자.
s3 모듈의 기존 모델을 바꿔주자.
기존)
@Entity
@Getter @Setter
@NoArgsConstructor // using Lombok annotation for constructor
public class S3Image {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Transient
private MultipartFile file; // not in database
private String fileName;
private String folderName;
private String fileType;
private Long fileSize;
private String fileUrl;
public S3Image(String folderName, MultipartFile file) {
this.folderName = folderName;
this.file = file;
this.fileName = file.getOriginalFilename();
this.fileType = file.getContentType();
this.fileSize = file.getSize();
}
}
변경 후 ) 상단의 모델에서 s3 와 데이터베이스 간의 의존성을 분리해주자.
패키지 : infra/aws/s3/domain/model/S3Image.class
S3 에 이미지 파일들을 다루기 위한 객체
import org.springframework.web.multipart.MultipartFile;
import travelfeeldog.global.file.domain.model.ImageFile;
@Getter
@NoArgsConstructor
public class S3Image extends ImageFile {
public S3Image(String folderName, MultipartFile file) {
this.folderName = folderName;
this.file = file;
this.fileName = file.getOriginalFilename();
this.fileType = file.getContentType();
this.fileSize = file.getSize();
}
}
패키지 : global/file/domain/model
클라우드에 상관없이 사용할 이미지 객체
@Entity
@Getter
@NoArgsConstructor
public class ImageFile {
@Id
Long id;
@Transient
protected MultipartFile file; // not in database
protected String fileName;
protected String fileType;
protected Long fileSize;
protected String folderName;
public ImageFile(MultipartFile file, String fileName, String folderName) {
this.fileName = fileName;
this.fileType = file.getContentType();
this.fileSize = file.getSize();
this.folderName = folderName;
}
public ImageFile(ImageDto imageDto){
this.fileName = imageDto.getFileName();
this.fileType = imageDto.getFileType();
this.fileSize = imageDto.getFileSize();
this.folderName = imageDto.getFolderName();
}
}
ImageFile 클라스를 추상 클라스로 선언을 하지 않는 이유는 다음과 같다.
추상 클라스는 객체를 만들수가 없다 . 즉 추상 클라스를 구현한 s3 객체를 Data JPA 를 사용하여 디비에 저장을 해야한다.
인프라가 변경 될때 마다 최소한의 변경의 목적을 둔 리펙토링의 목적에 맞지 않는 복잡한 방향으로 가게 된다.
데이터 베이스와 퍼블릭 클라우드의 코드를 분리를 하는게 목적이다.
데이터 베이스에 저장을 하지 않고 파일을 업로드 및 삭제를 위한 서비스레이어이다.
패키지 : global/file/domain/application
@Service
public class ImageFileService {
private final ImageFileHandle imageFileHandle;
public ImageFileService(ImageFileHandle imageFileHandle){
this.imageFileHandle = imageFileHandle;
}
public String uploadImageFile(MultipartFile file, String folderName) {
ImageFile imageFile= null;
try {
imageFile = imageFileHandle.uploadImageFile(file,folderName);
} catch (IOException e) {
throw new RuntimeException(e);
}
return imageFile.getFileName();
}
public List<ImageDto> uploadImageFiles(MultipartFile[] files, String folderName) {
return imageFileHandle.uploadImageFiles(files,folderName).stream().map(ImageDto::new).toList();
}
public void deleteImage(String fileName,String folderName) {
imageFileHandle.deleteImage(fileName,folderName);
}
}
여기서 ImageHandle 은 s3 와 gcp storage 와 같은 퍼블릭 클라우드에서 구현할 기능들의 인터페이스 이다.
패키지 : global/file/domain/application
public interface ImageFileHandle {
ImageFile uploadImageFile(MultipartFile file, String folderName) throws IOException;
List<ImageFile> uploadImageFiles(MultipartFile[] file, String folderName);
void deleteImage(String fileName, String folderName);
}
특정 도메인이 아닌 공용으로 사용되는 파일을 저장하기 위한 서비스 코드는 다음과 같다.
이미지 파일 서비스를 통해 클라우드에 업로드를 한후 mysql 과 같은 데이터 배이스에 datajpa 를 이용하여 저장한다.
패키지 : global/file/domain/application
@Transactional
@Service
public class GlobalImageFileService {
GlobalImageRepository globalImageRepository;
ImageFileService imageFileService;
@Transactional(readOnly = true)
public ImageDto getGlobalImageFileById(Long id) {
ImageFile imageFile = globalImageRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Image not found for id: " + id));
return new ImageDto(imageFile);
}
public ImageDto uploadGlobalImageFile(MultipartFile file, String folderName) {
String fileName = imageFileService.uploadImageFile(file,folderName);
ImageFile imageFile = new ImageFile(file,fileName,folderName);
globalImageRepository.save(imageFile);
return new ImageDto(imageFile);
}
public List<ImageDto> uploadGlobalImageFiles(MultipartFile[] files, String folderName) {
List<ImageDto> uploadImageFiles= imageFileService.uploadImageFiles(files,folderName);
List<ImageFile> uploadedFiles = uploadImageFiles
.stream()
.map(ImageFile::new)
.toList();
globalImageRepository.saveAll(uploadedFiles);
return uploadImageFiles;
}
}
이미지 파일 업로들 위하여 GCP 를 사용할지 S3를 사용할지 정해주는 Config 파일 이다.
Spring Framework 의 장점인 DI 와 다형성을 보여준다.
유연성과 테스트 용이성을 향상 시키며 코드의 결합도를 낮추고 유지보수성을 향상 시킨다.
패키지 : global/file/config
@Configuration
public class FileConfig {
private final S3ImageService s3ImageService;
public FileConfig(S3ImageService s3ImageService) {
this.s3ImageService = s3ImageService;
}
@Bean
public ImageFileHandle imageFileHandle() {
return s3ImageService;
}
}
만약 gcp 이미지 서비스도 사용한다면 다음과 같이 작성을 한다.
@Configuration
public class FileConfig {
private final S3ImageService s3ImageService;
private final GCPImageService gcpImageService;
private final String imageServiceType;
public FileConfig(S3ImageService s3ImageService, GCPImageService gcpImageService,
@Value("${image.service.type}") String imageServiceType) {
this.s3ImageService = s3ImageService;
this.gcpImageService = gcpImageService;
this.imageServiceType = imageServiceType;
}
@Bean
public ImageFileHandle imageFileHandle() {
if ("s3".equalsIgnoreCase(imageServiceType)) {
return s3ImageService;
} else if ("gcp".equalsIgnoreCase(imageServiceType)) {
return gcpImageService;
}
throw new IllegalArgumentException("Invalid image service type: " + imageServiceType);
}
}
s3FileUploader 빈은 AmazonS3를 의존성으로 주입받아 S3ImageService를 생성한다.
마지막으로 ImageFileHandle 인터페이스의 기능들을 구현한 S3ImageService 이다.
(리펙토리을 진행한 코드는 글 최 하단에 있으니 꼭 확인 하자)
패키지 : infra/aws/s3/domain/application
@Service
public class S3ImageService implements ImageFileHandle {
private final AmazonS3 amazonS3;
private final ImageFileNameUtil imageFileNameUtil;
@Value("${cloud.aws.s3.bucket}")
private String bucketName;
public S3ImageService(AmazonS3 amazonS3) {
this.amazonS3 = amazonS3;
this.imageFileNameUtil = new ImageFileNameUtil();
}
@Override
public ImageFile uploadImageFile(MultipartFile file, String folderName) throws IOException {
String fileUrl = uploadImageFileToS3(file,folderName).toString();
String fileName = imageFileNameUtil.cutFullFileUrlIntoNameOnly(fileUrl);
return new S3Image(file,fileName,folderName);
}
@Override
public List<ImageFile> uploadImageFiles(MultipartFile[] files, String folderName) {
if (doesNotExistFolder(folderName)) {
createFolder(folderName);
}
return Arrays.stream(files)
.map(file -> {
try {
return uploadImageFile(file, folderName);
} catch (IOException e) {
throw new RuntimeException(e);
}
})
.collect(Collectors.toList());
}
private URL uploadImageFileToS3(MultipartFile file, String folderName) throws IOException {
if (doesNotExistFolder(folderName)) {
createFolder(folderName);
}
String fileName = file.getOriginalFilename();
InputStream inputStream = file.getInputStream();
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(file.getSize());
metadata.setContentType(file.getContentType());
String key = folderName + "/" + fileName+imageFileNameUtil.getLocalDateTimeMilliseconds();
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, key, inputStream, metadata);
amazonS3.putObject(putObjectRequest);
return amazonS3.getUrl(bucketName, key);
}
private void createFolder(String folderName) {
InputStream stream = null;
try {
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(0);
stream = new ByteArrayInputStream(new byte[0]);
metadata.setContentType("application/x-directory");
String key = folderName + "/";
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, key, stream, metadata);
amazonS3.putObject(putObjectRequest);
} catch (Exception e) {
e.printStackTrace();
} finally {
if (stream != null) {
try {
stream.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
private boolean doesNotExistFolder(String folderName) {
return amazonS3.listObjects(bucketName, folderName)
.getObjectSummaries()
.stream()
.noneMatch(s -> s.getKey().startsWith(folderName + "/"));
}
}
여기서 사용된 이미지 파일명과 관련된 코드는 다음과 같다.
패키지 : global/file/domain/application
public class ImageFileNameUtil {
public String cutFullFileUrlIntoNameOnly(String fileUrl) {
int lastIndex = fileUrl.indexOf(".com/") + 5;
if (lastIndex != -1 && lastIndex < fileUrl.length()) {
return fileUrl.substring(lastIndex);
}
else{
throw new RuntimeException("fileUrl is Error");
}
}
public String getLocalDateTimeMilliseconds() {
LocalDateTime localDateTime = LocalDateTime.now();
return Long.toString(localDateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
}
}
작성한 코드는 다음 깃허브 주소에 있다.
추가 7월 16일 - s3 ImageService code 리팩토링
1.
file.getInputStream();
IOException 을 thorw 를 사용하여 다른 함수에 보내지 않고 처리 .
2. 중복 코드 삭제
3. magicNumber 삭제 ->빈 파일을 만들기위한 공통 정수 값 0 을 empty file size 로 정의
@Service
public class S3ImageService implements ImageFileHandle {
private final static int EMPTY_FILE_SIZE = 0 ;
private final AmazonS3 amazonS3;
private final ImageFileNameUtil imageFileNameUtil;
@Value("${cloud.aws.s3.bucket}")
private String bucketName;
public S3ImageService(AmazonS3 amazonS3) {
this.amazonS3 = amazonS3;
this.imageFileNameUtil = new ImageFileNameUtil();
}
@Override
public void deleteImage(String fileName, String folderName) {
String key = folderName + "/" + fileName;
amazonS3.deleteObject(bucketName, key);
}
@Override
public ImageFile uploadImageFile(MultipartFile file, String folderName) {
String fileUrl = sendImageFileToS3(file,folderName).toString();
String fileName = imageFileNameUtil.cutFullFileUrlIntoNameOnly(fileUrl);
return new S3Image(file,fileName,folderName);
}
@Override
public List<ImageFile> uploadImageFiles(MultipartFile[] files, String folderName) {
return Arrays.stream(files)
.map(file -> uploadImageFile(file, folderName))
.collect(Collectors.toList());
}
private URL sendImageFileToS3(MultipartFile file, String folderName) {
if (checkFolderExistence(folderName)) {
makeFolder(folderName);
}
String key = constructObjectForS3(file,folderName);
return amazonS3.getUrl(bucketName, key);
}
private boolean checkFolderExistence(String folderName) {
return amazonS3.listObjects(bucketName, folderName)
.getObjectSummaries()
.stream()
.noneMatch(s -> s.getKey().startsWith(folderName + "/"));
}
private void makeFolder(String folderName) {
InputStream inputStream = new ByteArrayInputStream(new byte[EMPTY_FILE_SIZE]);
String key = folderName + "/";
uploadObjectToS3(key, inputStream, EMPTY_FILE_SIZE, "application/x-directory");
}
private String constructObjectForS3(MultipartFile file, String folderName){
String fileName = file.getOriginalFilename();
String key = folderName + "/" + fileName + imageFileNameUtil.getLocalDateTimeMilliseconds();
try{
InputStream inputStream = file.getInputStream();
return uploadObjectToS3(key, inputStream, file.getSize(), file.getContentType());
} catch (IOException e){
throw new RuntimeException(e);
}
}
private String uploadObjectToS3(String key, InputStream inputStream, long size, String contentType){
ObjectMetadata metadata = createS3ObjectMetadata(size, contentType);
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, key, inputStream, metadata);
amazonS3.putObject(putObjectRequest);
return key;
}
private ObjectMetadata createS3ObjectMetadata(long size, String contentType){
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(size);
metadata.setContentType(contentType);
return metadata;
}
}