본문 바로가기
프로젝트 개발 기록/[개발] java | spring

[AWS S3] 이미지 처리 1탄: Pre-signed URL로 파일 업로드 구현

by HelloJudy 2023. 6. 27.

📷 이미지 처리

📑 목차

[AWS S3] 이미지 처리 1탄: Pre-signed URL로 파일 업로드 구현 - 현재 포스팅
[AWS S3+Lambda] 이미지 처리 2탄: Image Resizing으로 썸네일 이미지 만들기
 


 
프로젝트에서 이미지 업로드 기능을 구현해 본 경험이 있을 것이다.
나는 예전 프로젝트에서 Form data로 서버에 파일을 전송하면, 서버에서 S3에 파일을 업로드하고 해당 URL를 반환해 주는 Flow로 구현했다.
 

✔️ 서버를 통해서 업로드

 

개발자한테 시키면 안되는 이유.. (못생긴 그림 등장)

 
1. Client에서 Server로 Form Data로 Image 파일 전송
2. Server는 AWS S3에 이미지 업로드
3. 저장된 URL 받음
4. Server에서 이미지 URL 반환
 
이 방식은 서버에서 AWS Key 정보를 가지고 있고, S3에 객체를 업로드한다.
 
하지만 이 방식 뭔가 마음에 안든다. 🤔
우선 이미지 업로드 처리에서 서버가 꼭 필요할까? 필요없다. 단지 현재는 클라이언트와 S3 사이에서 중개해 주는 역할만 하고 있다.
 
또한 JSON으로 가볍게 주고 받는 API 요청과 비교해서 이미지 처리는 부하가 큰 작업이다.
그래서 이미지 파일이 서버를 타지 않고 업로드 하고 싶다.
 
 
🤖 : 엥? 그럼 클라이언트가 바로 S3에 파일 업로드하면 되는 거 아님?
🐰 : 오 그렇네?
 
 

✔️  Client에서 직접 S3에 업로드

 

 
이 방식은 클라이언트에서 AWS Key 정보를 가지고 있고, S3에 객체를 업로드한다.
 
🤖 : 이제 다 해결된거 아님?
🐰 :  아니야... 클라이언트가 Key를 가지고 있다..? 위험하다 위험해
 
클라이언트에서 AWS SDK를 이용하면 Key 정보가 브라우저에 노출될 수 있어 S3 리소스를 사용하는 것에 보안상 위험이 있다.
 
🤖 : 뭐야? 그럼 다시 서버 타!!
🐰 : 어우.. 성격 급하네 이 친구. 그래서 내가 더 좋은 방법 찾았어~~
 
 

✔️💡 클라이언트에서 pre-signed URL 발급받아서 업로드

 

 
1. 클라이언트는 서버에 pre-signed URL을 요청
2. 서버에서 S3에 pre-signed URL 요청
3. S3는 pre-signed URL을 서버에 반환
4. 서버는 pre-signed URL을 클라이언트에 반환
5. 클라이언트는 파일(이미지)을 pre-signed URL로 업로드 요청
6. 클라이언트는 서버에 해당 요청이 완료되었음을 알림
 
 
이런 방식으로 파일 업로드 할 수 있다.
 
 

와.. 놀랍게도 지금까지가 개요였다.
이제 진짜 pre-signed URL에 대해서 알아보자.

 


0. Pre-signed URL

 
pre-signed URL은 단어 그대로 '미리 서명된 URL'이다.
S3 버킷에 파일을 업로드 하기 위해서는 S3 접근 권한을 인증해야 한다. pre-signed URL을 이용해서 접근 권한을 얻을 수 있다.
 
여기서 발급 받은 URL은 S3 객체에 대한 접근 주소이다. URL에는 객체에 접근할 수 있는 정보와 만료 기간이 정해져 있다.
그래서 서버에서 발급받아서 준 URL만으로 일정 기간 객체에 접근할 수 있다. 이렇게 하면 클라이언트에서는 AWS S3 접근권한(credential)을 가지고 있을 필요가 없다.
발급받은 URL로 파일을 Request Body에 담아 PUT 요청만 하면 바로 구현이 가능하다.
 
 
이제 구현해보자!
현재 프로젝트는 스프링 프로젝트이기 때문에 스프링 기준으로 코드를 작성하겠다.
 

1. IAM 사용자 권한 추가

S3에 접근하기 위해서 S3 접근 권한을 가진 IAM 사용자를 만들자.
나는 권한을 AmazonS3FullAccess로 설정했다.
 
이때 Access KeySecret Key를 잘 저장해 두자!
 

 

