
지난 시간에 만든 데이터베이스를 활용하는 실습을 진행해 보자.
원하는 조건에 따라 데이터를 뽑아내는 코드를 작성하겠다.
검색 효율을 높이기 위해 인덱스를 추가한다.
scripts/init.sqlCREATE TABLE IF NOT EXISTS image_assets ( id UUID PRIMARY KEY DEFAULT uuidv7(), file_name TEXT NOT NULL, file_path TEXT NOT NULL, width INTEGER NOT NULL, height INTEGER NOT NULL, metadata JSONB, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); -- 이상 기존 코드 CREATE INDEX IF NOT EXISTS idx_image_assets_metadata ON image_assets USING GIN (metadata); CREATE INDEX IF NOT EXISTS idx_image_assets_file_name ON image_assets (file_name);
IF NOT EXISTS 를 사용하면 이 파일이 여러 번 전달되어도
각 작업을 중복해서 수행하지 않는다.
docker-compose.yml 파일의 volumns 에
scripts/init.sql:/docker-entrypoint-initdb.d/init.sql 를 추가하여
docker 서비스 실행 시 스크립트가 자동으로 실행되게 할 수도 있지만
여기서는 수동으로 전달하겠다.
~/workspace/image-assets-management$ docker exec -i image_assets_management_postgres psql -U peter -d image_db < scripts/init.sql
테이블은 이미 만들어져 있으니 skipping 으로 뜨고 인덱스가 생성될 것이다.
테스트용 데이터 셋을 넣을 스크립트를 작성한다.
seed_data.pyimport httpx import json BASE_URL = "http://localhost:8000/assets" data_set = [ {"file_name": "char_hero_idle.exr", "file_path": "/assets/textures/hero/", "width": 4096, "height": 4096, "metadata": {"format": "EXR", "layers": 64, "type": "render"}}, {"file_name": "char_hero_run.exr", "file_path": "/assets/textures/hero/", "width": 4096, "height": 4096, "metadata": {"format": "EXR", "layers": 64, "type": "render"}}, {"file_name": "env_forest_ground.exr", "file_path": "/assets/textures/env/", "width": 8192, "height": 4096, "metadata": {"format": "EXR", "layers": 12, "type": "environment"}}, {"file_name": "ui_button_normal.png", "file_path": "/assets/ui/buttons/", "width": 256, "height": 256, "metadata": {"format": "PNG", "alpha": True, "theme": "dark"}}, {"file_name": "ui_icon_star.png", "file_path": "/assets/ui/icons/", "width": 128, "height": 128, "metadata": {"format": "PNG", "alpha": True, "theme": "gold"}}, {"file_name": "ref_lighting_01.jpg", "file_path": "/assets/refs/lighting/", "width": 1920, "height": 1080, "metadata": {"format": "JPEG", "quality": 90, "source": "outdoor"}}, {"file_name": "ref_material_metal.jpg", "file_path": "/assets/refs/materials/", "width": 2048, "height": 2048, "metadata": {"format": "JPEG", "quality": 95, "source": "substance"}}, {"file_name": "fx_explosion_01.exr", "file_path": "/assets/fx/explosions/", "width": 2048, "height": 2048, "metadata": {"format": "EXR", "layers": 32, "type": "vfx"}}, {"file_name": "skybox_sunset.exr", "file_path": "/assets/env/skybox/", "width": 16384, "height": 8192, "metadata": {"format": "EXR", "layers": 1, "type": "hdr"}}, {"file_name": "char_npc_bakery.png", "file_path": "/assets/textures/npc/", "width": 1024, "height": 1024, "metadata": {"format": "PNG", "alpha": False, "type": "concept"}} ] def seed_data(): with httpx.Client() as client: print(f"{len(data_set)}개의 데이터 삽입 시작...") for item in data_set: try: response = client.post(BASE_URL, json=item) response.raise_for_status() result = response.json() print(f"성공 {item['file_name']} (ID: {result['id']}") except Exception as e: print(f"실패 {item['file_name']} - {str(e)}") print("모든 작업 완료") if __name__ == "__main__": seed_data()
~/workspace/image-assets-management$ uv add --dev httpx ~/workspace/image-assets-management$ uv run python seed_data.py
작업에 필요한 크레이트를 추가하기 위해
Cargo.toml 파일을 수정한다.
Cargo.toml[package] name = "image-assets-management" version = "0.1.0" edition = "2024" [lib] name = "rust_engine" crate-type = ["cdylib"] [dependencies] # Python 연결 pyo3 = "0.28.0" pythonize = "0.28" # 비동기 엔진 tokio = { version = "1.43", features = ["full"] } # 데이터베이스 연결 dotenvy = "0.15" sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "macros", "uuid", "chrono", "json"] } # 데이터베이스 호환 serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" uuid = { version = "1.23", features = ["v7", "serde"] } chrono = { version = "0.4", features = ["serde"] } # 로그 수집 tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2"
대소문자 구분 없이 파일 이름의 일부를 통해 검색하거나
metadata 에 format 이 있다면 이를 통해 검색하는
기본적인 검색 쿼리를 작성한다.
페이지네이션을 통해 응답 개수가 많아져도 부담되지 않게 작성하겠다.
이미지 에셋의 정보를 담을 객체는 #[pyclass] 속성을 붙여야 한다.
구조체 필드는 Python에서 읽을 수 있게 #[pyo3[get)] 속성을 붙이되,
바로 사용할 수 없는 필드에 대해서는
적절히 변환하여 반환하는 함수를 따로 구현해준다.
dict 는 Pydantic이 모델을 직렬화할 때 사용하는 __dict__ 속성을 생성한다.
구조체를 JSON으로 변환하고 반대로도 변환하기 위해
serde::Serialize, serde::Deserialize 가 필요하며
sqlx를 사용하기 위해 sqlx::FromRow 도 필요하다.
응답 데이터를 담는 객체를 위한 구조체도 생성한다.
#[pyclass] 속성에 get_all 을 붙이면 필드에 일일이 작성해 주지 않아도
Python에서 각 필드에 직접 접근할 수 있다.
src/image.rsuse pyo3::prelude::*; use pyo3::exceptions::PyRuntimeError; use sqlx::{Pool, Postgres}; use uuid::Uuid; use serde_json::Value as JsonValue; use tracing::{info, instrument, error}; #[pyclass(dict)] #[derive(Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow)] pub struct ImageAsset { pub id: Uuid, #[pyo3(get)] pub file_name: String, #[pyo3(get)] pub file_path: String, #[pyo3(get)] pub width: i32, #[pyo3(get)] pub height: i32, pub metadata: JsonValue, } #[pymethods] impl ImageAsset { #[getter] fn id(&self) -> String { self.id.to_string() } #[getter] fn metadata<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> { pythonize::pythonize(py, &self.metadata) .map_err(|e| PyRuntimeError::new_err(e.to_string())) } } #[pyclass(get_all, dict)] #[derive(Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow)] pub struct AssetListResponse { pub total_count: i64, pub items: Vec<ImageAsset>, pub page: i32, pub size: i32, } #[pymethods] impl AssetListResponse { fn to_dict<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> { pythonize::pythonize(py, self) .map_err(|e| PyRuntimeError::new_err(e.to_string())) } } #[instrument(skip(pool, asset), fields(file = %asset.file_name))] pub async fn insert_asset(pool: &Pool<Postgres>, asset: ImageAsset) -> Result<Uuid, sqlx::Error> { info!("이미지 자산 저장 중..."); let row = sqlx::query!( r#" INSERT INTO image_assets (file_name, file_path, width, height, metadata) VALUES ($1, $2, $3, $4, $5) RETURNING id "#, asset.file_name, asset.file_path, asset.width, asset.height, asset.metadata ) .fetch_one(pool) .await .map_err(|e| { error!("Insert 쿼리 실패: {}", e); e })?; info!(asset_id = %row.id, "자산 저장 완료"); Ok(row.id) } #[instrument(skip(pool))] pub async fn search_assets( pool: &Pool<Postgres>, name_query: Option<String>, format_query: Option<String>, limit: i64, offset: i64 ) -> Result<AssetListResponse, sqlx::Error> { let rows = sqlx::query!( r#" SELECT id, file_name, file_path, width, height, metadata, count(*) OVER() as "total_count!" FROM image_assets WHERE ($1::TEXT IS NULL OR file_name ILIKE $1) AND ($2::JSONB IS NULL OR metadata @> $2) ORDER BY id DESC LIMIT $3 OFFSET $4 "#, name_query.map(|n| format!("%{}%", n)), format_query.map(|f| serde_json::json!({"format": f})), limit, offset ) .fetch_all(pool) .await?; let total_count = rows.first().map(|r| r.total_count).unwrap_or(0); let items = rows.into_iter().map(|r| ImageAsset { id: r.id, file_name: r.file_name, file_path: r.file_path, width: r.width, height: r.height, metadata: r.metadata.unwrap_or(serde_json::Value::Null), }).collect(); let size = limit as i32; let page = (offset as i32 / size) + 1; Ok(AssetListResponse{ total_count, items, page, size }) }
#[pyo3(signature = ())] 를 사용하여 기본값을 지정하면
Python에서 인자를 선택적으로 넘길 수 있다.
src/lib.rsmod db; mod logger; mod image; use pyo3::prelude::*; use tokio::runtime::Runtime; use dotenvy::dotenv; use std::env; use std::sync::OnceLock; use tracing::{debug, error, info, instrument}; use tracing_appender::non_blocking::WorkerGuard; static LOG_GUARD: OnceLock<WorkerGuard> = OnceLock::new(); static TOKIO_RUNTIME: OnceLock<Runtime> = OnceLock::new(); fn get_runtime() -> &'static Runtime { TOKIO_RUNTIME.get_or_init(|| { Runtime::new().expect("Tokio 런타임 생성 실패") }) } #[pyfunction] fn init_engine() -> PyResult<String> { info!("엔진 초기화 절차 시작"); debug!("로깅 시작"); if LOG_GUARD.get().is_none() { let guard = logger::init_tracing(); let _ = LOG_GUARD.set(guard); } debug!("환경 변수 로드"); dotenv().ok(); let url = env::var("DATABASE_URL") .map_err(|e| { error!("DATABASE_URL 환경 변수를 찾을 수 없음: {}", e); pyo3::exceptions::PyRuntimeError::new_err("DATABASE_URL not found in .env") })?; debug!("데이터베이스 연결 시작"); let rt = get_runtime(); rt.block_on(async { db::connect(&url).await }) .map_err(|e| { error!("DB 연결 풀 생성 실패: {}", e); pyo3::exceptions::PyRuntimeError::new_err(format!("DB connection failed: {}", e)) })?; Ok("엔진 초기화 및 DB 연결 성공".to_string()) } #[pyfunction] fn shutdown_engine() -> PyResult<()> { info!("엔진 종료 절차 시작"); let rt = get_runtime(); rt.block_on(async { db::close().await; }); Ok(()) } #[pyfunction] fn check_connection() -> PyResult<bool> { debug!("DB 연결 상태 확인"); Ok(db::is_alive()) } #[pyfunction] #[instrument(skip(metadata))] fn add_image_asset( file_name: String, file_path: String, width: i32, height: i32, metadata: String ) -> PyResult<String> { let rt = get_runtime(); let meta_json: serde_json::Value = serde_json::from_str(&metadata) .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; let asset = image::ImageAsset { id: uuid::Uuid::nil(), file_name, file_path, width, height, metadata: meta_json, }; let pool = db::DB_POOL.get() .ok_or(pyo3::exceptions::PyRuntimeError::new_err("DB 연결 풀이 없습니다."))?; let id = rt.block_on(async { image::insert_asset(pool, asset).await }).map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; Ok(id.to_string()) } #[pyfunction] #[pyo3(signature = (name=None, format=None, limit=10, offset=0))] fn search_assets( name: Option<String>, format: Option<String>, limit: i64, offset: i64 ) -> PyResult<image::AssetListResponse> { let rt = get_runtime(); let pool = db::DB_POOL.get() .ok_or(pyo3::exceptions::PyRuntimeError::new_err("DB 연결 풀이 없습니다."))?; let results = rt.block_on(async { image::search_assets(pool, name, format, limit, offset).await }).map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; Ok(results) } #[pymodule] fn rust_engine(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(init_engine, m)?)?; m.add_function(wrap_pyfunction!(shutdown_engine, m)?)?; m.add_function(wrap_pyfunction!(check_connection, m)?)?; m.add_function(wrap_pyfunction!(add_image_asset, m)?)?; m.add_function(wrap_pyfunction!(search_assets, m)?)?; Ok(()) }
기본값을 설정해 놓고
쿼리로 넘어온 값에 대해서만 값을 지정한다.
app/main.pyfrom fastapi import FastAPI, HTTPException from fastapi.responses import ORJSONResponse from contextlib import asynccontextmanager import rust_engine import logging import json from pydantic import BaseModel logger = logging.getLogger("uvicorn.error") class UTF8ORJSONResponse(ORJSONResponse): media_type = "application/json; charset=utf-8" @asynccontextmanager async def lifespan(app: FastAPI): logger.info("Rust 엔진 초기화 중...") try: msg = rust_engine.init_engine() logger.info(msg) except Exception as e: logger.info(f"Rust 엔진 초기화 실패: {e}") yield # 앱 가동 # [SHUTDOWN] logger.info("서버 종료 감지: 자원 정리 중...") try: rust_engine.shutdown_engine() logger.info("모든 연결 안전하게 종료") except Exception as e: logger.error(f"종료 중 오류 발생: {e}") app = FastAPI(default_response_class=UTF8ORJSONResponse, lifespan=lifespan) @app.get("/") def read_root(): return { "status": "200", "info": "서버 가동 중입니다." } @app.get("/db-status") def get_db_status(): logger.info("DB 상태 체크 요청") is_alive = rust_engine.check_connection() if is_alive: logger.info("DB 연결 상태 양호") return { "status": "online", "message": "Rust 엔진이 PostgreSQL을 사용합니다." } else: logger.error("DB 연결 끊김 감지") raise HTTPException( status_code=500, detail="DB 연결이 끊겼거나 초기화되지 않았습니다." ) class ImageRequest(BaseModel): file_name: str file_path: str width: int height: int metadata: dict @app.post("/assets") async def create_asset(req: ImageRequest): try: asset_id = rust_engine.add_image_asset( req.file_name, req.file_path, req.width, req.height, json.dumps(req.metadata) ) return { "status": "success", "id": asset_id } except Exception as e: raise HTTPException( status_code=500, detail=str(e) ) @app.get("/assets/search") async def get_assets( name_query: str = None, format_query: str = None, page: int = 1, size: int = 5 ): offset = (page - 1) * size response = rust_engine.search_assets( name=name_query, format=format_query, limit=size, offset=offset ) return response.to_dict()
Maturin 라이브러리를 통해 Rust 코드를 Python에서 호출 가능한 형태로 컴파일한다.
pyproject.toml 파일에 build-backend = "maturin" 이 명시되어 있으니
uv를 사용하는 방식으로 컴파일하겠다.
uvicorn 라이브러리를 통해 FastAPI를 실행한다.
~/workspace/image-assets-management$ uv pip install -e . ~/workspace/image-assets-management$ uv run uvicorn app.main:app --reload
curl 명령어 또는 브라우저를 통해 다음과 같은 테스트를 해볼 수 있다.
http://127.0.0.1:8000/assets/searchhttp://127.0.0.1:8000/assets/search?fomrat_query=EXRhttp://127.0.0.1:8000/assets/search?name_query=hero아무 인자를 전달하지 않으면 전체 데이터가 출력되며
format_query 및 name_query 를 통해 쿼리를 전달할 수 있다.
curl 명령어로 쿼리 문자열을 전달할 때는 URL을 따옴표로 묶어야 한다.
~$ curl -i http://127.0.0.1:8000/assets/search HTTP/1.1 200 OK date: Tue, 21 Apr 2026 01:06:41 GMT server: uvicorn content-length: 1053 content-type: application/json; charset=utf-8 {"total_count":11,"items":[{"id":"019d9441-cbba-74b9-9667-1b06b469045e","file_name":"char_npc_bakery.png","file_path":"/assets/textures/npc/","width":1024,"height":1024,"metadata":{"alpha":false,"format":"PNG","type":"concept"}},{"id":"019d9441-cbb4-7f20-b5e8-285e9d3ced04","file_name":"skybox_sunset.exr","file_path":"/assets/env/skybox/","width":16384,"height":8192,"metadata":{"format":"EXR","layers":1,"type":"hdr"}},{"id":"019d9441-cbaf-7961-86d0-fe5c565d96d0","file_name":"fx_explosion_01.exr","file_path":"/assets/fx/explosions/","width":2048,"height":2048,"metadata":{"format":"EXR","layers":32,"type":"vfx"}},{"id":"019d9441-cbaa-7eb2-9c45-59a183cdb110","file_name":"ref_material_metal.jpg","file_path":"/assets/refs/materials/","width":2048,"height":2048,"metadata":{"format":"JPEG","quality":95,"source":"substance"}},{"id":"019d9441-cba5-7fe1-a000-000623614320","file_name":"ref_lighting_01.jpg","file_path":"/assets/refs/lighting/","width":1920,"height":1080,"metadata":{"format":"JPEG","quality":90,"source":"outdoor"}}],"page":1,"size":5}%
~$ curl -i "http://127.0.0.1:8000/assets/search?page=2" HTTP/1.1 200 OK date: Tue, 21 Apr 2026 01:08:27 GMT server: uvicorn content-length: 1035 content-type: application/json; charset=utf-8 {"total_count":11,"items":[{"id":"019d9441-cba0-7d0e-9008-e2201e853806","file_name":"ui_icon_star.png","file_path":"/assets/ui/icons/","width":128,"height":128,"metadata":{"alpha":true,"format":"PNG","theme":"gold"}},{"id":"019d9441-cb9c-7208-bcda-5061bbaf2cdc","file_name":"ui_button_normal.png","file_path":"/assets/ui/buttons/","width":256,"height":256,"metadata":{"alpha":true,"format":"PNG","theme":"dark"}},{"id":"019d9441-cb97-7099-ab84-e6cc1c087381","file_name":"env_forest_ground.exr","file_path":"/assets/textures/env/","width":8192,"height":4096,"metadata":{"format":"EXR","layers":12,"type":"environment"}},{"id":"019d9441-cb91-79bb-9bb5-4cd6326f0e68","file_name":"char_hero_run.exr","file_path":"/assets/textures/hero/","width":4096,"height":4096,"metadata":{"format":"EXR","layers":64,"type":"render"}},{"id":"019d9441-cb87-7f42-b228-2e3590d90a05","file_name":"char_hero_idle.exr","file_path":"/assets/textures/hero/","width":4096,"height":4096,"metadata":{"format":"EXR","layers":64,"type":"render"}}],"page":2,"size":5}%
~$ curl -i "http://127.0.0.1:8000/assets/search?page=3" HTTP/1.1 200 OK date: Tue, 21 Apr 2026 01:08:49 GMT server: uvicorn content-length: 253 content-type: application/json; charset=utf-8 {"total_count":11,"items":[{"id":"019d8eae-5943-76c4-9a85-195ce2c4f81a","file_name":"hero_char_diffuse.exr","file_path":"/assets/textures/hero/","width":4096,"height":4096,"metadata":{"avg_luminance":0.45,"format":"EXR","layers":32}}],"page":3,"size":5}%
~$ curl -i "http://127.0.0.1:8000/assets/search?format_query=EXR" HTTP/1.1 200 OK date: Tue, 21 Apr 2026 01:06:44 GMT server: uvicorn content-length: 1037 content-type: application/json; charset=utf-8 {"total_count":6,"items":[{"id":"019d9441-cbb4-7f20-b5e8-285e9d3ced04","file_name":"skybox_sunset.exr","file_path":"/assets/env/skybox/","width":16384,"height":8192,"metadata":{"format":"EXR","layers":1,"type":"hdr"}},{"id":"019d9441-cbaf-7961-86d0-fe5c565d96d0","file_name":"fx_explosion_01.exr","file_path":"/assets/fx/explosions/","width":2048,"height":2048,"metadata":{"format":"EXR","layers":32,"type":"vfx"}},{"id":"019d9441-cb97-7099-ab84-e6cc1c087381","file_name":"env_forest_ground.exr","file_path":"/assets/textures/env/","width":8192,"height":4096,"metadata":{"format":"EXR","layers":12,"type":"environment"}},{"id":"019d9441-cb91-79bb-9bb5-4cd6326f0e68","file_name":"char_hero_run.exr","file_path":"/assets/textures/hero/","width":4096,"height":4096,"metadata":{"format":"EXR","layers":64,"type":"render"}},{"id":"019d9441-cb87-7f42-b228-2e3590d90a05","file_name":"char_hero_idle.exr","file_path":"/assets/textures/hero/","width":4096,"height":4096,"metadata":{"format":"EXR","layers":64,"type":"render"}}],"page":1,"size":5}%
~$ curl -i "http://127.0.0.1:8000/assets/search?name_query=hero" HTTP/1.1 200 OK date: Tue, 21 Apr 2026 01:06:47 GMT server: uvicorn content-length: 649 content-type: application/json; charset=utf-8 {"total_count":3,"items":[{"id":"019d9441-cb91-79bb-9bb5-4cd6326f0e68","file_name":"char_hero_run.exr","file_path":"/assets/textures/hero/","width":4096,"height":4096,"metadata":{"format":"EXR","layers":64,"type":"render"}},{"id":"019d9441-cb87-7f42-b228-2e3590d90a05","file_name":"char_hero_idle.exr","file_path":"/assets/textures/hero/","width":4096,"height":4096,"metadata":{"format":"EXR","layers":64,"type":"render"}},{"id":"019d8eae-5943-76c4-9a85-195ce2c4f81a","file_name":"hero_char_diffuse.exr","file_path":"/assets/textures/hero/","width":4096,"height":4096,"metadata":{"avg_luminance":0.45,"format":"EXR","layers":32}}],"page":1,"size":5}%