비정형 데이터를 다루는 데이터베이스 - 조회

Pt J·2026년 4월 21일
post-thumbnail

비정형 데이터를 다루는 데이터베이스 - 조회

지난 시간에 만든 데이터베이스를 활용하는 실습을 진행해 보자.
원하는 조건에 따라 데이터를 뽑아내는 코드를 작성하겠다.

DB

인덱스

검색 효율을 높이기 위해 인덱스를 추가한다.

scripts/init.sql

CREATE 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.py

import 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

코드 작성

Rust 코드

작업에 필요한 크레이트를 추가하기 위해
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"

대소문자 구분 없이 파일 이름의 일부를 통해 검색하거나
metadataformat 이 있다면 이를 통해 검색하는
기본적인 검색 쿼리를 작성한다.

페이지네이션을 통해 응답 개수가 많아져도 부담되지 않게 작성하겠다.

이미지 에셋의 정보를 담을 객체는 #[pyclass] 속성을 붙여야 한다.
구조체 필드는 Python에서 읽을 수 있게 #[pyo3[get)] 속성을 붙이되,
바로 사용할 수 없는 필드에 대해서는
적절히 변환하여 반환하는 함수를 따로 구현해준다.
dict 는 Pydantic이 모델을 직렬화할 때 사용하는 __dict__ 속성을 생성한다.

구조체를 JSON으로 변환하고 반대로도 변환하기 위해
serde::Serialize, serde::Deserialize 가 필요하며
sqlx를 사용하기 위해 sqlx::FromRow 도 필요하다.

응답 데이터를 담는 객체를 위한 구조체도 생성한다.
#[pyclass] 속성에 get_all 을 붙이면 필드에 일일이 작성해 주지 않아도
Python에서 각 필드에 직접 접근할 수 있다.

src/image.rs

use 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.rs

mod 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(())
}

Python 코드

기본값을 설정해 놓고
쿼리로 넘어온 값에 대해서만 값을 지정한다.

app/main.py

from 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/search
  • http://127.0.0.1:8000/assets/search?fomrat_query=EXR
  • http://127.0.0.1:8000/assets/search?name_query=hero

아무 인자를 전달하지 않으면 전체 데이터가 출력되며
format_queryname_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}%    
profile
Peter J Online Space - since July 2020 | 아무데서나 채용해줬으면 좋겠다

0개의 댓글