[Spring] AWS S3에 이미지 업로드하기

파이 ఇ·2023년 10월 19일
1
post-thumbnail

이번 포스팅은 AWS S3에 이미지 업로드 기능 에 대해 구현을 진행하며 어려웠던 부분이나, 클래스의 역할등을 다뤄보려 합니다.

📣 이 글을 시작하기전에 미리 말씀드리자면 저는 Controller 부분은 작성하지 않고 오직 업로드를 구현하기 위해 필요한 로직만 작성되었음을 알립니다 ❗️

프로젝트 구성

Java 11
SpringBoot 2.7.16

build.gradle에 의존성 추가하기

필요한 의존성을 추가합니다.

    // S3
    implementation 'com.amazonaws:aws-java-sdk-s3:1.12.518'
    testImplementation 'com.amazonaws:aws-java-sdk-s3:1.12.518'

application.yml 작성

cloud:
  aws:
    credentials:
      access-key: ${aws.credentials.access-key}
      secret-key: ${aws.credentials.secret-key}
    s3:
      bucket: ${aws.s3.bucket}
    region:
      static: ${aws.region.static}
  stack:
    auto: false

access-keysecret-key는 노출되면 안되기 때문에 본인은 application-secret.yml 파일을 따로 작성하여 저장했습니다.
따로 secret파일을 두고 사용하실게 아니라면 .gitignore 처리 후 public한 곳으로 업로드 하지 않길❗️ (혹시 해킹당하면 과금이 .. 무섭습니다 😱)

S3Properties 작성

@Getter
@ConfigurationProperties("aws")
public class S3Properties {

	private final Credentials credentials;
	private final S3 s3;
	private final String region;

	@ConstructorBinding
	public S3Properties(Credentials credentials, S3 s3, Map<String, String> region) {
		this.credentials = credentials;
		this.s3 = s3;
		this.region = region.get("static");
	}

	@Getter
	@RequiredArgsConstructor
	public static class Credentials {

		private final String accessKey;
		private final String secretKey;
	}

	@Getter
	@RequiredArgsConstructor
	public static class S3 {
		private final String bucket;
	}
}

S3Properties는 .yml파일에 설정해놓은 환경변수를 읽어와 각 필드에 값을 바인딩 해줍니다.

@Configuration
public class S3Config {
    @Value("${cloud.aws.credentials.accessKey}")
    private String accessKey;

    @Value("${cloud.aws.credentials.secretKey}")
    private String secretKey;

    @Value("${cloud.aws.region.static}")
    private String region;

다른 블로그들을 찾아보면 따로 properties를 생성하지 않고 위와 같이 S3Config 필드위에 @Value애노테이션을 붙여서 사용하시던데 저는 config의 역할과 properties의 역할이 다르다고 생각해 따로 분리하여 사용하였습니다. (물론 지극히 개인적인 생각이기 때문에 위와 같이 사용하셔도 됩니다❗️)

S3Config 작성

@Configuration
@EnableConfigurationProperties(S3Properties.class)
public class S3Config {

	@Bean
	public AmazonS3Client amazonS3Client(S3Properties s3Properties) {
		BasicAWSCredentials credentials = new BasicAWSCredentials(s3Properties.getCredentials().getAccessKey(),
			s3Properties.getCredentials().getSecretKey());

		return (AmazonS3Client)AmazonS3ClientBuilder.standard()
			.withCredentials(new AWSStaticCredentialsProvider(credentials))
			.withRegion(s3Properties.getRegion())
			.build();
	}
}

S3Config는 S3에 이미지를 올리기 위해 AmazonS3Client를 bean으로 등록합니다.

@EnableConfigurationProperties를 사용하여 S3Properties를 spring bean처럼 사용할 수 있습니다.
참조 https://www.baeldung.com/spring-enable-config-properties

ImageService 작성

@Service
@Transactional
@RequiredArgsConstructor
public class ImageService {

	private final ImageUploader imageUploader;

	public String uploadImageToS3(MultipartFile multipartFile) {
		ImageFile file = ImageFile.from(multipartFile);
		return imageUploader.uploadImageToS3(file);
	}

