Project/TravelFeelDog

AWS s3 이미지 업로더 분리기 (feat. @Configurration 활용)

sung.hyun.1204 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;
    }
}