• 지난번에 구현한 Local에 파일 저장하기
    이번에는 S3에 저장으로 바꾸어보았다
    • 팀원들 각자 로컬에 이미지를 저장하다보니 공유가 안되어서, 한곳에서 관리하기 위해 S3와 연동해보았다
    • S3의 가장 큰 장점은 확장,축소에 신경쓰지 않아도 된다는 점이라고 생각한다
  • 간단하게 S3의 특징도 같이 정리해보기


Amazon S3 (AWS Simple Storage Service)

인터넷용 객체 스토리지 서비스

  • 개발자가 웹 규모 컴퓨팅 작업을 더 쉽게 할 수 있도록 설계되어 있음
  • 쉽게 말하면 Goole One, iCloud 같은 파일 저장 서비스
    • bucket이라는 폴더에 object인 파일을 저장할 수 있음


특징

  • S3의 버킷은 무한대의 객체 저장 가능
    -> 확장,축소에 신경쓰지 않아도 됨
    • 일반적인 파일서버는 트래픽이 증가함에 따라서 장비를 증설하는 작업을 해야 하는데 S3는 이와 같은 것을 대행
      = 많은 사용자가 접속을 해도 이를 감당하기 위해서 시스템적인 작업을 하지 않아도 됨
  • 제공하는 단순한 웹 서비스 인터페이스 사용
    -> 언제 어디서나 원하는 양의 데이터를 저장, 검색 가능
  • 높은 확장성 + 신뢰성 + 빠름 + 경제적인 데이터 스토리지 인프라에 access할 수 있음
    • Amazon이 자체 웹 사이트의 글로벌 네트워크 운영에 사용하는 것과 같은
  • 단독 스토리지로도 사용할 수 있고, 다른 AWS service(EC2, EBS, Glacier 등)랑 함께 사용할 수도 있음
  • HTTPS protocol을 사용하여 SSL로 암호화된 end point를 통해 데이터를 안전하게 업로드, 다운로드 가능
    • 키 관리 방법 선택 가능
      • 상주 데이터를 자동으로 암호화 하고 AWS KMS를 통해 S3에서 키를 관리하게 하는 방법
      • 고유한 키를 제공하는 방법
  • 사용한 스토리지만큼 요금 청구
    • 해당 region 내에서는 데이터 송수신은 무료 (다른 AWS region으로는 무료가 아님)



용어 정리

  • Object : S3에 데이터가 저장되는 기본 단위
    • S3에 저장된 데이터 하나 하나를 object라고 명명
    • object 하나의 크기는 1 byte 부터 5TB까지 허용
    • 파일과 메타데이터로 이루어져있음
      • 메타데이터는 MIME 형식으로 파일 확장자를 통해 자동으로 설정됨(사용자 임의로도 지정 가능)


  • Bucket : S3에서 생성할 수 있는 최상위 디렉토리 개념
    • 연관된 object들을 그룹핑한 최상위 디렉토리
    • bucket 단위로 region 지정 가능
    • bucket에 포함된 모든 object에 대해 일괄적으로 인증과 접속제한 걸기 가능
    • 이름이 s3 region 중 유일해야함
    • 계정별로 버킷 100개까지 생성 가능
      • 버킷에 저장할 수 있는 객체수와 용량은 무제한
    • 하위 폴더는 prefix라고 부름 (실제로 폴더 역할을 하는게 아니고 단순히 하나의 경로 역할만 함)
  • 버전관리 : s3에 저장된 object들의 변화를 저장함
    • ex) A라는 object를 사용자가 삭제하거나 변경해도 각각의 변화를 모두 기록하기 때문에 실수 만회 가능


  • 표준스토리지 : 객체에 대해 높은 내구성과 가용성을 제공하는 스토리지 서비스
    • 비용 높음
    • 유실되면 안되는 원본 데이터, 민감정보, 개인정보 등의 중요한 데이터를 저장에 적합
  • RSS(Reduced Redundancy Storage) : 일반 S3 객체에 비해 데이터가 손실될 확률이 높은 스토리지 서비스
    • 대신에 가격이 저렴
    • 복원 가능한 데이터(ex) thumbnail image) 저장에 적합
    • 그럼에도 불구하고 물리적인 하드 디스크 대비 400배 가량 안전하다는 것이 아마존의 주장
  • Glacier : 매우 저렴한 가격으로 데이터를 저장 할 수 있는 스토리지 서비스



