본문 바로가기
SpringBoot/구현 고민들

[Spring] AWS S3 Base64 인코딩된 이미지 처리 과정 고민 및 구현 과정

by rla124 2024. 7. 12.

백엔드에서 이미지를 처리하는 과정은 정말 다양하다. 내가 실제로 구현해 본 방법은 아래와 같다.

1. AWS S3 presigned url 발급 

2. List<Multipart> 형식의 업로드

3. base64 인코딩된 List<string>을 디코딩하여 S3에 업로드 

 

이 게시글에서는 3번을 적용하기까지 과정을 기록하고자 한다.   

PresignedUrl의 trade-off 고민 

이번 상반기에 진행했던 프로젝트 중 두 개의 프로젝트에서 내가 이미지 처리를 맡게 되었었다. 처음에는 presignedUrl을사용함으로써 제한된 시간 동안만 유효한 url을 발급하여 서버에서 직접 이미지 업로드 및 다운로드 요청을 하는 것이 아니라 프론트에서 직접 S3로 다이렉트 업로드를 함으로써 서버 부하 뿐만 아니라 네트워크 전송 비용을 줄이고자 하였다. 또한, S3의 경우 사용량 기반으로 비용이 측정되기 때문에 "필요한 경우에만" presignedUrl 발급 요청을 함으로써 이미지를 업로드 할 수 있었으므로 그동안 보안, 성능, 비용 이렇게 세 가지 측면에서 이 방식이 백엔드 입장에서 더 효과적이라고 생각해왔었다.

 

하지만 두 프로젝트에서 프론트의 입장이 모두 동일했다. 1번 프로젝트에서도 하나의 이미지를 업로드 하기 위해 프론트 입장에서 너무 빈번한 api 호출이 있다는 점, 그리고 이 경험(1번에서 2번으로 바꾼 경험)을 바탕으로 3 번 프로젝트 프론트 팀원들한테 다들 바빠서 며칠 전 새벽에 진행한ㅜㅠ 프론트-백엔드만 따로 했었던 단체 회의 때 언급을 했었었다.

"게시글당 최대 3개의 이미지를 업로드할 수 있는데 현재 presignedUrl 발급, 업로드 완료 요청 이렇게 두 번의 api를 요청해야 하고 최대 6번의 호출이 필요하다... "

 

이에 대해 너무 많은 서버 요청이 프론트 입장에서 부담이 될 수 있기 때문에 피드백을 받아들여 로직을 수정하기로 결정하게 되었다.

 

구현을 하는 과정에서 백엔드 입장에서 최적의 구현 방식일지라도 프론트-백엔드가 협업하여 결과물을 만들어내는 것이기 때문에 trade-off를 고민하는 것의 중요성을 알게 되었다. presignedUrl 로직을 구현하면 서버 백엔드에서 직접 s3로 업로드 요청을 보내지 않아도 된다는 편리함이 있으나 프론트 입장을 생각할 수 있는, trade-off를 고려할 수 있는 백엔드 개발자가 되어야겠다 라는 다짐을 하게 되었다. 

 

base64 string 인코딩 방식을 선택한 이유  

그래서 새벽 회의 때 여러가지 프론트-백엔드 일정 관련 이야기를 하면서 그 이후에 이미지를 맡은 프론트와 어떤 방식으로 이미지 업로드 처리를 하면 좋을지도 상의를 했었다. 나는 기존에 multipart 형식으로 구현을 해본 경험이 있기 때문에 새로운 방법을 시도해보고 싶었고 프론트에서도 이미지를 base64 인코딩 string 형식으로 주었을 때 처리가 가능한지 물어봐서 해보겠다고 했었다. 

 

그렇다면 왜?라는 질문을 스스로 해보고 답을 해봐야 한다. 모든 코드에는 마땅한 이유와 근거가 뒷받침되어야 한다고 생각하기 때문에...

 

base64 인코딩 방식을 적용하면  데이터의 크기가 늘어나지 않나?

 

맞다. base64로 인코딩 하게 되면 8비트 이진 데이터를 아스키 문자로만 이루어진 텍스트로 변환시키기 때문에 6bit 당 2bit의 오버헤드가 발생하여 전송하는 데이터의 크기가 늘어나게 되며 인코딩에 따른 디코딩을 해주어야 하기 때문에 CPU 연산까지 필요하다. 하지만 그럼에도 불구하고 이 방법을 채택한 이유 "통신 과정에서 바이너리 데이터의 손실을 막기 위해서"이다. 