2. S3 버킷 생성

1) 버킷 생성

 

2) 퍼블릭 엑세스

 

3) 권한 > 버킷 권한 정책

 

아래 버킷 정책을 등록해 준다. 이때 Resource에는 자신이 만든 버킷 명을 입력한다.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": "*",
            "Action": [
                "s3:Put*",
                "s3:Get*",
                "s3:Delete*"
            ],
            "Resource": "arn:aws:s3:::jalingobi-bucket-local/*"
        }
    ]
}

 

4) 권한 > CORS(Cross-origin 리소스 공유)

[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "HEAD",
            "GET",
            "PUT",
            "POST"
        ],
        "AllowedOrigins": [
            "*"
        ],
        "ExposeHeaders": [
            "ETag"
        ]
    }
]

 
이렇게 하면 이제 AWS에서 해야 할 설정은 끝났다.
 
 

3. 코드 작성

[ build.gradle ]

 
dependency를 추가하자!

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

 

[ application.yml ]

 
AWS 환경 설정을 해주자.

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

 

[ S3Config.java ]

 
S3 config 설정해 주자. AWS credential(자격 증명) 객체를 생성했다.

@Configuration
public class S3Config {

    @Value("${cloud.credentials.access-key}")
    private String accessKey;

    @Value("${cloud.credentials.secret-key}")
    private String secretKey;

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

    @Bean
    public AmazonS3Client amazonS3Client() {
        AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
        return (AmazonS3Client)
                AmazonS3ClientBuilder.standard()
                        .withCredentials(new AWSStaticCredentialsProvider(credentials))
                        .withRegion(region)
                        .build();
    }
}

 
 

[ S3UploadPresignedUrlService.java ]

 
파일 업로드에 필요한 Service 코드를 작성했다.
이때 여기서 GeneratePresignedUrlRequest를 생성할 때 넣어준 fileName(key)로 이미지가 저장되기 때문에 파일 명을 만들어줬다.
 
현재 프로젝트에서 이미지 업로드가 필요한 곳은 Record, Challenge, Profile, Custom Profile이다. 그래서 각각의 타입에 따라 경로를 나누었다.
 

@Service
@RequiredArgsConstructor
@Slf4j
public class S3UploadPresignedUrlService {

    private final AmazonS3Client amazonS3Client;

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

    public ImageUrlDto execute(
            String socialId, ImageFileExtension fileExtension, ImageUploadType type) {
        String valueFileExtension = fileExtension.getUploadExtension();
        String valueType = type.getType();
        String fileName = createFileName(socialId, valueFileExtension, valueType);
        log.info(fileName);

        GeneratePresignedUrlRequest generatePresignedUrlRequest =
                getGeneratePreSignedUrlRequest(bucket, fileName, valueFileExtension);
        URL url = amazonS3Client.generatePresignedUrl(generatePresignedUrlRequest);

        return ImageUrlDto.of(url.toString(), fileName);
    }

    private String createFileName(String socialId, String fileExtension, String valueType) {
        return valueType + "/" + socialId + "/" + UUID.randomUUID() + "." + fileExtension;
    }

    // 업로드용 Pre-Signed URL을 생성하기 때문에, PUT을 지정
    private GeneratePresignedUrlRequest getGeneratePreSignedUrlRequest(
            String bucket, String fileName, String fileExtension) {
        GeneratePresignedUrlRequest generatePresignedUrlRequest =
                new GeneratePresignedUrlRequest(bucket, fileName)
                        .withMethod(HttpMethod.PUT)
                        .withKey(fileName)
                        .withContentType("image/" + fileExtension)
                        .withExpiration(getPreSignedUrlExpiration());
        generatePresignedUrlRequest.addRequestParameter(
                Headers.S3_CANNED_ACL, CannedAccessControlList.PublicRead.toString());
        return generatePresignedUrlRequest;
    }

    private Date getPreSignedUrlExpiration() {
        Date expiration = new Date();
        long expTimeMillis = expiration.getTime();
        // 유효 기간 설정 (1분)
        expTimeMillis += 60 * 1000;
        expiration.setTime(expTimeMillis);
        return expiration;
    }

    public void deleteImage(String key) {
        DeleteObjectRequest deleteObjectRequest = new DeleteObjectRequest(bucket, key);
        try {
            amazonS3Client.deleteObject(deleteObjectRequest);
        } catch (AmazonServiceException e) {
            throw e;
        } catch (SdkClientException e) {
            throw e;
        }
    }
}

 
이때는 파일 이름에 social id를 넣었는데 람다 설정을 하면서 

