
비정형 데이터 JSON의 내용을 수정하는 방법을 알아보자.
에셋의 모든 정보를 바꾸기보다,
특정 메타데이터만 추가하거나 수정하는 일이 훨씬 많다.
PostgreSQL의 || (병합) 연산자를 사용하면
기존 JSON 데이터를 유지하면서 특정 키만 덮어쓰거나 추가할 수 있다.
최상위 키만 비교하기 때문에 중첩된 데이터가 있을 경우
일부 데이터가 누락될 수 있는데 이를 방지하려면
jsonb_set 함수를 중첩으로 사용해야 하지만
일반적인 태그 추가나 상태 변경은 || 로도 충분하다.
주의!
기존 메타데이터가{"info": {"color": "red"}}인데
{"info": {"size": 10}}을 병합하면
info내부가 합쳐지는 게 아니라info전체가 새 객체로 덮어씌워짐.
sqlx::query_as! 를 사용하면
두 번째 인자로 전달된 쿼리의 결과를
첫 번째 인자로 전달된 변수에 바로 대입할 수 있다.
세 번째 인자부터는 sqlx::query! 와 마찬가지로
쿼리에 대입되는 값이다.
UPDATE 연산은 RETURNING 을 통해 결과를 반환한다.
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())) } 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))] pub async fn update_metadata( pool: &Pool<Postgres>, id: uuid::Uuid, new_meta: serde_json::Value, ) -> Result<ImageAsset, sqlx::Error> { let updated = sqlx::query_as!( ImageAsset, r#" UPDATE image_assets SET metadata = metadata || $1 WHERE id = $2 RETURNING id, file_name, file_path, width, height, metadata "#, new_meta, id ) .fetch_one(pool) .await?; Ok(updated) }
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 patch_asset_metadata( id_str: String, metadata_json: String ) -> PyResult<image::ImageAsset> { let rt = get_runtime(); let pool = db::DB_POOL.get() .ok_or(pyo3::exceptions::PyRuntimeError::new_err("DB 연결 풀이 없습니다."))?; let id = uuid::Uuid::parse_str(&id_str) .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; let metadata: serde_json::Value = serde_json::from_str(&metadata_json) .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; let asset = rt.block_on(async { image::update_metadata(pool, id, metadata).await }).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; Ok(asset) } #[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)?)?; m.add_function(wrap_pyfunction!(patch_asset_metadata, 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 # 기존 함수 생략 @app.patch("/assets/{asset_id}/metadata") async def update_asset(asset_id: str, payload: dict): updated = rust_engine.patch_asset_metadata(asset_id, json.dumps(payload)) return { "status": "success", "data": updated.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
프론트엔드 단에서 /assets/search 로 조회하여
원하는 이미지 에셋의 id 를 파악하였고
그것에 대한 수정을 하는 것으로 가정하여
id 는 호출하는 쪽에서 알고 있는 상황을 상정한다.
실행 예시에서는 데이터 변화를 확인하기 위해 조회도 수행하겠다.
~$ curl -i "http://127.0.0.1:8000/assets/search?name_query=hero_char" HTTP/1.1 200 OK date: Wed, 22 Apr 2026 01:39:55 GMT server: uvicorn content-length: 252 content-type: application/json; charset=utf-8 {"total_count":1,"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":1,"size":5}% ```
~$ curl -iX PATCH "http://127.0.0.1:8000/assets/019d8eae-5943-76c4-9a85-195ce2c4f81a/metadata" \ -H "Content-Type: application/json" \ -d '{"author": "peter", "is_verified": true}' HTTP/1.1 200 OK date: Wed, 22 Apr 2026 01:40:13 GMT server: uvicorn content-length: 270 content-type: application/json; charset=utf-8 {"status":"success","data":{"id":"019d8eae-5943-76c4-9a85-195ce2c4f81a","file_name":"hero_char_diffuse.exr","file_path":"/assets/textures/hero/","width":4096,"height":4096,"metadata":{"author":"peter","avg_luminance":0.45,"format":"EXR","is_verified":true,"layers":32}}}%
~$ curl -i "http://127.0.0.1:8000/assets/search?name_query=hero_char" HTTP/1.1 200 OK date: Wed, 22 Apr 2026 01:40:20 GMT server: uvicorn content-length: 288 content-type: application/json; charset=utf-8 {"total_count":1,"items":[{"id":"019d8eae-5943-76c4-9a85-195ce2c4f81a","file_name":"hero_char_diffuse.exr","file_path":"/assets/textures/hero/","width":4096,"height":4096,"metadata":{"author":"peter","avg_luminance":0.45,"format":"EXR","is_verified":true,"layers":32}}],"page":1,"size":5}%
메타데이터가 수정된 것을 확인할 수 있다.