슬기로운 개발자생활/Backend

Rust AXUM+MongoDB로 RestAPI 서버 개발하기 - AWS S3 연동 이미지 업로드

개발자 소신 2024. 3. 27. 11:05
반응형

 

 

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 사이에서 직접 전송됩니다. 이러한 방식은 특히 대용량 파일을 다룰 때 서버의 부하를 크게 줄여줄 수 있는 장점이 있습니다.

 

 

혹시라도 문제가 있을 시 댓글 달아주세요.

반응형