비정형 데이터를 다루는 데이터베이스 - 설계

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

비정형 데이터를 다루는 데이터베이스 - 설계

현대 백엔드 아키텍처의 핵심은 모든 것을 표에 넣는 것이 아니라,
데이터의 성격에 따라 저장 방식을 결정하는 것이다.
정형 데이터와 비정형 데이터라는 두 방식을
한 테이블 안에서 어떻게 효율적으로 관리할 수 있는지 알아보도록 하자.

비정형 데이터를 포함하기 위해
이미지 파일의 메타데이터를 관리하는 데이터베이스를 상정하고 실습을 진행하겠다.
메타데이터의 구성은 이미지 파일마다 다르다.
JSONB를 사용하면 스키마 재정의 없이 다양한 구성의 메타데이터를 관리할 수 있다.

이미지 자체를 DB에 넣을 경우(BLOB)
백업과 복구 성능이 O(n)O(n) 으로 선형 증가하여 효율성이 떨어지므로
실제 데이터는 파일 시스템이나 스토리지에 두고,
그 메타데이터와 상태값만 DB에서 초고속으로 동기화하도록 한다.

작업공간 생성 및 구조 확인

기존에 만든 데이터베이스 템플릿을 복제해서 사용해 보겠다.
.env 파일과 docker-compose.yml 파일은 ⟨데이터베이스 연결⟩이 최신 버전이고
Rust 코드 및 Python 코드는 ⟨데이터베이스 연결 재구조화⟩가 최신 버전이고
Cargo.toml 파일과 pyproject.toml 파일은 ⟨(여담) uv 패키지 관리자⟩가 최신 버전이다.

~/workspace$ cp -r db-template image-assets-management
~/workspace$ cd image-assets-management
~/workspace/image-assets-management$ tree -a -I .venv
.
├── .env				# 우리 프로젝트의 DB로 연결되게 수정 필요
├── .github
│   └── workflows
│       └── CI.yml
├── .gitignore
├── app
│   └── main.py			# 우리 프로젝트의 라우트 함수 추가 필요
├── Cargo.toml			# 우리 프로젝트의 정보로 수정 필요
├── docker-compose.yml	# 우리 프로젝트의 DB로 연결되게 수정 필요
├── pyproject.toml		# 우리 프로젝트의 정보로 수정 필요
└── src
    ├── db.rs			# 우리 프로젝트에 필요한 함수 추가 필요
    ├── lib.rs			# 우리 프로젝트에 필요한 함수 추가 필요
    └── logger.rs		# 그대로 유지해도 무방

pyproject.toml 파일을 열어 프로젝트 이름을 수정해 주겠다.
나머지 의존성 부분은 이번 프로젝트에서도 사용하는 것들이니 가만히 둔다.

~/workspace/image-assets-management$ vi pyproject.toml

pyproject.toml

[build-system]
requires = ["maturin>=1.12,<2.0"]
build-backend = "maturin"

[project]
name = "image-assets-management"
requires-python = ">=3.8"
classifiers = [
    "Programming Language :: Rust",
    "Programming Language :: Python :: Implementation :: CPython",
    "Programming Language :: Python :: Implementation :: PyPy",
]
dynamic = ["version"]
dependencies = [
    "fastapi>=0.124.4",
    "orjson>=3.10.15",
    "uvicorn>=0.33.0",
]

[dependency-groups]
dev = [
    "maturin>=1.12.6",
]

다음과 같이 uv 를 통해 pyproject.toml 파일의
모든 의존성을 설치할 수 있다.

~/workspace/image-assets-management$ uv sync

마찬가지로 Cargo.toml 파일도 수정해 준다.

이 때, 필요한 크레이트도 추가해 주도록 하자.
데이터베이스의 기본값으로 사용할 UUID를 위해 uuid 크레이트를 추가하고
(UUID가 뭔지 궁금하다면 해당 크레이트에도 설명이 나와 있으니 클릭해 보자)
생성 일시를 기록하기 위해 chrono 크레이트를 추가하고
JSON 데이터를 다루기 위해 serde_json 크레이트도 추가한다.

~/workspace/image-assets-management$ vi Cargo.toml

Cargo.toml

[package]
name = "image-assets-management"
version = "0.1.0"
edition = "2024"

[lib]
name = "rust_engine"
crate-type = ["cdylib"]

