⌨️ 이미지 업로드 기능 구현 및 회고
프로젝트 진행 간 구현 한 이미지 업로드의 기능 구현과 해당 기능을 구현하면서 마주쳤던 에러나, 변동 사항들에 대해 회고하고 다양한 사항을 고려해보려 한다.
1. Local Storage 이의 이미지 파일 저장
1.1. Local Storage 에 이미지 업로드를 생각한 이유
처음 이미지 업로드를 구현할 때 가장 먼저 고민한 것은 이 이미지 파일을 어디에 보관할까였다.
우리의 프로젝트는 이커머스로 생각보다 많은 양의 이미지를 활용하고 그렇기에 이미지 전달을 빠르게 해줘야 한다는 생각이 우선이었다.
기능 구현 초창기의 기술적 지식의 부재도 있었지만, 이 전의 HTML에서 이미지를 접근하는 방식을 떠올려, 로컬 저장소의 Path를 통해 접근하면 어떨까 하는 생각으로 EC2의 로컬에 이미지를 업로드를 구현하는 것을 생각해 냈다.
1.2. 이미지 업로드 구현
@Transactional
@Component
public class FileStore {
@Value("${file_dir}")
private String fileDirPath;
public ProductReviewImage storeReviewImage(MultipartFile multipartFile) throws IOException{
if(multipartFile.isEmpty()){
return null;
}
String originalImageName = multipartFile.getOriginalFilename();
String storeImageName = createStoreImageName(originalImageName);
multipartFile.transferTo(new File(createReviewPath(storeImageName)));
return ProductReviewImage.builder()
.originImageName(originalImageName)
.storePath(storeImageName)
.build();
}
//이미지 여러 장 저장
public List<ProductReviewImage> storeReviewImages(List<MultipartFile> multipartFiles) throws IOException{
List<ProductReviewImage> productReviewImages = new ArrayList<>();
for(MultipartFile multipartFile : multipartFiles){
if(!multipartFile.isEmpty()){
productReviewImages.add(storeReviewImage(multipartFile));
}
}
return productReviewImages;
}
//리뷰 이미지 저장 경로
public String createReviewPath(String storeImageName){
String viaPath = "/images/review/";
return fileDirPath + viaPath + storeImageName;
}
//이미지 네임 중복 방지
private String createStoreImageName(String originalImageName){
String uuid = UUID.randomUUID().toString();
String ext = extractExt(originalImageName);
return uuid + ext;
}
//관리의 용이성을 위한 확장자 추출
private String extractExt(String originalImageName){
int idx = originalImageName.lastIndexOf(".");
return originalImageName.substring(idx);
}
}
로직 자체가 어렵지 않았고 Java 자체에서 제공하는 IO를 활용해서 구현을 했다. 일반적인 경우 몇 가지를 고려하여 로직을 만들었다
- 여러 사람이 이미지를 올리는 것이기 때문에 이미지 파일의 이름의 중복 문제
- 사진을 한 장 혹은 여러 장 올릴 때 모두 작동
- 이미지 미리 보기를 할 것인가? 혹은 확장자를 어떻게 제한할 것인가?
정도의 일반적인 경우만 고려하여 로직을 짰다.
1.3. 고려하지 못한 변수 및 아쉬운 점
첫 재로는 EC2의 성능을 고려하지 못했다. 일반적으로 free tier에서의 그렇게 좋은 성능의 인스턴스를 사용할 수 없다.
아무리 파일데이터의 화질과 용량을 제한하더라도 일반적인 EC2 인스턴스에서는 많은 양의 이미지를 저장하고 있을 수 없을 것이다.
두 번째로는 EC2의 로컬에 이미지를 저장한다면 통신 속도가 많이 저하될 것 같다는 생각이었다. 로컬에 저장되어 있는 이미지를 바이트로 변환하여 전송 후 다시 이미지로 변환하는 작업이 서버에 많은 무리를 줄 것이라 생각했다.
예전에 경험해 본 이미지를 로컬에서 호출해서 HTML을 만들어본 경험이 오히려 좋지 않은 결과로 이어졌다.
세 번째로는 트랜잭션 전파의 미적용이다. 이미지를 업로드하는 로직은 문의, 상품 업로드, 리뷰 등 다양하게 사용된다.
그러나 해당 기능이 실패하더라도 이미지 업로드는 트랜잭션이 적용되지 않아 로컬에 지속적으로 이미지가 쌓였다.
해당 부분을 기능을 실패가 없게 리팩토링 했지만 이 경우 글을 작성하다 취소할 때 이미지가 저장되는 문제는 고스란히 남아있었다.
이 외에도 다른 분야에서도 트랜잭션의 전파는 꽤 많이 다뤄야 하기 때문에 꼭 리팩토링을 하든지, 짚고 넘어가야겠다는 생각이 들었다.
2. 로컬에서 AWS S3로 이미지 업로드 저장소 변경
2.1. S3로 이미지 업로드 저장소를 변경한 이유
파일서버 즉 이미지가 저장되거나 혹은 이미지가 아니더라도 다른 파일들을 저장하는 파일 서버를 별도로 만드는 것에 대한 고민이 있었다.
해당 고민은 위 1.3에서 설명했듯이 로컬 저장소의 단점들을 보완하기 위해, 별도의 서버를 둬야겠다고 생각했기 때문이다.
또한 해당 프로젝트에서는 적용하지 못했지만, Pre-Signed-Url을 활용하여, 객체를 Private 하게 관리하며 일정 시간에만 업로드하고 다운로드할 수 있게 하는 관리 정책을 적용해 보는 것을 염두에 두고 교체했다.
2.2. S3 이미지 업로드 구현
S3 Configration
@Configuration
public class S3Config {
@Value("${cloud.aws.credentials.access-key}")
private String accessKey;
@Value("${cloud.aws.credentials.secret-key}")
private String secretKey;
@Value("${cloud.aws.region.static}")
private String region;
@Bean
public AmazonS3 amazonS3Client() {
AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
return AmazonS3ClientBuilder
.standard()
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.withRegion(region)
.build();
}
}
S3 File Uploder
@Slf4j
@RequiredArgsConstructor
@Component
@Service
public class S3Uploader {
private final AmazonS3 amazonS3;
private static final int CAPACITY_LIMIT_BYTE = 1024 * 1024 * 10;
@Value("${cloud.aws.s3.bucket}")
private String bucket;
public List<String> uploads(List<MultipartFile> multipartFiles) throws IOException {
List<String> imgUrlList = new ArrayList<>();
for (MultipartFile multipartFile : multipartFiles) {
if (!multipartFile.isEmpty()) {
imgUrlList.add(upload(multipartFile));
}
}
return imgUrlList;
}
public String upload(MultipartFile multipartFile) throws IOException {
if (multipartFile.isEmpty()) {
return null;
}
String s3FileName = UUID.randomUUID() + "-" + multipartFile.getOriginalFilename(); // s3에 저장되는 파일이름 중복안되게 하기
String ext = extractExt(s3FileName);
String contentType = "";
//파일크기 용량제한 넘으면 예외 던지기
if (multipartFile.getSize() > CAPACITY_LIMIT_BYTE) {
throw new RuntimeException("이미지가 10M 제한을 넘어갑니다.");
}
switch (ext) {
case "jpeg":
contentType = "image/jpeg";
break;
case "png":
contentType = "image/png";
break;
case "txt":
contentType = "text/plain";
break;
case "csv":
contentType = "text/csv";
break;
}
try {
ObjectMetadata objMeta = new ObjectMetadata();
objMeta.setContentType(contentType);
objMeta.setContentLength(multipartFile.getInputStream().available()); //파일의 사이즈 S3에 알려주기
amazonS3.putObject(bucket, s3FileName, multipartFile.getInputStream(), objMeta); //S3 API 메소드 이용해서 S3에 파일 업로드
// return amazonS3.getUrl(bucket, s3FileName).toString(); //getUrl로 S3에 업로드된 사진 URL 가져오기
} catch (AmazonServiceException e) {
e.printStackTrace();
} catch (SdkClientException e) {
e.printStackTrace();
}
return amazonS3.getUrl(bucket, s3FileName).toString();
}
//로컬에 저장된 이미지 지우기
private void removeNewFile(File targetFile) {
if (targetFile.delete()) {
log.info("파일이 삭제되었습니다.");
} else {
log.info("파일이 삭제되지 못했습니다.");
}
}
/* 파일 이름이 이미 업로드된 파일들과 겹치지 않게 UUID를 사용한다. */
private String createStoreFileName(String originalFilename) {
String ext = extractExt(originalFilename);
String uuid = UUID.randomUUID().toString();
return uuid + "." + ext;
}
/* 사용자가 업로드한 파일에서 확장자를 추출한다. */
private String extractExt(String originalFilename) {
int pos = originalFilename.lastIndexOf(".");
return originalFilename.substring(pos + 1);
}
}
기본적으로는 로컬 업로드를 활용해 로직을 만들었다. 중간에 콘텐츠 타입을 지정하는 로직을 추가하여, 프런트에서 파일 전송 시 별도의 콘텐츠 타입을 지정하지 않아도 알아서 지정되게 구현해 두었다.
2.3. S3에 이미지를 업로드하고 얻은 이점과 더욱 고려해 볼 부분
우선 고려했던 부분들에서 유의미한 차이를 보여줬다.
EC2에 부담을 좀 덜어 줄 수 있었고, 서버에서는 이미지가 저장 돼 있는 URL 만 건네주면 됐기에 통신 속도 또한 유의미하게 향상했다.
하지만 위 첨부한 그림과 같이 클라이언트에 부담을 위임한 느낌이 든다.
물론 서버와의 직접적인 데이터 전송보다는 이점이 있겠지만 이 부분도 조금 더 좋은 방법이 있지 않을까 고민을 해 볼 필요가 있다고 생각해서 메모해두려 한다.
3. Multi-Part에 대해
3.1. 이해하고 나니 쉬워진 전송방법
이미지 업로드를 구글링 하면 가장 많이 그리고 쉽게 접할 수 있는 것이 Multi-part data이다. 해당 기술의 기술적인 부분은 충분히 많은 레퍼런스가 있기에 이 포스트에서는 다루지 않으려 한다. 또한 기술적인 습득도 중요하지만 원리를 이해하고 나면 어렵지 않게 적용이 가능한 부분이라 생각이 든다.
3.2.결론은 여러 개의 객체
쉽게 결론부터 내자면, 여러 개의 객체를 전송하는 방법이다. 우리는 일반적으로 RESTful API 에서는 application/json 타입으로의 전송을 한다.
data: {
"title":"제목"
}
쉽게 말하면 위와 같은 형식인데 이렇게 하나의 객체를 통신할 때는 json 타입을 판단하는 데 어려움이 없다. 하지만 멀티파트에서는 여러개의 객체를 전송하기 때문에 그의 맞는 콘텐츠 타입을 지정해줘야 한다.
data: {
"title":"제목"
}
content-type: application/json
name: text
data:{
"imgName":"이름"
}
content-type: png
name: image
이런 식으로 각 객체의 파트의 name과 콘텐츠 타입을 지정해서 전송을 한다.
위 예시는 그냥 한눈에 보기 쉽게 작성했을 뿐이지 실제의 통신 방법은 아니다.
결국 위와 같은 방식으로 이미지뿐만 아니라 텍스트나 음성 등 file 도 마찬가지로 콘텐츠 타입을 잘 지정해 줘서 전송하면 된다는 인사이트에 이른다.
멀티파티의 구현과 구체적인 사용방법은 많은 레퍼가 있으니 참고하면 좋을 것 같다.
여기서는 간단히 멀티파트의 근본적인 원리만 가볍게 캐치하면 충분하다
4. 피드백
- 로컬과 S3를 활용한 이미지 업로드 방식에 대해 조금은 더 구체적인 수치화된 데이터를 확보했으면 좋았을 것 같다.
- 멀티파트에 대해 프런트엔드에게 가이드라인을 더 자세히 줬으면 통신 테스트에서 시간을 많이 절약했을 것 같다.
- 새로운 통신 방법에 대한 이해가 생긴 것은 좋지만 , 단순히 할 줄 안다가 아닌 원리를 이해하는 영역까지 끌어올린 후 사용하는 것이 좋을 것 같다.
그냥 그렇게 쓰던데요?라는 마인드로는 언제고 성장이 더딜 수밖에 없을 것 같다.