지난번에는 코드와 실행 결과만 보여주고 끝냈었는데, 오늘은 지난 시간에 설명 못했던 부분들을 설명한 뒤, 추가한 기능이 있어서 이 부분을 설명하고 마치도록 하겠습니다.
지난번에 Image Entity에 대한 설명은 했기 때문에 넘어가고, Repository나 DTO 파일은 설명할 부분이 없기 때문에 넘어가도록 하겠습니다.
그럼 S3에 대한 설정을 수행하는 설정파일부터 알아보도록 하겠습니다.
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@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 AmazonS3Client amazonS3Client() {
BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
return (AmazonS3Client) AmazonS3ClientBuilder.standard()
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.withRegion(region)
.build();
}
}
먼저, application.yml에 선언해주었던 값들을 @Value 어노테이션을 통하여 가져왔는데, 이렇게 어노테이션을 통하여 값을 가져오는 경우, 다른 사용자들에게 값을 직접적으로 노출시키지 않기 때문에 이러한 방식을 많이 사용한다고합니다.
비슷한 원리로 Spring Security를 활용하여 Key를 만드는 작업을 수행할 때에도 다음처럼 @Value 어노테이션을 통하여 값을 가져오는 방식을 사용하였습니다.
S3Config 파일에 @Configuration 어노테이션을 선언해줌으로써, @Bean 어노테이션이 선언된 메서드의 반환형을 빈으로 만든다는 것을 확인할 수 있습니다.
우리가 빈으로 만들어줄 파일은 바로 AmazonS3Client로, 해당 클래스에 대한 설명은 다음과 같습니다.
뭐 말이 많은데 간단하게 축약하면 다음과 같습니다.
사용자가 웹 환경에서 S3를 사용할 수 있도록 저장이나 삭제, 복구 등의 기능을 제공하는 클래스
즉, 우리는 S3를 활용하기 위해서 AmazonS3Client 클래스의 객체를 만들어야한다는 뜻이고, 이는 AmazonS3Client를 빈으로 만든뒤, 이를 Service로 끌고와 로직을 수행하도록 만들어야 함을 의미합니다.
@Bean
public AmazonS3Client amazonS3Client() {
BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
return (AmazonS3Client) AmazonS3ClientBuilder.standard()
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.withRegion(region)
.build();
}
다음의 과정을 통하여 S3에 접근할 수 있는 AmazonS3Client 객체를 생성할 수 있는데, 이 객체를 통하여 S3에 접근하기 때문에, 우리는 이 객체에 사용자와 관련된 정보들을 담아야합니다. (아무나 S3에 접근하는 것을 막기 위함)
그때 필요한 정보가 바로 우리가 IAM 사용자를 생성하면서 발급받은 2개의 key와 지역명입니다.
key를 통하여 BasicAwsCredentials를 생성한 뒤, AmazonS3Client 객체를 만드는 과정에서 이를 대입시켜주면, 우리의 정보를 담은 객체가 생성될 것입니다.
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.model.ObjectMetadata;
import lombok.RequiredArgsConstructor;
import minsub.S3Test.ImageStorage.ImageRepository;
import minsub.S3Test.ImageStorage.domain.Image;
import minsub.S3Test.ImageStorage.dto.ImageSaveDto;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
@Service
@RequiredArgsConstructor
public class ImageService {
private static String bucketName = "chrkb156920230508";
private final AmazonS3Client amazonS3Client;
private final ImageRepository imageRepository;
@Transactional
public List<String> saveImages(ImageSaveDto saveDto) {
List<String> resultList = new ArrayList<>();
for(MultipartFile multipartFile : saveDto.getImages()) {
String value = saveImage(multipartFile);
resultList.add(value);
}
return resultList;
}
@Transactional
public String saveImage(MultipartFile multipartFile) {
String originalName = multipartFile.getOriginalFilename();
Image image = new Image(originalName);
String filename = image.getStoredName();
try {
ObjectMetadata objectMetadata = new ObjectMetadata();
objectMetadata.setContentType(multipartFile.getContentType());
objectMetadata.setContentLength(multipartFile.getInputStream().available());
amazonS3Client.putObject(bucketName, filename, multipartFile.getInputStream(), objectMetadata);
String accessUrl = amazonS3Client.getUrl(bucketName, filename).toString();
image.setAccessUrl(accessUrl);
} catch(IOException e) {
}
imageRepository.save(image);
return image.getAccessUrl();
}
}
@RequiredArgsContstructor 어노테이션을 통하여 빈으로 생성된 AmazonS3Client 객체를 가져온 뒤, 이를 활용하여 S3에 이미지를 저장하거나, 삭제하는 기능을 수행하도록 코드를 작성하였습니다.
saveImages() 메소드와 saveImage() 메소드가 존재하는데, 사용자로부터 이미지 파일을 여러개 가져올 수 있도록하기 위하여 다음처럼 설계하였습니다.
먼저, 이미지 파일을 저장하는 로직을 살펴보면, 매개변수로 MultipartFile을 받아와, 이를 변환하여 S3에 저장하는 역할을 수행하는데, S3에 파일을 저장하는 putObject() 메소드의 구조를 살펴보면 다음과 같습니다.
bucketName -> 우리가 이미지 파일을 저장할 버킷의 이름
key -> 저장할때 활용할 이미지 파일의 이름
input -> 우리가 업로드한 파일의 내용을 읽기 위한 스트림
metatdata -> inputStream의 경우, Byte로만 구성되어 있기 때문에
파일에 대한 추가적인 정보를 제공해주기 위한 데이터
파일에 대한 inputStream의 경우에는 MultipartFile에서 제공하는 getInputStream() 메소드를 사용하면 해결할 수 있지만, ObjectMetadata의 경우에는 별도의 메소드가 없어 객체를 생성하여 매개변수로 전달해주어야합니다.
ObjectMetadata는 InputStream에 저장된 파일의 정보, 즉 MultipartFile의 정보이기 때문에 파일의 형식이 어떠한 형식인지, 파일의 길이가 어느정도 되는지의 정보를 입력해주어야합니다.
ObjectMetadata objectMetadata = new ObjectMetadata();
objectMetadata.setContentType(multipartFile.getContentType());
objectMetadata.setContentLength(multipartFile.getInputStream().available());
따라서, 다음과 같이 ObjectMetadata 객체를 만들어서 매개변수로 활용해주는 코드를 작성하였습니다.
그리고 이미지 접근 URL의 경우, 이미지가 저장된 버킷 이름과 저장된 파일의 이름을 통하여 얻을 수 있기 때문에 amazonS3Client.getUrl() 메소드를 통하여 이미지 접근 URL을 얻은뒤 이를 반환하도록 로직을 작성하였습니다.
이미지를 삭제하는 로직의 경우, 이미지 파일이 S3에 저장될 때의 이름을 통하여 삭제되도록 로직을 작성하였습니다.
@Transactional
public void deleteImage(String filename) {
amazonS3Client.deleteObject(bucketName, filename);
}
import lombok.RequiredArgsConstructor;
import minsub.S3Test.ImageStorage.dto.ImageSaveDto;
import minsub.S3Test.ImageStorage.service.ImageService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class ImageController {
private final ImageService imageService;
@PostMapping("/image")
@ResponseStatus(HttpStatus.OK)
public List<String> saveImage(@ModelAttribute ImageSaveDto imageSaveDto) {
return imageService.saveImages(imageSaveDto);
}
// S3에 저장된 이미지를 삭제하는 로직, 이미지 파일의 확장자까지 정확하게 입력해야 삭제 가능
// S3에 저장되지 않은 이미지 파일의 이름으로 요청하여도 오류 발생하지 않음
@DeleteMapping("/image")
@ResponseStatus(HttpStatus.OK)
public void deleteImage(@RequestParam("name") String fileName) {
imageService.deleteImage(fileName);
}
}
이미지를 생성하는 로직의 경우, form-data 형식으로 요청하기 때문에 @ModelAttribute 어노테이션을 추가하였으며, 이미지를 삭제하는 로직의 경우에는 이미지 파일을 통하여 삭제하기 때문에 쿼리 파라미터 형식으로 이미지 파일의 이름을 통해 로직을 수행하도록 설계하였습니다.
삭제하기 전 S3 상태
요청 후 정상적으로 이미지가 삭제된 모습을 확인할 수 있습니다.
그러나, 일단은 저장된 이미지의 이름을 알아야 한다는 점에서 이용하기에 불편하고, 존재하지 않는 이미지 파일의 이름을 입력하여 요청해도 오류가 반환되지 않기 때문에 정상적으로 삭제되었는지 아닌지를 판단하기가 어렵다는 한계점이 존재합니다.. 일단 이 부분은 나중에 다시 알아봐야할 것 같습니다.
https://galid1.tistory.com/591
https://memo-the-day.tistory.com/109