[dependencies]
pyo3 = "0.28.0"
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "macros", "uuid", "chrono", "json"] }
tokio = { version = "1.43", features = ["full"] }
tracing-appender = "0.2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
dotenvy = "0.15"
uuid = { version = "1.23", features = ["v7", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
serde_json = "1.0"

템플릿 프로젝트의 DB 정보를 그대로 사용하면 DB가 꼬이게 될 테니
DB 정보도 수정해 준다.

docker-compose.yml 의 변경 필요한 부분은 전부 환경변수로 되어 있으니
.env 파일만 편집하면 된다.

계정 정보는 그대로 사용해도 무방하지만
COMPOSE_PROJECT_NAMEPOSTGRES_DB 는 수정해 주어야 한다.

~/workspace/image-assets-management$ vi .env

.env

# Docker Compose Project
COMPOSE_PROJECT_NAME=image_assets_management

# PostgreSQL
POSTGRES_USER=peter
POSTGRES_PASSWORD=ku201711424
POSTGRES_DB=image_db
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432

# pgAdmin
PGADMIN_EMAIL=admin@pjos.dev
PGADMIN_PASSWORD=admin201711424

# SQLX
DATABASE_URL=postgres://peter:ku201711424@127.0.0.1:5432/image_db?sslmode=disable

재차 말하지만 학습 기록용이니까 업로드하지 .env 파일의 내용은 유출하는 거 아니다. 보안 이슈!

DB 설정

스키마

이미지 정보를 담을 데이터베이스 테이블을 만들 것이다.
사용할 데이터베이스 테이블의 구성은 다음과 같다.

변수명자료형설명
idUUID자동으로 생성되는 고유 식별자로, 기본키로 사용된다.
file_nameTEXT이미지 파일의 이름
file_pathTEXT이미지 자체가 아닌 메타데이터만 저장할 것이므로 이미지 경로가 필요하다.
widthINTEGER이미지 파일의 가로 크기
heightINTEGER이미지 파일의 세로 크기
metadataJSONB메타데이터가 담긴 JSON 데이터를 처리하기 좋게 JSONB로 저장한다.
created_atTIMESTAMPTZ데이터를 저장한 시점에 자동으로 작성되는 타임스탬프

이 데이터베이스 테이블이 존재하지 않는 경우에만
새로 생성하는 코드를 작성해 보자.
테이블 이름은 image_assets 로 하겠다.

~/workspace/image-assets-management$ mkdir scripts
~/workspace/image-assets-management$ vi scripts/init.sql

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

uuidv7() 은 UUID v7을 사용하겠다고 하는 것이고
UUID v4를 사용하고자 한다면 gen_random_uuid() 를 사용한다.

파일을 생성하지 않고 터미널에서 직접 쿼리를 날려도 되지만
따로 파일을 작성하는 편이 유지보수 측면에서 효과적이다.

docker

PostgreSQL을 docker 서비스로 실행한다.

~/workspace/image-assets-management$ docker compose up -d
~/workspace/image-assets-management$ docker ps                    
CONTAINER ID   IMAGE                COMMAND                   CREATED         STATUS         PORTS                                         NAMES
64808bc6afba   dpage/pgadmin4       "/entrypoint.sh"          5 seconds ago   Up 5 seconds   0.0.0.0:8080->80/tcp, [::]:8080->80/tcp       pgadmin_ui
0ca8e65aa3bd   postgres:18-alpine   "docker-entrypoint.s…"   5 seconds ago   Up 5 seconds   0.0.0.0:5432->5432/tcp, [::]:5432->5432/tcp   image_assets_management_postgres

image_assets_management_postgres 내부에서 작업을 수행할 것이다.

대화형 터미널을 열어 쿼리를 직접 날리고 싶을 땐
다음과 같이 -it 를 붙여 사용하지만

~/workspace/image-assets-management$ docker exec -it image_assets_management_postgres psql -U peter -d image_db

우리는 대화형으로 쿼리를 날리지 않고
기존에 작성해 놓은 쿼리 파일을 전달할 것이므로
-i 를 사용하고 파이프라인으로 전달한다.

~/workspace/image-assets-management$ docker exec -i image_assets_management_postgres psql -U peter -d image_db < scripts/init.sql

-U 뒤에 오는 건 .envPOSTGRES_USER 이며
-d 뒤에 오는 건 .envPOSTGRES_DB 이다.
실무에서 여럿이서 작업할 경우 POSTGRES_USERpostgres 로 설정하는 경우가 많다.

PostgreSQL 컨테이너는 데이터 볼륨이 이미 존재하면
.env 의 설정이 바뀌어도 새로운 사용자를 추가로 만들지 않으니
다른 설정으로 컨테이너를 띄운 적이 있을 때
사용자 이름을 변경하고자 한다면
볼륨을 지우고 다시 시작해야 바뀐 설정이 적용된다.

다음과 같이 데이터베이스 테이블이 생성된 것을 확인할 수 있다.

~/workspace/image-assets-management$ docker exec -it image_assets_management_postgres psql -U peter -d image_db -c "\dt"
            List of tables
 Schema |     Name     | Type  | Owner 
--------+--------------+-------+-------
 public | image_assets | table | peter
(1 row)

그리고 그것은 아직 비어 있다.

~/workspace/image-assets-management$ docker exec -it image_assets_management_postgres psql -U peter -d image_db -c "SELECT id, file_name, created_at FROM image_assets;"
 id | file_name | created_at 
----+-----------+------------
(0 rows)

여기에 데이터를 넣는 코드를 작성해 보자.

코드 작성

Rust 코드

기존의 로그 관리 모듈, 데이터베이스 관리 모듈, 인터페이스 모듈 외에
이미지 데이터를 다루는 Rust 파일을 새로 생성한다.

src/image.rs

use sqlx::{Pool, Postgres};
use uuid::Uuid;
use serde_json::Value as JsonValue;
use tracing::{info, instrument, error};

pub struct ImageAsset {
    pub file_name: String,
    pub file_path: String,
    pub width: i32,
    pub height: i32,
    pub metadata: JsonValue,
}

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

새로 추가한 이미지 모듈에 대한 정보를
인터페이스 모듈에 등록한다.

src/lib.rs

mod db;
mod logger;
mod image; // NEW!

use pyo3::prelude::*;
use tokio::runtime::Runtime;
use dotenvy::dotenv;
use std::env;
use std::sync::OnceLock;
use tracing::{debug, error, info, instrument}; // MODIFIED!
use tracing_appender::non_blocking::WorkerGuard;

static LOG_GUARD: OnceLock<WorkerGuard> = OnceLock::new();
static TOKIO_RUNTIME: OnceLock<Runtime> = OnceLock::new(); // 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(); // MODIFIED!
    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 = Runtime::new().unwrap();
    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(); // MODIFIED!

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

#[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)?)?; // NEW!

    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 # NEW!
from pydantic import BaseModel # NEW!

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)
        )

