[Spring] Spring image 업로드 / 다운로드(리턴) / 인코딩
spring에서 서버로 image를 업로드 해야 하는 경우가 있다. 일반적으로 database에 이미지를 넣지는 않는데, 생각하기에는
- front - back - db로 총 2번 이미지를 넣어야 함
- db로 넣으면 binary 형식으로 이미지가 저장되는데 낭비임
- file system에서 굳이 binary로 바꿀 이유가 없다.
이 2가지 정도 이유가 있는 것 같다. 그래서 일반적으로 A에 대한 이미지는 A entity에 A 이미지가 있는 path를 db에 넣고, 조회/수정/삽입/삭제 할 때 해당 path를 이용해서 저장하는 것 같다.
먼저 항상 그렇듯 의존성 추가 해 준다.
build.gradle
implementation 'commons-io:commons-io:2.6'
다음으로 application.properties에 파일 용량 설정을 해 준다.
application.properties
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=20MB
1. Frontend -> Spring으로 파일 올리기
파일을 서버로 올리는 것은 2가지 방법이 있을 것 같다.
1) form으로 올리기
2) base64로 encoding 된 파일을 올리기
먼저 나는 form으로 올리는 것을 선택했다. 왜냐하면 base64를 사용해 image를 byte로, byte를 base64로 encoding하는 경우에는 image 크기가 좀 더 커져서 서버에 좀 더 많은 부하가 걸릴 것이라 생각했기 때문이다.
또, image와 image에 대한 설명도 같이 올릴 것이다.
이미지를 받는 것은 다른 RequestParam과 같이 사용하면 되는데, 나는 RequestPart를 사용했다. (RequestParam으로 이용하고 Multipart로 받아도 된다.)
public ResponseEntity<Object> saveConsumption(@RequestPart(value = "image", required = false) MultipartFile image,
@RequestPart(value = "detail", required = true) ConsumptionDto.Detail detail) throws IOException{
String savePath;
if(image == null || image.isEmpty()){
savePath = null;
} // image가 없는 경우에는 아무것도 안 해주고, db에 null로 저장할 것임.
else{
Date currentDate = new Date(); // 현재 시간으로 저장할 것이기 때문에 이렇게 작성.
SimpleDateFormat transFormat = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss");
String currentPath = "./images/myfiles/"; // 저장경로
File checkPathFile = new File(currentPath);
if(!checkPathFile.exists()){ // 해당 경로가 없는 경우에는 directory를 생성함.
checkPathFile.mkdirs();
}
File savingImage = new File(currentPath + detail.imageId + "_" + transFormat.format(currentDate) + ".jpg");
image.transferTo(savingImage.toPath()); // 파일 저장
savePath = savingImage.toPath().toString();
}
myRepository.updatePastPicture(detail.imageId, savePath); // db에 저장
}
controller를 위와 같이 작성했다.
경로를 지정할 때 주의해야 하는 것은 window는 경로 구분자가 \\, 또는 \로 지정하는 데 반해 linux는 /로 지정하기 때문에 이를 유의해 주어야 한다. 또한 image가 비었는지 확인하는 것에서 null과 isEmpty 2개 다 사용했는데, null인 경우에는 존재하지 않는 경우, isEmpty인 경우 주소는 있지만 data가 없는 경우를 말한다. 그래서 2개 다 검사했다.
이렇게 할 때, 요청은 아래와 같이 보내면 된다. form에 json을 담을 수 있기 때문이다.
2. Spring -> Front로 이미지 리턴하기
경우가 2가지였기 때문에 2가지 방법을 사용했다.
예시로 들자면,
1) 킥보드 앱에서 최근 주차사진을 보여주고 싶다. 그러나 킥보드 리스트와 킥보드 주차사진을 같이 준다면 양이 너무 많아진다. (킥보드 리스트 100개만 되어도 주차사진 3mb라고 치면 300mb) 따라서 킥보드 리스트에는 이미지 링크를 보내고, 주차 사진은 하나하나 조회할 때 따로 값을 가져온다.
2) 쇼핑몰의 세부 정보를 보여주고 싶다. 해당 상품의 가격, 등등의 정보 + 이미지 n장을 받고 싶다.
1)의 경우에는 DTO 안에 image link를 달아주고, 해당 image link로 들어가면 사진이 나오게 하면 될 것이다.
2)의 경우에는 image 여러 장 + json 파일까지 주어야 한다. 그래서 base64로 encoding해서 주었다.
1) DTO에 image link를 이용해서 사진을 조회하는 방법
ResourceDto.java
public class ResourceDto {
Resource resource;
HttpHeaders httpHeaders;
HttpStatus httpStatus;
}
service.java
public ResourceDto getImage(Long kickboardId) throws IOException{
if(!kickboardRepository.existsById(kickboardId)) throw new CustomException(ErrorCode.KICKBOARD_NOT_EXIST);
Kickboard kickboard = kickboardRepository.findById(kickboardId);
String savedPath = kickboard.getPastPicture(); // kickboard entity 안의 저장된 Path를 가져옴.
HttpHeaders headers = new HttpHeaders();
Resource resource;
HttpStatus httpStatus;
ResourceDto resourceDto = ResourceDto.builder().build(); // Resource를 주기 위해서는 이렇게 주어야 한다.
File file = new File(savedPath); // 경로에 해당하는 file을 가져옴.
if(file.exists()){ // file이 있는 경우
Path filePath = Paths.get(savedPath);
resource = new FileSystemResource(filePath); // FileSystemResource로 resource를 가져옴.
headers.add("Content-Type", Files.probeContentType(filePath)); // header도 넣어줌.
httpStatus = HttpStatus.OK;
}
else{ // 없는 경우에는 resource를 null로, 그리고 error는 아니므로 no content로 http status code 지정.
resource = null;
httpStatus = HttpStatus.NO_CONTENT;
}
resourceDto.setResource(resource);
resourceDto.setHttpHeaders(headers);
resourceDto.setHttpStatus(httpStatus);
return resourceDto;
}
controller.java
@GetMapping("/kickboard/location/{kickboardId}")
public ResponseEntity<Resource> showLastParkedKickboardImage(@PathVariable(value="kickboardId", required = true) Long kickboardId) throws IOException{
ResourceDto resourceDto = kickboardService.getKickboardImage(kickboardId);
return new ResponseEntity<Resource>(resourceDto.getResource(), resourceDto.getHttpHeaders(), resourceDto.getHttpStatus());
}
이렇게 하면 결과로 이미지 1장이 뜬다. 파일이 없는 경우에는 204가 뜬다.
2) image 여러 장 + json 파일까지 주어야 한다. 그래서 base64로 encoding해서 주었다.
service.java
public List<String> findEncodedImages(Brand brand) throws IOException{
List<String> images = new ArrayList<>();
String basePath = "./images/brand/" + brand.getBrandName() + "/"; // 이 경로에 있는 파일을 가져옴.
for(int i = 1; i<=3; i++){ // default로 3개가 있음.
String currentPath = basePath + i +".jpg";
File savedImage = new File(currentPath); // 파일을 열고
if(savedImage.exists()) { // 있는지 검사. 없으면 아무것도 하지 않음.
byte[] fileContent = FileCopyUtils.copyToByteArray(savedImage); // file을 byte로 변경
String encodedString = Base64.getEncoder().encodeToString(fileContent); // byte를 base64로 encode
images.add(encodedString);
}
}
return images;
}
controller.java
@GetMapping("/manage/products/{brandName}")
public ResponseEntity<Object> showBrand(@PathVariable(value="brandName", required = false) String brandName) throws IOException {
Brand brand = brandService.findBrand(brandName);
if(kickboardBrand==null) throw new CustomException(ErrorCode.BRAND_NOT_EXIST);
List<String> encodedImages = brandService.findEncodedImages(brand);
BrandDto brandDto = BrandDto.builder()
.brandName(brand.getBrandName())
.text(brand.getText())
.helmet(brand.getHelmet())
.insurance(brand.getInsurance())
.price_per_hour(brand.getPricePerHour())
.service_location(brand.getDistricts())
.images(encodedImages)
.build();
return new ResponseEntity<Object>(brandDto, HttpStatus.OK);
}
이렇게 하면 된다.
* 문제가 하나 있다.
1)의 방식은 컴퓨터에서는 잘 작동하는데, linux 등의 OS가 없는 container에서는 잘 돌아가지 않는다. 왜냐하면 file system이 없기 때문... 그래서 base64로 encode해서 올렸다.