ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 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());
        }
    }
    

     

     

     

    작성한 코드는 다음 깃허브 주소에 있다.

     

    https://github.com/chosunghyun18/TravelFeelDog-Server/tree/develop/travelfeeldog/src/main/java/travelfeeldog/global/file/domain/application

     

     

    추가 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;
        }
    }
Designed by Tistory.