return valueType + "/" + socialId + "/" + UUID.randomUUID() + "." + fileExtension;

아래와 같이 수정했다. (부연 설명하면 람다 함수 인풋 아웃풋 버킷을 같이 쓸거라 경로를 나눠줬다. original에 업로드 트리거됐을 때 함수 실행되도록 하려고)

return valueType + "/original/" + UUID.randomUUID() + "." + fileExtension;

 
 

[ ImageController.java ]

 
pre-signed url을 요청하는 컨트롤러이다.
나는 파일명 생성할 때 유저의 id와 어떤 타입의 데이터, 그리고 파일 확장자에 따라서 만들기 때문에 필요한 데이터는 Request Body로 받았다.

@RestController
@RequestMapping("/image")
@RequiredArgsConstructor
public class ImageController {

    private final IssuePresignedUrlUseCase getPresignedUrlUseCase;

    // type은 [record, profile, custom-profile, challenge] 이게 폴더 명이 될거임.
    @Operation(summary = "presigned url을 발급받는 API")
    @PostMapping("/presigned")
    public Response<IssuePresignedUrlResponse> createPresigned(
            @RequestBody IssuePresignedUrlRequest issuePresignedUrlRequest) {
            
        return ResponseService.getDataResponse(
                getPresignedUrlUseCase.execute(getCurrentUserSocialId(), issuePresignedUrlRequest));
    }
}

 

[ IssuePresignedUrlUseCase.java ]

 
현재 프로젝트는 헥사고날 아키텍처를 사용하고 있어 UseCase에서 위에서 생성한 메서드를 호출했다.

@UseCase
@RequiredArgsConstructor
public class IssuePresignedUrlUseCase {
    private final S3UploadPresignedUrlService uploadPresignedUrlService;

    public IssuePresignedUrlResponse execute(
            String socialId, IssuePresignedUrlRequest issuePresignedUrlRequest) {

        return IssuePresignedUrlResponse.from(
                uploadPresignedUrlService.execute(socialId, issuePresignedUrlRequest.getImageFileExtension(), issuePresignedUrlRequest.getType()));
    }
}

 

4. 결과

pre-signed URL을 서버에 요청한다.

그러면 아래와 같이 Response를 보낸다.

 
그리고 해당 URL로 Request Body에 이미지 파일을 넣고 Put 요청을 보낸다.

업로드가 완료되었다.

 
pre-signed URL을 발급받을 때 넣은 Key 값 경로에 사진이 업로드된 것을 확인할 수 있다.

 


 

이제 업로드는 구현했다. 근데 실제로 우리가 화면에서 이미지를 보여줄 때 원본 이미지 파일을 전부 보여주기엔 파일이 너무 크다.
 
예를 들어 현재 프로젝트 UI를 보면 상단에 챌린지마다의 이미지, 지출 기록 이미지, 유저들의 이미지. 한 번에 보여줘야 할 이미지가 많다.
그래서 원본 데이터를 보내주기보다 썸네일 이미지를 보내줄 필요가 있다.
어떤 방식으로 구현할지는 다음 편에서 다루도록 하겠다! 빠~잉~


 

번외 : 🐛 Error 해결

✔️ [AWS] Failed to connect to service endpoint 에러

 com.amazonaws.SdkClientException: Failed to connect to service endpoint:

 
이런 에러가 났다. AWS SDK 에러로 spring-cloud-starter-aws 의존성 주입 시 로컬 환경이 AWS 환경이 아니라서 발생한 에러이다.
exclude를 추가해서 에러를 해결했다.

@SpringBootApplication(
        scanBasePackageClasses = {
            ModuleApiApplication.class,
            ModuleCommonApplication.class,
            ModuleDomainApplication.class
        },
        // 이 부분
        exclude = {
            org.springframework.cloud.aws.autoconfigure.context.ContextInstanceDataAutoConfiguration
                    .class,
            org.springframework.cloud.aws.autoconfigure.context.ContextStackAutoConfiguration.class,
            org.springframework.cloud.aws.autoconfigure.context
                    .ContextRegionProviderAutoConfiguration.class
        })

 

✔️ [403] SignatureDoesNotMatch

 

발급받은 url로 업로드하려고 하는데 이런 에러가 났다.

이유는 pre-signed url 발급할 때에 png로 발급받았는데 이미지 파일이 jpeg로 들어가서 content-type이 맞지 않아서!

<Error>
    <Code>SignatureDoesNotMatch</Code>
    <Message>The request signature we calculated does not match the signature you provided. Check your key and signing method.</Message>

 

 


📌 Reference

반응형

댓글