비정형 데이터를 다루는 데이터베이스 - 삭제

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

비정형 데이터를 다루는 데이터베이스 - 삭제

데이터베이스에 담긴 자료를 삭제하는 방식은 두 가지가 있다.
DELETE 를 사용하여 완전히 삭제해서 복구할 수 없게 되는 Hard Delete,
그리고 "삭제됨"을 표시하고 조회 대상에서 제외하여 복구 가능한 Soft Delete.

여기서는 후자를 알아보도록 하겠다.

DB

삭제 여부를 나타내는 칼럼을 추가해야 한다.
단순히 기다 아니다를 넘어 언제 삭제했는지 알 수 있도록
삭제 일자 칼럼을 추가하도록 하겠다.
이 값이 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_atNULL 인 값만 조회하도록 수정한다.

query_at! 매크로를 사용할 때는 RETURNING 하는 값의 구성이
데이터를 저장하는 구조체와 일치해야 한다는 것을 유의하자.

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

// 후략

코드

Rust 코드

어떤 이미지 에셋의 deleted_at 값을 설정하여
Soft Delete를 수행하는 코드를 작성한다.

DB에서 완전히 제거된 게 아니므로 다시 복구할 수 있다.
따라서 복구하는 코드도 함께 작성하겠다.

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};

// 기존 함수 생략

#[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.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 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(())
}

Python 코드

삭제하고자 하는 에셋 아이디가 존재하지 않거나 이미 삭제되어 있어서
삭제 연산이 이루어지지 않은 경우에는 404 오류를 반환하도록 구현한다.
복구할 때도 마찬가지로 처리할 수 없다며 400 오류를 반환하도록 한다.

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

# 기존 함수 생략

@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":"삭제되지 않았거나 없는 파일입니다."}%
profile
Peter J Online Space - since July 2020 | 아무데서나 채용해줬으면 좋겠다

0개의 댓글