S3 bucket 생성

  • 버킷 만들기
    • 버킷 이름, region 아시아 태평양(서울)로 선택
    • 퍼블릭 액세스 설정
      • 퍼블릭 액세스 차단하면 -> IAM에서 AWS access key와 AWS secret key를 발급받고 이를 이용해서 S3 객체에 접근할 수 있음


  • 퍼블릭 정책 활성화

s3정책생성기


  • 정책이 명시된 Json을 복사 후 버킷 정책에 입력해주기

s3정책json

  • action does not apply to any resource(s) in statement s3 bucket가 떠서 찾아보니 일부 서비스에서는 개별 리소스에 대한 작업을 지정할 수 없다고 한다
    • Resource 끝에 와일드카드를 붙여주면 되었다
    • "Resource": "arn:aws:s3:::~~~/*"


s3정책설정완료

  • 액세스가 퍼블릭으로 변경 = 외부에서 S3에 접근 가능한 상태



AWS S3 설정

dependency 추가

implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'


application-aws.yml

  • access key, secret key 등은 노출이 되면 일이 커지므로 겸사겸사 별도로 관리한다
cloud:
  aws:
    credentials:
      access-key: {access key}
      secret-key: {secret key}
    region:
      static: ap-northeast-2
      auto: false
    s3:
      bucket: {bucket 이름}
    stack:
      auto: false


S3 configuration file

@Configuration
public class AwsConfig {

	private final String accessKey;

	private final String secretKey;

	private final String region;

	public AwsConfig(
			@Value("${cloud.aws.credentials.access-key}") String accessKey,
			@Value("${cloud.aws.credentials.secret-key}") String secretKey,
			@Value("${cloud.aws.region.static}") String region) {
		this.accessKey = accessKey;
		this.secretKey = secretKey;
		this.region = region;
	}

	@Bean
	public AmazonS3 amazonS3() {
		AWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey);
		return AmazonS3ClientBuilder.standard()
				.withRegion(region)
				.withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
				.build();
	}
}
  • property 파일에 작성한 값들을 읽어와서 AmazonS3 객체를 만들어 Bean으로 주입해주는 것



구현하기

S3에 이미지 업로드 로직

@Service
public class ImageS3Service implements ImageService {

	private final String bucket;
	private final AmazonS3 amazonS3;
	private final ImageRepository imageRepository;

	public ImageS3Service(
			@Value("${cloud.aws.s3.bucket}") String bucket, AmazonS3 amazonS3, ImageRepository imageRepository) {
		this.bucket = bucket;
		this.amazonS3 = amazonS3;
		this.imageRepository = imageRepository;
	}
  • AmazonS3 주입


	@Override
	public void register(List<MultipartFile> multipartFiles, Long referenceId, DomainType domainType) {
		if (multipartFiles != null && !multipartFiles.isEmpty()) {
			List<Image> images = uploadImages(multipartFiles, referenceId, domainType);
			imageRepository.saveAllBulk(images);
		}
	}

	private List<Image> uploadImages(List<MultipartFile> multipartFiles, Long referenceId, DomainType domainType) {
		return multipartFiles.stream()
				.map(multipartFile -> uploadImage(multipartFile, referenceId, domainType))
				.collect(Collectors.toList());
	}

	private Image uploadImage(MultipartFile multipartFile, Long referenceId, DomainType domainType) {
		String originalName = multipartFile.getOriginalFilename();
		String uniqueName = createUniqueName(originalName);
		String fullPath = storeAndGetPath(multipartFile, uniqueName);

		return Image.builder()
				.originalName(originalName)
				.fullPath(fullPath)
				.referenceId(referenceId)
				.domainType(domainType)
				.build();
	}

	private String createUniqueName(String originalName) {
		String extension = extractExtension(originalName);
		String uuid = UUID.randomUUID().toString();

		return uuid + "-" + LocalDate.now() + extension;
	}
  
	private String extractExtension(String originalName) {
		try {
			return originalName.substring(originalName.lastIndexOf("."));
		} catch (StringIndexOutOfBoundsException e) {
			throw new FileUploadFailedException("invalid file format");
		}
	}
  • 여기까지는 지난번과 같다
    • 새로운 파일명으로 만드는 방식과 확장자 추출하는 부분만 조금 바꿔보았다


