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

nyoung·2023년 9월 12일
4

개발실습

목록 보기
4/4
post-thumbnail

System Storage Service란?

사진, 동영상 등 파일을 저장하기 위해 사용하는 파일 서버 서비스.
일반적인 파일서버는 트래픽이 증가함에 따라서 장비를 증설하는 작업을 해야 하는데 S3는 이와 같은 것을 대행한다. 트래픽에 따른 시스템적인 문제는 걱정할 필요가 없어진다. 또 파일에 대한 접근 권한을 지정 할 수 있어서 서비스를 호스팅 용도로 사용하는 것을 방지 할 수 있다.

Bucket : object(객체)를 저장하고 관리하는 역할. bucket을 생성하면 owner 권한을 부여받게 된다. 한 계정당 최대 100개까지 생성 가능. 소유권 이전 불가능

Object : 데이터와 메타데이터를 구성하고 있는 저장 단위. 하나 당 1Byte에서 최대 5TB까지 저장이 가능하며 저장할 수 있는 객체의 수는 제한이 없다.

푸디로그에서 S3를 사용하는 이유

  • 무제한 데이터 저장
  • REST 인터페이스 제공
  • 보안성

AWS Identity and Access Management란?

IAM은 AWS 계정 내에서 사용자와 역할을 관리하며, AWS 리소스에 대한 액세스 권한을 부여한다.
S3와 IAM을 함께 사용하는 이유는 IAM 키를 통해 버킷의 권한을 부여하여 보다 효율적으로 관리하기 위해서이다.

IAM 사용자 생성

사용자를 여러명 두어야한다면, 그룹을 생성해서 그룹에 권한을 부여한다. 아니라면 그냥 직접 정책 연결로 권한을 부여한다.

액세스 키 만들기를 선택하고 나오는 선택지에서는 아무거나 선택해도 된다.

액세스 키의 경우 이 화면에서만 보여주고 나중에 알 수 있는 방법이 없기 때문에, 복사를 해두거나 csv 파일로 미리 다운로드 해놓아야 한다.

버킷 생성


퍼플릭 액세스 차단 해제


버킷의 버킷정책을 설정해주어야 다른 사용자들이 해당 버킷의 객체에 접근할 수 있다.

현재 푸디로그에 적용된 정책

{
    "Version": "2012-10-17",
    "Id": "Policy1464968545158",
    "Statement": [
        {
            "Sid": "PublicReadGetObject",
            "Effect": "Allow", // 허용
            "Principal": "*",
            "Action": "s3:GetObject", // 객체 읽기 권한
            "Resource": "arn:aws:s3:::<버킷명>/*"
        },
        {
            "Sid": "DenyOtherAccess",
            "Effect": "Deny", // 차단
            "Principal": "*",
            "Action": "s3:PutObject", // 객체 업로드 권한
            "NotResource": [
                "arn:aws:s3:::<버킷명>/*.jpg",
                "arn:aws:s3:::<버킷명>/*.png",
                "arn:aws:s3:::<버킷명>/*.jpeg",
                "arn:aws:s3:::<버킷명>/*.gif"
            ] // 해당 확장자를 가지지 않은 객체
        }
    ]
}

Spring 연결

build.gradle에 의존성 추가

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

application.yml 추가

accessKey와 secretKey는 IAM 생성자 생성하면서 발급받은 액세스 키를 입력

cloud:
  aws:
    s3:
      bucket: <버킷명>
    credentials:
      accessKey: <발급받은 공개키>
      secretKey: <발급받은 비밀키>
    region:
      static: ap-northeast-2
    stack:
      auto: false

cloud.aws.stack.auto 속성을 false로 지정하지 않으면 StackTrace가 발생한다. Spring Cloud AWS는 기본적으로 서비스가 Cloud Formation 스택내에서 실행된다고 가정하기 때문에 그렇지 않은 경우 임의로 false 값으로 설정을 해줘야한다.

파일 업로드 용량 제한

max-file-size의 경우 default값이 1048576 bytes 로 약 1MB이다.
미리 용량을 풀어두지 않으면 FileSizeLimitExceededException 에러가 뜰수도 있다.

max-request-size의 경우 default값이 -1로 제한이 없다.

spring:
  servlet:
    multipart:
      max-file-size: 5MB // 파일의 용량 제한
      max-request-size: 10MB // 전체 최대 크기 용량 제한

파일 사이즈가 넘으면 스프링MVC에 넘어오기 전에 이미 해당 예외가 터져버리기 때문에, resolve-lazily=true 로 설정하여 실제 해당 파일에 접근하는 시점에 파일을 체크하도록 한다.
@ExceptionHandler에서 해당 예외를 잡을 수 있는 시점에 체크하고 파싱할 수 있도록 설정해준다.

@ExceptionHandler(MaxUploadSizeExceededException.class)
spring:
  servlet:
    multipart:
      resolve-lazily: true

