클라이언트로 하여금 파일을 업로드받거나, 그 파일을 워크로드를 거친 후 결과물을 다운로드받게 하고자 하는 경우 보통 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까지 해결해주면 파일 업로드 시 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을 발급받아 파일을 버킷으로 직접 업/다운로드하는 예시를 알아보았다.