aws S3 presigned_url

nikevapormax·2024년 3월 8일
0

TIL

목록 보기
116/116
post-custom-banner

문제상황

  • 기존에는 presigned_url을 생성할 때, 아래와 같이 파일의 이름을 uuid 형식으로 저장하였다.
     ext = name.split(".")[-1] 
     object_key = "/".join(["media/category", f"{uuid.uuid4()}.{ext}"])
    • s3의 경우, 파일명이 중복될 경우 덮어쓰기를 진행한다.
    • 같은 이름의 파일은 여러 번 올라갈 가능성이 있고, 서로 다른 사용자마다 같은 파일명을 사용할 가능성은 충분해 보였다.
    • 따라서 일반적인 파일명보다 겹칠 가능성이 적은 uuid를 사용하였다.
    • 추가적으로 일반 한글이름을 유지할 경우, url에서 보이는 이름은 urllib 의 quote_plus를 통해 인코딩한 것과 같이 보이게 된다. (너무 길어지고 더럽다.)
  • 사유에 대해 충분히 설명을 하였지만, 클라이언트 측에서 다운로드 시 파일의 이름을 그대로 유지하고 싶어하였다.
    • 유저 프로필과 같이, 파일만 보여지는 부분은 전혀 문제가 되지 않았다.
    • 사실 내가 봐도 기술적으로 겹침을 피할 수 있어 좋지만 어떤 파일인지 한 눈에 파악하기는 힘들었다.

해결방안

방식

  • 한글 이름을 그대로 사용하여 presigned_url을 생성한다.
  • 동일 이름의 파일이 존재하는지 먼저 체크한다.
    • s3_client.head_object(Bucket=bucket_name, Key=object_key) 를 사용하였다.
    • head_object() 는 HTTP method의 HEAD와 비슷하다. 실제로 프린트를 찍어보면 객체를 직접 가져오지는 않지만, 내가 찾는 객체가 존재한다면 해당 객체의 메타데이터 등을 리턴해준다.
    • 객체가 존재하지 않는다면 404 에러를 뱉기 때문에 try except 처리가 필수이다.
    • 나의 경우, 파일이 존재하는지만 알면 되기 때문에 최적이었다.
  • 동일 이름의 파일이 존재하지 않는다면 내가 받은 그대로 object_key를 만들어준다.
  • 만약 겹치는 이름이 있다면, 초특가_야놀자_15s_XXT6GPX.wav 와 같이 _{random_string}을 붙여주었다.
    • django admin에서 s3에 파일을 올릴 때와 동일하게 7자리의 랜덤 스트링을 채택하였다.

url 예시

  • 확인하기 쉽도록 버킷, 경로, 파일명으로 분리해 보았다.
https://sample-bucket.s3.ap-northeast-2.amazonaws.com/
_media/message/audio/
%E1%84%8E%E1%85%A9%E1%84%90%E1%85%B3%E1%86%A8%E1%84%80%E1%85%A1_%E1%84%8B%E1%85%A3%E1%84%82%E1%85%A9%E1%86%AF%E1%84%8C%E1%85%A1_15s_XXT6GPX.wav
  • 프론트앤드에서는 위의 파일명 %E1%84%8E%E1%85%A9%E1%84%90%E1%85%B3%E1%86%A8%E1%84%80%E1%85%A1_%E1%84%8B%E1%85%A3%E1%84%82%E1%85%A9%E1%86%AF%E1%84%8C%E1%85%A1_15s_XXT6GPX.wav을 가져와 디코딩해주면 된다.

코드

class PresignedUrlSerializer(serializers.Serializer):
    name = serializers.CharField(write_only=True)
    category = serializers.CharField(write_only=True, label="category")
    url = serializers.URLField(read_only=True)

    def validate(self, attrs):
        attrs["url"] = self.create_presigned_url(attrs["name"], attrs["category"])

        return attrs

    def create(self, validated_data):
        return validated_data

    def check_object_existence(self, s3_client=None, bucket_name=None, object_key=None, name=None, category=None):
        try:
            s3_client.head_object(Bucket=bucket_name, Key=object_key)

            ran_string = "".join(random.choices("".join([string.ascii_uppercase, string.digits]), k=7))
            file_name = name.split(".")

            new_object_key = "/".join([f"_media/{category}", f"{file_name[0]}_{ran_string}.{file_name[-1]}"])

            return self.check_object_existence(
                s3_client=s3_client, bucket_name=bucket_name, object_key=new_object_key, name=name, category=category
            )  
            # 객체가 존재하는 경우 
            # 객체가 존재하여 새로운 object_key를 만들었다해도, 
            # 해당 키가 겹칠 수 있기 때문에 재귀방식으로 한 번 더 체크한다.

        except ClientError as e:
            return object_key  # 객체가 존재하지 않는 경우 (그대로 활용한다.)

    def create_presigned_url(self, name, category):
        s3_config = Config(
            region_name="ap-northeast-2",
            signature_version="s3v4",
        )
        s3_client = boto3.client("s3", config=s3_config)
        bucket_name = settings.AWS_STORAGE_BUCKET_NAME

        object_key = self.check_object_existence(
            s3_client=s3_client,
            bucket_name=bucket_name,
            object_key="/".join([f"_media/{category}", f"{name}"]),
            name=name,
            category=category,
        )

        try:
            url = s3_client.generate_presigned_url(
                "put_object",
                Params={
                    "Bucket": bucket_name,
                    "Key": object_key,
                },
                ExpiresIn=300,
            )
        except ClientError as e:
            raise ValidationError({"s3": ["S3 Client Error"]})

        return url

결과

  • 이미 존재하는 이름인 초특가_야놀자_15s.wav의 경우, 뒤에 랜덤 스트링을 붙여 중복을 피할 수 있다. 즉, 같은 내용의 다른 이름의 파일들이 되는 것이다.
  • 클라이언트가 원했던 다운로드 시 기존 파일의 이름 유지가 가능해졌다.

개선방향

  • 사실 내가 사용하고 있던 generate_presigned_url() 로도 put_object를 할 수 있지만, 아래의 generate_presigned_post()을 활용하는 것으로도 변경해보고 싶다.
    • 기능적으로는 전혀 문제없다.
    • 처음에 유저에게 보여지는 파일의 이름을 어떻게든 유지하려고 uuid의 틀을 사용하면서 Metadata 를 같이 껴서 보내려고 노력했었다.
    • 아래의 generate_presigned_post() 를 사용하면 Metadata를 함께 사용할 수 있다.
    def generate_presigned_post(
        self,
        Bucket: str,
        Key: str,
        Fields: Optional[Dict[str, Any]] = ...,
        Conditions: Union[List[Any], Dict[str, Any], None] = ...,
        ExpiresIn: int = 3600,
    ) -> Dict[str, Any]:
        """
        Builds the url and the form fields used for a presigned s3 post.

        [Show boto3 documentation](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Client.generate_presigned_post)
        [Show boto3-stubs documentation](https://youtype.github.io/boto3_stubs_docs/mypy_boto3_s3/client/#generate_presigned_post)
        """
profile
https://github.com/nikevapormax
post-custom-banner

0개의 댓글