Rust AXUM+MongoDB로 RestAPI 서버 개발하기 - AWS S3 연동 이미지 업로드
Rust를 사용한 Axum 서버에서 Amazon S3에 이미지를 저장하는 과정에서 Pre-signed URL을 활용하는 방법은 클라이언트가 서버를 거치지 않고 직접 S3에 파일을 업로드할 수 있게 해주면서도 보안을 유지할 수 있는 효율적인 방법입니다. 이 과정에서 Axum 서버는 Pre-signed URL을 생성하고, 업로드 될 파일의 content_type
과 S3 key
를 관리하여 데이터베이스에 저장하는 로직만 수행합니다. 다음은 이 과정을 설명하기 위한 순서와 설명입니다:
0. 사전 준비 사항
AWS
이미지를 저장해놓을 S3 Bucket
디렉토리 구조는 원본 이미지를 저장해놓을 raw/ 이미지 크기 별 폴더 w140/ w600/로 구분해놓았습니다.
- lambda 활용하여 이미지 크기를 조정하여 저장해놓고, 이를 제공합니다.
S3 Bucket 관리를 위한 IAM 계정
- Permission의 경우에는 PutObject와 DeleteObject 권한을 부여합니다.
.env
AWS_ACCESS_KEY=IAM 계정 ACCESS_KEY
AWS_SECRET_KEY=IAM 계정 SECRET_KEY
AWS_S3_BUCKET=Bucket 이름
AWS_REGION=Bucket 사용 Region
cargo.toml
에 다음 의존성을 추가합니다.
[dependencies]
uuid = "1.6.1"
rust-s3 = "0.33.0"
src/config/s3.rs
use std::env;
use s3::bucket::Bucket;
use s3::creds::Credentials;
use tracing::error;
pub fn s3connect() -> Result<Bucket, String> {
let bucket_name = env::var("AWS_S3_BUCKET").expect("AWS_S3_BUCKET must be set");
let access_key = env::var("AWS_ACCESS_KEY").expect("AWS_ACCESS_KEY must be set");
let secret_key = env::var("AWS_SECRET_KEY").expect("AWS_SECRET_KEY must be set");
let region = env::var("AWS_REGION").expect("AWS_REGION must be set");
let credentials =
Credentials::new(Some(&access_key), Some(&secret_key), None, None, None).unwrap();
let bucket = Bucket::new(&bucket_name, region.parse().unwrap(), credentials).unwrap();
Ok(bucket)
}
// s3 라이브러리를 통해 presigned_url을 가져옵니다.
pub async fn get_presigned_url(object: &str) -> Result<String, String> {
let result = match s3connect() {
Ok(bucket) => bucket.presign_put(object, 300, None).unwrap(),
Err(e) => {
error!("Error: Get presigned url failed: {:?}", e);
return Err(format!("Error: Get presigned url failed"));
}
};
Ok(result)
}
src/config/mod.rs
pub mod s3; // 추가
1. 클라이언트 업로드 요청
- 클라이언트는 Axum 서버에 이미지 업로드 요청을 합니다. 이 요청에는 파일의
content_type
등 업로드할 이미지에 대한 정보가 포함되어야 합니다.
src/photo/mod.rs
먼저, domain을 위한 route를 구성합니다.
mod entity;
pub mod handler;
mod repository;
pub mod route;
mod usecase;
mod req;
유저의 요청을 관리하는 req를 작성합니다.
src/photo/req.rs
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
pub struct PresignedReq {
pub content_types: Vec<String>,
}
2. Pre-signed URL 생성
- 우선,
PresignedReq
에서 볼 수 있듯이, 클라이언트가 제공한content_type
을 사용하여 S3 Pre-signed URL을 생성합니다. Pre-signed URL은 제한된 시간 동안만 유효한 URL이며, 이 URL을 통해 클라이언트는 직접 S3에 파일을 업로드할 수 있습니다. - Axum 서버는 uuid를 사용해서 이미지 별 고유한
key
를 생성합니다. 이key
는 파일을 구분하기 위한 고유한 식별자 역할을 합니다.
유저의 요청을 처리하고 응답을 반환하기 위한 handler를 작성합니다.
src/photo/handler.rs
use axum::{http::StatusCode, response::IntoResponse, Extension, Json};
use serde_json::json;
use super::usecase;
use crate::photo::req::PresignedReq;
pub async fn presigned(Json(req): Json<PresignedReq>) -> impl IntoResponse {
match usecase::presigned(req.content_types).await {
Ok(pd) => (StatusCode::OK, Json(pd).into_response()),
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"message": "Error: Sign S3 url failed"
}))
.into_response(),
)
}
}
}
presigned 로직을 수행하는 usecase를 작성합니다.
src/photo/usecase.rs
use std::collections::HashMap;
use crate::config::s3;
use uuid::Uuid;
fn uuid() -> Uuid {
Uuid::new_v4()
}
pub async fn presigned(content_types: Vec<String>) -> Result<Vec<HashMap<String, String>>, String> {
let mut presigned_data = Vec::new();
// 파일 용량, 컨텐츠 길이 체크 -> content_type이라는 어떤 특정 header를 가져와야할듯?
for content_type in content_types {
// 컨텐츠 타입으로 확장자 확인
let image_key: String = format!("{:?}.{}", uuid(), content_type);
let key = format!("raw/{image_key}");
let presigned = match s3::get_presigned_url(&key).await {
Ok(uri) => uri,
Err(e) => {
error!("Error: Get presigned url failed {:?}", e);
e
}
};
let mut data = HashMap::<String, String>::new();
data.insert(String::from("image_key"), image_key);
data.insert(String::from("presigned"), presigned);
presigned_data.push(data);
}
Ok(presigned_data)
}
3. 데이터베이스에 정보 저장
- Axum 서버는 이미지를 업로드 한 클라이언트의 다음 동작에 따라 S3
key
와 필요한 경우 파일에 대한 추가 메타데이터를 데이터베이스에 저장합니다. 이 정보는 추후 파일을 검색하거나 접근 제어에 사용될 수 있습니다.
메타데이터 정보를 받기 위한 req를 작성합니다.
src/photo/req.rs
// 추가
#[derive(Debug, Deserialize)]
pub struct InsertImageReq {
pub key: String,
pub original_file_name: String,
}
이미지 메타데이터를 저장하고나서 반환받을 이미지 메타데이터 entity를 생성합니다.
src/photo/entity.rs
use bson::oid::ObjectId;
use mongodb::bson::DateTime;
use serde::{Deserialize, Serialize};
#[derive(PartialEq, Debug, Serialize, Deserialize)]
pub struct Image {
pub _id: String,
pub key: String,
pub original_file_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ImageBson {
pub _id: ObjectId,
pub key: String,
pub original_file_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime>,
}
요청의 처리를 위한 handler를 작성합니다. 이미지의 메타데이터는 Vec으로 여러개를 한 번에 받을 수 있습니다.
이미지 메타정보를 저장한 뒤, 저장된 이미지 메타정보를 DB에서 가져와 반환합니다.
src/photo/handler.rs
use crate::photo::req::{InsertImageReq, PresignedReq}; // 수정
pub async fn insert_images(Json(req): Json<Vec<InsertImageReq>>) -> impl IntoResponse {
let image_ids = match usecase::insert_images(req).await {
Ok(r) => r,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"message": "Error: Insert images failed"
}))
.into_response(),
)
}
};
let images = usecase::find_images(image_ids).await;
(StatusCode::OK, Json(images).into_response())
}
usecase를 작성합니다. 데이터베이스에 insert, find 하는 로직만 수행하므로 별도의 로직은 추가하지 않습니다.
srs/photo/usecase.rs
use bson::oid::ObjectId;
use super::{
entity::Image,
repository,
};
pub async fn insert_images(req: Vec<InsertImageReq>) -> Result<Vec<ObjectId>, String> {
repository::insert_images(req).await
}
pub async fn find_images(image_ids: Vec<ObjectId>) -> Vec<Image> {
repository::find_images(image_ids).await
}
DB와 통신하는 부분입니다.
src/photo/repository.rs
use bson::oid::ObjectId;
use mongodb::{
bson::{doc, from_document, DateTime, Document},
Cursor,
};
use crate::config::database::dbconnect;
use super::entity::{Image, ImageBson};
use crate::photo::req::InsertImageReq;
use tracing::{error, info};
pub async fn insert_images(req: Vec<InsertImageReq>) -> Result<Vec<ObjectId>, String> {
let db = match dbconnect().await {
Ok(r) => r,
Err(e) => panic!("Error: Database connection failed: {:?}", e),
};
let collection = db.collection::<Document>("images");
let docs: Vec<Document> = req
.into_iter()
.map(|image| {
doc! {
"key": image.key,
"original_file_name": image.original_file_name,
"created_at": DateTime::now()}
})
.collect();
let result = collection.insert_many(docs, None).await;
let inserted_ids = match result {
Ok(r) => r.inserted_ids,
Err(e) => {
error!("Error: Insert images failed: {:?}", e);
return Err(format!("Error: Insert images failed"));
}
};
let inserted_ids: Vec<ObjectId> = inserted_ids
.values()
.into_iter()
.map(|inserted_id| inserted_id.as_object_id().unwrap())
.collect();
Ok(inserted_ids)
}
pub async fn find_images(image_ids: Vec<ObjectId>) -> Vec<Image> {
let db = match dbconnect().await {
Ok(r) => r,
Err(e) => panic!("Error: Database connection failed: {:?}", e),
};
let collection = db.collection::<Document>("images");
let cursor_result = collection
.find(doc! { "_id": {"$in": &image_ids } }, None)
.await;
let mut cursor: Cursor<Document> = match cursor_result {
Ok(r) => r,
Err(e) => {
error!("Error: Find images failed: {:?}", e);
return Vec::new();
}
};
let mut images = Vec::new();
while let Ok(next) = cursor.advance().await {
if !next {
break;
}
let image_doc = match cursor.deserialize_current().ok() {
Some(doc) => doc,
None => break,
};
let image: ImageBson = match from_document(image_doc)
.map_err(|e| format!("Error: Deserialize object failed: {:?}", e))
{
Ok(i) => i,
Err(e) => {
error!("{:?}", e);
return Vec::new();
}
};
images.push(Image {
_id: image._id.to_hex(),
key: image.key,
original_file_name: image.original_file_name,
created_at: image.created_at,
})
}
images
}
4. Route 연결
src/photo/route.rs
use axum::{
routing::post,
Router,
};
use super::handler::*;
pub fn get_router() -> Router {
let photo_router = Router::new()
.route("/presigned", post(presigned))
.route("/images", post(insert_images))
photo_router
}
src/main.rs
mod photo;
use axum::{http::Method, routing::get, Router};
use tower_http::cors::{Any, CorsLayer};
async fn main() {
// 생략...
let photo_routes = photo_route::get_router();
let api_routes = Router::new()
.nest("/photos", photo_routes);
let app = Router::new()
.layer(CorsLayer::new().allow_origin(Any).allow_methods([
Method::GET,
Method::POST,
Method::PATCH,
Method::PUT,
Method::DELETE,
]))
.route("/", get(|| async { "OK" }))
.nest("/api", api_routes)
// 생략...
}
클라이언트 처리 (python)
- 생성된 Pre-signed URL과 S3
key
를 클라이언트에게 응답합니다. 클라이언트는 이 URL을 사용하여 직접 S3에 파일을 업로드할 수 있습니다. - 클라이언트는 받은 Pre-signed URL을 사용하여 S3에 파일을 업로드합니다. 이 과정은 서버를 거치지 않고 직접 S3와 통신합니다.
- 파일 업로드가 완료되면, 클라이언트는 업로드 성공 여부를 서버에 알려서 서버에 S3에 저장된 키와 원본 파일명을 저장할 수 있도록 합니다.
import os
import json
import requests
IMAGE_DIR = '테스트용 이미지 경로'
files = os.listdir(IMAGE_DIR)[:1]
content_types = [f.split('.')[-1] for f in files]
r = requests.post('http://localhost:3000/api/photos/presigned', data=json.dumps({
'content_types': content_types
}), headers={
'content-type': 'application/json;'
})
presigned_data = r.json()
for i, file in enumerate(presigned_data):
url = file['presigned']
requests.put(url, headers={'content-type': 'image/jpg'},
data=open(IMAGE_DIR + files[i], 'rb').read()
)
r = requests.post('http://localhost:3000/api/photos/images', data=json.dumps(
[{ 'key': presigned_data[i]['image_key'], 'original_file_name': file} for i, file in enumerate(files[:5])]
), headers={
'content-type': 'application/json;'
})
images = r.json()
print(images)
이 과정을 통해 클라이언트는 서버의 부하를 줄이면서도 보안성을 유지하며 S3에 직접 파일을 업로드할 수 있습니다. Axum 서버는 파일의 메타데이터 관리와 업로드 권한 부여의 역할만 수행하며, 실제 파일 데이터는 클라이언트와 S3 사이에서 직접 전송됩니다. 이러한 방식은 특히 대용량 파일을 다룰 때 서버의 부하를 크게 줄여줄 수 있는 장점이 있습니다.
혹시라도 문제가 있을 시 댓글 달아주세요.