	public List<String> uploadImagesToS3(List<MultipartFile> multipartFiles) {
		List<ImageFile> imageFiles = ImageFile.from(multipartFiles);
		return imageUploader.uploadImagesToS3(imageFiles);
	}
}

ImageService는 MultipartFile에서 이미지 업로드할 때 필요한 정보만을 가져와 ImageFile객체로 만들어줍니다. 또한 ImageService는 이미지를 업로드 시키는 역할이 아니라고 판단해 이미지를 업로드 시키는 역할은 ImageUploader 클래스를 따로 작성했습니다.

ImageFile 작성

@Getter
@RequiredArgsConstructor
public class ImageFile {

	private final String fileName;
	private final String contentType;
	private final Long fileSize;
	private final InputStream imageInputStream;

	private ImageFile(MultipartFile multipartFile) {
		this.fileName = getFileName(multipartFile);
		this.contentType = getImageContentType(multipartFile);
		this.imageInputStream = getImageInputStream(multipartFile);
		this.fileSize = multipartFile.getSize();
	}

	public static ImageFile from(MultipartFile multipartFile) {
		return new ImageFile(multipartFile);
	}

	public static List<ImageFile> from(List<MultipartFile> multipartFiles) {
		List<ImageFile> imageFiles = new ArrayList<>();
		for (MultipartFile multipartFile : multipartFiles) {
			imageFiles.add(new ImageFile(multipartFile));
		}
		return imageFiles;
	}

	public InputStream getImageInputStream(MultipartFile multipartFile) {
		try {
			return multipartFile.getInputStream();
		} catch (IOException e) {
			throw new InternalServerException(ErrorCode.FILE_IO_EXCEPTION);
		}
	}

	private String getImageContentType(MultipartFile multipartFile) {
		return ImageContentType.findEnum(StringUtils.getFilenameExtension(multipartFile.getOriginalFilename()));
	}

	private String getFileName(MultipartFile multipartFile) {
		String ext = extractExt(multipartFile.getOriginalFilename());
		String uuid = UUID.randomUUID().toString();
		return uuid + "." + ext;
	}

	private String extractExt(String originalFilename) {
		int pos = originalFilename.lastIndexOf(".");
		return originalFilename.substring(pos + 1);
	}

	@Getter
	@RequiredArgsConstructor
	enum ImageContentType {

		JPEG("jpeg"),
		JPG("jpg"),
		PNG("png"),
		SVG("svg");

		private final String contentType;

		public static String findEnum(String contentType) {

			for (ImageContentType imageContentType : ImageContentType.values()) {
				if (imageContentType.getContentType().equals(contentType.toLowerCase())) {
					return imageContentType.getContentType();
				}
			}
			throw new BadRequestException(ErrorCode.INVALID_FILE_EXTENSION);
		}
	}
}

S3에 이미지를 업로드하기 위해 위와 같이 4가지가 필요합니다.

  • 파일이름
  • 파일의 컨텐트타입
  • 파일 사이즈
  • 파일의 inputstream

InputStream이란?
바이트 기반 입력 스트림의 최상위 추상클래스입니다. (모든 바이트 기반 입력 스트림은 이 클래스를 상속받습니다.)
파일 데이터를 읽거나 네트워크 소켓을 통해 데이터를 읽거나 키보드에서 입력한 데이터를 읽을 때 사용합니다.

위에서부터 차례대로 설명해보겠습니다.

  • 2개의 static from 메서드들은 MultipartFile을 ImageFile로 변경시켜주는 역할을 합니다. (단일 이미지일때와 이미지 여러장을 받았을 때 차이 입니다.)
  • getInputStream : multipartFile일의 inputStream을 가져옵니다. 이때 IOException이 발생할 수 있어 try, catch를 사용합니다.
  • getImageContentType : 이너 클래스에 있는 findEnum 메서드를 통해 multipartFile에서 일치하는 contentType을 가져옵니다.
    • StringUtils의 getFilenameExtension 메서드는 파일의 확장자를 반환합니다 ex. image.png -> png를 문자열로 반환합니다.
    • 일치하지 않는 타입이 들어오면 BadRequestException을 던집니다.
  • getImageFileName : 파일 이름을 가져오기 위한 메서드입니다.
    • extractExt : 실제 파일 이름만 추출하기 위해 사용된 메서드입니다
    • UUID.randomUUID : 파일의 이름이 중복되지 않기 위해 사용하였습니다.

ImageUploader 작성

@Component
public class ImageUploader {