s3 Config 파일 생성

@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;

    @Bean
    public AmazonS3 amazonS3Client() {
        AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);

        return AmazonS3ClientBuilder
                .standard()
                .withCredentials(new AWSStaticCredentialsProvider(credentials))
                .withRegion(region)
                .build();
    }
}

s3Uploader 파일 생성

@Slf4j
@RequiredArgsConstructor
@Service
public class S3Uploader {

    private final AmazonS3 amazonS3;
    private Set<String> uploadedFileNames = new HashSet<>();
    private Set<Long> uploadedFileSizes = new HashSet<>();

    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    @Value("${spring.servlet.multipart.max-file-size}")
    private String maxSizeString;

	// 여러장의 파일 저장
    public List<String> saveFiles(List<MultipartFile> multipartFiles) {
        List<String> uploadedUrls = new ArrayList<>();

        for (MultipartFile multipartFile : multipartFiles) {

            if (isDuplicate(multipartFile)) {
                throw new Exception400("file", ErrorMessage.DUPLICATE_IMAGE);
            }

            String uploadedUrl = saveFile(multipartFile);
            uploadedUrls.add(uploadedUrl);
        }

        clear();
        return uploadedUrls;
    }

	// 파일 삭제
    public void deleteFile(String fileUrl) {
        String[] urlParts = fileUrl.split("/");
        String fileBucket = urlParts[2].split("\\.")[0];

        if (!fileBucket.equals(bucket)) {
            throw new Exception400("fileUrl", ErrorMessage.NO_IMAGE_EXIST);
        }

        String objectKey = String.join("/", Arrays.copyOfRange(urlParts, 3, urlParts.length));

        if (!amazonS3.doesObjectExist(bucket, objectKey)) {
            throw new Exception400("fileUrl", ErrorMessage.NO_IMAGE_EXIST);
        }

        try {
            amazonS3.deleteObject(bucket, objectKey);
        } catch (AmazonS3Exception e) {
            log.error("File delete fail : " + e.getMessage());
            throw new Exception500(ErrorMessage.FAIL_DELETE);
        } catch (SdkClientException e) {
            log.error("AWS SDK client error : " + e.getMessage());
            throw new Exception500(ErrorMessage.FAIL_DELETE);
        }

        log.info("File delete complete: " + objectKey);
    }

	// 단일 파일 저장
    public String saveFile(MultipartFile file) {
        String randomFilename = generateRandomFilename(file);

        log.info("File upload started: " + randomFilename);

        ObjectMetadata metadata = new ObjectMetadata();
        metadata.setContentLength(file.getSize());
        metadata.setContentType(file.getContentType());

        try {
            amazonS3.putObject(bucket, randomFilename, file.getInputStream(), metadata);
        } catch (AmazonS3Exception e) {
            log.error("Amazon S3 error while uploading file: " + e.getMessage());
            throw new Exception500(ErrorMessage.FAIL_UPLOAD);
        } catch (SdkClientException e) {
            log.error("AWS SDK client error while uploading file: " + e.getMessage());
            throw new Exception500(ErrorMessage.FAIL_UPLOAD);
        } catch (IOException e) {
            log.error("IO error while uploading file: " + e.getMessage());
            throw new Exception500(ErrorMessage.FAIL_UPLOAD);
        }

        log.info("File upload completed: " + randomFilename);

        return amazonS3.getUrl(bucket, randomFilename).toString();
    }

	// 요청에 중복되는 파일 여부 확인
    private boolean isDuplicate(MultipartFile multipartFile) {
        String fileName = multipartFile.getOriginalFilename();
        Long fileSize = multipartFile.getSize();

        if (uploadedFileNames.contains(fileName) && uploadedFileSizes.contains(fileSize)) {
            return true;
        }

        uploadedFileNames.add(fileName);
        uploadedFileSizes.add(fileSize);

        return false;
    }

    private void clear() {
        uploadedFileNames.clear();
        uploadedFileSizes.clear();
    }

	// 랜덤파일명 생성 (파일명 중복 방지)
    private String generateRandomFilename(MultipartFile multipartFile) {
        String originalFilename = multipartFile.getOriginalFilename();
        String fileExtension = validateFileExtension(originalFilename);
        String randomFilename = UUID.randomUUID() + "." + fileExtension;
        return randomFilename;
    }

	// 파일 확장자 체크
    private String validateFileExtension(String originalFilename) {
        String fileExtension = originalFilename.substring(originalFilename.lastIndexOf(".") + 1).toLowerCase();
        List<String> allowedExtensions = Arrays.asList("jpg", "png", "gif", "jpeg");

        if (!allowedExtensions.contains(fileExtension)) {
            throw new Exception400("file", ErrorMessage.NOT_IMAGE_EXTENSION);
        }
        return fileExtension;
    }
}
profile
새싹 개발자

0개의 댓글