빌드 및 실행

테스트용 더미 파일을 생성한다.

create_assets.py

import os

def create_dummy_assets():
    # 에셋 저장 폴더 생성
    base_dir = "assets/textures/hero"
    os.makedirs(base_dir, exist_ok=True)
 
    # 생성할 파일 목록 (파일명, 크기)
    files = {
        "hero_char_diffuse.exr": 1024 * 10, # 10KB dummy
        "ui_button_hover.png": 1024 * 2,
        "env_forest_01_norm.tga": 1024 * 5,
        "emotion_icon_joy.svg": 1024 * 1
    }
  
    print(f"'{base_dir}' 폴더에 테스트 에셋을 생성합니다...")
    for name, size in files.items():
        path = os.path.join(base_dir, name)
        with open(path, "wb") as f:
            f.write(os.urandom(size)) # 실제 바이너리 데이터처럼 보이기 위해 난수 입력
        print(f"생성 완료: {name} ({size} bytes)")

if __name__ == "__main__":
    create_dummy_assets()
~/workspace/image-assets-management$ uv run create_assets.py 
'assets/textures/hero' 폴더에 테스트 에셋을 생성합니다...
생성 완료: hero_char_diffuse.exr (10240 bytes)
생성 완료: ui_button_hover.png (2048 bytes)
생성 완료: env_forest_01_norm.tga (5120 bytes)
생성 완료: emotion_icon_joy.svg (1024 bytes)

Maturin 라이브러리를 통해 Rust 코드를 Python에서 호출 가능한 형태로 컴파일한다.

uvicorn 라이브러리를 통해 FastAPI를 실행한다.

~/workspace/image-assets-management$ uv run maturin develop
~/workspace/image-assets-management$ uv run uvicorn app.main:app --reload

curl 명령어 또는 브라우저를 통해 다음과 같은 테스트를 해볼 수 있다.

  • http://127.0.0.1:8000/assets
~$ curl -X POST "http://localhost:8000/assets" \
     -H "Content-Type: application/json" \
     -d '{
       "file_name": "hero_char_diffuse.exr",
       "file_path": "/assets/textures/hero/",
       "width": 4096,
       "height": 4096,
       "metadata": {"format": "EXR", "layers": 32, "avg_luminance": 0.45}
     }'
{"status":"success","id":"019d8eae-5943-76c4-9a85-195ce2c4f81a"}%
~/workspace/image-assets-management$ docker exec -it image_assets_management_postgres psql -U peter -d image_db -c "SELECT id, file_name, created_at FROM image_assets;"
                  id                  |       file_name       |          created_at          
--------------------------------------+-----------------------+------------------------------
 019d8eae-5943-76c4-9a85-195ce2c4f81a | hero_char_diffuse.exr | 2026-04-15 01:08:04.03512+00
(1 row)

여담

  • 실무적 팁: 최근 가장 현대적인 패키지 관리자인 uv는 속도가 빠른 대신 '캐싱(Caching)'이 매우 공격적입니다. uv run maturin develop 을 실행할 때 가상환경은 활성화되지만, 파이썬 쪽 스크립트 시그니처나 의존성이 크게 바뀌지 않았다고 판단하면 uv의 캐시나 이전 빌드 잔재 때문에 .so.pyd 확장이 제대로 덮어씌워지지 않는 경우가 잦습니다.
  • 해결책: 이럴 때는 억지로 원인을 찾으며 스트레스(자형)를 받기보다, 아주 물리적인 방식으로 접근하는 것이 좋습니다. uv cache clean 으로 캐시를 강제로 날려버리거나, Rust의 target/ 폴더를 통째로 날리고 재빌드하거나, 아예 uv pip install -e . (pyproject.toml 에 maturin 백엔드가 설정된 경우) 방식으로 링킹을 강제하는 것이 멘탈 방어에 유리합니다.

➔ Rust 코드를 컴파일해도 rust_engine 이 변하지 않는다면 uv pip install -e . 를 하자.

profile
Peter J Online Space - since July 2020 | 아무데서나 채용해줬으면 좋겠다

0개의 댓글