	private static final String UPLOADED_IMAGES_DIR = "public/";

	private final AmazonS3Client amazonS3Client;
	private final String bucket;

	public ImageUploader(AmazonS3Client amazonS3Client, S3Properties s3Properties) {
		this.amazonS3Client = amazonS3Client;
		this.bucket = s3Properties.getS3().getBucket();
	}

	public String uploadImageToS3(ImageFile imageFile) {
		final String fileName = putImage(imageFile);
		return getObjectUrl(fileName);
	}

	public List<String> uploadImagesToS3(List<ImageFile> imageFile) {
		List<String> urls = new ArrayList<>();
		for (ImageFile file : imageFile) {
			final String fileName = putImage(file);
			urls.add(getObjectUrl(fileName));
		}
		return urls;
	}

	private String putImage(ImageFile imageFile) {
		ObjectMetadata metadata = new ObjectMetadata();
		metadata.setContentType(imageFile.getContentType());

		final String fileName = UPLOADED_IMAGES_DIR + imageFile.getFileName();
		amazonS3Client.putObject(bucket, fileName, imageFile.getImageInputStream(), metadata);
		return fileName;
	}

	private String getObjectUrl(final String fileName) {
		return URLDecoder.decode(amazonS3Client.getUrl(bucket, fileName).toString(), StandardCharsets.UTF_8);
	}
}

자 이제 마지막입니다. 실제 S3에 이미지를 업로드하기 위한 클래스입니다. 위에서부터 차례대로 설명하겠습니다.

  • UPLOADED_IMAGES_DIR : S3 bucket에 이미지를 보낼 경로입니다.
  • AmazonS3Client : 이미지를 S3에 저장하기 위해 사용되는 객체입니다. S3Config에서 Bean으로 등록해놓았습니다.
  • bucket : 버켓의 이름을 가지고 있습니다.
  • uploadImageToS3, uploadImagesToS3 : putImage 메서드를 사용해 S3에 업로드합니다.
  • putImage : 실제 파일을 S3에 업로드 해주고 S3 URL 주소를 반환 받습니다.
  • getObjectUrl : 반환받은 S3 URL주소를 decode해서 사람이 읽을 수 있는 이름으로 변환해줍니다.

Test 작성

@Transactional
@SpringBootTest
class ImageServiceTest {

	@InjectMocks
	private ImageService imageService;
	@Mock
	private ImageUploader imageUploader;

	@DisplayName("이미지 파일이 주어지면 업로드에 성공한다.")
	@Test
	void imageUpload() throws IOException {
		// given
        // (1)
        MockMultipartFile mockMultipartFile = new MockMultipartFile(
			"test-image", "test.png",
			MediaType.IMAGE_PNG_VALUE, "imageBytes".getBytes(StandardCharsets.UTF_8));

		// (2)
        given(imageUploader.uploadImageToS3(any(ImageFile.class))).willReturn("url");

		// when & then
        // (3)
		assertThatCode(() -> imageService.uploadImageToS3(mockMultipartFile))
			.doesNotThrowAnyException();
	}
}
  1. MockMultipartFile 객체를 생성합니다.
  2. imageUploader.uploadImageToS3 메서드를 실행시켰을 때 매개변수로 아무 ImageFile클래스만 넣으면 url을 리턴받길 기대한다 라는 의미입니다.
  3. imageService.uploadImageToS3 메서드를 실행시켰을 때 매개변수로 mockMultipartFile을 넣으면 어떤 에러도 발생하지 않는다는 의미입니다.

끝 !

긴 글 읽어주셔서 감사합니다 🦋 🩵
틀린 부분이 있다면 가감없이 알려주시면 감사하겠습니다 !

profile
⋆。゚★⋆⁺₊⋆ ゚☾ ゚。⋆ ☁︎。₊⋆

0개의 댓글