Cloud/AWS

S3 Presigned-URL를 통한 파일의 업로드 & 다운로드

Omoknooni 2024. 7. 14. 12:51

클라이언트로 하여금 파일을 업로드받거나, 그 파일을 워크로드를 거친 후 결과물을 다운로드받게 하고자 하는 경우 보통 S3 버킷을 하나 생성해서 파일을 관리한다. 그리고 그 버킷을 Public하게 오픈시켜 클라이언트들이 접근하게 만든다.

 

하지만 이렇게 단순히 Public하게 오픈시켜 모든 클라이언트로 하여금 파일을 업/다운로드하게 하는 것은 과연 안전한 방법일까?

그리고 클라이언트의 파일을 우리의 백엔드 API를 통해서 S3로 업로드하면, 우리의 백엔드에도 파일을 받고 전송하는데에 대한 부하가 발생하게 된다.

 

이러한 문제를 위해 S3에서는 Presigned-URL이라는 기능을 지원한다.

 

 

Presigned-URL

버킷에 대한 접근 권한이 있는 사용자를 통해 버킷의 특정 객체에 대한 접근을 허용하는 단기간성 URL

이를 통해 클라이언트가 Application 서버를 거치지 않고 버킷에 직접 접근해 파일을 업/다운로드 할 수 있다.  

 

이러한 Presigned-URL은 다음과 같은 장점을 갖는다.

 

보안적 측면
접근 제어 Presigned-URL을 사용해 S3 버킷에 직접 접근 권한을 부여하지 않고도 특정 객체에 대한 일시적인 접근을 허용할 수 있음
액세스 시간 제한 URL에 만료 시간을 설정할 수 있어, 필요한 기간 동안만 접근을 허용 가능
세분화된 권한 버킷에 특정 작업(GET, PUT 등)에 대해서만 권한을 부여 가능
인증 정보 보호 AWS 인증 정보를 클라이언트에 노출시키지 않고도 S3 리소스에 접근할 수 있게 함
비용적 측면
트래픽 감소 클라이언트가 S3에 직접 접근하므로 애플리케이션 서버를 통한 데이터 전송의 감소
서버 리소스 절약 파일 업로드/다운로드를 애플리케이션 서버가 처리하지 않아도 되어 서버 리소스를 절약
CDN 통합 용이 Presigned-URL은 CloudFront와 같은 CDN과 쉽게 통합되어 컨텐츠 전송 비용을 더욱 최적화

 

 

 

Presigned-URL을 통한 업/다운로드

flask를 통해 간단한 파일 업/다운로드 서비스를 구축해 보도록 한다.

서비스를 위한 버킷을 생성하고, 정책은 Private하게 설정한다.

 

 

먼저, 파일을 업로드하는 로직을 생각해본다.

Presigned-URL은 파일을 다운로드하는 부분뿐만 아니라, 업로드하는데에도 사용할 수 있다. 파일이 저장될 버킷에 접근할 수 있는 URL을 부여받는 것부터 로직은 시작된다.

s3의 generate_presigned_url()를 통해 presigned URL을 생성해줄 수 있다.

# 파일 업로드 전 Presigned-URL 획득
@app.route('/get_upload_url', methods=['POST'])
def get_upload_url():
    filename = request.json.get('filename')
    content_type = request.json.get('content_type')
    if not filename or not content_type:
        return jsonify({'error': 'Filename is required'}), 400

    # 고유한 파일 ID 생성
    file_id = str(uuid.uuid4())
    
    # Presigned URL 생성 (업로드용)
    try:
        presigned_url = s3_client.generate_presigned_url(
            'put_object',
            Params={
                'Bucket': BUCKET_NAME,
                'Key': file_id,
                'ContentType': content_type,
                'Metadata': {
                    'original_name': filename
                }
            },
            ExpiresIn=3600,
            HttpMethod='PUT'
        )
    except ClientError as e:
        return jsonify({'error': str(e)}), 500

    return jsonify({
        'upload_url': presigned_url,
        'file_id': file_id
    }), 200

 

 

front파트에서는 부여받은 Presigned-URL을 통해 클라이언트는가 버킷으로 직접 파일을 업로드할 수 있게 된다.

async function uploadFile() {
            const fileInput = document.getElementById('fileInput');
            const file = fileInput.files[0];
            if (!file) {
                alert('Please select a file first.');
                return;
            }

            const statusElement = document.getElementById('uploadStatus');
            statusElement.textContent = 'Uploading...';

            try {
                // upload를 위한 Presigned URL 발급
                const urlResponse = await fetch('/get_upload_url', {
                    method: 'POST',
                    headers: {'Content-Type': 'application/json'},
                    body: JSON.stringify({
                        filename: file.name,
                        content_type: file.type
                    })
                });
                const urlData = await urlResponse.json();

                // 버킷으로 파일 바로 업로드
                const uploadResponse = await fetch(urlData.upload_url, {
                    method: 'PUT',
                    body: file,
                    headers: {
                        'Content-Type': file.type
                    }
                });
                
                if (!uploadResponse.ok) {
                    throw new Error('Failed to upload to PresignedURL');
                }
                ....

 

 

하지만 이렇게 구성 후, 파일을 업로드하면 CORS 정책에 의해 버킷에 파일이 업로드되지 않는다.

 

 

이를 해결하기 위해 버킷의 CORS 설정을 해주도록 한다. 현재 이 프로젝트는 별도로 도메인 연결을 하지 않았으므로 간단하게 다음과 같이 작성해 해결해줄 수 있다.

[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "PUT",
            "POST"
        ],
        "AllowedOrigins": [
            "http://localhost:5000"
        ]
    }
]

 