즉, media에 binary data를 포함해야 할 경우 포함된 binary data가 시스템 독립적으로 동일하게 전송 또는 저장되는 것을 보존하기 위함이다.  

 


그리고 이어서 위와 같이 프론트에서 base64 string을 전해주면 백에서는 어떻게 처리해야 할지 에 대한 문제에 봉착하였다. 코드 설명은 이 링크에 있다. 커밋 내역을 통해서도 알 수 있지만 커밋 구현 방식 변화는 아래 고민 사항에 기반한다. 

 

1. 이미지를 인코딩한 string 문자열이 어마무시하게 길기 때문에 이를 image 테이블에 direct로 저장 할 수 있을까?

- DB에 저장하는 string의 길이가 너무 크기 때문에 DB 부하 우려가 있어 지양하였다.

(하지만 찾아보니 방법은 있었다. @Lob 어노테이션을 별도로 엔티티 필드에 부여하면 됐었다. 하지만 DB가 품고 있어야 할 데이터의 사이즈가 너무 컸다.)

 

2. base64 string txt을 multipart로 변환하여 컨트롤러에서 받은 뒤 txt 파일 자체를 S3에 업로드해야할까?

- (그렇다면 S3 경로를 DB에 저장하면 이것은 txt 파일을 보여주기 때문에 프론트에서 다시 이 txt s3 url 내용을 디코딩해서 유저에 보여줘야 한다.)

- 실제 @ModelAttribute라는 어노테이션을 통해 스프링에서 자동적으로 base64 string을 multipart file로 컨트롤러에서 변환해서 받아올 수 있는 어노테이션이 있음을 알아냈다.

- 나는 이전에 multipart 업로드를 구현해 본 경험이 있으므로 여기서 base64 multipart txt 파일을 S3에 업로드 해야 하나 생각했었다.

-  하지만 나는 프론트에서 S3 url을 게시글 상세 조회 때 반환해주면 바로 프론트에서 base64로 인코딩된 string을 디코딩하지 않고도 이미지를 띄워주면 되는 상황을 원했다.

 

그래서 인코딩된 string을 단순히 s3에 올리는 것이 아니라

3. 백에서 string을 이미지 확장자를 가질 수 있도록 png, jpeg, jpg로 디코딩하여 이를 s3에 업로드하고 반환받은 s3 url을 db에 저장하면 되지 않을까?

라는 생각에 도달하였다.

이렇게 되면 내가 원하는 로직대로 프론트에서 request로 List<String>으로 base64 encoded string을 받아 디코딩한 뒤 기존 "그림"이 가지고 있던 확장자인  png, jpeg, jpg를 유지하면서 S3에 업로드하면 아래와 같이 URL을 바로 접속할 때 시각적으로 그림이라고 확인 할 수 있는 이미지가 뜰 것이었다.

 

위 링크를 접속하면 base64 string이 아니라 "그림"을 확인하는 것! 이것이 내가 지향한 구현 방향성이었다.

위 s3 url을 반환받기까지 내가 노력한 점은 굳이 front에서 fileName을 받지 않아도 UUID로 파일명을 지정한 것, 뒤에 기존 이미지 원본 확장자를 지키려고 한 것이다.

(이 이미지는 올해 1학기 때 들었던 지옥의 4학년 전공 "영상 처리" 과목의 첫 주차 실습 test 캡쳐본 이미지이다ㅎㅎ 아직도 정확하게 상황이 기억이 남... LUT를 이용해서 픽셀 값을 문제 조건에 따라 바꾸는 거였다..)

 

 또한 나는 이 List request를 순차적으로 for문을 돌면서 s3에 업로드 하는 것이 아니라 completableFuture을 활용하여 비동기적 처리를 하였으며 이를 통해 성능 향상을 추구하고자 하였다. 

 


base64 string을 다루면서 직면했던 고민사항에 따른 배운 점은 아래와 같았다.

1. 이미지 파일 이름을 프론트에서 받는 것보다 백엔드에서 radomUUID를 생성하여 임의로 filename을 부여하는 것이 더 효율적이라는 점이었다.

- request로 base64 string을 받을 때 이에 따른 fileName도 같이 request에 포함하면 한번의 호출로 여러개의 base64를 처리하기 힘들다. 왜냐면 request를 @RequestBody로 받아오기 때문에 원하는 파일 이름과 어떤 base64 string인지 이 내용이 한 쌍이 되어야 하므로 각각 별도로 이미지 한장 당 처리만 가능했기 때문이다.

2. base64 string의 prefix는 data:image/png;base64,.....로 시작하고 /png가 기존 이미지의 확장자이며 base64, 컴마 이후에 본격적인 인코딩 문자열이 뜬다.

