
데이터베이스에 담긴 자료를 삭제하는 방식은 두 가지가 있다.
DELETE 를 사용하여 완전히 삭제해서 복구할 수 없게 되는 Hard Delete,
그리고 "삭제됨"을 표시하고 조회 대상에서 제외하여 복구 가능한 Soft Delete.
여기서는 후자를 알아보도록 하겠다.
삭제 여부를 나타내는 칼럼을 추가해야 한다.
단순히 기다 아니다를 넘어 언제 삭제했는지 알 수 있도록
삭제 일자 칼럼을 추가하도록 하겠다.
이 값이 NULL 이면 아직 삭제되지 않은 것이다.
칼럼 추가를 위해 다음과 같이 DB 스키마를 변경한다.
~/workspace/image-assets-management$ docker exec -it image_assets_management_postgres psql -U peter -d image_db -c "ALTER TABLE image_assets ADD COLUMN deleted_at TIMESTAMPTZ DEFAULT NULL;"
변경 후 확인해 보면,
~/workspace/image-assets-management$ docker exec -it image_assets_management_postgres psql -U peter -d image_db -c "SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = 'image_assets';" column_name | data_type | is_nullable | column_default -------------+--------------------------+-------------+------------------- deleted_at | timestamp with time zone | YES | created_at | timestamp with time zone | YES | CURRENT_TIMESTAMP id | uuid | NO | uuidv7() width | integer | NO | height | integer | NO | metadata | jsonb | YES | file_name | text | NO | file_path | text | NO | (8 rows)
이에 따라 Rust 코드에 작성된 구조체도 수정한다.
Python에서 이 값을 직접 사용하지는 않을 것이므로
getter 를 추가하지는 않아도 되지만
sqlx를 사용하는 코드는 구조체에 맞게 수정해야 한다.
deleted_at 값을 변경하지 않고 쿼리로 직접 넣어줄 경우
이에 대한 구조체 필드가 당장은 필요하지 않을 수 있지만
"삭제한 지 한 달이 넘으면 영구 삭제" 같은 로직 구현 시 필요하므로
확장성을 고려해 추가해 두는 게 좋다.
조회 함수는 deleted_at 이 NULL 인 값만 조회하도록 수정한다.
query_at! 매크로를 사용할 때는 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, from_py_object)] #[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, pub deleted_at: Option<chrono::DateTime<chrono::Utc>>, // NEW! } # 중략 #[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, deleted_at, count(*) OVER() as "total_count!" -- MODIFIED! FROM image_assets WHERE ($1::TEXT IS NULL OR file_name ILIKE $1) AND ($2::JSONB IS NULL OR metadata @> $2) AND deleted_at IS NULL 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), deleted_at: r.deleted_at, // NEW! }).collect(); let size = limit as i32; let page = (offset as i32 / size) + 1; Ok(AssetListResponse{ total_count, items, page, size }) } #[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 AND deleted_at IS NULL RETURNING id, file_name, file_path, width, height, metadata, deleted_at -- MODIFIED! "#, new_meta, id ) .fetch_one(pool) .await?; Ok(updated) }
어차피 기본값으로 생성할 거라 필요하지는 않지만
이미지 에셋 데이터를 처음 생성하는 부분에도
새로 추가된 필드의 값을 명확히 설정해 주어야 오류가 나지 않는다.
src/lib.rs// 전략 #[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, deleted_at: None, // NEW! }; 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()) } // 후략
어떤 이미지 에셋의 deleted_at 값을 설정하여
Soft Delete를 수행하는 코드를 작성한다.
DB에서 완전히 제거된 게 아니므로 다시 복구할 수 있다.
따라서 복구하는 코드도 함께 작성하겠다.
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}; // 기존 함수 생략 #[instrument(skip(pool))] pub async fn soft_delete_asset( pool: &Pool<Postgres>, id: uuid::Uuid, ) -> Result<bool, sqlx::Error> { let result = sqlx::query!( r#" UPDATE image_assets SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL "#, id ) .execute(pool) .await?; Ok(result.rows_affected() > 0) } #[instrument(skip(pool))] pub async fn restore_asset( pool: &Pool<Postgres>, id: uuid::Uuid, ) -> Result<bool, sqlx::Error> { let result = sqlx::query!( r#" UPDATE image_assets SET deleted_at = NULL WHERE id = $1 AND deleted_at IS NOT NULL "#, id ) .execute(pool) .await?; Ok(result.rows_affected() > 0) }
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 delete_asset(id_str: String) -> PyResult<bool> { 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 success = rt.block_on(async { image::soft_delete_asset(pool, id).await }).map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; Ok(success) } #[pyfunction] fn restore_asset(id_str: String) -> PyResult<bool> { 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 success = rt.block_on(async { image::restore_asset(pool, id).await }).map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; Ok(success) } #[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)?)?; m.add_function(wrap_pyfunction!(delete_asset, m)?)?; m.add_function(wrap_pyfunction!(restore_asset, m)?)?; Ok(()) }
삭제하고자 하는 에셋 아이디가 존재하지 않거나 이미 삭제되어 있어서
삭제 연산이 이루어지지 않은 경우에는 404 오류를 반환하도록 구현한다.
복구할 때도 마찬가지로 처리할 수 없다며 400 오류를 반환하도록 한다.
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.delete("/assets/{asset_id}") async def delete_asset(asset_id: str): success = rust_engine.delete_asset(asset_id) if not success: raise HTTPException( status_code=404, detail="존재하지 않거나 이미 삭제되었습니다." ) return { "status": "success", "message": f"에셋 {asset_id} 삭제 완료" } @app.post("/assets/{asset_id}/restore") async def restore_asset(asset_id: str): success = rust_engine.restore_asset(asset_id) if not success: raise HTTPException( status_code=400, detail="삭제되지 않았거나 없는 파일입니다." ) return { "status": "success", "message": f"에셋 {asset_id} 복구 완료" }
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 -i "http://127.0.0.1:8000/assets/search?name_query=hero" HTTP/1.1 200 OK date: Tue, 28 Apr 2026 01:18:21 GMT server: uvicorn content-length: 739 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"},"deleted_at":null},{"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"},"deleted_at":null},{"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},"deleted_at":null}],"page":1,"size":5}%
$ curl -iX DELETE "http://127.0.0.1:8000/assets/019d9441-cb91-79bb-9bb5-4cd6326f0e68" HTTP/1.1 200 OK date: Tue, 28 Apr 2026 01:19:11 GMT server: uvicorn content-length: 90 content-type: application/json; charset=utf-8 {"status":"success","message":"에셋 019d9441-cb91-79bb-9bb5-4cd6326f0e68 삭제 완료"}%
갯수가 줄었다.
$ curl -i "http://127.0.0.1:8000/assets/search?name_query=hero" HTTP/1.1 200 OK date: Tue, 28 Apr 2026 01:19:14 GMT server: uvicorn content-length: 523 content-type: application/json; charset=utf-8 {"total_count":2,"items":[{"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"},"deleted_at":null},{"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},"deleted_at":null}],"page":1,"size":5}%
다시 시도하면 없다고 뜬다.
$ curl -iX DELETE "http://127.0.0.1:8000/assets/019d9441-cb91-79bb-9bb5-4cd6326f0e68" HTTP/1.1 404 Not Found date: Tue, 28 Apr 2026 01:19:24 GMT server: uvicorn content-length: 47 content-type: application/json {"detail":""존재하지 않거나 이미 삭제되었습니다."}%
$ curl -iX POST "http://127.0.0.1:8000/assets/019d9441-cb91-79bb-9bb5-4cd6326f0e68/restore" HTTP/1.1 200 OK date: Tue, 28 Apr 2026 01:44:26 GMT server: uvicorn content-length: 90 content-type: application/json; charset=utf-8 {"status":"success","message":"에셋 019d9441-cb91-79bb-9bb5-4cd6326f0e68 복구 완료"}%
갯수가 늘었다.
$ curl -i "http://127.0.0.1:8000/assets/search?name_query=hero" HTTP/1.1 200 OK date: Tue, 28 Apr 2026 01:44:33 GMT server: uvicorn content-length: 739 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"},"deleted_at":null},{"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"},"deleted_at":null},{"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},"deleted_at":null}],"page":1,"size":5}%
다시 하면 안 된다고 뜬다.
$ curl -iX POST "http://127.0.0.1:8000/assets/019d9441-cb91-79bb-9bb5-4cd6326f0e68/restore" HTTP/1.1 400 Bad Request date: Tue, 28 Apr 2026 01:45:31 GMT server: uvicorn content-length: 62 content-type: application/json {"detail":"삭제되지 않았거나 없는 파일입니다."}%