파일에는 다양한 타입의 파일들이 존재하며 유저의 개인 프로필 이미지처럼 단일 형태로 업로드 될 수 있지만,
게시물에 업로드되는 파일들처럼 다양한 타입의 파일들이 다중 파일 형태로 업로드 될 수 있습니다.
그렇기에 제가 프로젝트에서 구현해보고 싶었던 점은 업로드 되는 파일의 개수와 타입에 대해 유기적으로 대응할 수 있는 효율적인 코드를 만들어 보고 싶었습니다.
다음은 현재 프로젝트 기준에서 기능 개발에 예상되는 부분을 간단히 정리해보았습니다.
- 유저의 프로필 이미지 기능
- 단일 업로드
- 단일 타입 (이미지)
- 게시물 업로드 기능
- 다중 업로드
- 다양한 타입 (이미지, 음성 파일 ...)
(AWS S3의 계정 생성 및 버킷 설정등 대략적인 환경 세팅은 생략하였습니다. )
지금은 메인 스토리지로 AWS S3로 결정하였지만 확장성 측면에서 다른 스토리지 사용 가능성을 늘 염두해야 하기 때문에 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
인터페이스를 생성하였습니다.
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에 대한 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;
}
}
(현재는 이미지에 대한 확장자를 검수하지만 이후 기능을 추가하면서 확장 될 예정입니다.)
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);
}
}