	private String storeAndGetPath(MultipartFile multipartFile, String uniqueName) {
		ObjectMetadata objectMetadata = new ObjectMetadata();

		try {
			objectMetadata.setContentType(multipartFile.getContentType());
			objectMetadata.setContentLength(multipartFile.getInputStream().available());
			amazonS3.putObject(bucket, uniqueName, multipartFile.getInputStream(), objectMetadata);
		} catch (IOException e) {
			throw new FileUploadFailedException("Failed to save (S3)");
		}

		return amazonS3.getUrl(bucket, uniqueName).toString();
	}
  • 이 부분이 S3에 업로드 하는 로직


  • S3 API 메소드
    • putObject(String bucketname, String key, InputStream input, ObjectMetadata metadata)
      • 요거 선택함
      • 여기서 key 값은 버킷 내에서 객체를 찾기위해 사용되는 고유 식별자를 의미
    • putObject(String bucketname, String key, File file)
      • file을 넘겨줄 때 실제 파일이 존재해야해서, 로컬에도 파일이 저장되게 됨 -> 자원 낭비
    • putObject(PutObjectRequest putObjectRequest) : S3에 대한 세부 설정 가능
      • 세부 설정 불필요했음


  • ObjectMetadata 사용 : 파일에 대한 정보 추가
    • 내가 선택한 메서드에서는 InputStream을 통해 byte만 전달되기 때문에 해당 파일에 대한 정보가 없어서
  • getUrl() : S3에 업로드된 image URL 가져옴



S3에 이미지 삭제 로직

	@Override
	public void deleteAllByReference(Long referenceId, DomainType domainType) {
		List<Image> images = imageRepository.findAllByReferenceIdAndDomainType(referenceId, domainType);
		images.stream()
		    .map(image -> image.getFullPath().substring(56))
		    .forEach(this::deleteImage);
		imageRepository.deleteAllByReferenceIdAndDomainType(referenceId, domainType);
	}

	private void deleteImage(String fileName) {
		try {
			amazonS3.deleteObject(bucket, fileName);
		} catch (AmazonServiceException e) {
			throw new FileDeleteFailedException("Failed to delete (S3)");
		}
	}
}
  • 위의 2개 메서드 중 두번째가 실제로 S3의 이미지 파일을 지우는 로직
  • 마찬가지로, deleteObject(String bucketname, String key)를 사용했다
    • 삭제는 업로드보다도 더 쉽게 구현 가능



구현하다 만난 에러

com.amazonaws.SdkClientException: Failed to connect to service endpoint:
Caused by: java.net.SocketTimeoutException: connect timed out
  • aws sdk error : spring-cloud-starter-aws 의존성 주입시, 로컬환경은 aws환경이 아니기때문에 나는 에러라고 함
    • InstanceMetadataServiceResourceFetcher class의 readResource를 호출하면서 발생한 에러
    • EC2 인스턴스가 아닌 다른곳에서 해당 애플리케이션을 실행할 때 나는 에러
      • 서비스의 endpoint를 연결하지 못해 발생하는 에러
      • EC2의 메타데이터를 읽다가 발생하는 에러로써, EC2인스턴스가 아닌 곳에서는 의미가 없는 에러



  • application이 돌아는가는, 꼭 해결해야하는 에러는 아니였지만,
    실행 시 시간이 오래걸림 + 콘솔에 찍히는게 거슬림으로 조치를 취해보았다
    • 관련 클래스를 auto configuration loading에서 제외시켰다
@SpringBootApplication(
        exclude = {
                org.springframework.cloud.aws.autoconfigure.context.ContextInstanceDataAutoConfiguration.class
        }
)



  • 또 다른 해결방안 : VM option 커스텀 + yml 파일에 로깅관련 설정 추가하기
-Dcom.amazonaws.sdk.disableEc2Metadata=true
logging:
  level:
    com:
      amazonaws:
        util:
          EC2MetadataUtils: error



Reference
https://aws.amazon.com/ko/s3/faqs/
https://aws.amazon.com/ko/blogs/korea/getting-started-with-spring-boot-on-aws/
https://opentutorials.org/course/608/3006
https://devocean.sk.com/blog/techBoardDetail.do?ID=163606
https://stackoverflow.com/questions/44228422/s3-bucket-action-doesnt-apply-to-any-resources
https://docs.aws.amazon.com/ko_kr/sdk-for-java/v1/developer-guide/examples-s3-objects.html


업데이트: