[프로젝트] 다중 파일 업로드 구현하기

조찬영·2023년 9월 12일
0

[내가 구현하고자 했던 것]

파일에는 다양한 타입의 파일들이 존재하며 유저의 개인 프로필 이미지처럼 단일 형태로 업로드 될 수 있지만,
게시물에 업로드되는 파일들처럼 다양한 타입의 파일들이 다중 파일 형태로 업로드 될 수 있습니다.

그렇기에 제가 프로젝트에서 구현해보고 싶었던 점은 업로드 되는 파일의 개수와 타입에 대해 유기적으로 대응할 수 있는 효율적인 코드를 만들어 보고 싶었습니다.

다음은 현재 프로젝트 기준에서 기능 개발에 예상되는 부분을 간단히 정리해보았습니다.

  • 유저의 프로필 이미지 기능
    • 단일 업로드
    • 단일 타입 (이미지)

  • 게시물 업로드 기능
    • 다중 업로드
    • 다양한 타입 (이미지, 음성 파일 ...)


이를 구현하기 위해서는 대규모 파일들을 쉽게 가용하면서 확장성있는 스토리지가 필요하다고 판단하였고 AWS S3를 사용하기로 결정하였습니다.

Storage Service

(AWS S3의 계정 생성 및 버킷 설정등 대략적인 환경 세팅은 생략하였습니다. )


지금은 메인 스토리지로 AWS S3로 결정하였지만 확장성 측면에서 다른 스토리지 사용 가능성을 늘 염두해야 하기 때문에 StorageService 인터페이스로 추상화 하였습니다.



  • StorageService
public interface StorageService {

    FileInfo upload(MultipartFile multipartFile);

    List<FileInfo> uploadFiles(List<MultipartFile> multipartFile);

    void delete(String key);

    void deleteFiles(String key);

}

추상화를 하였으니 이를 구현하는 구현체인 AwsS3Service 를 생성하였습니다.

@Service
@RequiredArgsConstructor
@Slf4j
public class AwsS3Service implements StorageService {


   @Override
   public FileInfo upload(MultipartFile multipartFile) {

   }

   @Override
   public List<FileInfo> uploadFiles(List<MultipartFile> multipartFile) {
	
   }

   @Override
   public void delete(String filename) {
   }

   @Override
   public void deleteFiles(String filename) {

   }
}

이제 빈 껍데기인 메서드에 하나씩 로직을 채워 나가야 하는데요.
여기서 고민이 되었던 부분은 단일, 다중 파일에 대한 문제는 어떻게 해결할 수 있을것 같은데 다양한 타입들에 대해서는 어떻게 처리할 수 있을까? 였습니다.

예를 들어 이미지 파일에 대해서는 리사이징 작업이 있을 수 있고 음성 파일에는 용량 제한을 걸어둔다고 하였을때 이것들을 위의 구현체에서 구현하기에는 복잡한 코드를 야기시키거나 많은else-if 문이 생길 것 같았습니다.

고민에 고민을 더하는 중 내린 결론은 "다양한 파일들에 대한 상세 코드를 구현할 수 있는 클래스들을 생성하고 그것들을 하나로 묶을 수 있는 인터페이스를 상속받게 하면 어떨까?" 였습니다.


(예전 초창기 인터페이스의 다형성을 공부할 때 계산기의 세부 기능(덧셈,뺼셈,곱,나눗셈)에 클래스를 생성하고 입력받는 연산자로 덧셈인지 뺄샘인지등을 구분하여 기능을 수행하게 하였던 내용이 힌트가 되었습니다.)

FileUploadStrategy

파일의 상세 기능에 대한 추상화인 FileUploadStrategy 인터페이스를 생성하였습니다.

    public interface FileUploadStrategy {

    boolean supports(String contentType);

    MultipartFile uploadFile(MultipartFile file, String filename);

    StorageType getStorageType();
}

그 뒤에 이를 상속받고 본격적으로 파일에 대한 기능을 구현하는 클래스들을 만들었습니다.
(현재는 우선 이미지에 대해서만 구체적인 구현을 했습니다.)


이미지에 대한 클래스

@Service
@Slf4j
public class ImageUploadStrategy implements FileUploadStrategy {

    @Override
    public boolean supports(String contentType) {

        return contentType != null && contentType.startsWith("image/");
    }

    @Override
    public MultipartFile uploadFile(MultipartFile file, String filename) {

        log.info(" create file name from origin : {}", filename);
        String fileFormatName = FilenameGenerator.extractFormat(file);
        try (InputStream inputStream = file.getInputStream()) {

            return ImageResizer.resizeImage(filename, fileFormatName, file,
                convertToBufferImage(inputStream));

        } catch (IOException e) {
            throw new AppException(ErrorCode.FILE_UPLOAD_FAILED);
        }
    }

    @Override
    public StorageType getStorageType() {
        return StorageType.IMAGE;
    }

    private BufferedImage convertToBufferImage(InputStream inputStream)
        throws IOException {
        return ImageIO.read(inputStream);
    }




음성 파일에 대한 클래스

@Service
public class AudioUploadStrategy implements FileUploadStrategy {

    @Override
    public boolean supports(String contentType) {
        return "audio/mpeg".equals(contentType);  
    }

    @Override
    public MultipartFile uploadFile(MultipartFile file,String filename) {
        // Implement the logic to process and upload audio files...
        return file;
    }

    @Override
    public StorageType getStorageType() {
        return StorageType.AUDIO;
    }
}

다음의 클래스들을 생성함으로서 파일 타입에 대한 각각의 기능(이미지 리사이징,
오디오 용량 제한)을 수행할 수 있습니다.

그리고 AwsS3Service 내에서 확장자 명으로 파일 타입을 검증하고 각각의 타입에 맞는 클래스에 기능을 수행케하도록 만들었습니다.

AwsS3Service

@Service
@RequiredArgsConstructor
@Slf4j
public class AwsS3Service implements StorageService {

  private final List<FileUploadStrategy> strategies;
  
  ... 생략 ...

  private FileInfo uploadFile(String bucket, MultipartFile file) {

      String originalFilename = file.getOriginalFilename();
      String filename = FilenameGenerator.createFilename(originalFilename);

      FileUploadStrategy fileUploadStrategy = strategies.stream()
          .filter(strategy -> strategy.supports(file.getContentType()))
          .findFirst()
          .orElseThrow(() -> new AppException(ErrorCode.INVALID_FILE_TYPE));

      MultipartFile multiFile = fileUploadStrategy.uploadFile(file, filename);

      ObjectMetadata objectMetadata = putObjectMetadata(multiFile);

      try {
          amazonS3Client.putObject(
              new PutObjectRequest(bucket, filename, multiFile.getInputStream(), objectMetadata)
                  .withCannedAcl(CannedAccessControlList.PublicRead));

          String s3ClientUrl = amazonS3Client.getUrl(bucket, filename).toString();
          return FileInfo.of(originalFilename, filename, s3ClientUrl,
              fileUploadStrategy.getStorageType());
      } catch (IOException e) {
          throw new AppException(ErrorCode.FILE_UPLOAD_FAILED);
      }
  }

다음의 코드를 확인하면 List<FileUploadStrategy> strategies; 를 통해서 FileUploadStrategy 의 구현체들을 하나의 list에 값을 주입받는 것을 확인할 수 있습니다.

     FileUploadStrategy fileUploadStrategy = strategies.stream()
          .filter(strategy -> strategy.supports(file.getContentType()))
          .findFirst()
          .orElseThrow(() -> new AppException(ErrorCode.INVALID_FILE_TYPE));

그리고 이 부분에서 다형성을 이용해 타입을 검증하고 그에 맞는 클래스로 보내는 것을 확인할 수 있습니다.
이로서 이미지 파일이라면 이미지 클래스에, 음성 파일이라면 음성 파일 클래스에서 구체적엔 세부 기능을 수행할 수 있습니다.

이후 대략적인 설계는 끝나고 본격적인 구현을 하였습니다.
전체적인 소스 코드를 보여드리겠습니다.

AWS S3 Service 구현

AWS S3에 대한 properties 값을 세팅후 주었습니다.
(ConfigurationProperties를 통해 갑을 바인딩 받기 위해서는 Java Beans 규칙을 따르기에 @Getter , @Setter 를 지정해 주어야 합니다.)

@Getter
@ConstructorBinding
@RequiredArgsConstructor
@ConfigurationProperties(prefix = "cloud.aws")
public class AwsProperties {

   private final Credentials credentials = new Credentials();
   private final S3 s3 = new S3();
   private final Region region = new Region();

   @Getter
   @Setter
   public static class Credentials {

       private String accessKey;
       private String secretKey;
   }

   @Getter
   @Setter
   public static class S3 {

       private String image;
       private String storage;
   }

   @Getter
   @Setter
   public static class Region {

       private String statics;
   }

   public String getAccessKey() {
       return credentials.getAccessKey();
   }

   public String getSecretKey() {
       return credentials.getSecretKey();
   }

   public String getImageBucket() {
       return s3.getImage();
   }

   public String getStorageBucket() {
       return s3.getStorage();
   }

   public String getRegionStatic() {
       return region.getStatics();
   }
}

AWS S3 구현체인 AwsS3Service 클래스입니다.

AwsS3Service

@Service
@RequiredArgsConstructor
@Slf4j
public class AwsS3Service implements StorageService {

    private AmazonS3 amazonS3Client;
    private final AwsProperties awsProperties;
    private final List<FileUploadStrategy> strategies;

    @PostConstruct
    public void amazonS3Client() {
        BasicAWSCredentials awsCredentials = new BasicAWSCredentials(awsProperties.getAccessKey(),
            awsProperties.getSecretKey());
        amazonS3Client = AmazonS3ClientBuilder.standard()
            .withRegion(awsProperties.getRegionStatic())
            .withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
            .build();
    }

    @Override
    public FileInfo upload(MultipartFile multipartFile) {
        return uploadFile(awsProperties.getImageBucket(), multipartFile);
    }

    @Override
    public List<FileInfo> uploadFiles(List<MultipartFile> multipartFile) {
        List<FileInfo> fileInfoList = new ArrayList<>();

        multipartFile.forEach(file -> {
            fileInfoList.add(uploadFile(awsProperties.getStorageBucket(), file));
        });
        log.info("ready set {} images to upload", fileInfoList.size());
        return fileInfoList;
    }

    private FileInfo uploadFile(String bucket, MultipartFile file) {

        String originalFilename = file.getOriginalFilename();
        String filename = FilenameGenerator.createFilename(originalFilename);

        FileUploadStrategy fileUploadStrategy = strategies.stream()
            .filter(strategy -> strategy.supports(file.getContentType()))
            .findFirst()
            .orElseThrow(() -> new AppException(ErrorCode.INVALID_FILE_TYPE));

        MultipartFile multiFile = fileUploadStrategy.uploadFile(file, filename);

        ObjectMetadata objectMetadata = putObjectMetadata(multiFile);

        try {
            amazonS3Client.putObject(
                new PutObjectRequest(bucket, filename, multiFile.getInputStream(), objectMetadata)
                    .withCannedAcl(CannedAccessControlList.PublicRead));
            String s3ClientUrl = amazonS3Client.getUrl(bucket, filename).toString();
    
            return FileInfo.of(originalFilename, filename, s3ClientUrl,
                fileUploadStrategy.getStorageType());
        } catch (IOException e) {
            throw new AppException(ErrorCode.FILE_UPLOAD_FAILED);
        }
    }

    @Override
    public void delete(String filename) {
        amazonS3Client.deleteObject(awsProperties.getImageBucket(), filename);
    }

    @Override
    public void deleteFiles(String filename) {
        amazonS3Client.deleteObject(awsProperties.getStorageBucket(), filename);
    }

    private ObjectMetadata putObjectMetadata(MultipartFile multipartFile) {
        ObjectMetadata objectMetadata = new ObjectMetadata();
        objectMetadata.setContentLength(multipartFile.getSize());
        objectMetadata.setContentType(multipartFile.getContentType());
        return objectMetadata;
    }
}
  • 유저 프로필의 단일 이미지 파일 업로드는 upload 에서 기능 수행됩니다.
  • 게시물의 다중 파일 및 타입에 대한 업로드는 uploadFiles 에서 이루어집니다.

파일의 유니크한 키 값을 만들어 낼 수 있는 FileNameGenerator
최종적으로 반환되는 파일들의 데이터들을 담을 수 있는 FileInfo 클래스를 사용하였습니다.

먼저 FileNameGenerator 클래스를 살펴보겠습니다.

FileNameGererator

@Slf4j
public class FilenameGenerator {

    private static final String[] SUPPORT_EXTENSION = {".jpg", ".jpeg", ".png"};

    public static String createFilename(String originFilename) {
        return UUID.randomUUID() + "-" + getFileExtension(originFilename);
    }

    private static String getFileExtension(String filename) {
        try {
            String extension = filename.substring(filename.lastIndexOf("."));
            log.info("extension for upload image : {}", extension);
            validateExtension(extension);
            return extension;
        } catch (StringIndexOutOfBoundsException e) {
            throw new AppException(ErrorCode.NOT_SUPPORT_FORMAT,
                "not support this filename: " + filename);
        }
    }

    private static void validateExtension(String extension) {
        if (!isSupportExtension(extension)) {
            throw new AppException(ErrorCode.NOT_SUPPORT_FORMAT,
                "not support this file extension: " + extension);
        }
    }

    private static boolean isSupportExtension(String extension) {
        return Arrays.stream(SUPPORT_EXTENSION)
            .anyMatch(supportExtension -> supportExtension.equals(extension));
    }

    public static String extractFormat(MultipartFile file) {
        String fileFormatName = file.getContentType()
            .substring(file.getContentType().lastIndexOf("/") + 1);
        return fileFormatName;
    }
}
  • 주어진 원본 파일 이름에서 UUID와 파일 확장자를 조합하여 새로운 파일 이름을 생성합니다.

  • 주어진 확장자가 지원되는 확장자인지 확인합니다. 지원되지 않는 경우 예외를 발생시킵니다.

(현재는 이미지에 대한 확장자를 검수하지만 이후 기능을 추가하면서 확장 될 예정입니다.)


FileInfo 클래스를 살펴보겠습니다.

FileInfo

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class FileInfo {

   public static final FileInfo EMPTY = FileInfo.of("", "", "", StorageType.EMPTY);

   private String originalFilename;
   private String storeFilename;
   private String pathUrl;
   private StorageType storageType;

   public static FileInfo of(String originalFilename, String storeFilename, String pathUrl,
       StorageType storageType) {
       return new FileInfo(originalFilename, storeFilename, pathUrl, storageType);
   }
}
  • 파일 정보를 담고 전달하거나 처리할 때 사용되며, 다른 클래스에서 해당 정보에 접근하고 활용할 수 있습니다.

  • 상수인 EMPTY 필드가 선언되어 있는데 이는 이후 서비스에서 유저가 아무런 파일 정보를 보내지 않았을때 이를 나타내기 위해 사용됩니다.
profile
보안/응용 소프트웨어 개발자

0개의 댓글