트래블 캐리어는 여행 사진을 중점으로 하는 기록용 웹서비스로, 사진 저장 로직이 필수적이었다.
'여행사진'이라는 특성상 예상되는 리소스 사용량이 상당했기 때문에 서버 용량이 큰 문제로 다가왔다.
그래서 이를 극복하기 위해 구상한 아이디어가 다음과 같았다.
(1) 이미지 리사이징 최적화
(2) AWS S3 저장을 통한 로컬서버 용량 한계 극복
(이제와 생각해보면 프론트엔드에서 리사이징 하는 방법이 네트워크 오버헤드가 줄어 더 좋은 방법일것 같다. 당시 프론트 프레임워크 없이 Thymleaf를 사용중이어서 데이터를 체계적으로 다루기 힘들었고, 나 또한 백엔드에 더 익숙한 풀스택으로 참여하고 있어서 백엔드에서 처리해보기로 했다.)
결론적으로, 여러개의 이미지를 한번에 전송받은 뒤 각 이미지에 대해 리사이징&S3업로드를 수행하고, 저장된 경로를 DB에 저장하는것까지가 하나의 트랜잭션 안에서 수행되도록 작성했다.
1. 이미지 리사이징 최적화 및 회전처리
리사이징은 imgscalr 라는 라이브러리를 통해 수행했다.
부가적인 서비스 코드는 제외하고, 핵심적인 리사이징 코드만 작성하자면 다음과 같다.
// Multipart 이미지를 리사이징+회전 한 후 File로 변환하여 업로드 S3에 업로드
public String[] upload(MultipartFile multipartFile, String dirName) throws IOException {
BufferedImage resizedFile = resizeImageFile(multipartFile);
File uploadFile = convert(resizedFile,multipartFile.getOriginalFilename());
return upload(uploadFile, dirName);
}
upload메소드는 오버라이딩 되어 있는데, S3에 업로드하기 위해선 파라미터가 File형태여야 했기 때문에 Multipart형식의 파일을 사용하더라도 정상동작 하도록 만든것이다. 본격적인 upload를 수행하기 전에, 이미지를 리사이징 하는 resizedImageFile 메소드를 수행한다.
// 이미지 리사이징
private BufferedImage resizeImageFile(MultipartFile mFile) throws IOException {
File file = new File(tmpFileDir + mFile.getOriginalFilename());
mFile.transferTo(file);
// 회전부터
BufferedImage readImage = turnImage(file);
removeNewFile(file); //임시파일 삭제
int originWidth = readImage.getWidth();
int originHeight = readImage.getHeight();
if(originHeight > 900){
double aspectRatio = (double) originHeight / 900;
int newWidth = (int) Math.round(originWidth / aspectRatio);
int newHeight = (int) Math.round(originHeight / aspectRatio);
return Scalr.resize(readImage, newWidth, newHeight);
}
return readImage;
}
특정 디렉토리 경로에 임시파일을 만든 후, 리사이징을 수행한다. 기준이 되는 newWidth와 newHeight의 경우, 최적화된 사이즈를 만들어 주기 위해 위와같이 계산식을 작성했다. 트래블 캐리어에서 지원하는 최대 높이값이 900px이었기 때문에 해당 크기를 상회하는 이미지들에 대해 새로운 비율을 만들어 계산한다. 위 식을통해 사진비율이 망가지지 않게 리사이징 할 수 있었다.
참고로 모바일 사진과 카메라 사진 등 다양한 데이터를 테스트 해본결과, 자이로스코프 설정에 따라 사진이 뒤집어져서 저장되는 문제가 있었다. 따라서 각 설정에 맞게 사진이 정렬되도록 turnImage 메소드를 만들어 해결했다.
또한 임시 파일 경로에 이미지가 삭제되지 않고 쌓이면서 종종 오류를 발생시켰다. 따라서 회전 작업 후 이전의 임시파일은 삭제했다.
private BufferedImage turnImage(File imageFile) throws IOException {
// 원본 파일의 Orientation 정보를 읽는다.
int orientation = 1; // 회전정보, 1. 0도, 3. 180도, 6. 270도, 8. 90도 회전한 정보
int width = 0; // 이미지의 가로폭
int height = 0; // 이미지의 세로높이
Metadata metadata; // 이미지 메타 데이터 객체
Directory directory; // 이미지의 Exif 데이터를 읽기 위한 객체
try {
metadata = ImageMetadataReader.readMetadata(imageFile);
directory = metadata.getFirstDirectoryOfType(ExifIFD0Directory.class);
if(directory != null){
orientation = directory.getInt(ExifIFD0Directory.TAG_ORIENTATION); // 회전정보
}
}catch (Exception e) {
orientation=1;
}
//imageFile
BufferedImage srcImg = ImageIO.read(imageFile);
// 회전 시킨다.
switch (orientation) {
case 1:
break;
case 3:
srcImg = Scalr.rotate(srcImg, Scalr.Rotation.CW_180, (BufferedImageOp[])null);
break;
case 6:
srcImg = Scalr.rotate(srcImg, Scalr.Rotation.CW_90, (BufferedImageOp[])null);
break;
case 8:
srcImg = Scalr.rotate(srcImg, Scalr.Rotation.CW_270, (BufferedImageOp[])null);
break;
default:
orientation=1;
break;
}
return srcImg;
}
회전 메소드는 위와 같다. 원본파일의 회전정보를 획득하여 Switch문에서 회전한다.
2. S3에 이미지 업로드
S3에 업로드 하기위해 필요한 설정은 따로 작성하지 않겠다. S3Client를 활용해 Config클래스를 만들어 활용했고, 이제 해당 라이브러리의 메소드를 어떻게 작성했는지 위주로 작성하겠다.
리사이징이 끝났으므로, 아까 1번에서 오버라이딩된 upload메소드를 호출하게된다.
CF. convert는 Multipart > File, BufferedImage > File 등이 필요하며, 이를 미리 Util로 만들어 사용하면 편리하다.
/**
* Multipart를 File객체로 변환
*/
private Optional<File> convert(MultipartFile file) throws IOException {
File convertFile = new File(tmpFileDir + file.getOriginalFilename());
if(convertFile.createNewFile()) {
try (FileOutputStream fos = new FileOutputStream(convertFile)) {
fos.write(file.getBytes());
}
return Optional.of(convertFile);
}
return Optional.empty();
}
/**
* BufferedImg를 File객체로 변환
*/
private File convert(BufferedImage file, String originalName) throws IOException {
try {
// 저장할 파일 경로 및 이름 지정
File outputFile = new File(tmpFileDir + originalName);
// BufferedImage를 파일로 저장
String extension = originalName.substring(originalName.lastIndexOf(".")+1);
if(extension.equals("blob")) extension = "png";
ImageIO.write(file, extension, outputFile);
return outputFile;
} catch (IOException e) {
e.printStackTrace();
}
throw new IllegalStateException("Cannot convert BufferedImage to File");
}
private String[] upload(File uploadFile, String dirName) {
//디렉토리명, 파일명 커스텀
String uuid = UUID.randomUUID().toString();
String extension = uploadFile.getName().substring(uploadFile.getName().lastIndexOf(".")+1);
if(extension.equals("blob")) extension = "png";
String fileName = dirName + "/" + uuid + "." + extension;
//S3에 업로드
String uploadImageUrl = putS3(uploadFile, fileName);
removeNewFile(uploadFile); // 로컬에 생성된 File 삭제 (MultipartFile -> File 전환 하며 로컬에 파일 생성됨)
return new String[]{uuid+"."+extension, uploadImageUrl}; // 업로드된 파일의 S3 URL 주소 반환
}
S3 업로드를 위해 경로와 파일명을 생성해준다. 트래블 캐리어는 다양한 기능에서 저장을 지원한다. 프로필사진, 배경사진, 위클리다이어리, 데일리다이어리, 썸네일 등 이에대한 구분을 위해 디렉토리명을 파라미터화 해서 동적으로 변화할 수 있게 작성했다. 또한 간혹 blob형태의 확장자가 있어 오류를 만들었기 때문에, png형태로 강제 변환해줬다.
private String putS3(File uploadFile, String fileName) {
amazonS3Client.putObject(
new PutObjectRequest(bucket, fileName, uploadFile)
.withCannedAcl(CannedAccessControlList.PublicRead) // PublicRead 권한으로 업로드 됨
);
return amazonS3Client.getUrl(bucket, fileName).toString();
}
마지막으로, 완성된 File객체와 경로변수를 바탕으로 S3에 업로드하는 메소드를 실행한다.
그리고 저장경로 Url을 리턴 받은 뒤, 이 데이터를 DB에 저장해주면 된다.
사설이지만, 사실 초기에 개발할땐 로컬서버 기준으로 개발했기 때문에 내컴퓨터 자체, 즉 Spring의 static경로 아래에 저장했었다.
@Deprecated
public String[] saveAttach(MultipartFile file, String folder) throws Exception {
if(file.isEmpty()){
throw new FileNotFoundException("File " + folder + "");
}
// 1. 새로운 파일이름 생성
String uuid = UUID.randomUUID().toString();
// 2. 원래 파일이름으로부터 확장자 분리
String extension = file.getOriginalFilename().substring(file.getOriginalFilename().lastIndexOf(".")+1);
if(extension.equals("blob")) extension = "png";
// 3. UUID+확장자명으로 새 파일제목 완성
String newTitle = uuid+"."+extension;
// 썸네일로 변환한 파일의 저장경로
String thumbPath = fileDir+"user/"+folder+"/"+newTitle;
// 이미지 저장
boolean a = ImageIO.write(resizeImageFile(file), extension,
new File(thumbPath));
String[] saveArr = {newTitle,thumbPath};
return saveArr;
}
그러나 개발과정에서 앞서 말한 문제들을 발견했었다. 파일 용량이 너무 커서 약 40GB밖에 없던 용량의 반까지 찼었다 ^_ㅠ 그래서 로직에 리사이징을 추가했던것!
그리고 배포과정에서 또 한가지 문제점에 부딪히는데, AWS 프리티어 요금제의 용량은 8GB로.. 과금의 위험이 있었다. 그래서 당시 25GB까지 지원하는 AWS S3를 이용함으로써 저장용량의 한계까지 극복할 수 있었다! (관리가 편했던것은 금상첨화..)
코드는 이곳에서 볼 수 있다. 다만 첫 작성 이후 2년이 되어가는 코드다보니 부족한 점이 정말 많이 보인다. 현재는 프리티어가 종료되어 사용이 불가능한데, 추후 개인서버에서 다시 서비스할 예정이기에 리팩토링까지 생각하고있다.
리팩토링하면 Util함수와 Service단의 세부적인 분리, 인터페이스를 통해 로컬 저장 로직 분리 등을 해보려고 한다.
또한 가장 중요한 업로드 속도의 개선! 이부분은 멀티스레딩을 사용해서 개선하려고 계획중에 있다 ^-^
'프로젝트 > 트래블 캐리어' 카테고리의 다른 글
SSE를 활용해 실시간 알림 구현하기(Feat. 웹소켓, Long Polling) (3) | 2024.11.12 |
---|