-
AWS s3 이미지 업로더 분리기 (feat. @Configurration 활용)Project/TravelFeelDog 2023. 7. 14. 18:07
기존 코드의 설명을 하면 ,
하단의 코드는 서비스 레이어이며 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; } }
'Project > TravelFeelDog' 카테고리의 다른 글
SpringBoot MySql bulkInsert 도입기 (0) 2023.08.30 Service Layer 에 읽기 쓰기 분리기 (0) 2023.08.10 Firebase 에서 OAuth2 , JWT 로 전환기(1) (0) 2023.08.08 yml 환경 변수 설정 (0) 2023.07.18 경로 에러 추가 하기 (0) 2023.07.18