- 나는 유저가 게시글을 작성할 때 png, jpeg, jpg를 갤러리에서 선택했으면 이를 모두 반영하여 최종 s3 url 주소를 반환받을 때 확장자를 s3 url에 적용하고 싶어서 extension을 Pattern의 compile을 이용하여 추출하였고 이를 전체 파일 명 뒤에 붙여주었다.

- 또한 디코딩을 할 때는 prefix를 제외하고 base64, 컴마 이후부터 해야 한다. 그렇지 않으면 디코딩 할 수 없다는 오류가 뜨고 이 부분에 대해서는 prefix를 제거하기 위해 replaceFirst라는 자바 내장 함수를 이용하였다.

 


최종 코드는 아래와 같다. 새로운 시도를 해볼 수 있어서 좋았다. 역시 개발자에게는 새로운 시도가 성장의 발판이 되는 것 같다.

 

 

ImageController.java

@Operation(
		summary = "스터디 게시글 별 이미지 업로드",
		description = "스터디 이미지를 클라우드에 업로드하여 이미지 경로를 반환합니다.")
	@PostMapping("/study/upload")
	public List<StudyImageUrlResponse> uploadStudyImage(@RequestBody StudyImageUploadRequest request) throws ExecutionException, InterruptedException {
		return imageService.uploadFiles(request.studyId(), request);
	}

 

 ImageService.java : completableFuture을 활용한 여러 이미지 비동기 처리

public List<StudyImageUrlResponse> uploadFiles(Long studyId, StudyImageUploadRequest request) throws ExecutionException, InterruptedException {
		List<String> base64ImagesList = request.base64ImagesList();
		List<CompletableFuture<StudyImageUrlResponse>> futures = new ArrayList<>();

		for (String base64Image : base64ImagesList) {
			futures.add(CompletableFuture.supplyAsync(() -> {
				try {
					return uploadFile(studyId, base64Image);
				} catch (IOException e) {
					throw new RuntimeException(e);
				}
			}));
		}

		List<StudyImageUrlResponse> responses = new ArrayList<>();
		for (CompletableFuture<StudyImageUrlResponse> future : futures) {
			responses.add(future.get());
		}

		return responses;
	}

	private StudyImageUrlResponse uploadFile(Long studyId, String base64Image) throws IOException {
		String extension = "";
		Pattern pattern = Pattern.compile("^data:image/([a-zA-Z]+);base64,");
		Matcher matcher = pattern.matcher(base64Image);
		if (matcher.find()) {
			extension = matcher.group(1);
		}

		String base64ImageData = base64Image.replaceFirst("^data:image/[^;]+;base64,", "");

		byte[] imageBytes = Base64.getDecoder().decode(base64ImageData);
		ByteArrayInputStream inputStream = new ByteArrayInputStream(imageBytes);

		ObjectMetadata metadata = new ObjectMetadata();
		metadata.setContentLength(imageBytes.length);
		metadata.setContentType("image/" + extension);

		String randomFileName = generateUUID();
		String fullFileName = randomFileName + "." + extension;

		amazonS3.putObject(s3Properties.bucket(), fullFileName, inputStream, metadata);

		String base64ToS3url = amazonS3.getUrl(s3Properties.bucket(), fullFileName).toExternalForm();

		Study study = findStudyById(studyId);

		Image savedImage = imageRepository.save(
			Image.createBase64ToImage(
				study,
				base64ToS3url
			)
		);

		return new StudyImageUrlResponse(savedImage.getId(), base64ToS3url);
	}

 

StudyImageUploadRequest.java : 이렇게 여러 이미지 base64 인코딩 string을 한꺼번에 받아 병렬처리를 함으로써 기존 presignedUrl의 빈번한 api 호출 문제점을 해결했다

package com.sejong.sejongpeer.domain.image.dto.request;


import java.util.List;

public record StudyImageUploadRequest(
	Long studyId,
	List<String> base64ImagesList
) { }

 

StudyImageUrlResponse.java 

package com.sejong.sejongpeer.domain.image.dto.response;

import com.sejong.sejongpeer.domain.image.entity.Image;

public record StudyImageUrlResponse(
	Long imageId,
	String imgUrl
) {
	public static StudyImageUrlResponse fromImage(Image image) {
		return new StudyImageUrlResponse(
			image.getId(),
			image.getImgUrl()
		);
	}
}

 


AWS S3 이미지를 백에서 처리하는 방법을 3가지 모두 경험해보니까 이제 S3에 있어서는 자신감이 생긴 것 같다~ 화이팅탱팅탱