버킷의 CORS 관련 설정 내용은 아래 참조한 Docs를 확인하도록 한다.

 

CORS 구성 - Amazon Simple Storage Service

CORS 구성 Cross-Origin 요청을 허용하도록 버킷을 구성하려면 CORS 구성을 생성합니다. CORS 구성은 버킷에 액세스할 수 있도록 허용할 오리진, 각 오리진에 대해 지원되는 작업(HTTP 메서드) 및 기타 작

docs.aws.amazon.com

 

 

CORS까지 해결해주면 파일 업로드 시 Presigned-URL 발급을 받고, 버킷에 바로 업로드되는 것을 볼 수 있다.

 

 

다음은 버킷 내의 파일을 다운로드하는 로직을 생각해본다. 마찬가지로 해당 파일에 대한 접근을 위해 Presigned-URL을 부여받아야 한다. 

여기에서 Presigned URL 발급을 위한 Token 개념을 하나 더 두었다. Token을 먼저 발급 받고, 그 Token을 바탕으로 Presigned-URL을 발급받는 구조인 것이다.

# Presigned-URL 발급을 위한 Token 발급
@app.route('/download', methods=['POST'])
def request_download():
    file_id = request.json.get('file_id')
    if not file_id:
        return jsonify({'error': 'Invalid file ID'}), 400

    try:
        response = s3_client.head_object(Bucket=BUCKET_NAME, Key=file_id)
        upload_time = response['LastModified']

    except ClientError:
        return jsonify({'error': 'File not found'}), 404

    # 1시간 경과 체크, 시간변수 format 통일화 필요
    now = datetime.now()
    naive_now = now.replace(tzinfo=None)
    naive_upload_time = (upload_time + timedelta(hours=9)).replace(tzinfo=None)

    if naive_now - naive_upload_time > timedelta(hours=1):
        return jsonify({'error': 'File access expired'}), 403

    # 일회용 토큰 생성
    token = secrets.token_urlsafe()
    
    tokens[token] = {
        'file_id': file_id,
        'expiry': datetime.now() + timedelta(minutes=5)
    }

    return jsonify({'download_token': token}), 200

 

 

이어서 이 Token을 바탕으로 Presigned URL을 발급해주었다. Token은 파일을 다운로드하는 과정에서 일회용으로 사용하기위해 발급했기에 token으로 파일을 다운로드 한 경우, 해당 token을 삭제해주었다.  

(본 코드에서는 Token은 tokens dictionary를 통해서 관리만 해주었다. 실 사용하는 경우 Redis와 같은 저장소와 연동하는 것을 추천)

# 파일 다운로드용 Presigned-URL 발급
@app.route('/download/<token>', methods=['GET'])
def get_download_url(token):
    if token not in tokens or datetime.now() > tokens[token]['expiry']:
        return jsonify({'error': 'Invalid or Expired token'}), 400

    file_id = tokens[token]['file_id']
    del tokens[token]  # 토큰 사용 후 삭제

    try:
        response = s3_client.head_object(Bucket=BUCKET_NAME, Key=file_id)
        original_name = response['Metadata'].get('original_name', file_id)

        url = s3_client.generate_presigned_url(
            'get_object',
            Params={
                'Bucket': BUCKET_NAME,
                'Key': file_id
            },
            ExpiresIn=300     # 5분 동안만 유효
        )
        return jsonify({'download_url': url}), 200
    except ClientError as e:
        return jsonify({'error': str(e)}), 500

 

 

다운로드도 마찬가지로 클라이언트가 다운로드 받고자 하는 Object의 Presigned URL을 통해 버킷에서 직접 다운로드 가능하다.  

async function downloadFile(fileId) {
            try {
                // download token 발급
                const tokenResponse = await fetch('/download', {
                    method: 'POST',
                    headers: {'Content-Type': 'application/json'},
                    body: JSON.stringify({file_id: fileId})
                });
                const tokenData = await tokenResponse.json();

                if (tokenResponse.ok) {
                    // download URL 획득
                    const urlResponse = await fetch(`/download/${tokenData.download_token}`);
                    const urlData = await urlResponse.json();
    
                    if (urlResponse.ok) {
                        // download 시작
                        window.location.href = urlData.download_url;
                    }
                    else {
                        throw new Error(urlData.error);
                    }
                }
                else {
                    throw new Error(tokenData.error);
                }
            } catch (error) {
                alert('Download failed: ' + error.message);
            }
        }

 

 

버킷에 존재하는 파일을 다운로드할 수 있고, 업로드된지 1시간 이상 지난 파일들은 접근이 불가능한 것을 확인할 수 있다. 

 

 

이렇게 S3의 Presigned-URL을 통해 버킷에 액세스하는 일회성 URL을 발급받아 파일을 버킷으로 직접 업/다운로드하는 예시를 알아보았다.