<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>PJOS</title>
        <link>https://velog.io/</link>
        <description>Peter J Online Space - since July 2020 | 아무데서나 채용해줬으면 좋겠다</description>
        <lastBuildDate>Wed, 29 Apr 2026 04:34:05 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>PJOS</title>
            <url>https://images.velog.io/images/peeeeeter_j/profile/ee9e1790-4d3d-4dba-a678-b8788cfef636/20200820_003148.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. PJOS. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/peeeeeter_j" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[복잡한 관계형 로직의 데이터베이스 - 설계]]></title>
            <link>https://velog.io/@peeeeeter_j/pt-study-27</link>
            <guid>https://velog.io/@peeeeeter_j/pt-study-27</guid>
            <pubDate>Wed, 29 Apr 2026 04:34:05 GMT</pubDate>
            <description><![CDATA[<h1 id="복잡한-관계형-로직의-데이터베이스---설계">복잡한 관계형 로직의 데이터베이스 - 설계</h1>
<p>데이터 사이의 복잡한 관계를 효율적으로 필터링하는 방법을 알아보자.
음양오행의 조화와 상호작용을 담는 데이터베이스를 만들어 보겠다.</p>
<p>60갑자는 10가지 천간과 12가지 지지가 조합되어 구성된다.
60개의 글자는 서로 상호작용하여 어떤 관계를 이룬다.
깊게 들어가면 너무 방대해지므로 여기선 간단한 관계만 살펴보겠다.</p>
<p>각 천간과 지지가 가진 음양오행 속성과
글자들간의 합/충/형/파 관계,
그리고 프로필 정보는 데이터베이스를 통해 관리하고
만세력 변환 및 분석은 Rust 로직으로 구현하겠다.
분석된 데이터는 다시 데이터베이스에 JSONB로 저장한다.</p>
<p>전반적인 흐름은 다음과 같다.</p>
<blockquote>
</blockquote>
<ol>
<li><strong>입력 (Input)</strong>: 사용자가 생년월일시를 입력하면, Rust 엔진의 만세력 로직이 이를 8개의 간지 코드로 변환합니다.</li>
<li><strong>저장 (Store)</strong>: 변환된 간지 코드와 기본 정보를 saju_profiles에 저장합니다.</li>
<li><strong>분석 (Analyze)</strong>: Rust 엔진이 DB의 ganji_interactions(정적 관계)를 참고하여 이 사주의 특징(오행 균형, 합충 등)을 계산합니다.</li>
<li><strong>캐싱 (Cache)</strong>: 계산된 복잡한 결과물을 JSON 객체로 만들어 analysis_result 필드에 UPDATE 합니다.</li>
<li><strong>조회 (Query)</strong>: 이후 사용자가 프로필을 볼 때는 다시 계산할 필요 없이 JSONB 필드만 읽어서 보여줍니다. 만약 &quot;목(Wood) 기운이 30점 이상인 사람&quot;을 찾는다면 GIN 인덱스를 통해 빠르게 필터링합니다.</li>
</ol>
<h2 id="작업공간-생성-및-구조-확인">작업공간 생성 및 구조 확인</h2>
<p>기존에 만든 데이터베이스 템플릿을 복제해서 사용해 보겠다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace$ cp -r db-template saju-analysis
~/workspace$ cd saju-analysis
~/workspace/saju-analysis$ 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        # 그대로 유지해도 무방</code></pre>
<p><code>pyproject.toml</code> 파일을 열어 프로젝트 이름을 수정해 주겠다.
나머지 의존성 부분은 이번 프로젝트에서도 사용하는 것들이니 가만히 둔다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/saju-analysis$ vi pyproject.toml</code></pre>
<blockquote>
<p><code>pyproject.toml</code></p>
</blockquote>
<pre><code class="language-toml">[build-system]
requires = [&quot;maturin&gt;=1.12,&lt;2.0&quot;]
build-backend = &quot;maturin&quot;
&gt;
[project]
name = &quot;saju-analysis&quot;
requires-python = &quot;&gt;=3.12&quot;
classifiers = [
    &quot;Programming Language :: Rust&quot;,
    &quot;Programming Language :: Python :: Implementation :: CPython&quot;,
    &quot;Programming Language :: Python :: Implementation :: PyPy&quot;,
]
dynamic = [&quot;version&quot;]
dependencies = [
    &quot;fastapi&gt;=0.124.4&quot;,
    &quot;orjson&gt;=3.10.15&quot;,
    &quot;uvicorn&gt;=0.33.0&quot;,
]
&gt;
[dependency-groups]
dev = [
    &quot;maturin&gt;=1.12.6&quot;,
]</code></pre>
<p>다음과 같이 uv 를 통해 <code>pyproject.toml</code> 파일의
모든 의존성을 설치할 수 있다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/saju-analysis$ uv sync</code></pre>
<p>마찬가지로 <code>Cargo.toml</code> 파일도 수정해 준다.
이 때, 필요한 크레이트도 추가해 주도록 하자.</p>
<blockquote>
<p><code>Cargo.toml</code></p>
</blockquote>
<pre><code class="language-toml">[package]
name = &quot;saju-analysis&quot;
version = &quot;0.1.0&quot;
edition = &quot;2024&quot;
&gt;
[lib]
name = &quot;rust_engine&quot;
crate-type = [&quot;cdylib&quot;]
&gt;
[dependencies]
# Python 연결
pyo3 = &quot;0.28.0&quot;
pythonize = &quot;0.28&quot;
# 비동기 엔진
tokio = { version = &quot;1.43&quot;, features = [&quot;full&quot;] }
# 데이터베이스 연결
dotenvy = &quot;0.15&quot;
sqlx = { version = &quot;0.8&quot;, features = [&quot;runtime-tokio&quot;, &quot;tls-rustls&quot;, &quot;postgres&quot;, &quot;macros&quot;, &quot;uuid&quot;, &quot;chrono&quot;, &quot;json&quot;] }
# 데이터베이스 호환
serde = { version = &quot;1.0&quot;, features = [&quot;derive&quot;] }
serde_json = &quot;1.0&quot;
uuid = { version = &quot;1.23&quot;, features = [&quot;v7&quot;, &quot;serde&quot;] }
# 로그 수집
tracing = &quot;0.1&quot;
tracing-subscriber = { version = &quot;0.3&quot;, features = [&quot;env-filter&quot;, &quot;json&quot;] }
tracing-appender = &quot;0.2&quot;</code></pre>
<p>템플릿 프로젝트의 DB 정보를 그대로 사용하면 DB가 꼬이게 될 테니
DB 정보도 수정해 준다.</p>
<p><code>docker-compose.yml</code> 의 변경 필요한 부분은 전부 환경변수로 되어 있으니
<code>.env</code> 파일만 편집하면 된다.</p>
<p>계정 정보는 그대로 사용해도 무방하지만
<code>COMPOSE_PROJECT_NAME</code> 과 <code>POSTGRES_DB</code> 는 수정해 주어야 한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/saju-analysis$ vi .env</code></pre>
<blockquote>
<p><code>.env</code></p>
</blockquote>
<pre><code class="language-bash"># Docker Compose Project
COMPOSE_PROJECT_NAME=saju-analysis
&gt;
# PostgreSQL
POSTGRES_USER=peter
POSTGRES_PASSWORD=ku201711424
POSTGRES_DB=saju_db
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432
&gt;
# pgAdmin
PGADMIN_EMAIL=admin@pjos.dev
PGADMIN_PASSWORD=admin201711424
&gt;
# SQLX
DATABASE_URL=postgres://peter:ku201711424@127.0.0.1:5432/saju_db?sslmode=disable</code></pre>
<p>재차 말하지만 학습 기록용이니까 업로드하지 <code>.env</code> 파일의 내용은 유출하는 거 아니다. 보안 이슈!</p>
<h2 id="db-설정">DB 설정</h2>
<h3 id="스키마">스키마</h3>
<p>이번 실습에서는 여러 개의 데이터베이스 테이블을 사용할 것이다.</p>
<p>기본적인 간지 정보를 담은 테이블의 스키마는 다음과 같다.</p>
<table>
<thead>
<tr>
<th align="center">변수명</th>
<th align="center">자료형</th>
<th align="center">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><code>code</code></td>
<td align="center">SMALLINT</td>
<td align="center">문자열로 관리하면 느리므로 천간(0<del>9), 지지(10</del>21)를 숫자로 관리하는 기본키</td>
</tr>
<tr>
<td align="center"><code>name_ko</code></td>
<td align="center">CHAR(1)</td>
<td align="center">천간 혹은 지지의 이름 (한글)</td>
</tr>
<tr>
<td align="center"><code>name_hani</code></td>
<td align="center">CHAR(1)</td>
<td align="center">천간 혹은 지지의 이름 (한자)</td>
</tr>
<tr>
<td align="center"><code>type</code></td>
<td align="center">TEXT</td>
<td align="center">이 글자가 천간인지 지지인지 여부</td>
</tr>
<tr>
<td align="center"><code>element</code></td>
<td align="center">element_type</td>
<td align="center">열거형 타입 <code>element_type</code> 을 정의하여 오행 표기</td>
</tr>
<tr>
<td align="center"><code>yin_yang</code></td>
<td align="center">yin_yang_type</td>
<td align="center">열거형 타입 <code>yin_yang_type</code> 을 정의하여 음양 표기</td>
</tr>
</tbody></table>
<p>각 글자의 고정적인 상호작용을 담은 테이블의 스키마는 다음과 같다.</p>
<p>(부모)</p>
<table>
<thead>
<tr>
<th align="center">변수명</th>
<th align="center">자료형</th>
<th align="center">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><code>id</code></td>
<td align="center">INTEGER</td>
<td align="center">상호작용을 식별하기 위한 기본키로, 유형에 따라 백의 자리 값 다르게 설정할 것이다.</td>
</tr>
<tr>
<td align="center"><code>category</code></td>
<td align="center">VARCHAR(20)</td>
<td align="center">상호작용 유형</td>
</tr>
</tbody></table>
<p>(자식1: 글자를 기준으로 그것이 가질 수 있는 상호작용 및 설명을 담고 있다.)</p>
<table>
<thead>
<tr>
<th align="center">변수명</th>
<th align="center">자료형</th>
<th align="center">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><code>id</code></td>
<td align="center">SERIAL</td>
<td align="center">1씩 증가하는 값으로 임의 설정된 기본키</td>
</tr>
<tr>
<td align="center"><code>code</code></td>
<td align="center">SMALLINT</td>
<td align="center">간지 정보 테이블의 기본키를 참조하여 나타낸 기준 글자</td>
</tr>
<tr>
<td align="center"><code>group_id</code></td>
<td align="center">INTEGER</td>
<td align="center">부모 테이블의 기본키를 참조하여 기준 글자가 가질 수 있는 관계 식별</td>
</tr>
<tr>
<td align="center"><code>description</code></td>
<td align="center">TEXT</td>
<td align="center">해당 상호작용에 대한 설명</td>
</tr>
</tbody></table>
<p>(자식2: 상호작용의 해석을 담고 있다.)</p>
<table>
<thead>
<tr>
<th align="center">변수명</th>
<th align="center">자료형</th>
<th align="center">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><code>id</code></td>
<td align="center">SERIAL</td>
<td align="center">1씩 증가하는 값으로 임의 설정된 기본키</td>
</tr>
<tr>
<td align="center"><code>group_id</code></td>
<td align="center">INTEGER</td>
<td align="center">부모 테이블의 기본키를 참조하여 기준 글자가 가질 수 있는 관계 식별</td>
</tr>
<tr>
<td align="center"><code>title</code></td>
<td align="center">VARCHAR(100)</td>
<td align="center">이 관계의 고유 명칭</td>
</tr>
<tr>
<td align="center"><code>keywords</code></td>
<td align="center">TEXT[]</td>
<td align="center">이 관계가 상징하는 핵심 에너지 목록</td>
</tr>
<tr>
<td align="center"><code>interpretation</code></td>
<td align="center">TEXT</td>
<td align="center">이 관계에 대한 인문학적 해석</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>관계 식별 예시</strong>
사주 팔자의 여덟 글자를 중복 없이 나열하여 관계를 확인한다.
관계의 유형별로 천간합은 100번대, 천간충은 200번대 등의 그룹 ID를 지정한다.
&#39;을&#39;이 2개, &#39;경&#39;이 1개 있었어도 여기서는 &#39;을&#39;과 &#39;경&#39;을 하나씩 확인하고
<code>(1, &#39;합&#39;, 102, &#39;을경합금&#39;)</code>, <code>(6, &#39;합&#39;, 102, &#39;을경합금&#39;)</code> 과 같이
&quot;2개가 만났을 때 합을 이루는 관계&quot;에 대해 2개의 값이 있을 경우 이 관계가 있다고 본다.
1대1이 아닌 &#39;을&#39; 2개와 &#39;경&#39; 1개가 만나는 상황을 쟁합(爭合)이라고 하는데
이런 부분에 대한 처리는 데이터베이스가 아닌 Rust 엔진에서 하도록 하겠다.
데이터베이스에서는 단지 관계의 존재 여부만 확인한다.</p>
</blockquote>
<p>개인의 사주 팔자가 담긴 사주 프로필 테이블의 스키마는 다음과 같다.</p>
<table>
<thead>
<tr>
<th align="center">변수명</th>
<th align="center">자료형</th>
<th align="center">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><code>id</code></td>
<td align="center">UUID</td>
<td align="center">자동으로 생성되는 고유 식별자로, 기본키로 사용된다.</td>
</tr>
<tr>
<td align="center"><code>name</code></td>
<td align="center">TEXT</td>
<td align="center">사주 팔자 주인의 이름</td>
</tr>
<tr>
<td align="center"><code>gender</code></td>
<td align="center">CHAR(1)</td>
<td align="center">출생 시 성별에 따라 대운의 방향이 다르므로 이분법적인 성별을 사용한다.</td>
</tr>
<tr>
<td align="center"><code>birth_date</code></td>
<td align="center">TIMESTAMPTZ</td>
<td align="center">사용자로부터 입력받은 생년월일시</td>
</tr>
<tr>
<td align="center"><code>is_solar</code></td>
<td align="center">BOOLEAN</td>
<td align="center">양력인지 여부로, <code>TRUE</code> 를 기본값으로 한다.</td>
</tr>
<tr>
<td align="center"><code>year_gan</code></td>
<td align="center">SMALLINT</td>
<td align="center">간지 정보 테이블의 기본키 <code>code</code> 를 참조하여 나타낸 연주의 천간</td>
</tr>
<tr>
<td align="center"><code>year_ji</code></td>
<td align="center">SMALLINT</td>
<td align="center">간지 정보 테이블의 기본키 <code>code</code> 를 참조하여 나타낸 연주의 지지</td>
</tr>
<tr>
<td align="center"><code>month_gan</code></td>
<td align="center">SMALLINT</td>
<td align="center">간지 정보 테이블의 기본키 <code>code</code> 를 참조하여 나타낸 월주의 천간</td>
</tr>
<tr>
<td align="center"><code>month_ji</code></td>
<td align="center">SMALLINT</td>
<td align="center">간지 정보 테이블의 기본키 <code>code</code> 를 참조하여 나타낸 월주의 지지</td>
</tr>
<tr>
<td align="center"><code>day_gan</code></td>
<td align="center">SMALLINT</td>
<td align="center">간지 정보 테이블의 기본키 <code>code</code> 를 참조하여 나타낸 일주의 천간</td>
</tr>
<tr>
<td align="center"><code>day_ji</code></td>
<td align="center">SMALLINT</td>
<td align="center">간지 정보 테이블의 기본키 <code>code</code> 를 참조하여 나타낸 일주의 지지</td>
</tr>
<tr>
<td align="center"><code>hour_gan</code></td>
<td align="center">SMALLINT</td>
<td align="center">간지 정보 테이블의 기본키 <code>code</code> 를 참조하여 나타낸 시주의 천간</td>
</tr>
<tr>
<td align="center"><code>hour_ji</code></td>
<td align="center">SMALLINT</td>
<td align="center">간지 정보 테이블의 기본키 <code>code</code> 를 참조하여 나타낸 시주의 지지</td>
</tr>
<tr>
<td align="center"><code>analysis_result</code></td>
<td align="center">JSONB</td>
<td align="center">Rust 엔진으로부터 전달받은 사주 분석 결과</td>
</tr>
<tr>
<td align="center"><code>created_at</code></td>
<td align="center">TIMESTAMPTZ</td>
<td align="center">데이터를 저장한 시점에 자동으로 작성되는 타임스탬프</td>
</tr>
<tr>
<td align="center"><code>updated_d_at</code></td>
<td align="center">TIMESTAMPTZ</td>
<td align="center">데이터를 수정한 시점에 자동으로 변경되는 타임스탬프</td>
</tr>
</tbody></table>
<p>이 데이터베이스 테이블들이 존재하지 않는 경우에만
새로 생성하는 코드를 작성해 보자.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/saju-analysis$ mkdir scripts
~/workspace/saju-analysis$ vi scripts/init.sql</code></pre>
<blockquote>
<p><code>scripts/init.sql</code></p>
</blockquote>
<pre><code class="language-sql">-- 간지 정보
&gt;
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = &#39;element_type&#39;) THEN
        CREATE TYPE element_type AS ENUM (&#39;목&#39;, &#39;화&#39;, &#39;토&#39;, &#39;금&#39;, &#39;수&#39;);
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = &#39;yin_yang_type&#39;) THEN
        CREATE TYPE yin_yang_type AS ENUM (&#39;음&#39;, &#39;양&#39;);
    END IF;
END $$;
&gt;
CREATE TABLE IF NOT EXISTS ganji_metadata (
    code SMALLINT PRIMARY KEY, -- 천간: 0~9, 지지: 10-21
    name_ko CHAR(1) NOT NULL,
    name_hani CHAR(1) NOT NULL,
    type TEXT CHECK (type IN (&#39;천간&#39;, &#39;지지&#39;)),
    element element_type NOT NULL,
    yin_yang yin_yang_type NOT NULL
);
&gt;
-- 정적 관계
&gt;
CREATE TABLE IF NOT EXISTS interaction_groups (
    id INTEGER PRIMARY KEY,
    category VARCHAR(20) NOT NULL
);
&gt;
CREATE TABLE IF NOT EXISTS ganji_interaction (
    id SERIAL PRIMARY KEY,
    code SMALLINT REFERENCES ganji_metadata(code) ON DELETE CASCADE,
    group_id INTEGER REFERENCES interaction_groups(id) ON DELETE CASCADE,
    description TEXT
);
&gt;
CREATE TABLE IF NOT EXISTS ganji_interaction (
    id SERIAL PRIMARY KEY,
    group_id INTEGER UNIQUE REFERENCES interaction_groups(id) ON DELETE CASCADE,
    title VARCHAR(100) NOT NULL,
    keyword TEXT[] DEFAULT &#39;{}&#39;,
    interpretation TEXT NOT NULL
);
&gt;
-- 사주 팔자
&gt;
CREATE TABLE IF NOT EXISTS saju_profiles (
    id UUID PRIMARY KEY DEFAULT uuidv7(),
    name TEXT NOT NULL,
    gender CHAR(1) CHECK (gender IN (&#39;M&#39;, &#39;F&#39;)),
    birth_date TIMESTAMPTZ NOT NULL,
    is_solar BOOLEAN DEFAULT TRUE,
&gt;
    year_gan SMALLINT REFERENCES ganji_metadata(code),
    year_ji SMALLINT REFERENCES ganji_metadata(code),
    month_gan SMALLINT REFERENCES ganji_metadata(code),
    month_ji SMALLINT REFERENCES ganji_metadata(code),
    day_gan SMALLINT REFERENCES ganji_metadata(code),
    dayr_ji SMALLINT REFERENCES ganji_metadata(code),
    hour_gan SMALLINT REFERENCES ganji_metadata(code),
    hour_ji SMALLINT REFERENCES ganji_metadata(code),
&gt;
    analysis_result JSONB DEFAULT &#39;{}&#39;,
&gt;
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);
&gt;
CREATE INDEX IF NOT EXISTS idx_saju_birth_date ON saju_profiles(birth_date);
CREATE INDEX IF NOT EXISTS idx_saju_analysis_result ON saju_profiles USING GIN (analysis_result);</code></pre>
<p>파일을 생성하지 않고 터미널에서 직접 쿼리를 날려도 되지만
따로 파일을 작성하는 편이 유지보수 측면에서 효과적이다.</p>
<h3 id="docker">docker</h3>
<p>PostgreSQL을 docker 서비스로 실행한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/saju-analysis$ docker compose up -d
~/workspace/saju-analysis$ docker ps
CONTAINER ID   IMAGE                COMMAND                   CREATED         STATUS         PORTS                                         NAMES
0e23bd848f7a   dpage/pgadmin4       &quot;/entrypoint.sh&quot;          8 seconds ago   Up 8 seconds   0.0.0.0:8080-&gt;80/tcp, [::]:8080-&gt;80/tcp       pgadmin_ui
42f80ebad4a1   postgres:18-alpine   &quot;docker-entrypoint.s…&quot;   8 seconds ago   Up 8 seconds   0.0.0.0:5432-&gt;5432/tcp, [::]:5432-&gt;5432/tcp   saju-analysis_postgres</code></pre>
<p>작성해 놓은 데이터베이스 생성 쿼리 파일을 전달한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/saju-analysis$ docker exec -i saju-analysis_postgres psql -U peter -d saju_db &lt; scripts/init.sql</code></pre>
<p>다음과 같이 데이터베이스 테이블이 생성된 것을 확인할 수 있다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/image-assets-management$ docker exec -it saju-analysis_postgres psql -U peter -d saju_db -c &quot;\dt&quot;
&gt;
                List of tables
 Schema |         Name         | Type  | Owner 
--------+----------------------+-------+-------
 public | ganji_interaction    | table | peter
 public | ganji_metadata       | table | peter
 public | interaction_groups   | table | peter
 public | interaction_metadata | table | peter
 public | saju_profiles        | table | peter
(5 rows)</code></pre>
<h3 id="데이터-삽입">데이터 삽입</h3>
<p>간지 정보는 변하지 않는 값이니 스크립트에 적어 미리 넣어둔다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/saju-analysis$ vi scripts/insert_saju_metadata.sql</code></pre>
<blockquote>
<p><code>scripts/insert_saju_metadata.sql</code></p>
</blockquote>
<pre><code class="language-sql">INSERT INTO ganji_metadata (code, name_ko, name_hani, type, element, yin_yang) VALUES
-- 천간 (0~9)
(0, &#39;갑&#39;, &#39;甲&#39;, &#39;천간&#39;, &#39;목&#39;, &#39;양&#39;),
(1, &#39;을&#39;, &#39;乙&#39;, &#39;천간&#39;, &#39;목&#39;, &#39;음&#39;),
(2, &#39;병&#39;, &#39;丙&#39;, &#39;천간&#39;, &#39;화&#39;, &#39;양&#39;),
(3, &#39;정&#39;, &#39;丁&#39;, &#39;천간&#39;, &#39;화&#39;, &#39;음&#39;),
(4, &#39;무&#39;, &#39;戊&#39;, &#39;천간&#39;, &#39;토&#39;, &#39;양&#39;),
(5, &#39;기&#39;, &#39;己&#39;, &#39;천간&#39;, &#39;토&#39;, &#39;음&#39;),
(6, &#39;경&#39;, &#39;庚&#39;, &#39;천간&#39;, &#39;금&#39;, &#39;양&#39;),
(7, &#39;신&#39;, &#39;辛&#39;, &#39;천간&#39;, &#39;금&#39;, &#39;음&#39;),
(8, &#39;임&#39;, &#39;壬&#39;, &#39;천간&#39;, &#39;수&#39;, &#39;양&#39;),
(9, &#39;계&#39;, &#39;癸&#39;, &#39;천간&#39;, &#39;수&#39;, &#39;음&#39;),
-- 지지 (10~21)
(10, &#39;자&#39;, &#39;子&#39;, &#39;지지&#39;, &#39;수&#39;, &#39;양&#39;),
(11, &#39;축&#39;, &#39;丑&#39;, &#39;지지&#39;, &#39;토&#39;, &#39;음&#39;),
(12, &#39;인&#39;, &#39;寅&#39;, &#39;지지&#39;, &#39;목&#39;, &#39;양&#39;),
(13, &#39;묘&#39;, &#39;卯&#39;, &#39;지지&#39;, &#39;목&#39;, &#39;음&#39;),
(14, &#39;진&#39;, &#39;辰&#39;, &#39;지지&#39;, &#39;토&#39;, &#39;양&#39;),
(15, &#39;사&#39;, &#39;巳&#39;, &#39;지지&#39;, &#39;화&#39;, &#39;음&#39;),
(16, &#39;오&#39;, &#39;午&#39;, &#39;지지&#39;, &#39;화&#39;, &#39;양&#39;),
(17, &#39;미&#39;, &#39;未&#39;, &#39;지지&#39;, &#39;토&#39;, &#39;음&#39;),
(18, &#39;신&#39;, &#39;申&#39;, &#39;지지&#39;, &#39;금&#39;, &#39;양&#39;),
(19, &#39;유&#39;, &#39;酉&#39;, &#39;지지&#39;, &#39;금&#39;, &#39;음&#39;),
(20, &#39;술&#39;, &#39;戌&#39;, &#39;지지&#39;, &#39;토&#39;, &#39;양&#39;),
(21, &#39;해&#39;, &#39;亥&#39;, &#39;지지&#39;, &#39;수&#39;, &#39;음&#39;);</code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/saju-analysis$ docker exec -i saju-analysis_postgres psql -U peter -d saju_db &lt; scripts/insert_ganji_metadata.sql</code></pre>
<p>관계에 대해서도 마찬가지로 스크립트로 넣어둔다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/saju-analysis$ vi scripts/insert_saju_interaction.sql</code></pre>
<blockquote>
<p><code>scripts/insert_saju_interaction.sql</code></p>
</blockquote>
<pre><code class="language-sql">INSERT INTO interaction_groups (id, category) VALUES
(100, &#39;천간합&#39;), (200, &#39;천간충&#39;),
(300, &#39;지지육합&#39;), (400, &#39;지지삼합&#39;), (500, &#39;지지방합&#39;),
(600, &#39;지지육충&#39;), (700, &#39;지지형&#39;), (800, &#39;지지파&#39;), (900, &#39;지지해&#39;);
&gt;
-- 세부 그룹 ID (상속 관계 시각화용)
INSERT INTO interaction_groups (id, category) VALUES
(101, &#39;천간합&#39;), (102, &#39;천간합&#39;), (103, &#39;천간합&#39;), (104, &#39;천간합&#39;), (105, &#39;천간합&#39;),
(201, &#39;천간충&#39;), (202, &#39;천간충&#39;), (203, &#39;천간충&#39;), (204, &#39;천간충&#39;),
(301, &#39;지지육합&#39;), (302, &#39;지지육합&#39;), (303, &#39;지지육합&#39;), (304, &#39;지지육합&#39;), (305, &#39;지지육합&#39;), (306, &#39;지지육합&#39;),
(401, &#39;지지삼합&#39;), (402, &#39;지지삼합&#39;), (403, &#39;지지삼합&#39;), (404, &#39;지지삼합&#39;),
(501, &#39;지지방합&#39;), (502, &#39;지지방합&#39;), (503, &#39;지지방합&#39;), (504, &#39;지지방합&#39;),
(601, &#39;지지육충&#39;), (602, &#39;지지육충&#39;), (603, &#39;지지육충&#39;), (604, &#39;지지육충&#39;), (605, &#39;지지육충&#39;), (606, &#39;지지육충&#39;),
(701, &#39;지지형&#39;), (702, &#39;지지형&#39;), (703, &#39;지지형&#39;),
(801, &#39;지지파&#39;), (802, &#39;지지파&#39;), (803, &#39;지지파&#39;), (804, &#39;지지파&#39;), (805, &#39;지지파&#39;), (806, &#39;지지파&#39;),
(901, &#39;지지해&#39;), (902, &#39;지지해&#39;), (903, &#39;지지해&#39;), (904, &#39;지지해&#39;), (905, &#39;지지해&#39;), (906, &#39;지지해&#39;);
&gt;
INSERT INTO ganji_interaction (code, group_id, description) VALUES
-- 천간합
(0, 101, &#39;갑기합토&#39;), (5, 101, &#39;갑기합토&#39;),
(1, 102, &#39;을경합금&#39;), (6, 102, &#39;을경합금&#39;),
(2, 103, &#39;병신합수&#39;), (7, 103, &#39;병신합수&#39;),
(3, 104, &#39;정임합목&#39;), (8, 104, &#39;정임합목&#39;),
(4, 105, &#39;무계합화&#39;), (9, 105, &#39;무계합화&#39;),
-- 천간충
(0, 201, &#39;갑경충&#39;), (6, 201, &#39;갑경충&#39;),
(1, 202, &#39;을신충&#39;), (7, 202, &#39;을신충&#39;),
(2, 203, &#39;병임충&#39;), (8, 203, &#39;병임충&#39;),
(3, 204, &#39;정계충&#39;), (9, 204, &#39;정계충&#39;),
-- 지지육합
(10, 301, &#39;자축합토&#39;), (11, 301, &#39;자축합토&#39;),
(12, 302, &#39;인해합목&#39;), (21, 302, &#39;인해합목&#39;),
(13, 303, &#39;묘술합화&#39;), (20, 303, &#39;묘술합화&#39;),
(14, 304, &#39;진유합금&#39;), (19, 304, &#39;진유합금&#39;),
(15, 305, &#39;사신합수&#39;), (18, 305, &#39;사신합수&#39;),
(16, 306, &#39;오미합화&#39;), (17, 306, &#39;오미합화&#39;),
-- 지지삼합
(21, 401, &#39;해묘미&#39;), (13, 401, &#39;해묘미&#39;), (17, 401, &#39;해묘미&#39;),
(12, 402, &#39;인오술&#39;), (16, 402, &#39;인오술&#39;), (20, 402, &#39;인오술&#39;),
(15, 403, &#39;사유축&#39;), (19, 403, &#39;사유축&#39;), (11, 403, &#39;사유축&#39;),
(18, 404, &#39;신자진&#39;), (10, 404, &#39;신자진&#39;), (14, 404, &#39;신자진&#39;),
-- 지지방합
(12, 501, &#39;인묘진&#39;), (13, 501, &#39;인묘진&#39;), (14, 501, &#39;인묘진&#39;),
(15, 502, &#39;사오미&#39;), (16, 502, &#39;사오미&#39;), (17, 502, &#39;사오미&#39;),
(18, 503, &#39;신유술&#39;), (19, 503, &#39;신유술&#39;), (20, 503, &#39;신유술&#39;),
(21, 504, &#39;해자축&#39;), (10, 504, &#39;해자축&#39;), (11, 504, &#39;해자축&#39;),
-- 지지육충
(10, 601, &#39;자오충&#39;), (16, 601, &#39;자오충&#39;),
(11, 602, &#39;축미충&#39;), (17, 602, &#39;축미충&#39;),
(12, 603, &#39;인신충&#39;), (18, 603, &#39;인신충&#39;),
(13, 604, &#39;묘유충&#39;), (19, 604, &#39;묘유충&#39;),
(14, 605, &#39;진술충&#39;), (20, 605, &#39;진술충&#39;),
(15, 606, &#39;사해충&#39;), (21, 606, &#39;사해충&#39;),
-- 지지형
(12, 701, &#39;인사신 삼형&#39;), (15, 701, &#39;인사신 삼형&#39;), (18, 701, &#39;인사신 삼형&#39;),
(11, 702, &#39;축술미 삼형&#39;), (20, 702, &#39;축술미 삼형&#39;), (17, 702, &#39;축술미 삼형&#39;),
(10, 703, &#39;자묘형&#39;), (13, 703, &#39;자묘형&#39;),
-- 지지파
(10, 801, &#39;자유파&#39;), (19, 801, &#39;자유파&#39;),
(11, 802, &#39;축진파&#39;), (14, 802, &#39;축진파&#39;),
(12, 803, &#39;인해파&#39;), (21, 803, &#39;인해파&#39;),
(13, 804, &#39;묘오파&#39;), (16, 804, &#39;묘오파&#39;),
(15, 805, &#39;사신파&#39;), (18, 805, &#39;사신파&#39;),
(20, 806, &#39;술미파&#39;), (17, 806, &#39;술미파&#39;),
-- 지지해
(10, 901, &#39;자미해&#39;), (17, 901, &#39;자미해&#39;),
(11, 902, &#39;축오해&#39;), (16, 902, &#39;축오해&#39;),
(12, 903, &#39;인사해&#39;), (15, 903, &#39;인사해&#39;),
(13, 904, &#39;묘진해&#39;), (14, 904, &#39;묘진해&#39;),
(18, 905, &#39;신해해&#39;), (21, 905, &#39;신해해&#39;),
(19, 906, &#39;유술해&#39;), (20, 906, &#39;유술해&#39;);
&gt;
INSERT INTO interaction_metadata (group_id, title, keyword, interpretation) VALUES
-- 천간합
(101, &#39;갑기합(甲己合): 중정의 합&#39;, ARRAY[&#39;신뢰&#39;, &#39;중심&#39;, &#39;안정&#39;], &#39;강직한 갑목과 포용력 있는 기토가 만나 흔들림 없는 중심을 잡고 신뢰를 형성합니다.&#39;),
(102, &#39;을경합(乙庚合): 인의의 합&#39;, ARRAY[&#39;정의&#39;, &#39;결속&#39;, &#39;의리&#39;], &#39;유연한 을목과 강직한 경금이 만나 서로를 보완하며 강한 결속력과 의리를 발휘합니다.&#39;),
(103, &#39;병신합(丙辛합): 위제의 합&#39;, ARRAY[&#39;변화&#39;, &#39;냉정&#39;, &#39;권위&#39;], &#39;뜨거운 병화와 차가운 신금이 만나 매서운 위엄을 갖추며 새로운 기운으로 변화합니다.&#39;),
(104, &#39;정임합(丁壬合): 인수(淫)의 합&#39;, ARRAY[&#39;다정&#39;, &#39;생산&#39;, &#39;예술&#39;], &#39;따뜻한 정화와 넓은 임수가 만나 다정다감한 기운을 만들며 창의적인 생산력을 발휘합니다.&#39;),
(105, &#39;무계합(戊癸合): 무정의 합&#39;, ARRAY[&#39;냉정&#39;, &#39;지혜&#39;, &#39;현실&#39;], &#39;무게감 있는 무토와 흐르는 계수가 만나 감정보다는 현실적인 지혜와 효율을 추구합니다.&#39;),
-- 천간충
(201, &#39;갑경충(甲庚沖): 정면충돌&#39;, ARRAY[&#39;결단&#39;, &#39;충격&#39;, &#39;변화&#39;], &#39;시작의 기운과 결실의 기운이 부딪혀 삶의 큰 방향 전환과 결단이 필요한 상황을 만듭니다.&#39;),
(202, &#39;을신충(乙辛沖): 날카로운 대립&#39;, ARRAY[&#39;예민&#39;, &#39;스트레스&#39;, &#39;섬세&#39;], &#39;부드러운 초목이 날카로운 칼날을 만난 격으로, 신경이 예민해지기 쉬우나 섬세한 감각을 발휘합니다.&#39;),
(203, &#39;병임충(丙壬沖): 수화기제&#39;, ARRAY[&#39;폭발&#39;, &#39;충돌&#39;, &#39;창의&#39;], &#39;거대한 빛과 넓은 물이 부딪혀 역동적인 변화를 일으키며 강렬한 에너지를 분출합니다.&#39;),
(204, &#39;정계충(丁癸沖): 감정의 격변&#39;, ARRAY[&#39;민감&#39;, &#39;변화&#39;, &#39;갈등&#39;], &#39;작은 불씨와 빗물이 만나듯이 작은 자극에도 감정의 동요가 크고 세심한 조율이 필요합니다.&#39;),
-- 지지육합
(301, &#39;자축합(子丑合): 비밀스러운 결속&#39;, ARRAY[&#39;협력&#39;, &#39;신중&#39;, &#39;안정&#39;], &#39;추운 겨울의 기운들이 만나 겉으로 드러나지 않는 끈끈한 유대감과 안정감을 형성합니다.&#39;),
(302, &#39;인해합(寅亥合): 성장의 동력&#39;, ARRAY[&#39;생동&#39;, &#39;시작&#39;, &#39;활력&#39;], &#39;큰 물이 나무를 생하며 합하는 구조로, 끊임없는 생명력과 추진력을 만들어냅니다.&#39;),
(303, &#39;묘술합(卯戌合): 실리적인 결합&#39;, ARRAY[&#39;현실&#39;, &#39;안정&#39;, &#39;타협&#39;], &#39;부드러운 기운과 마른 땅이 만나 현실적인 이익을 추구하며 안정적인 관계를 맺습니다.&#39;),
(304, &#39;진유합(辰酉合): 견고한 결실&#39;, ARRAY[&#39;완성&#39;, &#39;명예&#39;, &#39;단단함&#39;], &#39;습한 기운이 금기를 생하여 더욱 단단하게 만드니 결과물이 명확하고 자부심이 강해집니다.&#39;),
(305, &#39;사신합(巳申合): 변화의 합&#39;, ARRAY[&#39;복합&#39;, &#39;재주&#39;, &#39;변동&#39;], &#39;불과 금이 만나 합을 이루나 속에는 형(刑)의 기운이 있어 복잡한 변화와 재주를 발휘합니다.&#39;),
(306, &#39;오미합(午未合): 열정의 결집&#39;, ARRAY[&#39;뜨거움&#39;, &#39;집중&#39;, &#39;팽창&#39;], &#39;여름의 절정 기운들이 만나 에너지의 밀도가 매우 높으며 열정적인 결과를 지향합니다.&#39;),
-- 지지삼합
(401, &#39;해묘미(亥卯未) 삼합: 목(木)의 국&#39;, ARRAY[&#39;성장&#39;, &#39;창작&#39;, &#39;협동&#39;], &#39;다른 성질의 기운들이 모여 거대한 성장의 기류를 형성하고 공동의 목적을 달성합니다.&#39;),
(402, &#39;인오술(寅午戌) 삼합: 화(火)의 국&#39;, ARRAY[&#39;발산&#39;, &#39;열정&#39;, &#39;화려&#39;], &#39;에너지가 외부로 강력하게 분출되며 화려한 성과와 강한 행동력을 보여줍니다.&#39;),
(403, &#39;사유축(巳酉丑) 삼합: 금(金)의 국&#39;, ARRAY[&#39;결실&#39;, &#39;수렴&#39;, &#39;엄격&#39;], &#39;단단하게 굳어지는 기운으로 목표 중심적이며 결과에 대한 집요함이 강합니다.&#39;),
(404, &#39;신자진(申子辰) 삼합: 수(水)의 국&#39;, ARRAY[&#39;지혜&#39;, &#39;유연&#39;, &#39;깊이&#39;], &#39;유연하게 흐르는 거대한 물줄기를 형성하여 지적 깊이와 탁월한 적응력을 발휘합니다.&#39;),
-- 지지방합
(501, &#39;인묘진(寅卯辰) 방합: 봄의 세력&#39;, ARRAY[&#39;생동&#39;, &#39;추진&#39;, &#39;동료&#39;], &#39;계절적 동질성을 가진 강력한 목기가 형성되어 거침없는 시작과 추진력을 보여줍니다.&#39;),
(502, &#39;사오미(巳午未) 방합: 여름의 세력&#39;, ARRAY[&#39;팽창&#39;, &#39;강렬&#39;, &#39;확산&#39;], &#39;극강의 화기가 형성되어 자신의 영역을 넓히고 중심에 서려는 욕구가 강해집니다.&#39;),
(503, &#39;신유술(申酉戌) 방합: 가을의 세력&#39;, ARRAY[&#39;견고&#39;, &#39;냉철&#39;, &#39;정리&#39;], &#39;날카로운 금기가 모여 불필요한 것을 정리하고 결과물의 밀도를 높이는 힘이 강력합니다.&#39;),
(504, &#39;해자축(亥子丑) 방합: 겨울의 세력&#39;, ARRAY[&#39;저장&#39;, &#39;침잠&#39;, &#39;지력&#39;], &#39;차가운 수기가 결집하여 내면의 세계를 탐구하고 지식을 저장하는 힘이 탁월합니다.&#39;),
-- 지지육충
(601, &#39;자오충(子午沖): 자존심의 충돌&#39;, ARRAY[&#39;폭발&#39;, &#39;극단&#39;, &#39;예민&#39;], &#39;정반대의 강한 에너지가 부딪혀 감정의 변화가 크고 환경의 변동이 잦을 수 있습니다.&#39;),
(602, &#39;축미충(丑未沖): 터전의 변동&#39;, ARRAY[&#39;조정&#39;, &#39;불화&#39;, &#39;이동&#39;], &#39;단단한 흙들이 부딪혀 기반이 흔들리는 형상으로, 환경적인 변화나 조정 과정을 겪습니다.&#39;),
(603, &#39;인신충(寅申沖): 역마의 정면충돌&#39;, ARRAY[&#39;활동&#39;, &#39;변화&#39;, &#39;속도&#39;], &#39;시작과 결실의 기운이 부딪혀 매우 활동적이며 거주지나 직업의 변동이 잦을 수 있습니다.&#39;),
(604, &#39;묘유충(卯酉沖): 상처와 대립&#39;, ARRAY[&#39;민감&#39;, &#39;분리&#39;, &#39;충격&#39;], &#39;부드러운 나무와 날카로운 칼날이 부딪혀 대인관계의 소외감이나 건강상 주의가 필요합니다.&#39;),
(605, &#39;진술충(辰戌沖): 영적인 충돌&#39;, ARRAY[&#39;복잡&#39;, &#39;변화&#39;, &#39;충돌&#39;], &#39;거대한 기운들이 부딪혀 정신적인 혼란이나 예기치 못한 환경 변화를 가져옵니다.&#39;),
(606, &#39;사해충(巳亥沖): 정보와 이동&#39;, ARRAY[&#39;변동&#39;, &#39;마찰&#39;, &#39;속도&#39;], &#39;지식과 활동의 기운이 부딪혀 소통 과정의 마찰이나 잦은 이동수가 발생합니다.&#39;),
-- 지지형
(701, &#39;인사신(寅巳申) 삼형: 권력의 제어&#39;, ARRAY[&#39;조절&#39;, &#39;수술&#39;, &#39;기술&#39;], &#39;강력한 세 기운이 얽혀 시시비비를 가려야 하니, 전문적인 제어 기술이나 리더십이 요구됩니다.&#39;),
(702, &#39;축술미(丑戌未) 삼형: 무은의 형&#39;, ARRAY[&#39;배신&#39;, &#39;시비&#39;, &#39;조율&#39;], &#39;가까운 관계에서의 시비나 배신수가 있을 수 있으니 철저한 문서 관리와 조율이 필요합니다.&#39;),
(703, &#39;자묘형(子卯刑): 무례의 형&#39;, ARRAY[&#39;구설&#39;, &#39;불화&#39;, &#39;무례&#39;], &#39;생(生)하는 관계임에도 예의에 어긋나는 상황이 생길 수 있어 대인관계의 주의가 필요합니다.&#39;),
-- 지지파
(801, &#39;자유파(子酉破): 조율의 마찰&#39;, ARRAY[&#39;분열&#39;, &#39;파괴&#39;, &#39;수정&#39;], &#39;서로 다른 목적이 부딪혀 진행 중인 일이 일시적으로 중단되거나 수정이 필요할 수 있습니다.&#39;),
(802, &#39;축진파(丑辰破): 내부적 균열&#39;, ARRAY[&#39;미묘&#39;, &#39;불편&#39;, &#39;조정&#39;], &#39;비슷한 흙들이 서로를 밀어내며 내부적인 갈등이나 건강상의 미묘한 문제를 일으킵니다.&#39;),
(803, &#39;인해파(寅亥破): 합 뒤의 균열&#39;, ARRAY[&#39;배신&#39;, &#39;마찰&#39;, &#39;변심&#39;], &#39;합을 이루었으나 내부에 균열이 있으니 다 된 밥에 코 빠뜨리는 격으로 마무리에 주의해야 합니다.&#39;),
(804, &#39;묘오파(卯午破): 열정의 과부하&#39;, ARRAY[&#39;손상&#39;, &#39;충돌&#39;, &#39;과열&#39;], &#39;목기가 화기를 너무 급하게 생하여 에너지가 소진되거나 마찰이 생길 수 있습니다.&#39;),
(805, &#39;사신파(巳申破): 기술적 수정&#39;, ARRAY[&#39;복잡&#39;, &#39;수리&#39;, &#39;변동&#39;], &#39;변화가 많은 합 속에서 마찰이 생겨 지속적인 수리와 조정이 필요한 구조입니다.&#39;),
(806, &#39;술미파(戌未破): 기반의 손상&#39;, ARRAY[&#39;조정&#39;, &#39;불편&#39;, &#39;변화&#39;], &#39;건조한 흙들이 부딪혀 실질적인 기반이 약해지거나 관계의 소외감이 발생합니다.&#39;),
-- 지지해
(901, &#39;자미해(子未害): 감정의 앙금&#39;, ARRAY[&#39;질투&#39;, &#39;원망&#39;, &#39;방해&#39;], &#39;서로의 합을 방해하는 관계라 예기치 못한 원망이나 질투로 인한 마찰이 생기기 쉽습니다.&#39;),
(902, &#39;축오해(丑午害): 열정의 동결&#39;, ARRAY[&#39;냉담&#39;, &#39;방해&#39;, &#39;불신&#39;], &#39;뜨거운 열정을 차가운 기운이 방해하니 의욕이 꺾이거나 주변의 시기 질투를 받습니다.&#39;),
(903, &#39;인사해(寅巳害): 소통의 단절&#39;, ARRAY[&#39;오해&#39;, &#39;방해&#39;, &#39;충격&#39;], &#39;강한 추진력들이 서로를 방해하여 소통 과정에서 오해가 생기고 진행이 더뎌집니다.&#39;),
(904, &#39;묘진해(卯辰害): 성장판의 위축&#39;, ARRAY[&#39;침해&#39;, &#39;방해&#39;, &#39;시기&#39;], &#39;부드러운 목기와 습한 토가 만나 자존심 대결을 벌이며 서로의 성장을 미묘하게 방해합니다.&#39;),
(905, &#39;신해해(申亥害): 결실의 훼손&#39;, ARRAY[&#39;방해&#39;, &#39;변동&#39;, &#39;마찰&#39;], &#39;결과를 맺으려는 금기와 시작하려는 수기가 부딪혀 마무리 단계에서 장애가 생길 수 있습니다.&#39;),
(906, &#39;유술해(酉戌해): 날카로운 대립&#39;, ARRAY[&#39;질투&#39;, &#39;마찰&#39;, &#39;분열&#39;], &#39;날카로운 결실들이 만나 서로를 시기하니 대인관계의 소외감이나 스트레스에 주의해야 합니다.&#39;);</code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/saju-analysis$ docker exec -i saju-analysis_postgres psql -U peter -d saju_db &lt; scripts/insert_ganji_interaction.sql</code></pre>
<h3 id="데이터-확인">데이터 확인</h3>
<p>데이터가 제대로 들어갔는지 확인한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/saju-analysis$ docker exec -it saju-analysis_postgres psql -U peter -d saju_db -c &quot;SELECT * FROM ganji_metadata;&quot;             
 code | name_ko | name_hani | type | element | yin_yang 
------+---------+-----------+------+---------+----------
    0 | 갑      | 甲        | 천간 | 목      | 양
    1 | 을      | 乙        | 천간 | 목      | 음
    2 | 병      | 丙        | 천간 | 화      | 양
    3 | 정      | 丁        | 천간 | 화      | 음
    4 | 무      | 戊        | 천간 | 토      | 양
    5 | 기      | 己        | 천간 | 토      | 음
    6 | 경      | 庚        | 천간 | 금      | 양
    7 | 신      | 辛        | 천간 | 금      | 음
    8 | 임      | 壬        | 천간 | 수      | 양
    9 | 계      | 癸        | 천간 | 수      | 음
   10 | 자      | 子        | 지지 | 수      | 양
   11 | 축      | 丑        | 지지 | 토      | 음
   12 | 인      | 寅        | 지지 | 목      | 양
   13 | 묘      | 卯        | 지지 | 목      | 음
   14 | 진      | 辰        | 지지 | 토      | 양
   15 | 사      | 巳        | 지지 | 화      | 음
   16 | 오      | 午        | 지지 | 화      | 양
   17 | 미      | 未        | 지지 | 토      | 음
   18 | 신      | 申        | 지지 | 금      | 양
   19 | 유      | 酉        | 지지 | 금      | 음
   20 | 술      | 戌        | 지지 | 토      | 양
   21 | 해      | 亥        | 지지 | 수      | 음
(22 rows)</code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/saju-analysis$ docker exec -it saju-analysis_postgres psql -U peter -d saju_db -c &quot;SELECT * FROM interaction_groups;&quot;
 id  | category 
-----+----------
 100 | 천간합
 200 | 천간충
 300 | 지지육합
 400 | 지지삼합
 500 | 지지방합
 600 | 지지육충
 700 | 지지형
 800 | 지지파
 900 | 지지해
 101 | 천간합
 102 | 천간합
 103 | 천간합
 104 | 천간합                                           
 105 | 천간합
 201 | 천간충
 202 | 천간충
 203 | 천간충
 204 | 천간충
 301 | 지지육합
 302 | 지지육합
 303 | 지지육합
 304 | 지지육합
 305 | 지지육합
 306 | 지지육합
 401 | 지지삼합
 402 | 지지삼합
 403 | 지지삼합
 404 | 지지삼합
 501 | 지지방합
 502 | 지지방합
 503 | 지지방합
 504 | 지지방합
 601 | 지지육충
 602 | 지지육충
 603 | 지지육충
 604 | 지지육충
 605 | 지지육충
 606 | 지지육충
 701 | 지지형
 702 | 지지형
 703 | 지지형
 801 | 지지파
 802 | 지지파
 803 | 지지파
 804 | 지지파
 805 | 지지파
 806 | 지지파
 901 | 지지해
 902 | 지지해
 903 | 지지해
 904 | 지지해
 905 | 지지해
 906 | 지지해
(53 rows)</code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/saju-analysis$ docker exec -it saju-analysis_postgres psql -U peter -d saju_db -c &quot;SELECT * FROM ganji_interaction;&quot;
 id | code | group_id | description 
----+------+----------+-------------
  1 |    0 |      101 | 갑기합토
  2 |    5 |      101 | 갑기합토
  3 |    1 |      102 | 을경합금
  4 |    6 |      102 | 을경합금
  5 |    2 |      103 | 병신합수
  6 |    7 |      103 | 병신합수
  7 |    3 |      104 | 정임합목
  8 |    8 |      104 | 정임합목
  9 |    4 |      105 | 무계합화
 10 |    9 |      105 | 무계합화
 11 |    0 |      201 | 갑경충
 12 |    6 |      201 | 갑경충
 13 |    1 |      202 | 을신충
 14 |    7 |      202 | 을신충
 15 |    2 |      203 | 병임충
 16 |    8 |      203 | 병임충
 17 |    3 |      204 | 정계충
 18 |    9 |      204 | 정계충
 19 |   10 |      301 | 자축합토
 20 |   11 |      301 | 자축합토
 21 |   12 |      302 | 인해합목
 22 |   21 |      302 | 인해합목
 23 |   13 |      303 | 묘술합화
 24 |   20 |      303 | 묘술합화
 25 |   14 |      304 | 진유합금
 26 |   19 |      304 | 진유합금
 27 |   15 |      305 | 사신합수
 28 |   18 |      305 | 사신합수
 29 |   16 |      306 | 오미합화
 30 |   17 |      306 | 오미합화
 31 |   21 |      401 | 해묘미
 32 |   13 |      401 | 해묘미
 33 |   17 |      401 | 해묘미
 34 |   12 |      402 | 인오술
 35 |   16 |      402 | 인오술
 36 |   20 |      402 | 인오술
 37 |   15 |      403 | 사유축
 38 |   19 |      403 | 사유축
 39 |   11 |      403 | 사유축
 40 |   18 |      404 | 신자진
 41 |   10 |      404 | 신자진
 42 |   14 |      404 | 신자진
 43 |   12 |      501 | 인묘진
 44 |   13 |      501 | 인묘진
 45 |   14 |      501 | 인묘진
 46 |   15 |      502 | 사오미
 47 |   16 |      502 | 사오미
 48 |   17 |      502 | 사오미
 49 |   18 |      503 | 신유술
 50 |   19 |      503 | 신유술
 51 |   20 |      503 | 신유술
 52 |   21 |      504 | 해자축
 53 |   10 |      504 | 해자축
 54 |   11 |      504 | 해자축
 55 |   10 |      601 | 자오충
 56 |   16 |      601 | 자오충
 57 |   11 |      602 | 축미충
 58 |   17 |      602 | 축미충
 59 |   12 |      603 | 인신충
 60 |   18 |      603 | 인신충
 61 |   13 |      604 | 묘유충
 62 |   19 |      604 | 묘유충
 63 |   14 |      605 | 진술충
 64 |   20 |      605 | 진술충
 65 |   15 |      606 | 사해충
 66 |   21 |      606 | 사해충
 67 |   12 |      701 | 인사신 삼형
 68 |   15 |      701 | 인사신 삼형
 69 |   18 |      701 | 인사신 삼형
 70 |   11 |      702 | 축술미 삼형
 71 |   20 |      702 | 축술미 삼형
 72 |   17 |      702 | 축술미 삼형
 73 |   10 |      703 | 자묘형
 74 |   13 |      703 | 자묘형
 75 |   10 |      801 | 자유파
 76 |   19 |      801 | 자유파
 77 |   11 |      802 | 축진파
 78 |   14 |      802 | 축진파
 79 |   12 |      803 | 인해파
 80 |   21 |      803 | 인해파
 81 |   13 |      804 | 묘오파
 82 |   16 |      804 | 묘오파
 83 |   15 |      805 | 사신파
 84 |   18 |      805 | 사신파
 85 |   20 |      806 | 술미파
 86 |   17 |      806 | 술미파
 87 |   10 |      901 | 자미해
 88 |   17 |      901 | 자미해
 89 |   11 |      902 | 축오해
 90 |   16 |      902 | 축오해
 91 |   12 |      903 | 인사해
 92 |   15 |      903 | 인사해
 93 |   13 |      904 | 묘진해
 94 |   14 |      904 | 묘진해
 95 |   18 |      905 | 신해해
 96 |   21 |      905 | 신해해
 97 |   19 |      906 | 유술해
 98 |   20 |      906 | 유술해
(98 rows)</code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/saju-analysis$ docker exec -it saju-analysis_postgres psql -U peter -d saju_db -c &quot;SELECT * FROM interaction_metadata;&quot;
 id | group_id |              title               |       keyword        |                                         interpretation                                          
----+----------+----------------------------------+----------------------+-------------------------------------------------------------------------------------------------
  1 |      101 | 갑기합(甲己合): 중정의 합        | {신뢰,중심,안정}     | 강직한 갑목과 포용력 있는 기토가 만나 흔들림 없는 중심을 잡고 신뢰를 형성합니다.
  2 |      102 | 을경합(乙庚合): 인의의 합        | {정의,결속,의리}     | 유연한 을목과 강직한 경금이 만나 서로를 보완하며 강한 결속력과 의리를 발휘합니다.
  3 |      103 | 병신합(丙辛합): 위제의 합        | {변화,냉정,권위}     | 뜨거운 병화와 차가운 신금이 만나 매서운 위엄을 갖추며 새로운 기운으로 변화합니다.
  4 |      104 | 정임합(丁壬合): 인수(淫)의 합    | {다정,생산,예술}     | 따뜻한 정화와 넓은 임수가 만나 다정다감한 기운을 만들며 창의적인 생산력을 발휘합니다.
  5 |      105 | 무계합(戊癸合): 무정의 합        | {냉정,지혜,현실}     | 무게감 있는 무토와 흐르는 계수가 만나 감정보다는 현실적인 지혜와 효율을 추구합니다.
  6 |      201 | 갑경충(甲庚沖): 정면충돌         | {결단,충격,변화}     | 시작의 기운과 결실의 기운이 부딪혀 삶의 큰 방향 전환과 결단이 필요한 상황을 만듭니다.
  7 |      202 | 을신충(乙辛沖): 날카로운 대립    | {예민,스트레스,섬세} | 부드러운 초목이 날카로운 칼날을 만난 격으로, 신경이 예민해지기 쉬우나 섬세한 감각을 발휘합니다.
  8 |      203 | 병임충(丙壬沖): 수화기제         | {폭발,충돌,창의}     | 거대한 빛과 넓은 물이 부딪혀 역동적인 변화를 일으키며 강렬한 에너지를 분출합니다.
  9 |      204 | 정계충(丁癸沖): 감정의 격변      | {민감,변화,갈등}     | 작은 불씨와 빗물이 만나듯이 작은 자극에도 감정의 동요가 크고 세심한 조율이 필요합니다.
 10 |      301 | 자축합(子丑合): 비밀스러운 결속  | {협력,신중,안정}     | 추운 겨울의 기운들이 만나 겉으로 드러나지 않는 끈끈한 유대감과 안정감을 형성합니다.
 11 |      302 | 인해합(寅亥合): 성장의 동력      | {생동,시작,활력}     | 큰 물이 나무를 생하며 합하는 구조로, 끊임없는 생명력과 추진력을 만들어냅니다.
 12 |      303 | 묘술합(卯戌合): 실리적인 결합    | {현실,안정,타협}     | 부드러운 기운과 마른 땅이 만나 현실적인 이익을 추구하며 안정적인 관계를 맺습니다.
 13 |      304 | 진유합(辰酉合): 견고한 결실      | {완성,명예,단단함}   | 습한 기운이 금기를 생하여 더욱 단단하게 만드니 결과물이 명확하고 자부심이 강해집니다.
 14 |      305 | 사신합(巳申合): 변화의 합        | {복합,재주,변동}     | 불과 금이 만나 합을 이루나 속에는 형(刑)의 기운이 있어 복잡한 변화와 재주를 발휘합니다.
 15 |      306 | 오미합(午未合): 열정의 결집      | {뜨거움,집중,팽창}   | 여름의 절정 기운들이 만나 에너지의 밀도가 매우 높으며 열정적인 결과를 지향합니다.
 16 |      401 | 해묘미(亥卯未) 삼합: 목(木)의 국 | {성장,창작,협동}     | 다른 성질의 기운들이 모여 거대한 성장의 기류를 형성하고 공동의 목적을 달성합니다.
 17 |      402 | 인오술(寅午戌) 삼합: 화(火)의 국 | {발산,열정,화려}     | 에너지가 외부로 강력하게 분출되며 화려한 성과와 강한 행동력을 보여줍니다.
 18 |      403 | 사유축(巳酉丑) 삼합: 금(金)의 국 | {결실,수렴,엄격}     | 단단하게 굳어지는 기운으로 목표 중심적이며 결과에 대한 집요함이 강합니다.
 19 |      404 | 신자진(申子辰) 삼합: 수(水)의 국 | {지혜,유연,깊이}     | 유연하게 흐르는 거대한 물줄기를 형성하여 지적 깊이와 탁월한 적응력을 발휘합니다.
 20 |      501 | 인묘진(寅卯辰) 방합: 봄의 세력   | {생동,추진,동료}     | 계절적 동질성을 가진 강력한 목기가 형성되어 거침없는 시작과 추진력을 보여줍니다.
 21 |      502 | 사오미(巳午未) 방합: 여름의 세력 | {팽창,강렬,확산}     | 극강의 화기가 형성되어 자신의 영역을 넓히고 중심에 서려는 욕구가 강해집니다.
 22 |      503 | 신유술(申酉戌) 방합: 가을의 세력 | {견고,냉철,정리}     | 날카로운 금기가 모여 불필요한 것을 정리하고 결과물의 밀도를 높이는 힘이 강력합니다.
 23 |      504 | 해자축(亥子丑) 방합: 겨울의 세력 | {저장,침잠,지력}     | 차가운 수기가 결집하여 내면의 세계를 탐구하고 지식을 저장하는 힘이 탁월합니다.
 24 |      601 | 자오충(子午沖): 자존심의 충돌    | {폭발,극단,예민}     | 정반대의 강한 에너지가 부딪혀 감정의 변화가 크고 환경의 변동이 잦을 수 있습니다.
 25 |      602 | 축미충(丑未沖): 터전의 변동      | {조정,불화,이동}     | 단단한 흙들이 부딪혀 기반이 흔들리는 형상으로, 환경적인 변화나 조정 과정을 겪습니다.
 26 |      603 | 인신충(寅申沖): 역마의 정면충돌  | {활동,변화,속도}     | 시작과 결실의 기운이 부딪혀 매우 활동적이며 거주지나 직업의 변동이 잦을 수 있습니다.
 27 |      604 | 묘유충(卯酉沖): 상처와 대립      | {민감,분리,충격}     | 부드러운 나무와 날카로운 칼날이 부딪혀 대인관계의 소외감이나 건강상 주의가 필요합니다.
 28 |      605 | 진술충(辰戌沖): 영적인 충돌      | {복잡,변화,충돌}     | 거대한 기운들이 부딪혀 정신적인 혼란이나 예기치 못한 환경 변화를 가져옵니다.
 29 |      606 | 사해충(巳亥沖): 정보와 이동      | {변동,마찰,속도}     | 지식과 활동의 기운이 부딪혀 소통 과정의 마찰이나 잦은 이동수가 발생합니다.
 30 |      701 | 인사신(寅巳申) 삼형: 권력의 제어 | {조절,수술,기술}     | 강력한 세 기운이 얽혀 시시비비를 가려야 하니, 전문적인 제어 기술이나 리더십이 요구됩니다.
 31 |      702 | 축술미(丑戌未) 삼형: 무은의 형   | {배신,시비,조율}     | 가까운 관계에서의 시비나 배신수가 있을 수 있으니 철저한 문서 관리와 조율이 필요합니다.
 32 |      703 | 자묘형(子卯刑): 무례의 형        | {구설,불화,무례}     | 생(生)하는 관계임에도 예의에 어긋나는 상황이 생길 수 있어 대인관계의 주의가 필요합니다.
 33 |      801 | 자유파(子酉破): 조율의 마찰      | {분열,파괴,수정}     | 서로 다른 목적이 부딪혀 진행 중인 일이 일시적으로 중단되거나 수정이 필요할 수 있습니다.
 34 |      802 | 축진파(丑辰破): 내부적 균열      | {미묘,불편,조정}     | 비슷한 흙들이 서로를 밀어내며 내부적인 갈등이나 건강상의 미묘한 문제를 일으킵니다.
 35 |      803 | 인해파(寅亥破): 합 뒤의 균열     | {배신,마찰,변심}     | 합을 이루었으나 내부에 균열이 있으니 다 된 밥에 코 빠뜨리는 격으로 마무리에 주의해야 합니다.
 36 |      804 | 묘오파(卯午破): 열정의 과부하    | {손상,충돌,과열}     | 목기가 화기를 너무 급하게 생하여 에너지가 소진되거나 마찰이 생길 수 있습니다.
 37 |      805 | 사신파(巳申破): 기술적 수정      | {복잡,수리,변동}     | 변화가 많은 합 속에서 마찰이 생겨 지속적인 수리와 조정이 필요한 구조입니다.
 38 |      806 | 술미파(戌未破): 기반의 손상      | {조정,불편,변화}     | 건조한 흙들이 부딪혀 실질적인 기반이 약해지거나 관계의 소외감이 발생합니다.
 39 |      901 | 자미해(子未害): 감정의 앙금      | {질투,원망,방해}     | 서로의 합을 방해하는 관계라 예기치 못한 원망이나 질투로 인한 마찰이 생기기 쉽습니다.
 40 |      902 | 축오해(丑午害): 열정의 동결      | {냉담,방해,불신}     | 뜨거운 열정을 차가운 기운이 방해하니 의욕이 꺾이거나 주변의 시기 질투를 받습니다.
 41 |      903 | 인사해(寅巳害): 소통의 단절      | {오해,방해,충격}     | 강한 추진력들이 서로를 방해하여 소통 과정에서 오해가 생기고 진행이 더뎌집니다.
 42 |      904 | 묘진해(卯辰害): 성장판의 위축    | {침해,방해,시기}     | 부드러운 목기와 습한 토가 만나 자존심 대결을 벌이며 서로의 성장을 미묘하게 방해합니다.
 43 |      905 | 신해해(申亥害): 결실의 훼손      | {방해,변동,마찰}     | 결과를 맺으려는 금기와 시작하려는 수기가 부딪혀 마무리 단계에서 장애가 생길 수 있습니다.
 44 |      906 | 유술해(酉戌해): 날카로운 대립    | {질투,마찰,분열}     | 날카로운 결실들이 만나 서로를 시기하니 대인관계의 소외감이나 스트레스에 주의해야 합니다.
(44 rows)</code></pre>
<hr>
<p>이후, 이 데이터를 조합하여 분석하는 Rust 로직을 구현해 보자.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[비정형 데이터를 다루는 데이터베이스 - 삭제]]></title>
            <link>https://velog.io/@peeeeeter_j/pt-study-26</link>
            <guid>https://velog.io/@peeeeeter_j/pt-study-26</guid>
            <pubDate>Tue, 28 Apr 2026 01:49:52 GMT</pubDate>
            <description><![CDATA[<h1 id="비정형-데이터를-다루는-데이터베이스---삭제">비정형 데이터를 다루는 데이터베이스 - 삭제</h1>
<p>데이터베이스에 담긴 자료를 삭제하는 방식은 두 가지가 있다.
<code>DELETE</code> 를 사용하여 완전히 삭제해서 복구할 수 없게 되는 Hard Delete,
그리고 &quot;삭제됨&quot;을 표시하고 조회 대상에서 제외하여 복구 가능한 Soft Delete.</p>
<p>여기서는 후자를 알아보도록 하겠다.</p>
<h2 id="db">DB</h2>
<p>삭제 여부를 나타내는 칼럼을 추가해야 한다.
단순히 기다 아니다를 넘어 언제 삭제했는지 알 수 있도록
삭제 일자 칼럼을 추가하도록 하겠다.
이 값이 <code>NULL</code> 이면 아직 삭제되지 않은 것이다.</p>
<h2 id="스키마-변경">스키마 변경</h2>
<p>칼럼 추가를 위해 다음과 같이 DB 스키마를 변경한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/image-assets-management$ docker exec -it image_assets_management_postgres psql -U peter -d image_db -c &quot;ALTER TABLE image_assets ADD COLUMN deleted_at TIMESTAMPTZ DEFAULT NULL;&quot;</code></pre>
<p>변경 후 확인해 보면,</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/image-assets-management$ docker exec -it image_assets_management_postgres psql -U peter -d image_db -c &quot;SELECT         
    column_name, 
    data_type, 
    is_nullable, 
    column_default
FROM 
    information_schema.columns
WHERE 
    table_name = &#39;image_assets&#39;;&quot;
 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)</code></pre>
<h3 id="변경된-스키마-반영">변경된 스키마 반영</h3>
<p>이에 따라 Rust 코드에 작성된 구조체도 수정한다.
Python에서 이 값을 직접 사용하지는 않을 것이므로
<code>getter</code> 를 추가하지는 않아도 되지만
sqlx를 사용하는 코드는 구조체에 맞게 수정해야 한다.</p>
<p><code>deleted_at</code> 값을 변경하지 않고 쿼리로 직접 넣어줄 경우
이에 대한 구조체 필드가 당장은 필요하지 않을 수 있지만
&quot;삭제한 지 한 달이 넘으면 영구 삭제&quot; 같은 로직 구현 시 필요하므로
확장성을 고려해 추가해 두는 게 좋다.</p>
<p>조회 함수는 <code>deleted_at</code> 이 <code>NULL</code> 인 값만 조회하도록 수정한다.</p>
<p><code>query_at!</code> 매크로를 사용할 때는 <code>RETURNING</code> 하는 값의 구성이
데이터를 저장하는 구조체와 일치해야 한다는 것을 유의하자.</p>
<blockquote>
<p><code>src/image.rs</code></p>
</blockquote>
<pre><code class="language-rust">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};
&gt;
#[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&lt;chrono::DateTime&lt;chrono::Utc&gt;&gt;, // NEW!
}
&gt;
# 중략
&gt;
#[instrument(skip(pool))]
pub async fn search_assets(
    pool: &amp;Pool&lt;Postgres&gt;,
    name_query: Option&lt;String&gt;,
    format_query: Option&lt;String&gt;,
    limit: i64,
    offset: i64
) -&gt; Result&lt;AssetListResponse, sqlx::Error&gt; {
    let rows = sqlx::query!(
        r#&quot;
        SELECT id, file_name, file_path, width, height, metadata, deleted_at, count(*) OVER() as &quot;total_count!&quot; -- MODIFIED!
        FROM image_assets
        WHERE ($1::TEXT IS NULL OR file_name ILIKE $1)
            AND ($2::JSONB IS NULL OR metadata @&gt; $2)
            AND deleted_at IS NULL
        ORDER BY id DESC
        LIMIT $3 OFFSET $4
        &quot;#,
        name_query.map(|n| format!(&quot;%{}%&quot;, n)),
        format_query.map(|f| serde_json::json!({&quot;format&quot;: f})),
        limit,
        offset
    )
    .fetch_all(pool)
    .await?;
&gt;
    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();
&gt;
    let size = limit as i32;
    let page = (offset as i32 / size) + 1;
&gt;
    Ok(AssetListResponse{ total_count, items, page, size })
}
&gt;
#[instrument(skip(pool))]
pub async fn update_metadata(
    pool: &amp;Pool&lt;Postgres&gt;,
    id: uuid::Uuid,
    new_meta: serde_json::Value,
) -&gt; Result&lt;ImageAsset, sqlx::Error&gt; {
    let updated = sqlx::query_as!(
        ImageAsset,
        r#&quot;
        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!
        &quot;#,
        new_meta,
        id
    )
    .fetch_one(pool)
    .await?;
&gt;
    Ok(updated)
}</code></pre>
<p>어차피 기본값으로 생성할 거라 필요하지는 않지만
이미지 에셋 데이터를 처음 생성하는 부분에도
새로 추가된 필드의 값을 명확히 설정해 주어야 오류가 나지 않는다.</p>
<blockquote>
<p><code>src/lib.rs</code></p>
</blockquote>
<pre><code class="language-rust">// 전략
&gt;
#[pyfunction]
#[instrument(skip(metadata))]
fn add_image_asset(
    file_name: String,
    file_path: String,
    width: i32,
    height: i32,
    metadata: String
) -&gt; PyResult&lt;String&gt; {
    let rt = get_runtime();
&gt;
    let meta_json: serde_json::Value = serde_json::from_str(&amp;metadata)
        .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
&gt;
    let asset = image::ImageAsset {
        id: uuid::Uuid::nil(),
        file_name,
        file_path,
        width,
        height,
        metadata: meta_json,
        deleted_at: None, // NEW!
    };
&gt;
    let pool = db::DB_POOL.get()
        .ok_or(pyo3::exceptions::PyRuntimeError::new_err(&quot;DB 연결 풀이 없습니다.&quot;))?;
&gt;
    let id = rt.block_on(async {
        image::insert_asset(pool, asset).await
    }).map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
&gt;
    Ok(id.to_string())
}
&gt; 
// 후략</code></pre>
<h2 id="코드">코드</h2>
<h3 id="rust-코드">Rust 코드</h3>
<p>어떤 이미지 에셋의 <code>deleted_at</code> 값을 설정하여
Soft Delete를 수행하는 코드를 작성한다.</p>
<p>DB에서 완전히 제거된 게 아니므로 다시 복구할 수 있다.
따라서 복구하는 코드도 함께 작성하겠다.</p>
<blockquote>
<p><code>src/image.rs</code></p>
</blockquote>
<pre><code class="language-rust">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};
&gt;
// 기존 함수 생략
&gt;
#[instrument(skip(pool))]
pub async fn soft_delete_asset(
    pool: &amp;Pool&lt;Postgres&gt;,
    id: uuid::Uuid,
) -&gt; Result&lt;bool, sqlx::Error&gt; {
    let result = sqlx::query!(
        r#&quot;
        UPDATE image_assets
        SET deleted_at = NOW()
        WHERE id = $1 AND deleted_at IS NULL
        &quot;#,
        id
    )
    .execute(pool)
    .await?;
&gt;
    Ok(result.rows_affected() &gt; 0)
}
&gt;
#[instrument(skip(pool))]
pub async fn restore_asset(
    pool: &amp;Pool&lt;Postgres&gt;,
    id: uuid::Uuid,
) -&gt; Result&lt;bool, sqlx::Error&gt; {
    let result = sqlx::query!(
        r#&quot;
        UPDATE image_assets
        SET deleted_at = NULL
        WHERE id = $1 AND deleted_at IS NOT NULL
        &quot;#,
        id
    )
    .execute(pool)
    .await?;
&gt;
    Ok(result.rows_affected() &gt; 0)
}</code></pre>
<blockquote>
<p><code>src/lib.rs</code></p>
</blockquote>
<pre><code class="language-rust">mod db;
mod logger;
mod image;
&gt;
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;
&gt;
static LOG_GUARD: OnceLock&lt;WorkerGuard&gt; = OnceLock::new();
static TOKIO_RUNTIME: OnceLock&lt;Runtime&gt; = OnceLock::new();
&gt;
fn get_runtime() -&gt; &amp;&#39;static Runtime {
    TOKIO_RUNTIME.get_or_init(|| {
        Runtime::new().expect(&quot;Tokio 런타임 생성 실패&quot;)
    })
}
&gt;
// 기존 함수 생략
&gt;
#[pyfunction]
fn delete_asset(id_str: String) -&gt; PyResult&lt;bool&gt; {
    let rt = get_runtime();
    let pool = db::DB_POOL.get()
        .ok_or(pyo3::exceptions::PyRuntimeError::new_err(&quot;DB 연결 풀이 없습니다.&quot;))?;
&gt;
    let id = uuid::Uuid::parse_str(&amp;id_str)
        .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
&gt;
    let success = rt.block_on(async {
        image::soft_delete_asset(pool, id).await
    }).map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
&gt;
    Ok(success)
}
&gt;
#[pyfunction]
fn restore_asset(id_str: String) -&gt; PyResult&lt;bool&gt; {
    let rt = get_runtime();
    let pool = db::DB_POOL.get()
        .ok_or(pyo3::exceptions::PyRuntimeError::new_err(&quot;DB 연결 풀이 없습니다.&quot;))?;
&gt;
    let id = uuid::Uuid::parse_str(&amp;id_str)
        .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
&gt;
    let success = rt.block_on(async {
        image::restore_asset(pool, id).await
    }).map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
&gt;
    Ok(success)
}
&gt;
#[pymodule]
fn rust_engine(m: &amp;Bound&lt;&#39;_, PyModule&gt;) -&gt; PyResult&lt;()&gt; {
    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)?)?;
&gt;
    Ok(())
}</code></pre>
<h3 id="python-코드">Python 코드</h3>
<p>삭제하고자 하는 에셋 아이디가 존재하지 않거나 이미 삭제되어 있어서
삭제 연산이 이루어지지 않은 경우에는 404 오류를 반환하도록 구현한다.
복구할 때도 마찬가지로 처리할 수 없다며 400 오류를 반환하도록 한다.</p>
<blockquote>
<p><code>app/main.py</code></p>
</blockquote>
<pre><code class="language-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
&gt;
# 기존 함수 생략
&gt;
@app.delete(&quot;/assets/{asset_id}&quot;)
async def delete_asset(asset_id: str):
    success = rust_engine.delete_asset(asset_id)
&gt;
    if not success:
        raise HTTPException(
            status_code=404,
            detail=&quot;존재하지 않거나 이미 삭제되었습니다.&quot;
        )
&gt;
    return {
        &quot;status&quot;: &quot;success&quot;,
        &quot;message&quot;: f&quot;에셋 {asset_id} 삭제 완료&quot;
    }
&gt;
@app.post(&quot;/assets/{asset_id}/restore&quot;)
async def restore_asset(asset_id: str):
    success = rust_engine.restore_asset(asset_id)
&gt;
    if not success:
        raise HTTPException(
            status_code=400,
            detail=&quot;삭제되지 않았거나 없는 파일입니다.&quot;
        )
&gt;
    return {
        &quot;status&quot;: &quot;success&quot;,
        &quot;message&quot;: f&quot;에셋 {asset_id} 복구 완료&quot;
    }</code></pre>
<h2 id="빌드-및-실행">빌드 및 실행</h2>
<p>Maturin 라이브러리를 통해 Rust 코드를 Python에서 호출 가능한 형태로 컴파일한다.
<code>pyproject.toml</code> 파일에 <code>build-backend = &quot;maturin&quot;</code> 이 명시되어 있으니
uv를 사용하는 방식으로 컴파일하겠다.</p>
<p>uvicorn 라이브러리를 통해 FastAPI를 실행한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/image-assets-management$ uv pip install -e .
~/workspace/image-assets-management$ uv run uvicorn app.main:app --reload</code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">$ curl -i &quot;http://127.0.0.1:8000/assets/search?name_query=hero&quot;
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
&gt;
{&quot;total_count&quot;:3,&quot;items&quot;:[{&quot;id&quot;:&quot;019d9441-cb91-79bb-9bb5-4cd6326f0e68&quot;,&quot;file_name&quot;:&quot;char_hero_run.exr&quot;,&quot;file_path&quot;:&quot;/assets/textures/hero/&quot;,&quot;width&quot;:4096,&quot;height&quot;:4096,&quot;metadata&quot;:{&quot;format&quot;:&quot;EXR&quot;,&quot;layers&quot;:64,&quot;type&quot;:&quot;render&quot;},&quot;deleted_at&quot;:null},{&quot;id&quot;:&quot;019d9441-cb87-7f42-b228-2e3590d90a05&quot;,&quot;file_name&quot;:&quot;char_hero_idle.exr&quot;,&quot;file_path&quot;:&quot;/assets/textures/hero/&quot;,&quot;width&quot;:4096,&quot;height&quot;:4096,&quot;metadata&quot;:{&quot;format&quot;:&quot;EXR&quot;,&quot;layers&quot;:64,&quot;type&quot;:&quot;render&quot;},&quot;deleted_at&quot;:null},{&quot;id&quot;:&quot;019d8eae-5943-76c4-9a85-195ce2c4f81a&quot;,&quot;file_name&quot;:&quot;hero_char_diffuse.exr&quot;,&quot;file_path&quot;:&quot;/assets/textures/hero/&quot;,&quot;width&quot;:4096,&quot;height&quot;:4096,&quot;metadata&quot;:{&quot;author&quot;:&quot;peter&quot;,&quot;avg_luminance&quot;:0.45,&quot;format&quot;:&quot;EXR&quot;,&quot;is_verified&quot;:true,&quot;layers&quot;:32},&quot;deleted_at&quot;:null}],&quot;page&quot;:1,&quot;size&quot;:5}%  </code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">$ curl -iX DELETE &quot;http://127.0.0.1:8000/assets/019d9441-cb91-79bb-9bb5-4cd6326f0e68&quot;
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
&gt;
{&quot;status&quot;:&quot;success&quot;,&quot;message&quot;:&quot;에셋 019d9441-cb91-79bb-9bb5-4cd6326f0e68 삭제 완료&quot;}%</code></pre>
<blockquote>
<p>갯수가 줄었다.</p>
</blockquote>
<pre><code class="language-bash">$ curl -i &quot;http://127.0.0.1:8000/assets/search?name_query=hero&quot;
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
&gt;
{&quot;total_count&quot;:2,&quot;items&quot;:[{&quot;id&quot;:&quot;019d9441-cb87-7f42-b228-2e3590d90a05&quot;,&quot;file_name&quot;:&quot;char_hero_idle.exr&quot;,&quot;file_path&quot;:&quot;/assets/textures/hero/&quot;,&quot;width&quot;:4096,&quot;height&quot;:4096,&quot;metadata&quot;:{&quot;format&quot;:&quot;EXR&quot;,&quot;layers&quot;:64,&quot;type&quot;:&quot;render&quot;},&quot;deleted_at&quot;:null},{&quot;id&quot;:&quot;019d8eae-5943-76c4-9a85-195ce2c4f81a&quot;,&quot;file_name&quot;:&quot;hero_char_diffuse.exr&quot;,&quot;file_path&quot;:&quot;/assets/textures/hero/&quot;,&quot;width&quot;:4096,&quot;height&quot;:4096,&quot;metadata&quot;:{&quot;author&quot;:&quot;peter&quot;,&quot;avg_luminance&quot;:0.45,&quot;format&quot;:&quot;EXR&quot;,&quot;is_verified&quot;:true,&quot;layers&quot;:32},&quot;deleted_at&quot;:null}],&quot;page&quot;:1,&quot;size&quot;:5}% </code></pre>
<blockquote>
<p>다시 시도하면 없다고 뜬다.</p>
</blockquote>
<pre><code class="language-bash">$ curl -iX DELETE &quot;http://127.0.0.1:8000/assets/019d9441-cb91-79bb-9bb5-4cd6326f0e68&quot;
HTTP/1.1 404 Not Found
date: Tue, 28 Apr 2026 01:19:24 GMT
server: uvicorn
content-length: 47
content-type: application/json
&gt;
{&quot;detail&quot;:&quot;&quot;존재하지 않거나 이미 삭제되었습니다.&quot;}%  </code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">$ curl -iX POST &quot;http://127.0.0.1:8000/assets/019d9441-cb91-79bb-9bb5-4cd6326f0e68/restore&quot;
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
&gt;
{&quot;status&quot;:&quot;success&quot;,&quot;message&quot;:&quot;에셋 019d9441-cb91-79bb-9bb5-4cd6326f0e68 복구 완료&quot;}%</code></pre>
<blockquote>
<p>갯수가 늘었다.</p>
</blockquote>
<pre><code class="language-bash">$ curl -i &quot;http://127.0.0.1:8000/assets/search?name_query=hero&quot;                      
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
&gt;
{&quot;total_count&quot;:3,&quot;items&quot;:[{&quot;id&quot;:&quot;019d9441-cb91-79bb-9bb5-4cd6326f0e68&quot;,&quot;file_name&quot;:&quot;char_hero_run.exr&quot;,&quot;file_path&quot;:&quot;/assets/textures/hero/&quot;,&quot;width&quot;:4096,&quot;height&quot;:4096,&quot;metadata&quot;:{&quot;format&quot;:&quot;EXR&quot;,&quot;layers&quot;:64,&quot;type&quot;:&quot;render&quot;},&quot;deleted_at&quot;:null},{&quot;id&quot;:&quot;019d9441-cb87-7f42-b228-2e3590d90a05&quot;,&quot;file_name&quot;:&quot;char_hero_idle.exr&quot;,&quot;file_path&quot;:&quot;/assets/textures/hero/&quot;,&quot;width&quot;:4096,&quot;height&quot;:4096,&quot;metadata&quot;:{&quot;format&quot;:&quot;EXR&quot;,&quot;layers&quot;:64,&quot;type&quot;:&quot;render&quot;},&quot;deleted_at&quot;:null},{&quot;id&quot;:&quot;019d8eae-5943-76c4-9a85-195ce2c4f81a&quot;,&quot;file_name&quot;:&quot;hero_char_diffuse.exr&quot;,&quot;file_path&quot;:&quot;/assets/textures/hero/&quot;,&quot;width&quot;:4096,&quot;height&quot;:4096,&quot;metadata&quot;:{&quot;author&quot;:&quot;peter&quot;,&quot;avg_luminance&quot;:0.45,&quot;format&quot;:&quot;EXR&quot;,&quot;is_verified&quot;:true,&quot;layers&quot;:32},&quot;deleted_at&quot;:null}],&quot;page&quot;:1,&quot;size&quot;:5}%  </code></pre>
<blockquote>
<p>다시 하면 안 된다고 뜬다.</p>
</blockquote>
<pre><code class="language-bash">$ curl -iX POST &quot;http://127.0.0.1:8000/assets/019d9441-cb91-79bb-9bb5-4cd6326f0e68/restore&quot;
HTTP/1.1 400 Bad Request
date: Tue, 28 Apr 2026 01:45:31 GMT
server: uvicorn
content-length: 62
content-type: application/json
&gt;
{&quot;detail&quot;:&quot;삭제되지 않았거나 없는 파일입니다.&quot;}%</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[비정형 데이터를 다루는 데이터베이스 - 수정]]></title>
            <link>https://velog.io/@peeeeeter_j/pt-study-25</link>
            <guid>https://velog.io/@peeeeeter_j/pt-study-25</guid>
            <pubDate>Wed, 22 Apr 2026 01:46:58 GMT</pubDate>
            <description><![CDATA[<h1 id="비정형-데이터를-다루는-데이터베이스---수정">비정형 데이터를 다루는 데이터베이스 - 수정</h1>
<p>비정형 데이터 JSON의 내용을 수정하는 방법을 알아보자.</p>
<p>에셋의 모든 정보를 바꾸기보다,
특정 메타데이터만 추가하거나 수정하는 일이 훨씬 많다.
PostgreSQL의 <code>||</code> (병합) 연산자를 사용하면
기존 JSON 데이터를 유지하면서 특정 키만 덮어쓰거나 추가할 수 있다.</p>
<p>최상위 키만 비교하기 때문에 중첩된 데이터가 있을 경우
일부 데이터가 누락될 수 있는데 이를 방지하려면
<code>jsonb_set</code> 함수를 중첩으로 사용해야 하지만
일반적인 태그 추가나 상태 변경은 <code>||</code> 로도 충분하다.</p>
<blockquote>
<p><strong>주의!</strong>
기존 메타데이터가 <code>{&quot;info&quot;: {&quot;color&quot;: &quot;red&quot;}}</code> 인데
<code>{&quot;info&quot;: {&quot;size&quot;: 10}}</code> 을 병합하면
<code>info</code> 내부가 합쳐지는 게 아니라 <code>info</code> 전체가 새 객체로 덮어씌워짐.</p>
</blockquote>
<h2 id="코드-작성">코드 작성</h2>
<h3 id="rust-코드">Rust 코드</h3>
<p><code>sqlx::query_as!</code> 를 사용하면
두 번째 인자로 전달된 쿼리의 결과를
첫 번째 인자로 전달된 변수에 바로 대입할 수 있다.
세 번째 인자부터는 <code>sqlx::query!</code> 와 마찬가지로
쿼리에 대입되는 값이다.
<code>UPDATE</code> 연산은 <code>RETURNING</code> 을 통해 결과를 반환한다.</p>
<blockquote>
<p><code>src/image.rs</code></p>
</blockquote>
<pre><code class="language-rust">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};
&gt;
#[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,
}
&gt;
#[pymethods]
impl ImageAsset {
    #[getter]
    fn id(&amp;self) -&gt; String {
        self.id.to_string()
    }
&gt;
    #[getter]
    fn metadata&lt;&#39;py&gt;(&amp;self, py: Python&lt;&#39;py&gt;) -&gt; PyResult&lt;Bound&lt;&#39;py, PyAny&gt;&gt; {
        pythonize::pythonize(py, &amp;self.metadata)
            .map_err(|e| PyRuntimeError::new_err(e.to_string()))
    }
&gt;
    fn to_dict&lt;&#39;py&gt;(&amp;self, py: Python&lt;&#39;py&gt;) -&gt; PyResult&lt;Bound&lt;&#39;py, PyAny&gt;&gt; {
        pythonize::pythonize(py, self)
            .map_err(|e| PyRuntimeError::new_err(e.to_string()))
    }
}
&gt;
// 기존 함수 생략
&gt;
#[instrument(skip(pool))]
pub async fn update_metadata(
    pool: &amp;Pool&lt;Postgres&gt;,
    id: uuid::Uuid,
    new_meta: serde_json::Value,
) -&gt; Result&lt;ImageAsset, sqlx::Error&gt; {
    let updated = sqlx::query_as!(
        ImageAsset,
        r#&quot;
        UPDATE image_assets
        SET metadata = metadata || $1
        WHERE id = $2
        RETURNING id, file_name, file_path, width, height, metadata
        &quot;#,
        new_meta,
        id
    )
    .fetch_one(pool)
    .await?;
&gt;
    Ok(updated)
}</code></pre>
<blockquote>
<p><code>src/lib.rs</code></p>
</blockquote>
<pre><code class="language-rust">mod db;
mod logger;
mod image;
&gt;
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;
&gt;
static LOG_GUARD: OnceLock&lt;WorkerGuard&gt; = OnceLock::new();
static TOKIO_RUNTIME: OnceLock&lt;Runtime&gt; = OnceLock::new();
&gt;
fn get_runtime() -&gt; &amp;&#39;static Runtime {
    TOKIO_RUNTIME.get_or_init(|| {
        Runtime::new().expect(&quot;Tokio 런타임 생성 실패&quot;)
    })
}
&gt;
// 기존 함수 생략
&gt;
#[pyfunction]
fn patch_asset_metadata(
    id_str: String,
    metadata_json: String
) -&gt; PyResult&lt;image::ImageAsset&gt; {
    let rt = get_runtime();
    let pool = db::DB_POOL.get()
        .ok_or(pyo3::exceptions::PyRuntimeError::new_err(&quot;DB 연결 풀이 없습니다.&quot;))?;
&gt;
    let id = uuid::Uuid::parse_str(&amp;id_str)
        .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
&gt;
    let metadata: serde_json::Value = serde_json::from_str(&amp;metadata_json)
        .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
&gt;
    let asset = rt.block_on(async {
        image::update_metadata(pool, id, metadata).await
    }).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
&gt;
    Ok(asset)
}
&gt;
#[pymodule]
fn rust_engine(m: &amp;Bound&lt;&#39;_, PyModule&gt;) -&gt; PyResult&lt;()&gt; {
    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)?)?;
&gt;
    Ok(())
}</code></pre>
<h3 id="python-코드">Python 코드</h3>
<blockquote>
<p><code>app/main.py</code></p>
</blockquote>
<pre><code class="language-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
&gt;
# 기존 함수 생략
&gt;
@app.patch(&quot;/assets/{asset_id}/metadata&quot;)
async def update_asset(asset_id: str, payload: dict):
    updated = rust_engine.patch_asset_metadata(asset_id, json.dumps(payload))
&gt;
    return {
        &quot;status&quot;: &quot;success&quot;,
        &quot;data&quot;: updated.to_dict()
    }</code></pre>
<h2 id="빌드-및-실행">빌드 및 실행</h2>
<p>Maturin 라이브러리를 통해 Rust 코드를 Python에서 호출 가능한 형태로 컴파일한다.
<code>pyproject.toml</code> 파일에 <code>build-backend = &quot;maturin&quot;</code> 이 명시되어 있으니
uv를 사용하는 방식으로 컴파일하겠다.</p>
<p>uvicorn 라이브러리를 통해 FastAPI를 실행한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/image-assets-management$ uv pip install -e .
~/workspace/image-assets-management$ uv run uvicorn app.main:app --reload</code></pre>
<p>프론트엔드 단에서 <code>/assets/search</code> 로 조회하여
원하는 이미지 에셋의 <code>id</code> 를 파악하였고
그것에 대한 수정을 하는 것으로 가정하여
<code>id</code> 는 호출하는 쪽에서 알고 있는 상황을 상정한다.</p>
<p>실행 예시에서는 데이터 변화를 확인하기 위해 조회도 수행하겠다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ curl -i &quot;http://127.0.0.1:8000/assets/search?name_query=hero_char&quot;
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
&gt;
{&quot;total_count&quot;:1,&quot;items&quot;:[{&quot;id&quot;:&quot;019d8eae-5943-76c4-9a85-195ce2c4f81a&quot;,&quot;file_name&quot;:&quot;hero_char_diffuse.exr&quot;,&quot;file_path&quot;:&quot;/assets/textures/hero/&quot;,&quot;width&quot;:4096,&quot;height&quot;:4096,&quot;metadata&quot;:{&quot;avg_luminance&quot;:0.45,&quot;format&quot;:&quot;EXR&quot;,&quot;layers&quot;:32}}],&quot;page&quot;:1,&quot;size&quot;:5}%         ``` 

&gt;
```bash
~$ curl -iX PATCH &quot;http://127.0.0.1:8000/assets/019d8eae-5943-76c4-9a85-195ce2c4f81a/metadata&quot; \
     -H &quot;Content-Type: application/json&quot; \
     -d &#39;{&quot;author&quot;: &quot;peter&quot;, &quot;is_verified&quot;: true}&#39;
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
&gt;
{&quot;status&quot;:&quot;success&quot;,&quot;data&quot;:{&quot;id&quot;:&quot;019d8eae-5943-76c4-9a85-195ce2c4f81a&quot;,&quot;file_name&quot;:&quot;hero_char_diffuse.exr&quot;,&quot;file_path&quot;:&quot;/assets/textures/hero/&quot;,&quot;width&quot;:4096,&quot;height&quot;:4096,&quot;metadata&quot;:{&quot;author&quot;:&quot;peter&quot;,&quot;avg_luminance&quot;:0.45,&quot;format&quot;:&quot;EXR&quot;,&quot;is_verified&quot;:true,&quot;layers&quot;:32}}}%</code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ curl -i &quot;http://127.0.0.1:8000/assets/search?name_query=hero_char&quot;                           
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
&gt;
{&quot;total_count&quot;:1,&quot;items&quot;:[{&quot;id&quot;:&quot;019d8eae-5943-76c4-9a85-195ce2c4f81a&quot;,&quot;file_name&quot;:&quot;hero_char_diffuse.exr&quot;,&quot;file_path&quot;:&quot;/assets/textures/hero/&quot;,&quot;width&quot;:4096,&quot;height&quot;:4096,&quot;metadata&quot;:{&quot;author&quot;:&quot;peter&quot;,&quot;avg_luminance&quot;:0.45,&quot;format&quot;:&quot;EXR&quot;,&quot;is_verified&quot;:true,&quot;layers&quot;:32}}],&quot;page&quot;:1,&quot;size&quot;:5}% </code></pre>
<p>메타데이터가 수정된 것을 확인할 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[비정형 데이터를 다루는 데이터베이스 - 조회]]></title>
            <link>https://velog.io/@peeeeeter_j/pt-study-24</link>
            <guid>https://velog.io/@peeeeeter_j/pt-study-24</guid>
            <pubDate>Tue, 21 Apr 2026 01:12:51 GMT</pubDate>
            <description><![CDATA[<h1 id="비정형-데이터를-다루는-데이터베이스---조회">비정형 데이터를 다루는 데이터베이스 - 조회</h1>
<p><a href="pt-study-23">지난 시간</a>에 만든 데이터베이스를 활용하는 실습을 진행해 보자.
원하는 조건에 따라 데이터를 뽑아내는 코드를 작성하겠다.</p>
<h2 id="db">DB</h2>
<h3 id="인덱스">인덱스</h3>
<p>검색 효율을 높이기 위해 인덱스를 추가한다.</p>
<blockquote>
<p><code>scripts/init.sql</code></p>
</blockquote>
<pre><code class="language-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
);
&gt;
-- 이상 기존 코드
&gt;
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);</code></pre>
<p><code>IF NOT EXISTS</code> 를 사용하면 이 파일이 여러 번 전달되어도
각 작업을 중복해서 수행하지 않는다.</p>
<p><code>docker-compose.yml</code> 파일의 <code>volumns</code> 에
<code>scripts/init.sql:/docker-entrypoint-initdb.d/init.sql</code> 를 추가하여
docker 서비스 실행 시 스크립트가 자동으로 실행되게 할 수도 있지만
여기서는 수동으로 전달하겠다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/image-assets-management$ docker exec -i image_assets_management_postgres psql -U peter -d image_db &lt; scripts/init.sql</code></pre>
<p>테이블은 이미 만들어져 있으니 <code>skipping</code> 으로 뜨고 인덱스가 생성될 것이다.</p>
<h3 id="테스트용-데이터-셋">테스트용 데이터 셋</h3>
<p>테스트용 데이터 셋을 넣을 스크립트를 작성한다.</p>
<blockquote>
<p><code>seed_data.py</code></p>
</blockquote>
<pre><code class="language-py">import httpx
import json
&gt;
BASE_URL = &quot;http://localhost:8000/assets&quot;
&gt;
data_set = [
    {&quot;file_name&quot;: &quot;char_hero_idle.exr&quot;, &quot;file_path&quot;: &quot;/assets/textures/hero/&quot;, &quot;width&quot;: 4096, &quot;height&quot;: 4096, &quot;metadata&quot;: {&quot;format&quot;: &quot;EXR&quot;, &quot;layers&quot;: 64, &quot;type&quot;: &quot;render&quot;}},
    {&quot;file_name&quot;: &quot;char_hero_run.exr&quot;, &quot;file_path&quot;: &quot;/assets/textures/hero/&quot;, &quot;width&quot;: 4096, &quot;height&quot;: 4096, &quot;metadata&quot;: {&quot;format&quot;: &quot;EXR&quot;, &quot;layers&quot;: 64, &quot;type&quot;: &quot;render&quot;}},
    {&quot;file_name&quot;: &quot;env_forest_ground.exr&quot;, &quot;file_path&quot;: &quot;/assets/textures/env/&quot;, &quot;width&quot;: 8192, &quot;height&quot;: 4096, &quot;metadata&quot;: {&quot;format&quot;: &quot;EXR&quot;, &quot;layers&quot;: 12, &quot;type&quot;: &quot;environment&quot;}},
    {&quot;file_name&quot;: &quot;ui_button_normal.png&quot;, &quot;file_path&quot;: &quot;/assets/ui/buttons/&quot;, &quot;width&quot;: 256, &quot;height&quot;: 256, &quot;metadata&quot;: {&quot;format&quot;: &quot;PNG&quot;, &quot;alpha&quot;: True, &quot;theme&quot;: &quot;dark&quot;}},
    {&quot;file_name&quot;: &quot;ui_icon_star.png&quot;, &quot;file_path&quot;: &quot;/assets/ui/icons/&quot;, &quot;width&quot;: 128, &quot;height&quot;: 128, &quot;metadata&quot;: {&quot;format&quot;: &quot;PNG&quot;, &quot;alpha&quot;: True, &quot;theme&quot;: &quot;gold&quot;}},
    {&quot;file_name&quot;: &quot;ref_lighting_01.jpg&quot;, &quot;file_path&quot;: &quot;/assets/refs/lighting/&quot;, &quot;width&quot;: 1920, &quot;height&quot;: 1080, &quot;metadata&quot;: {&quot;format&quot;: &quot;JPEG&quot;, &quot;quality&quot;: 90, &quot;source&quot;: &quot;outdoor&quot;}},
    {&quot;file_name&quot;: &quot;ref_material_metal.jpg&quot;, &quot;file_path&quot;: &quot;/assets/refs/materials/&quot;, &quot;width&quot;: 2048, &quot;height&quot;: 2048, &quot;metadata&quot;: {&quot;format&quot;: &quot;JPEG&quot;, &quot;quality&quot;: 95, &quot;source&quot;: &quot;substance&quot;}},
    {&quot;file_name&quot;: &quot;fx_explosion_01.exr&quot;, &quot;file_path&quot;: &quot;/assets/fx/explosions/&quot;, &quot;width&quot;: 2048, &quot;height&quot;: 2048, &quot;metadata&quot;: {&quot;format&quot;: &quot;EXR&quot;, &quot;layers&quot;: 32, &quot;type&quot;: &quot;vfx&quot;}},
    {&quot;file_name&quot;: &quot;skybox_sunset.exr&quot;, &quot;file_path&quot;: &quot;/assets/env/skybox/&quot;, &quot;width&quot;: 16384, &quot;height&quot;: 8192, &quot;metadata&quot;: {&quot;format&quot;: &quot;EXR&quot;, &quot;layers&quot;: 1, &quot;type&quot;: &quot;hdr&quot;}},
    {&quot;file_name&quot;: &quot;char_npc_bakery.png&quot;, &quot;file_path&quot;: &quot;/assets/textures/npc/&quot;, &quot;width&quot;: 1024, &quot;height&quot;: 1024, &quot;metadata&quot;: {&quot;format&quot;: &quot;PNG&quot;, &quot;alpha&quot;: False, &quot;type&quot;: &quot;concept&quot;}}
]
&gt;
def seed_data():
    with httpx.Client() as client:
        print(f&quot;{len(data_set)}개의 데이터 삽입 시작...&quot;)
 &gt;       
        for item in data_set:
            try:
                response = client.post(BASE_URL, json=item)
                response.raise_for_status()
                result = response.json()
                print(f&quot;성공 {item[&#39;file_name&#39;]} (ID: {result[&#39;id&#39;]}&quot;)
            except Exception as e:
                print(f&quot;실패 {item[&#39;file_name&#39;]} - {str(e)}&quot;)
&gt;
    print(&quot;모든 작업 완료&quot;)
&gt;
if __name__ == &quot;__main__&quot;:
    seed_data()</code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/image-assets-management$ uv add --dev httpx
~/workspace/image-assets-management$ uv run python seed_data.py</code></pre>
<h2 id="코드-작성">코드 작성</h2>
<h3 id="rust-코드">Rust 코드</h3>
<p>작업에 필요한 크레이트를 추가하기 위해
<code>Cargo.toml</code> 파일을 수정한다.</p>
<blockquote>
<p><code>Cargo.toml</code></p>
</blockquote>
<pre><code class="language-toml">[package]
name = &quot;image-assets-management&quot;
version = &quot;0.1.0&quot;
edition = &quot;2024&quot;
&gt;
[lib]
name = &quot;rust_engine&quot;
crate-type = [&quot;cdylib&quot;]
&gt;
[dependencies]
# Python 연결
pyo3 = &quot;0.28.0&quot;
pythonize = &quot;0.28&quot;
# 비동기 엔진
tokio = { version = &quot;1.43&quot;, features = [&quot;full&quot;] }
# 데이터베이스 연결
dotenvy = &quot;0.15&quot;
sqlx = { version = &quot;0.8&quot;, features = [&quot;runtime-tokio&quot;, &quot;tls-rustls&quot;, &quot;postgres&quot;, &quot;macros&quot;, &quot;uuid&quot;, &quot;chrono&quot;, &quot;json&quot;] }
# 데이터베이스 호환
serde = { version = &quot;1.0&quot;, features = [&quot;derive&quot;] }
serde_json = &quot;1.0&quot;
uuid = { version = &quot;1.23&quot;, features = [&quot;v7&quot;, &quot;serde&quot;] }
chrono = { version = &quot;0.4&quot;, features = [&quot;serde&quot;] }
# 로그 수집
tracing = &quot;0.1&quot;
tracing-subscriber = { version = &quot;0.3&quot;, features = [&quot;env-filter&quot;, &quot;json&quot;] }
tracing-appender = &quot;0.2&quot;</code></pre>
<p>대소문자 구분 없이 파일 이름의 일부를 통해 검색하거나
<code>metadata</code> 에 <code>format</code> 이 있다면 이를 통해 검색하는
기본적인 검색 쿼리를 작성한다.</p>
<p>페이지네이션을 통해 응답 개수가 많아져도 부담되지 않게 작성하겠다.</p>
<p>이미지 에셋의 정보를 담을 객체는 <code>#[pyclass]</code> 속성을 붙여야 한다.
구조체 필드는 Python에서 읽을 수 있게 <code>#[pyo3[get)]</code> 속성을 붙이되,
바로 사용할 수 없는 필드에 대해서는
적절히 변환하여 반환하는 함수를 따로 구현해준다.
<code>dict</code> 는 Pydantic이 모델을 직렬화할 때 사용하는 <code>__dict__</code> 속성을 생성한다.</p>
<p>구조체를 JSON으로 변환하고 반대로도 변환하기 위해
<code>serde::Serialize</code>, <code>serde::Deserialize</code> 가 필요하며
sqlx를 사용하기 위해 <code>sqlx::FromRow</code> 도 필요하다.</p>
<p>응답 데이터를 담는 객체를 위한 구조체도 생성한다.
<code>#[pyclass]</code> 속성에 <code>get_all</code> 을 붙이면 필드에 일일이 작성해 주지 않아도
Python에서 각 필드에 직접 접근할 수 있다.</p>
<blockquote>
<p><code>src/image.rs</code></p>
</blockquote>
<pre><code class="language-rust">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};
&gt;
#[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,
}
&gt;
#[pymethods]
impl ImageAsset {
    #[getter]
    fn id(&amp;self) -&gt; String {
        self.id.to_string()
    }
&gt;
    #[getter]
    fn metadata&lt;&#39;py&gt;(&amp;self, py: Python&lt;&#39;py&gt;) -&gt; PyResult&lt;Bound&lt;&#39;py, PyAny&gt;&gt; {
        pythonize::pythonize(py, &amp;self.metadata)
            .map_err(|e| PyRuntimeError::new_err(e.to_string()))
    }
}
&gt;
#[pyclass(get_all, dict)]
#[derive(Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow)]
pub struct AssetListResponse {
    pub total_count: i64,
    pub items: Vec&lt;ImageAsset&gt;,
    pub page: i32,
    pub size: i32,
}
&gt;
#[pymethods]
impl AssetListResponse {
    fn to_dict&lt;&#39;py&gt;(&amp;self, py: Python&lt;&#39;py&gt;) -&gt; PyResult&lt;Bound&lt;&#39;py, PyAny&gt;&gt; {
        pythonize::pythonize(py, self)
            .map_err(|e| PyRuntimeError::new_err(e.to_string()))
    }
}
&gt;
#[instrument(skip(pool, asset), fields(file = %asset.file_name))]
pub async fn insert_asset(pool: &amp;Pool&lt;Postgres&gt;, asset: ImageAsset) -&gt; Result&lt;Uuid, sqlx::Error&gt; {
    info!(&quot;이미지 자산 저장 중...&quot;);
&gt;
    let row = sqlx::query!(
        r#&quot;
        INSERT INTO image_assets (file_name, file_path, width, height, metadata)
        VALUES ($1, $2, $3, $4, $5)
        RETURNING id
        &quot;#,
        asset.file_name,
        asset.file_path,
        asset.width,
        asset.height,
        asset.metadata
    )
    .fetch_one(pool)
    .await
    .map_err(|e| {
        error!(&quot;Insert 쿼리 실패: {}&quot;, e);
        e
    })?;
&gt;
    info!(asset_id = %row.id, &quot;자산 저장 완료&quot;);
&gt;
    Ok(row.id)
}
&gt;
#[instrument(skip(pool))]
pub async fn search_assets(
    pool: &amp;Pool&lt;Postgres&gt;,
    name_query: Option&lt;String&gt;,
    format_query: Option&lt;String&gt;,
    limit: i64,
    offset: i64
) -&gt; Result&lt;AssetListResponse, sqlx::Error&gt; {
    let rows = sqlx::query!(
        r#&quot;
        SELECT id, file_name, file_path, width, height, metadata, count(*) OVER() as &quot;total_count!&quot;
        FROM image_assets
        WHERE ($1::TEXT IS NULL OR file_name ILIKE $1)
            AND ($2::JSONB IS NULL OR metadata @&gt; $2)
        ORDER BY id DESC
        LIMIT $3 OFFSET $4
        &quot;#,
        name_query.map(|n| format!(&quot;%{}%&quot;, n)),
        format_query.map(|f| serde_json::json!({&quot;format&quot;: f})),
        limit,
        offset
    )
    .fetch_all(pool)
    .await?;
&gt;
    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();
&gt;
    let size = limit as i32;
    let page = (offset as i32 / size) + 1;
&gt;
    Ok(AssetListResponse{ total_count, items, page, size })
}</code></pre>
<p><code>#[pyo3(signature = ())]</code> 를 사용하여 기본값을 지정하면
Python에서 인자를 선택적으로 넘길 수 있다.</p>
<blockquote>
<p><code>src/lib.rs</code></p>
</blockquote>
<pre><code class="language-rust">mod db;
mod logger;
mod image;
&gt;
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;
&gt;
static LOG_GUARD: OnceLock&lt;WorkerGuard&gt; = OnceLock::new();
static TOKIO_RUNTIME: OnceLock&lt;Runtime&gt; = OnceLock::new();
&gt;
fn get_runtime() -&gt; &amp;&#39;static Runtime {
    TOKIO_RUNTIME.get_or_init(|| {
        Runtime::new().expect(&quot;Tokio 런타임 생성 실패&quot;)
    })
}
&gt;
#[pyfunction]
fn init_engine() -&gt; PyResult&lt;String&gt; {
    info!(&quot;엔진 초기화 절차 시작&quot;);
&gt;
    debug!(&quot;로깅 시작&quot;);
    if LOG_GUARD.get().is_none() {
        let guard = logger::init_tracing();
        let _ = LOG_GUARD.set(guard);
    }
&gt;
    debug!(&quot;환경 변수 로드&quot;);
    dotenv().ok();
    let url = env::var(&quot;DATABASE_URL&quot;)
        .map_err(|e| {
            error!(&quot;DATABASE_URL 환경 변수를 찾을 수 없음: {}&quot;, e);
            pyo3::exceptions::PyRuntimeError::new_err(&quot;DATABASE_URL not found in .env&quot;)
        })?;
 &gt;   
    debug!(&quot;데이터베이스 연결 시작&quot;);
    let rt = get_runtime();
    rt.block_on(async {
        db::connect(&amp;url).await
    })
    .map_err(|e| {
        error!(&quot;DB 연결 풀 생성 실패: {}&quot;, e);
        pyo3::exceptions::PyRuntimeError::new_err(format!(&quot;DB connection failed: {}&quot;, e))
    })?;
&gt;
    Ok(&quot;엔진 초기화 및 DB 연결 성공&quot;.to_string())
}
&gt;
#[pyfunction]
fn shutdown_engine() -&gt; PyResult&lt;()&gt; {
    info!(&quot;엔진 종료 절차 시작&quot;);
 &gt;   
    let rt = get_runtime();
    rt.block_on(async {
        db::close().await;
    });
&gt;
    Ok(())
}
&gt;
#[pyfunction]
fn check_connection() -&gt; PyResult&lt;bool&gt; {
    debug!(&quot;DB 연결 상태 확인&quot;);
&gt;
    Ok(db::is_alive())
}
&gt;
#[pyfunction]
#[instrument(skip(metadata))]
fn add_image_asset(
    file_name: String,
    file_path: String,
    width: i32,
    height: i32,
    metadata: String
) -&gt; PyResult&lt;String&gt; {
    let rt = get_runtime();
&gt;
    let meta_json: serde_json::Value = serde_json::from_str(&amp;metadata)
        .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
&gt;
    let asset = image::ImageAsset {
        id: uuid::Uuid::nil(),
        file_name,
        file_path,
        width,
        height,
        metadata: meta_json,
    };
&gt;
    let pool = db::DB_POOL.get()
        .ok_or(pyo3::exceptions::PyRuntimeError::new_err(&quot;DB 연결 풀이 없습니다.&quot;))?;
&gt;
    let id = rt.block_on(async {
        image::insert_asset(pool, asset).await
    }).map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
&gt;
    Ok(id.to_string())
}
&gt;
#[pyfunction]
#[pyo3(signature = (name=None, format=None, limit=10, offset=0))]
fn search_assets(
    name: Option&lt;String&gt;,
    format: Option&lt;String&gt;,
    limit: i64,
    offset: i64
) -&gt; PyResult&lt;image::AssetListResponse&gt; {
    let rt = get_runtime();
    let pool = db::DB_POOL.get()
        .ok_or(pyo3::exceptions::PyRuntimeError::new_err(&quot;DB 연결 풀이 없습니다.&quot;))?;
&gt;
    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()))?;
&gt;
    Ok(results)
}
&gt;
#[pymodule]
fn rust_engine(m: &amp;Bound&lt;&#39;_, PyModule&gt;) -&gt; PyResult&lt;()&gt; {
    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)?)?;
&gt;
    Ok(())
}</code></pre>
<h3 id="python-코드">Python 코드</h3>
<p>기본값을 설정해 놓고
쿼리로 넘어온 값에 대해서만 값을 지정한다.</p>
<blockquote>
<p><code>app/main.py</code></p>
</blockquote>
<pre><code class="language-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
&gt;
logger = logging.getLogger(&quot;uvicorn.error&quot;)
&gt;
class UTF8ORJSONResponse(ORJSONResponse):
    media_type = &quot;application/json; charset=utf-8&quot;
&gt;
@asynccontextmanager
async def lifespan(app: FastAPI):
    logger.info(&quot;Rust 엔진 초기화 중...&quot;)
    try:
        msg = rust_engine.init_engine()
        logger.info(msg)
    except Exception as e:
        logger.info(f&quot;Rust 엔진 초기화 실패: {e}&quot;)
&gt;
    yield # 앱 가동
&gt;
    # [SHUTDOWN]
&gt;
    logger.info(&quot;서버 종료 감지: 자원 정리 중...&quot;)
    try:
        rust_engine.shutdown_engine()
        logger.info(&quot;모든 연결 안전하게 종료&quot;)
    except Exception as e:
        logger.error(f&quot;종료 중 오류 발생: {e}&quot;)
&gt;
app = FastAPI(default_response_class=UTF8ORJSONResponse, lifespan=lifespan)
&gt;
@app.get(&quot;/&quot;)
def read_root():
    return {
        &quot;status&quot;: &quot;200&quot;,
        &quot;info&quot;: &quot;서버 가동 중입니다.&quot;
    }
&gt;
@app.get(&quot;/db-status&quot;)
def get_db_status():
    logger.info(&quot;DB 상태 체크 요청&quot;)
    is_alive = rust_engine.check_connection()
    if is_alive:
        logger.info(&quot;DB 연결 상태 양호&quot;)
        return {
            &quot;status&quot;: &quot;online&quot;,
            &quot;message&quot;: &quot;Rust 엔진이 PostgreSQL을 사용합니다.&quot;
        }
    else:
        logger.error(&quot;DB 연결 끊김 감지&quot;)
        raise HTTPException(
            status_code=500,
            detail=&quot;DB 연결이 끊겼거나 초기화되지 않았습니다.&quot;
        )
&gt;
class ImageRequest(BaseModel):
    file_name: str
    file_path: str
    width: int
    height: int
    metadata: dict
&gt;
@app.post(&quot;/assets&quot;)
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)
        )
&gt;
        return {
            &quot;status&quot;: &quot;success&quot;,
            &quot;id&quot;: asset_id
        }
    except Exception as e:
        raise HTTPException(
            status_code=500,
            detail=str(e)
        )
&gt;
@app.get(&quot;/assets/search&quot;)
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
    )
&gt;
    return response.to_dict()</code></pre>
<h2 id="빌드-및-실행">빌드 및 실행</h2>
<p>Maturin 라이브러리를 통해 Rust 코드를 Python에서 호출 가능한 형태로 컴파일한다.
<code>pyproject.toml</code> 파일에 <code>build-backend = &quot;maturin&quot;</code> 이 명시되어 있으니
uv를 사용하는 방식으로 컴파일하겠다.</p>
<p>uvicorn 라이브러리를 통해 FastAPI를 실행한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/image-assets-management$ uv pip install -e .
~/workspace/image-assets-management$ uv run uvicorn app.main:app --reload</code></pre>
<p>curl 명령어 또는 브라우저를 통해 다음과 같은 테스트를 해볼 수 있다.</p>
<ul>
<li><code>http://127.0.0.1:8000/assets/search</code></li>
<li><code>http://127.0.0.1:8000/assets/search?fomrat_query=EXR</code></li>
<li><code>http://127.0.0.1:8000/assets/search?name_query=hero</code></li>
</ul>
<p>아무 인자를 전달하지 않으면 전체 데이터가 출력되며
<code>format_query</code> 및 <code>name_query</code> 를 통해 쿼리를 전달할 수 있다.
curl 명령어로 쿼리 문자열을 전달할 때는 URL을 따옴표로 묶어야 한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ 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
&gt;
{&quot;total_count&quot;:11,&quot;items&quot;:[{&quot;id&quot;:&quot;019d9441-cbba-74b9-9667-1b06b469045e&quot;,&quot;file_name&quot;:&quot;char_npc_bakery.png&quot;,&quot;file_path&quot;:&quot;/assets/textures/npc/&quot;,&quot;width&quot;:1024,&quot;height&quot;:1024,&quot;metadata&quot;:{&quot;alpha&quot;:false,&quot;format&quot;:&quot;PNG&quot;,&quot;type&quot;:&quot;concept&quot;}},{&quot;id&quot;:&quot;019d9441-cbb4-7f20-b5e8-285e9d3ced04&quot;,&quot;file_name&quot;:&quot;skybox_sunset.exr&quot;,&quot;file_path&quot;:&quot;/assets/env/skybox/&quot;,&quot;width&quot;:16384,&quot;height&quot;:8192,&quot;metadata&quot;:{&quot;format&quot;:&quot;EXR&quot;,&quot;layers&quot;:1,&quot;type&quot;:&quot;hdr&quot;}},{&quot;id&quot;:&quot;019d9441-cbaf-7961-86d0-fe5c565d96d0&quot;,&quot;file_name&quot;:&quot;fx_explosion_01.exr&quot;,&quot;file_path&quot;:&quot;/assets/fx/explosions/&quot;,&quot;width&quot;:2048,&quot;height&quot;:2048,&quot;metadata&quot;:{&quot;format&quot;:&quot;EXR&quot;,&quot;layers&quot;:32,&quot;type&quot;:&quot;vfx&quot;}},{&quot;id&quot;:&quot;019d9441-cbaa-7eb2-9c45-59a183cdb110&quot;,&quot;file_name&quot;:&quot;ref_material_metal.jpg&quot;,&quot;file_path&quot;:&quot;/assets/refs/materials/&quot;,&quot;width&quot;:2048,&quot;height&quot;:2048,&quot;metadata&quot;:{&quot;format&quot;:&quot;JPEG&quot;,&quot;quality&quot;:95,&quot;source&quot;:&quot;substance&quot;}},{&quot;id&quot;:&quot;019d9441-cba5-7fe1-a000-000623614320&quot;,&quot;file_name&quot;:&quot;ref_lighting_01.jpg&quot;,&quot;file_path&quot;:&quot;/assets/refs/lighting/&quot;,&quot;width&quot;:1920,&quot;height&quot;:1080,&quot;metadata&quot;:{&quot;format&quot;:&quot;JPEG&quot;,&quot;quality&quot;:90,&quot;source&quot;:&quot;outdoor&quot;}}],&quot;page&quot;:1,&quot;size&quot;:5}%    </code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ curl -i &quot;http://127.0.0.1:8000/assets/search?page=2&quot;          
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
&gt;
{&quot;total_count&quot;:11,&quot;items&quot;:[{&quot;id&quot;:&quot;019d9441-cba0-7d0e-9008-e2201e853806&quot;,&quot;file_name&quot;:&quot;ui_icon_star.png&quot;,&quot;file_path&quot;:&quot;/assets/ui/icons/&quot;,&quot;width&quot;:128,&quot;height&quot;:128,&quot;metadata&quot;:{&quot;alpha&quot;:true,&quot;format&quot;:&quot;PNG&quot;,&quot;theme&quot;:&quot;gold&quot;}},{&quot;id&quot;:&quot;019d9441-cb9c-7208-bcda-5061bbaf2cdc&quot;,&quot;file_name&quot;:&quot;ui_button_normal.png&quot;,&quot;file_path&quot;:&quot;/assets/ui/buttons/&quot;,&quot;width&quot;:256,&quot;height&quot;:256,&quot;metadata&quot;:{&quot;alpha&quot;:true,&quot;format&quot;:&quot;PNG&quot;,&quot;theme&quot;:&quot;dark&quot;}},{&quot;id&quot;:&quot;019d9441-cb97-7099-ab84-e6cc1c087381&quot;,&quot;file_name&quot;:&quot;env_forest_ground.exr&quot;,&quot;file_path&quot;:&quot;/assets/textures/env/&quot;,&quot;width&quot;:8192,&quot;height&quot;:4096,&quot;metadata&quot;:{&quot;format&quot;:&quot;EXR&quot;,&quot;layers&quot;:12,&quot;type&quot;:&quot;environment&quot;}},{&quot;id&quot;:&quot;019d9441-cb91-79bb-9bb5-4cd6326f0e68&quot;,&quot;file_name&quot;:&quot;char_hero_run.exr&quot;,&quot;file_path&quot;:&quot;/assets/textures/hero/&quot;,&quot;width&quot;:4096,&quot;height&quot;:4096,&quot;metadata&quot;:{&quot;format&quot;:&quot;EXR&quot;,&quot;layers&quot;:64,&quot;type&quot;:&quot;render&quot;}},{&quot;id&quot;:&quot;019d9441-cb87-7f42-b228-2e3590d90a05&quot;,&quot;file_name&quot;:&quot;char_hero_idle.exr&quot;,&quot;file_path&quot;:&quot;/assets/textures/hero/&quot;,&quot;width&quot;:4096,&quot;height&quot;:4096,&quot;metadata&quot;:{&quot;format&quot;:&quot;EXR&quot;,&quot;layers&quot;:64,&quot;type&quot;:&quot;render&quot;}}],&quot;page&quot;:2,&quot;size&quot;:5}% </code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ curl -i &quot;http://127.0.0.1:8000/assets/search?page=3&quot;
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
&gt;
{&quot;total_count&quot;:11,&quot;items&quot;:[{&quot;id&quot;:&quot;019d8eae-5943-76c4-9a85-195ce2c4f81a&quot;,&quot;file_name&quot;:&quot;hero_char_diffuse.exr&quot;,&quot;file_path&quot;:&quot;/assets/textures/hero/&quot;,&quot;width&quot;:4096,&quot;height&quot;:4096,&quot;metadata&quot;:{&quot;avg_luminance&quot;:0.45,&quot;format&quot;:&quot;EXR&quot;,&quot;layers&quot;:32}}],&quot;page&quot;:3,&quot;size&quot;:5}% </code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ curl -i &quot;http://127.0.0.1:8000/assets/search?format_query=EXR&quot;
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
&gt;
{&quot;total_count&quot;:6,&quot;items&quot;:[{&quot;id&quot;:&quot;019d9441-cbb4-7f20-b5e8-285e9d3ced04&quot;,&quot;file_name&quot;:&quot;skybox_sunset.exr&quot;,&quot;file_path&quot;:&quot;/assets/env/skybox/&quot;,&quot;width&quot;:16384,&quot;height&quot;:8192,&quot;metadata&quot;:{&quot;format&quot;:&quot;EXR&quot;,&quot;layers&quot;:1,&quot;type&quot;:&quot;hdr&quot;}},{&quot;id&quot;:&quot;019d9441-cbaf-7961-86d0-fe5c565d96d0&quot;,&quot;file_name&quot;:&quot;fx_explosion_01.exr&quot;,&quot;file_path&quot;:&quot;/assets/fx/explosions/&quot;,&quot;width&quot;:2048,&quot;height&quot;:2048,&quot;metadata&quot;:{&quot;format&quot;:&quot;EXR&quot;,&quot;layers&quot;:32,&quot;type&quot;:&quot;vfx&quot;}},{&quot;id&quot;:&quot;019d9441-cb97-7099-ab84-e6cc1c087381&quot;,&quot;file_name&quot;:&quot;env_forest_ground.exr&quot;,&quot;file_path&quot;:&quot;/assets/textures/env/&quot;,&quot;width&quot;:8192,&quot;height&quot;:4096,&quot;metadata&quot;:{&quot;format&quot;:&quot;EXR&quot;,&quot;layers&quot;:12,&quot;type&quot;:&quot;environment&quot;}},{&quot;id&quot;:&quot;019d9441-cb91-79bb-9bb5-4cd6326f0e68&quot;,&quot;file_name&quot;:&quot;char_hero_run.exr&quot;,&quot;file_path&quot;:&quot;/assets/textures/hero/&quot;,&quot;width&quot;:4096,&quot;height&quot;:4096,&quot;metadata&quot;:{&quot;format&quot;:&quot;EXR&quot;,&quot;layers&quot;:64,&quot;type&quot;:&quot;render&quot;}},{&quot;id&quot;:&quot;019d9441-cb87-7f42-b228-2e3590d90a05&quot;,&quot;file_name&quot;:&quot;char_hero_idle.exr&quot;,&quot;file_path&quot;:&quot;/assets/textures/hero/&quot;,&quot;width&quot;:4096,&quot;height&quot;:4096,&quot;metadata&quot;:{&quot;format&quot;:&quot;EXR&quot;,&quot;layers&quot;:64,&quot;type&quot;:&quot;render&quot;}}],&quot;page&quot;:1,&quot;size&quot;:5}% </code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ curl -i &quot;http://127.0.0.1:8000/assets/search?name_query=hero&quot; 
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
&gt;
{&quot;total_count&quot;:3,&quot;items&quot;:[{&quot;id&quot;:&quot;019d9441-cb91-79bb-9bb5-4cd6326f0e68&quot;,&quot;file_name&quot;:&quot;char_hero_run.exr&quot;,&quot;file_path&quot;:&quot;/assets/textures/hero/&quot;,&quot;width&quot;:4096,&quot;height&quot;:4096,&quot;metadata&quot;:{&quot;format&quot;:&quot;EXR&quot;,&quot;layers&quot;:64,&quot;type&quot;:&quot;render&quot;}},{&quot;id&quot;:&quot;019d9441-cb87-7f42-b228-2e3590d90a05&quot;,&quot;file_name&quot;:&quot;char_hero_idle.exr&quot;,&quot;file_path&quot;:&quot;/assets/textures/hero/&quot;,&quot;width&quot;:4096,&quot;height&quot;:4096,&quot;metadata&quot;:{&quot;format&quot;:&quot;EXR&quot;,&quot;layers&quot;:64,&quot;type&quot;:&quot;render&quot;}},{&quot;id&quot;:&quot;019d8eae-5943-76c4-9a85-195ce2c4f81a&quot;,&quot;file_name&quot;:&quot;hero_char_diffuse.exr&quot;,&quot;file_path&quot;:&quot;/assets/textures/hero/&quot;,&quot;width&quot;:4096,&quot;height&quot;:4096,&quot;metadata&quot;:{&quot;avg_luminance&quot;:0.45,&quot;format&quot;:&quot;EXR&quot;,&quot;layers&quot;:32}}],&quot;page&quot;:1,&quot;size&quot;:5}%    </code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[비정형 데이터를 다루는 데이터베이스 - 설계]]></title>
            <link>https://velog.io/@peeeeeter_j/pt-study-23</link>
            <guid>https://velog.io/@peeeeeter_j/pt-study-23</guid>
            <pubDate>Wed, 15 Apr 2026 01:15:55 GMT</pubDate>
            <description><![CDATA[<h1 id="비정형-데이터를-다루는-데이터베이스---설계">비정형 데이터를 다루는 데이터베이스 - 설계</h1>
<p>현대 백엔드 아키텍처의 핵심은 모든 것을 표에 넣는 것이 아니라,
데이터의 성격에 따라 저장 방식을 결정하는 것이다.
정형 데이터와 비정형 데이터라는 두 방식을
한 테이블 안에서 어떻게 효율적으로 관리할 수 있는지 알아보도록 하자.</p>
<p>비정형 데이터를 포함하기 위해
이미지 파일의 메타데이터를 관리하는 데이터베이스를 상정하고 실습을 진행하겠다.
메타데이터의 구성은 이미지 파일마다 다르다.
JSONB를 사용하면 스키마 재정의 없이 다양한 구성의 메타데이터를 관리할 수 있다.</p>
<p>이미지 자체를 DB에 넣을 경우(BLOB)
백업과 복구 성능이 $O(n)$ 으로 선형 증가하여 효율성이 떨어지므로
실제 데이터는 파일 시스템이나 스토리지에 두고,
그 메타데이터와 상태값만 DB에서 초고속으로 동기화하도록 한다.</p>
<h2 id="작업공간-생성-및-구조-확인">작업공간 생성 및 구조 확인</h2>
<p>기존에 만든 데이터베이스 템플릿을 복제해서 사용해 보겠다.
<code>.env</code> 파일과 <code>docker-compose.yml</code> 파일은 <a href="pt-study-20">⟨데이터베이스 연결⟩</a>이 최신 버전이고
Rust 코드 및 Python 코드는 <a href="pt-study-21">⟨데이터베이스 연결 재구조화⟩</a>가 최신 버전이고
<code>Cargo.toml</code> 파일과 <code>pyproject.toml</code> 파일은 <a href="pt-study-22">⟨(여담) uv 패키지 관리자⟩</a>가 최신 버전이다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/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        # 그대로 유지해도 무방</code></pre>
<p><code>pyproject.toml</code> 파일을 열어 프로젝트 이름을 수정해 주겠다.
나머지 의존성 부분은 이번 프로젝트에서도 사용하는 것들이니 가만히 둔다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/image-assets-management$ vi pyproject.toml</code></pre>
<blockquote>
<p><code>pyproject.toml</code></p>
</blockquote>
<pre><code class="language-toml">[build-system]
requires = [&quot;maturin&gt;=1.12,&lt;2.0&quot;]
build-backend = &quot;maturin&quot;
&gt;
[project]
name = &quot;image-assets-management&quot;
requires-python = &quot;&gt;=3.8&quot;
classifiers = [
    &quot;Programming Language :: Rust&quot;,
    &quot;Programming Language :: Python :: Implementation :: CPython&quot;,
    &quot;Programming Language :: Python :: Implementation :: PyPy&quot;,
]
dynamic = [&quot;version&quot;]
dependencies = [
    &quot;fastapi&gt;=0.124.4&quot;,
    &quot;orjson&gt;=3.10.15&quot;,
    &quot;uvicorn&gt;=0.33.0&quot;,
]
&gt;
[dependency-groups]
dev = [
    &quot;maturin&gt;=1.12.6&quot;,
]</code></pre>
<p>다음과 같이 <code>uv</code> 를 통해 <code>pyproject.toml</code> 파일의
모든 의존성을 설치할 수 있다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/image-assets-management$ uv sync</code></pre>
<p>마찬가지로 <code>Cargo.toml</code> 파일도 수정해 준다.</p>
<p>이 때, 필요한 크레이트도 추가해 주도록 하자.
데이터베이스의 기본값으로 사용할 UUID를 위해 <a href="https://docs.rs/uuid/latest/uuid">uuid</a> 크레이트를 추가하고
(UUID가 뭔지 궁금하다면 해당 크레이트에도 설명이 나와 있으니 클릭해 보자)
생성 일시를 기록하기 위해 <a href="https://docs.rs/chrono/latest/chrono">chrono</a> 크레이트를 추가하고
JSON 데이터를 다루기 위해 <a href="https://docs.rs/serde_json/latest/serde_json">serde_json</a> 크레이트도 추가한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/image-assets-management$ vi Cargo.toml</code></pre>
<blockquote>
<p><code>Cargo.toml</code></p>
</blockquote>
<pre><code class="language-toml">[package]
name = &quot;image-assets-management&quot;
version = &quot;0.1.0&quot;
edition = &quot;2024&quot;
&gt;
[lib]
name = &quot;rust_engine&quot;
crate-type = [&quot;cdylib&quot;]
&gt;
[dependencies]
pyo3 = &quot;0.28.0&quot;
sqlx = { version = &quot;0.8&quot;, features = [&quot;runtime-tokio&quot;, &quot;tls-rustls&quot;, &quot;postgres&quot;, &quot;macros&quot;, &quot;uuid&quot;, &quot;chrono&quot;, &quot;json&quot;] }
tokio = { version = &quot;1.43&quot;, features = [&quot;full&quot;] }
tracing-appender = &quot;0.2&quot;
tracing = &quot;0.1&quot;
tracing-subscriber = { version = &quot;0.3&quot;, features = [&quot;env-filter&quot;, &quot;json&quot;] }
dotenvy = &quot;0.15&quot;
uuid = { version = &quot;1.23&quot;, features = [&quot;v7&quot;, &quot;serde&quot;] }
chrono = { version = &quot;0.4&quot;, features = [&quot;serde&quot;] }
serde_json = &quot;1.0&quot;</code></pre>
<p>템플릿 프로젝트의 DB 정보를 그대로 사용하면 DB가 꼬이게 될 테니
DB 정보도 수정해 준다.</p>
<p><code>docker-compose.yml</code> 의 변경 필요한 부분은 전부 환경변수로 되어 있으니
<code>.env</code> 파일만 편집하면 된다.</p>
<p>계정 정보는 그대로 사용해도 무방하지만
<code>COMPOSE_PROJECT_NAME</code> 과 <code>POSTGRES_DB</code> 는 수정해 주어야 한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/image-assets-management$ vi .env</code></pre>
<blockquote>
<p><code>.env</code></p>
</blockquote>
<pre><code class="language-bash"># Docker Compose Project
COMPOSE_PROJECT_NAME=image_assets_management
&gt;
# PostgreSQL
POSTGRES_USER=peter
POSTGRES_PASSWORD=ku201711424
POSTGRES_DB=image_db
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432
&gt;
# pgAdmin
PGADMIN_EMAIL=admin@pjos.dev
PGADMIN_PASSWORD=admin201711424
&gt;
# SQLX
DATABASE_URL=postgres://peter:ku201711424@127.0.0.1:5432/image_db?sslmode=disable</code></pre>
<p>재차 말하지만 학습 기록용이니까 업로드하지 <code>.env</code> 파일의 내용은 유출하는 거 아니다. 보안 이슈!</p>
<h2 id="db-설정">DB 설정</h2>
<h3 id="스키마">스키마</h3>
<p>이미지 정보를 담을 데이터베이스 테이블을 만들 것이다.
사용할 데이터베이스 테이블의 구성은 다음과 같다.</p>
<table>
<thead>
<tr>
<th align="center">변수명</th>
<th align="center">자료형</th>
<th align="center">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><code>id</code></td>
<td align="center">UUID</td>
<td align="center">자동으로 생성되는 고유 식별자로, 기본키로 사용된다.</td>
</tr>
<tr>
<td align="center"><code>file_name</code></td>
<td align="center">TEXT</td>
<td align="center">이미지 파일의 이름</td>
</tr>
<tr>
<td align="center"><code>file_path</code></td>
<td align="center">TEXT</td>
<td align="center">이미지 자체가 아닌 메타데이터만 저장할 것이므로 이미지 경로가 필요하다.</td>
</tr>
<tr>
<td align="center"><code>width</code></td>
<td align="center">INTEGER</td>
<td align="center">이미지 파일의 가로 크기</td>
</tr>
<tr>
<td align="center"><code>height</code></td>
<td align="center">INTEGER</td>
<td align="center">이미지 파일의 세로 크기</td>
</tr>
<tr>
<td align="center"><code>metadata</code></td>
<td align="center">JSONB</td>
<td align="center">메타데이터가 담긴 JSON 데이터를 처리하기 좋게 JSONB로 저장한다.</td>
</tr>
<tr>
<td align="center"><code>created_at</code></td>
<td align="center">TIMESTAMPTZ</td>
<td align="center">데이터를 저장한 시점에 자동으로 작성되는 타임스탬프</td>
</tr>
</tbody></table>
<p>이 데이터베이스 테이블이 존재하지 않는 경우에만
새로 생성하는 코드를 작성해 보자.
테이블 이름은 <code>image_assets</code> 로 하겠다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/image-assets-management$ mkdir scripts
~/workspace/image-assets-management$ vi scripts/init.sql</code></pre>
<blockquote>
<p><code>scripts/init.sql</code></p>
</blockquote>
<pre><code class="language-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
};</code></pre>
<p><code>uuidv7()</code> 은 UUID v7을 사용하겠다고 하는 것이고
UUID v4를 사용하고자 한다면 <code>gen_random_uuid()</code> 를 사용한다.</p>
<p>파일을 생성하지 않고 터미널에서 직접 쿼리를 날려도 되지만
따로 파일을 작성하는 편이 유지보수 측면에서 효과적이다.</p>
<h3 id="docker">docker</h3>
<p>PostgreSQL을 docker 서비스로 실행한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/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       &quot;/entrypoint.sh&quot;          5 seconds ago   Up 5 seconds   0.0.0.0:8080-&gt;80/tcp, [::]:8080-&gt;80/tcp       pgadmin_ui
0ca8e65aa3bd   postgres:18-alpine   &quot;docker-entrypoint.s…&quot;   5 seconds ago   Up 5 seconds   0.0.0.0:5432-&gt;5432/tcp, [::]:5432-&gt;5432/tcp   image_assets_management_postgres</code></pre>
<p><code>image_assets_management_postgres</code> 내부에서 작업을 수행할 것이다.</p>
<p>대화형 터미널을 열어 쿼리를 직접 날리고 싶을 땐
다음과 같이 <code>-it</code> 를 붙여 사용하지만</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/image-assets-management$ docker exec -it image_assets_management_postgres psql -U peter -d image_db</code></pre>
<p>우리는 대화형으로 쿼리를 날리지 않고
기존에 작성해 놓은 쿼리 파일을 전달할 것이므로
<code>-i</code> 를 사용하고 파이프라인으로 전달한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/image-assets-management$ docker exec -i image_assets_management_postgres psql -U peter -d image_db &lt; scripts/init.sql</code></pre>
<p><code>-U</code> 뒤에 오는 건 <code>.env</code> 의 <code>POSTGRES_USER</code> 이며
<code>-d</code> 뒤에 오는 건 <code>.env</code> 의 <code>POSTGRES_DB</code> 이다.
실무에서 여럿이서 작업할 경우 <code>POSTGRES_USER</code> 는 <code>postgres</code> 로 설정하는 경우가 많다.</p>
<p>PostgreSQL 컨테이너는 데이터 볼륨이 이미 존재하면
<code>.env</code> 의 설정이 바뀌어도 새로운 사용자를 추가로 만들지 않으니
다른 설정으로 컨테이너를 띄운 적이 있을 때
사용자 이름을 변경하고자 한다면
볼륨을 지우고 다시 시작해야 바뀐 설정이 적용된다.</p>
<p>다음과 같이 데이터베이스 테이블이 생성된 것을 확인할 수 있다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/image-assets-management$ docker exec -it image_assets_management_postgres psql -U peter -d image_db -c &quot;\dt&quot;
            List of tables
 Schema |     Name     | Type  | Owner 
--------+--------------+-------+-------
 public | image_assets | table | peter
(1 row)</code></pre>
<p>그리고 그것은 아직 비어 있다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/image-assets-management$ docker exec -it image_assets_management_postgres psql -U peter -d image_db -c &quot;SELECT id, file_name, created_at FROM image_assets;&quot;
 id | file_name | created_at 
----+-----------+------------
(0 rows)</code></pre>
<p>여기에 데이터를 넣는 코드를 작성해 보자.</p>
<h2 id="코드-작성">코드 작성</h2>
<h3 id="rust-코드">Rust 코드</h3>
<p>기존의 로그 관리 모듈, 데이터베이스 관리 모듈, 인터페이스 모듈 외에
이미지 데이터를 다루는 Rust 파일을 새로 생성한다.</p>
<blockquote>
<p><code>src/image.rs</code></p>
</blockquote>
<pre><code class="language-rust">use sqlx::{Pool, Postgres};
use uuid::Uuid;
use serde_json::Value as JsonValue;
use tracing::{info, instrument, error};
&gt;
pub struct ImageAsset {
    pub file_name: String,
    pub file_path: String,
    pub width: i32,
    pub height: i32,
    pub metadata: JsonValue,
}
&gt;
#[instrument(skip(pool, asset), fields(file = %asset.file_name))]
pub async fn insert_asset(pool: &amp;Pool&lt;Postgres&gt;, asset: ImageAsset) -&gt; Result&lt;Uuid, sqlx::Error&gt; {
    info!(&quot;이미지 자산 저장 중...&quot;);
&gt;
    let row = sqlx::query!(
        r#&quot;
        INSERT INTO image_assets (file_name, file_path, width, height, metadata)
        VALUES ($1, $2, $3, $4, $5)
        RETURNING id
        &quot;#,
        asset.file_name,
        asset.file_path,
        asset.width,
        asset.height,
        asset.metadata
    )
    .fetch_one(pool)
    .await
    .map_err(|e| {
        error!(&quot;Insert 쿼리 실패: {}&quot;, e);
        e
    })?;
&gt;
    info!(asset_id = %row.id, &quot;자산 저장 완료&quot;);
&gt;
    Ok(row.id)
}</code></pre>
<p>새로 추가한 이미지 모듈에 대한 정보를
인터페이스 모듈에 등록한다.</p>
<blockquote>
<p><code>src/lib.rs</code></p>
</blockquote>
<pre><code class="language-rust">mod db;
mod logger;
mod image; // NEW!
&gt;
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;
&gt;
static LOG_GUARD: OnceLock&lt;WorkerGuard&gt; = OnceLock::new();
static TOKIO_RUNTIME: OnceLock&lt;Runtime&gt; = OnceLock::new(); // NEW!
&gt;
&gt; // 추가
fn get_runtime() -&gt; &amp;&#39;static Runtime {
    TOKIO_RUNTIME.get_or_init(|| {
        Runtime::new().expect(&quot;Tokio 런타임 생성 실패&quot;)
    })
}
&gt;
#[pyfunction]
fn init_engine() -&gt; PyResult&lt;String&gt; {
    info!(&quot;엔진 초기화 절차 시작&quot;);
&gt;
    debug!(&quot;로깅 시작&quot;);
    if LOG_GUARD.get().is_none() {
        let guard = logger::init_tracing();
        let _ = LOG_GUARD.set(guard);
    }
&gt;
    debug!(&quot;환경 변수 로드&quot;);
    dotenv().ok();
    let url = env::var(&quot;DATABASE_URL&quot;)
        .map_err(|e| {
            error!(&quot;DATABASE_URL 환경 변수를 찾을 수 없음: {}&quot;, e);
            pyo3::exceptions::PyRuntimeError::new_err(&quot;DATABASE_URL not found in .env&quot;)
        })?;
 &gt;   
    debug!(&quot;데이터베이스 연결 시작&quot;);
    let rt = get_runtime(); // MODIFIED!
    rt.block_on(async {
        db::connect(&amp;url).await
    })
    .map_err(|e| {
        error!(&quot;DB 연결 풀 생성 실패: {}&quot;, e);
        pyo3::exceptions::PyRuntimeError::new_err(format!(&quot;DB connection failed: {}&quot;, e))
    })?;
&gt;
    Ok(&quot;엔진 초기화 및 DB 연결 성공&quot;.to_string())
}
&gt;
#[pyfunction]
fn shutdown_engine() -&gt; PyResult&lt;()&gt; {
    info!(&quot;엔진 종료 절차 시작&quot;);
 &gt;   
    let rt = Runtime::new().unwrap();
    rt.block_on(async {
        db::close().await;
    });
&gt;
    Ok(())
}
&gt;
#[pyfunction]
fn check_connection() -&gt; PyResult&lt;bool&gt; {
    debug!(&quot;DB 연결 상태 확인&quot;);
&gt;
    Ok(db::is_alive())
}
&gt;
// 이상 기존 코드
&gt;
#[pyfunction]
#[instrument(skip(metadata))]
fn add_image_asset(
    file_name: String,
    file_path: String,
    width: i32,
    height: i32,
    metadata: String
) -&gt; PyResult&lt;String&gt; {
    let rt = get_runtime(); // MODIFIED!
&gt;
    let meta_json: serde_json::Value = serde_json::from_str(&amp;metadata)
        .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
&gt;
    let asset = image::ImageAsset {
        file_name, file_path, width, height, metadata: meta_json
    };
&gt;
    let pool = db::DB_POOL.get()
        .ok_or(pyo3::exceptions::PyRuntimeError::new_err(&quot;DB 연결 풀이 없습니다.&quot;))?;
&gt;
    let id = rt.block_on(async {
        image::insert_asset(pool, asset).await
    }).map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
&gt;
    Ok(id.to_string())
}
&gt;
#[pymodule]
fn rust_engine(m: &amp;Bound&lt;&#39;_, PyModule&gt;) -&gt; PyResult&lt;()&gt; {
    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!
&gt;
    Ok(())
}</code></pre>
<h3 id="python-코드">Python 코드</h3>
<blockquote>
<p><code>app/main.py</code></p>
</blockquote>
<pre><code class="language-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!
&gt;
logger = logging.getLogger(&quot;uvicorn.error&quot;)
&gt;
class UTF8ORJSONResponse(ORJSONResponse):
    media_type = &quot;application/json; charset=utf-8&quot;
&gt;
@asynccontextmanager
async def lifespan(app: FastAPI):
    logger.info(&quot;Rust 엔진 초기화 중...&quot;)
    try:
        msg = rust_engine.init_engine()
        logger.info(msg)
    except Exception as e:
        logger.info(f&quot;Rust 엔진 초기화 실패: {e}&quot;)
&gt;
    yield # 앱 가동
&gt;
    # [SHUTDOWN]
&gt;
    logger.info(&quot;서버 종료 감지: 자원 정리 중...&quot;)
    try:
        rust_engine.shutdown_engine()
        logger.info(&quot;모든 연결 안전하게 종료&quot;)
    except Exception as e:
        logger.error(f&quot;종료 중 오류 발생: {e}&quot;)
&gt;
app = FastAPI(default_response_class=UTF8ORJSONResponse, lifespan=lifespan)
&gt;
@app.get(&quot;/&quot;)
def read_root():
    return {
        &quot;status&quot;: &quot;200&quot;,
        &quot;info&quot;: &quot;서버 가동 중입니다.&quot;
    }
&gt;
@app.get(&quot;/db-status&quot;)
def get_db_status():
    logger.info(&quot;DB 상태 체크 요청&quot;)
    is_alive = rust_engine.check_connection()
    if is_alive:
        logger.info(&quot;DB 연결 상태 양호&quot;)
        return {
            &quot;status&quot;: &quot;online&quot;,
            &quot;message&quot;: &quot;Rust 엔진이 PostgreSQL을 사용합니다.&quot;
        }
    else:
        logger.error(&quot;DB 연결 끊김 감지&quot;)
        raise HTTPException(
            status_code=500,
            detail=&quot;DB 연결이 끊겼거나 초기화되지 않았습니다.&quot;
        )
&gt;
class ImageRequest(BaseModel):
    file_name: str
    file_path: str
    width: int
    height: int
    metadata: dict
&gt;
# 이상 기존 코드
&gt;
@app.post(&quot;/assets&quot;)
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 {
            &quot;status&quot;: &quot;success&quot;,
            &quot;id&quot;: asset_id
        }
    except Exception as e:
        raise HTTPException(
            status_code=500,
            detail=str(e)
        )</code></pre>
<h2 id="빌드-및-실행">빌드 및 실행</h2>
<p>테스트용 더미 파일을 생성한다.</p>
<blockquote>
<p><code>create_assets.py</code></p>
</blockquote>
<pre><code class="language-py">import os
&gt;
def create_dummy_assets():
    # 에셋 저장 폴더 생성
    base_dir = &quot;assets/textures/hero&quot;
    os.makedirs(base_dir, exist_ok=True)
&gt;  
    # 생성할 파일 목록 (파일명, 크기)
    files = {
        &quot;hero_char_diffuse.exr&quot;: 1024 * 10, # 10KB dummy
        &quot;ui_button_hover.png&quot;: 1024 * 2,
        &quot;env_forest_01_norm.tga&quot;: 1024 * 5,
        &quot;emotion_icon_joy.svg&quot;: 1024 * 1
    }
&gt;   
    print(f&quot;&#39;{base_dir}&#39; 폴더에 테스트 에셋을 생성합니다...&quot;)
    for name, size in files.items():
        path = os.path.join(base_dir, name)
        with open(path, &quot;wb&quot;) as f:
            f.write(os.urandom(size)) # 실제 바이너리 데이터처럼 보이기 위해 난수 입력
        print(f&quot;생성 완료: {name} ({size} bytes)&quot;)
&gt;
if __name__ == &quot;__main__&quot;:
    create_dummy_assets()</code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/image-assets-management$ uv run create_assets.py 
&#39;assets/textures/hero&#39; 폴더에 테스트 에셋을 생성합니다...
생성 완료: 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)</code></pre>
<p>Maturin 라이브러리를 통해 Rust 코드를 Python에서 호출 가능한 형태로 컴파일한다.</p>
<p>uvicorn 라이브러리를 통해 FastAPI를 실행한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/image-assets-management$ uv run maturin develop
~/workspace/image-assets-management$ uv run uvicorn app.main:app --reload</code></pre>
<p>curl 명령어 또는 브라우저를 통해 다음과 같은 테스트를 해볼 수 있다.</p>
<ul>
<li><code>http://127.0.0.1:8000/assets</code></li>
</ul>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ curl -X POST &quot;http://localhost:8000/assets&quot; \
     -H &quot;Content-Type: application/json&quot; \
     -d &#39;{
       &quot;file_name&quot;: &quot;hero_char_diffuse.exr&quot;,
       &quot;file_path&quot;: &quot;/assets/textures/hero/&quot;,
       &quot;width&quot;: 4096,
       &quot;height&quot;: 4096,
       &quot;metadata&quot;: {&quot;format&quot;: &quot;EXR&quot;, &quot;layers&quot;: 32, &quot;avg_luminance&quot;: 0.45}
     }&#39;
{&quot;status&quot;:&quot;success&quot;,&quot;id&quot;:&quot;019d8eae-5943-76c4-9a85-195ce2c4f81a&quot;}%</code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/image-assets-management$ docker exec -it image_assets_management_postgres psql -U peter -d image_db -c &quot;SELECT id, file_name, created_at FROM image_assets;&quot;
                  id                  |       file_name       |          created_at          
--------------------------------------+-----------------------+------------------------------
 019d8eae-5943-76c4-9a85-195ce2c4f81a | hero_char_diffuse.exr | 2026-04-15 01:08:04.03512+00
(1 row)</code></pre>
<h3 id="여담">여담</h3>
<blockquote>
</blockquote>
<ul>
<li><strong>실무적 팁</strong>: 최근 가장 현대적인 패키지 관리자인 uv는 속도가 빠른 대신 &#39;캐싱(Caching)&#39;이 매우 공격적입니다. <code>uv run maturin develop</code> 을 실행할 때 가상환경은 활성화되지만, 파이썬 쪽 스크립트 시그니처나 의존성이 크게 바뀌지 않았다고 판단하면 uv의 캐시나 이전 빌드 잔재 때문에 <code>.so</code> 나 <code>.pyd</code> 확장이 제대로 덮어씌워지지 않는 경우가 잦습니다.</li>
<li><strong>해결책</strong>: 이럴 때는 억지로 원인을 찾으며 스트레스(자형)를 받기보다, 아주 물리적인 방식으로 접근하는 것이 좋습니다. <code>uv cache clean</code> 으로 캐시를 강제로 날려버리거나, Rust의 <code>target/</code> 폴더를 통째로 날리고 재빌드하거나, 아예 <code>uv pip install -e .</code> (<code>pyproject.toml</code> 에 maturin 백엔드가 설정된 경우) 방식으로 링킹을 강제하는 것이 멘탈 방어에 유리합니다.<blockquote>
</blockquote>
</li>
<li><em>➔ Rust 코드를 컴파일해도 <code>rust_engine</code> 이 변하지 않는다면 <code>uv pip install -e .</code> 를 하자.*</em></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[(여담) uv 패키지 관리자]]></title>
            <link>https://velog.io/@peeeeeter_j/pt-study-22</link>
            <guid>https://velog.io/@peeeeeter_j/pt-study-22</guid>
            <pubDate>Thu, 09 Apr 2026 01:30:20 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>pip에서 uv로 갈아타려고 찾아보다가 이 게시물을 발견했다면
맨 아래 <a href="#%EC%97%AC%EB%8B%B4">여담</a> 부분 확인 바란다.</p>
</blockquote>
<h1 id="uv-패키지-관리자">uv 패키지 관리자</h1>
<p>지금까지 우리는 pip 패키지 관리자를 사용하여 프로젝트를 관리했다.
그런데 Gemini 녀석이 프로젝트 관리와 관련해서
더 알아보아야 할 게 있다면 설명해 달라고 하니까 그제서야
다음과 같은 이야기를 한다.</p>
<blockquote>
</blockquote>
<ol>
<li>현대적인 의존성 관리 (uv &amp; Cargo)<blockquote>
</blockquote>
파이썬 생태계에서는 최근 uv라는 도구가 표준으로 자리 잡고 있습니다. [cite: 2026-03-20]<blockquote>
</blockquote>
현대적 방식: pip 대신 Rust로 작성된 초고속 패키지 매니저인 <strong>uv</strong>를 사용하여 pyproject.toml로 의존성을 관리하세요. [cite: 2026-03-20]<blockquote>
</blockquote>
이점: Rust의 Cargo와 철학이 비슷하여 패키지 설치 속도가 압도적이며, 프로젝트별 가상 환경 관리가 매우 견고합니다. [cite: 2026-03-20]</li>
</ol>
<p>처음 듣는 녀석인데, 하고 살펴보니 2024년에 출시되어
Python 생태계의 새로운 사실상 표준으로서 자리 잡아가고 있는 녀석이라고 한다.</p>
<p>Rust로 작성되어 매우 빠르고, (역시 Rust!)
기존에 여러 도구로 분산되어 있던 핵심 기능들을 단일 CLI로 통합하여 제공하고,
여러 가지 현대적인 기법과 최적화 알고리즘이 적용되어 있다고...</p>
<p>Gemini 녀석, 그걸 왜 이제 알려주는 거람.
오래된 버전 제시해 주는 거라거나 내가 인지한 이슈에 대해서는
보완해서 예제를 수정해 달라고 요청하며 학습하고 있었는데
uv는 내가 이 분야를 완전히 등지고 있던 사이에 출시된 녀석이라
전혀 인지하지 못하고 있었다.</p>
<p>하여간 이런 녀석을 인지한 순간 굳이 pip를 사용할 이유는 없지.
작업공간 생성 프로세스를 수정해 보자.</p>
<h2 id="uv-명령어">uv 명령어</h2>
<p>먼저 uv를 설치해야 한다.
명령어 한 줄이면 금방 설치된다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ curl -LsSf https://astral.sh/uv/install.sh | sh
downloading uv 0.11.5 aarch64-apple-darwin
installing to /Users/edenjint3927/.local/bin
  uv
  uvx
everything&#39;s installed!
&gt;
To add $HOME/.local/bin to your PATH, either restart your shell or run:
&gt;
    source $HOME/.local/bin/env (sh, bash, zsh)
    source $HOME/.local/bin/env.fish (fish)</code></pre>
<p><strong>(우리는 <code>uv init</code> 를 사용하지 않을 거지만)</strong>
<code>uv init</code> 명령으로 프로젝트를 초기화한 뒤
<code>uv add</code> 로 필요한 패키지를 설치하면
자동으로 가상환경을 위한 <code>.venv</code> 디렉토리가 생성되며
이 프로젝트에서 사용 가능하게 로컬로 설치된다.</p>
<p>런타임에서 필요한 패키지는 그냥 <code>uv add</code> 로 설치하면 되고
빌드 타임 도구는 런타임까지 남겨 놓으면 프로젝트가 불필요하게 커지므로
<code>uv add --dev</code> 로 설치하여 빌드 타임에만 필요함을 명시한다.</p>
<p>기존에 추가한 패키지를 제거하고 싶으면 <code>uv remove</code> 를 사용하면 된다.</p>
<p><code>uv add</code> 로 설치한 패키지들의 정보는 <code>pyproject.toml</code> 파일에 기록되며
<code>uv sync</code> 를 통해 현재 설치된 버전과 기록된 버전이 일치하는지 검증하고
로컬에 설치되지 않은 패키지를 설치할 수 있다.
나중에 프로젝트를 템플릿으로 복제해서 사용할 때는
<code>uv add</code> 과정을 생략하고 <code>uv sync</code> 를 통해
<code>pyproject.toml</code> 파일에 기록된 패키지를 설치하면 된다.</p>
<p>프로젝트를 처음 초기화하면 다음과 같은 구조가 자동 생성된다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/project$ ls -a
.        ..        .git        .gitignore    .python-version    main.py        pyproject.toml    README.md</code></pre>
<p>uv 환경에서는 <code>source .venv/bin/activate</code> 와 같은 명령어를 통해
가상환경을 실행하여 직접 들어갈 필요 없이
<code>uv run</code> 명령어로 가상환경에서 Python을 실행할 수 있다.</p>
<p><code>uv run main.py</code> 로 Python 서버를 실행할 수 있다지만
우리는 바닐라 Python 서버를 사용할 게 아니므로 그 실습은 패스한다.
언젠가 Python 테스트 코드를 작성하고 나서야
<code>uv run test.py</code> 라는 식으로 스크립트를 실행하게 되지 않을까.</p>
<p>Maturin 빌드는 마찬가지로 <code>uv run</code> 을 붙여
<code>uv run maturin develop</code> 로 실행하며
uvicorn 실행도 <code>uv run uvicorn app.main:app</code> 로 실행한다.</p>
<p>파일 상단에 <code>#! /usr/bin/env uv run</code> 같은
Shebang을 넣어서 실행 권한만 주면 <code>uv run</code> 을 생략할 수도 있다.</p>
<p>IDE 환경에서는 에디터 우측 하단의 파이썬 인터프리터 설정에서
프로젝트 폴더 내의 <code>.venv/bin/python</code> 을 지정해 주어야 한다고 하는데
프로젝터 규모가 커지기 전까지는 zsh+vim 환경에서 실습할 것이므로
그냥 알아만 두도록 하자.</p>
<p>앞서 우리는 <code>uv init</code> 을 사용하지 않을 거라고 했는데,
<code>uv init</code> 으로 프로젝트의 뼈대를 잡은 후에 <code>maturin init</code> 을 하고자 하면
이미 생성된 프로젝트에는 <code>maturin init</code> 을 못 한다고 오류가 날 테니
<code>uvx maturin init</code> 으로 프로젝트 구조를 잡고 시작한다.
<code>uvx</code> 는 아직 <code>uv init</code> 하지 않은 프로젝트에서
특정 패키지를 임시로 사용하고 보내준다.
<code>uvx maturin init</code> 을 해주었으면 <code>uv init</code> 는 생략한다.
둘 다 <code>pyproject.toml</code> 를 생성하여 설계도를 만드는 역할이라
(그러면서 부수적인 파일들도 생성하지만)
중복해서 사용할 경우 충돌이 일어난다.</p>
<p>작업을 하다 환경이 꼬일 경우
<code>rm -rf target .venv</code> 실행 후 <code>uv sync</code> 하여
패키지를 다시 설치할 수 있다.</p>
<h2 id="작업공간-생성-및-구조-확인">작업공간 생성 및 구조 확인</h2>
<p>데이터베이스를 사용하는 프로젝트의 템플릿 만들었던 것부터
uv 활용으로 바꾸면 좋을 것 같다.
그 전의 예제들은 어차피 지난 예제니까 그대로 두고
지금부터 uv를 사용하면 되는 거니까.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace$ mkdir db-template &amp; cd db-template
~/workspace/db-template$ uvx maturin init # 프로젝트 초기화
~/workspace/db-template$ # 선택지 중 기본값인 PyO3 선택
~/workspace/db-template$ uv add fastapi uvicorn orjson # 실행 의존성 추가
~/workspace/db-template$ uv add --dev maturin # 개발 의존성 추가
~/workspace/db-template$ uv sync # 의존성 검증 및 누락된 패키지 설치
~/workspace/db-template$ mkdir app &amp;&amp; touch app/main.py # Python 파일 직접 생성
~/workspace/db-template$ tree -a -I .venv -I target
.
├── .github
│   └── workflows
│       └── CI.yml
├── .gitignore
├── app
│   └── main.py
├── Cargo.lock
├── Cargo.toml
├── pyproject.toml
├── src
│   └── lib.rs
└── uv.lock</code></pre>
<p>사실 여기서는 기존에 작성한 코드를 가져올 것이기 때문에
<code>app/main.py</code> 를 생성하지 않아도 무방하지만
전체적인 작업 공간 확인을 위해 생성했다.</p>
<p><code>uv add</code> 및 <code>uv add --dev</code> 를 통해 의존성 패키지를 설치하면
다음과 같이 <code>pyproject.toml</code> 파일의 적절한 위치에 해당 내용이 자동으로 기록된다.</p>
<blockquote>
<p><code>pyproject.toml</code></p>
</blockquote>
<pre><code class="language-toml">[build-system]
requires = [&quot;maturin&gt;=1.12,&lt;2.0&quot;]
build-backend = &quot;maturin&quot;
&gt;
[project]
name = &quot;db-template&quot;
requires-python = &quot;&gt;=3.8&quot;
classifiers = [
    &quot;Programming Language :: Rust&quot;,
    &quot;Programming Language :: Python :: Implementation :: CPython&quot;,
    &quot;Programming Language :: Python :: Implementation :: PyPy&quot;,
]
dynamic = [&quot;version&quot;]
dependencies = [
    &quot;fastapi&gt;=0.124.4&quot;,
    &quot;orjson&gt;=3.10.15&quot;,
    &quot;uvicorn&gt;=0.33.0&quot;,
]
&gt;
[dependency-groups]
dev = [
    &quot;maturin&gt;=1.12.6&quot;,
]</code></pre>
<h2 id="코드-이전">코드 이전</h2>
<h3 id="cargotoml">Cargo.toml</h3>
<p><code>[lib]</code> 의 <code>name</code> 을 늘 하던 대로 <code>&quot;rust_engine&quot;</code> 으로 수정하고
기존 프로젝트의 <code>[dev]</code> 부분을 가져온다.</p>
<blockquote>
<p><code>Cargo.toml</code></p>
</blockquote>
<pre><code class="language-toml">[package]
name = &quot;db-template&quot;
version = &quot;0.1.0&quot;
edition = &quot;2024&quot;
&gt;
[lib]
name = &quot;rust_engine&quot;
crate-type = [&quot;cdylib&quot;]
&gt;
[dependencies]
pyo3 = &quot;0.28.0&quot;
sqlx = { version = &quot;0.8&quot;, features = [&quot;runtime-tokio&quot;, &quot;tls-rustls&quot;, &quot;postgres&quot;, &quot;macros&quot;] }
tokio = { version = &quot;1.43&quot;, features = [&quot;full&quot;] }
tracing-appender = &quot;0.2&quot;
tracing = &quot;0.1&quot;
tracing-subscriber = { version = &quot;0.3&quot;, features = [&quot;env-filter&quot;, &quot;json&quot;] }
dotenvy = &quot;0.15&quot;</code></pre>
<h3 id="rust-코드-및-python-코드">Rust 코드 및 Python 코드</h3>
<p>기존 프로젝트에 있던 것을 긁어오면 된다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/db-template$ cp ../db-connection/src/* src
~/workspace/db-template$ cp ../db-connection/app/* app</code></pre>
<p>Docker와 관련된 파일도 추가로 긁어오도록 하자.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/db-template$ cp ../db-connection/.env .
~/workspace/db-template$ cp ../db-connection/docker-compose.yml .</code></pre>
<h2 id="빌드-및-실행">빌드 및 실행</h2>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/db-template$ docker compose up -d # PostgreSQL docker 서비스 실행
~/workspace/db-template$ uv run maturin develop # uv 가상환경에서 Rust 컴파일
~/workspace/db-template$ uv run uvicorn app.main:app --reload # uv 가상환경에서 서버 실행</code></pre>
<pre><code class="language-bash">INFO:     Started reloader process [6247] using StatReload
INFO:     Started server process [6249]
INFO:     Waiting for application startup.
INFO:     Rust 엔진 초기화 중...
2026-04-09T00:53:24.617492Z  INFO ThreadId(17) connect: rust_engine::db: PostgreSQL 18 연결 풀 초기화 완료
INFO:     엔진 초기화 및 DB 연결 성공
INFO:     Application startup complete.
^CINFO:     Shutting down
INFO:     Waiting for application shutdown.
INFO:     서버 종료 감지: 자원 정리 중...
2026-04-09T00:53:38.260590Z  INFO ThreadId(17) rust_engine: 엔진 종료 절차 시작
2026-04-09T00:53:38.260906Z  INFO ThreadId(17) close: rust_engine::db: PostgreSQL 18 연결 안전하게 종료
INFO:     모든 연결 안전하게 종료
INFO:     Application shutdown complete.
INFO:     Finished server process [6249]
INFO:     Stopping reloader process [6247]</code></pre>
<p>기존 실습에서처럼 잘 작동하는 것을 확인할 수 있다.</p>
<h3 id="여담">여담</h3>
<p>사실 이렇게 프로젝트를 새로 만들어서 복사해 올 거 없이
기존 프로젝트에서 <code>uv add</code> 및 <code>uv add --dev</code> 명령어로
<code>pyproject.toml</code> 파일에 의존성을 명시하고
바로 <code>uv run uvicorn app.main:app --reload</code> 해도
정상적으로 작동한다.</p>
<p>하지만 <code>uvx maturin init</code> 부터 시작하여 작업 환경을 구축하는 것도
필요한 과정이니 여기서는 처음부터 프로젝트를 다시 구축하는 걸로 진행했다.</p>
<p>만약 기존 프로젝트가 존재하고 pip에서 uv로 갈아타려고 하는 거라면
번거롭게 새로 만들 필요 없이 <code>uv add</code> 와 <code>uv run</code> 을 사용하자.
(<code>uv run</code> 은 내부적으로 <code>uv sync</code> 를 먼저 실행한다.)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[데이터베이스 연결 재구조화]]></title>
            <link>https://velog.io/@peeeeeter_j/pt-study-21</link>
            <guid>https://velog.io/@peeeeeter_j/pt-study-21</guid>
            <pubDate>Wed, 08 Apr 2026 00:08:35 GMT</pubDate>
            <description><![CDATA[<h1 id="데이터베이스-연결-재구조화">데이터베이스 연결 재구조화</h1>
<p><a href="pt-study-20">이전 실습</a>에서는 데이터베이스 연결 자체에 집중하느라
구조적인 확장에 대해 고려하지 않고 코드를 작성해 보았다.</p>
<p>하지만 모든 기능이 <code>src/lib.rs</code> 파일 하나에 들어가면
프로젝트를 관리하기 어려워진다는 것을 <a href="pt-study-8">⟨모듈화⟩</a>에서 언급한 바 있다.</p>
<p>따라서 본격적인 데이터베이스 실습에 들어가기에 앞서
프로젝트를 재구조화하도록 하겠다.</p>
<h2 id="작업공간-조정-및-구조-확인">작업공간 조정 및 구조 확인</h2>
<p>Tracing 및 로그 아카이빙 로직 관리를 담당할 <code>src/logger.rs</code> 파일과
PostgreSQL 연결 및 Pool 관리를 담당할 <code>src/db.rs</code> 파일을 생성하고
<code>src/lib.rs</code> 파일은 Python과의 인터페이스만 담당하도록 가볍게 유지할 것이다.</p>
<p>필요에 따라 공통 유틸리티 및 에러 처리를 담당할
<code>src/common.rs</code> 파일도 생성할 수 있지만 여기선 생략한다.</p>
<p>이후 각 프로젝트에서 필요한 로직이 있다면 이것을 템플릿삼아
해당 프로젝트를 위한 파일을 생성하여 사용하면 된다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/db-connection$ # 기존 구조 확인
~/workspace/db-connection$ tree -a -I venv -I target -I postgres_data -I logs
.
├── .env
├── .github
│   └── workflows
│       └── CI.yml
├── .gitignore
├── app
│   └── main.py
├── Cargo.lock
├── Cargo.toml
├── docker-compose.yml
├── pyproject.toml
└── src
    └── lib.rs
~/workspace/db-connection$ touch src/logger.rs src/db.rs
~/workspace/db-connection$ tree -a -I venv -I target -I postgres_data -I logs
.
├── .env
├── .github
│   └── workflows
│       └── CI.yml
├── .gitignore
├── app
│   └── main.py
├── Cargo.lock
├── Cargo.toml
├── docker-compose.yml
├── pyproject.toml
└── src
    ├── db.rs
    ├── lib.rs
    └── logger.rs</code></pre>
<h2 id="코드-분리">코드 분리</h2>
<h3 id="로그-관리-모듈">로그 관리 모듈</h3>
<p>기존 코드에서는 <code>server.log</code> prefix가 붙은 로그 파일이
한 시간마다 생성되도록 구현되어 있었는데
Python 로그와 구분하기 위해 <code>rust-engine.log</code> prefix를 사용하고
하루 단위로 로그를 생성하도록 수정하겠다.</p>
<p>기존에는 콘솔에 출력할 것과 파일로 저장할 것에 대한 정보를
<code>tracing_subscriber::registry()</code> 에 <code>with()</code> 로 바로 전달하였는데
코드를 알아보기 쉽게 변수로 따로 작성하도록 하겠다.
추가로, 텍스트 파일에 ANSI 색상 코드가 들어가면 가독성이 떨어지므로
콘솔에서만 색상 코드를 적용하고 파일로 저장 시 생략하도록 하겠다.</p>
<blockquote>
<p><code>src/logger.rs</code></p>
</blockquote>
<pre><code class="language-rust">use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
&gt;
pub fn init_tracing() -&gt; WorkerGuard {
    let file_appender = tracing_appender::rolling::daily(&quot;logs&quot;, &quot;rust-engine.log&quot;);
    let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
&gt;
    // 로그 수준
    let filter = EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| EnvFilter::new(&quot;info&quot;));
&gt;
    // 콘솔 출력
    let stdout_layer = fmt::layer()
        .with_thread_ids(true)
        .with_thread_names(true)
        .with_ansi(true);
&gt;
    // 파일 저장
    let file_layer = fmt::layer()
        .with_writer(non_blocking)
        .json()
        .with_thread_ids(true)
        .with_ansi(false);
&gt;
    tracing_subscriber::registry()
        .with(filter)
        .with(stdout_layer)
        .with(file_layer)
        .init();
&gt;
    guard
}</code></pre>
<h3 id="데이터베이스-관리-모듈">데이터베이스 관리 모듈</h3>
<p>데이터베이스 작업은 <code>#[instrument]</code> 속성을 붙여 로그를 남기되
연결 함수에는 <code>skip(url)</code> 을 사용하여 보안 이슈가 발생하지 않도록 한다.</p>
<blockquote>
<p><code>src/db.rs</code></p>
</blockquote>
<pre><code class="language-rust">use sqlx::postgres::PgPoolOptions;
use sqlx::{Pool, Postgres};
use std::sync::OnceLock;
use tracing::{info, warn, error, instrument};
&gt;
pub static DB_POOL: OnceLock&lt;Pool&lt;Postgres&gt;&gt; = OnceLock::new();
&gt;
#[instrument(skip(url))]
pub async fn connect(url: &amp;str) -&gt; Result&lt;(), sqlx::Error&gt; {
    let pool = PgPoolOptions::new()
        .max_connections(10)
        .connect(url)
        .await
        .map_err(|e| {
            error!(&quot;PostgreSQL 18 연결 실패&quot;);
            e   
        })?;
&gt;
    if DB_POOL.set(pool).is_ok() {
        info!(&quot;PostgreSQL 18 연결 풀 초기화 완료&quot;);
    } else {
        warn!(&quot;PostgreSQL 18 연결 풀이 이미 초기화되어 있음&quot;);
    }   
&gt;
    Ok(())
}
&gt;
#[instrument]
pub async fn close() {
    if let Some(pool) = DB_POOL.get() {
        pool.close().await;
&gt;
        info!(&quot;PostgreSQL 18 연결 안전하게 종료&quot;);
    } else {
        warn!(&quot;종료할 DB 연결 풀이 존재하지 않음&quot;);
    }   
}
&gt;
#[instrument]
pub fn is_alive() -&gt; bool {
    match DB_POOL.get() {
        Some(pool) =&gt; !pool.is_closed(),
        None =&gt; false,
    }   
}</code></pre>
<h3 id="인터페이스-모듈">인터페이스 모듈</h3>
<p>로그 관리 모듈과 데이터베이스 관리 모듈을 불러와 사용한다.
이후에 추가되는 로직들도 이곳에서 Python과 연결될 것이다.</p>
<blockquote>
<p><code>src/lib.rs</code></p>
</blockquote>
<pre><code class="language-rust">mod db;
mod logger;
&gt;
use pyo3::prelude::*;
use tokio::runtime::Runtime;
use dotenvy::dotenv;
use std::env;
use std::sync::OnceLock;
use tracing::{debug, error, info};
use tracing_appender::non_blocking::WorkerGuard;
&gt;
static LOG_GUARD: OnceLock&lt;WorkerGuard&gt; = OnceLock::new();
&gt;
#[pyfunction]
fn init_engine() -&gt; PyResult&lt;String&gt; {
    info!(&quot;엔진 초기화 절차 시작&quot;);
&gt;
    debug!(&quot;로깅 시작&quot;);
    if LOG_GUARD.get().is_none() {
        let guard = logger::init_tracing();
        let _ = LOG_GUARD.set(guard);
    }
&gt;
    debug!(&quot;환경 변수 로드&quot;);
    dotenv().ok();
    let url = env::var(&quot;DATABASE_URL&quot;)
        .map_err(|e| {
            error!(&quot;DATABASE_URL 환경 변수를 찾을 수 없음: {}&quot;, e);
            pyo3::exceptions::PyRuntimeError::new_err(&quot;DATABASE_URL not found in .env&quot;)
        })?;
&gt;   
    debug!(&quot;데이터베이스 연결 시작&quot;);
    let rt = Runtime::new().unwrap();
    rt.block_on(async {
        db::connect(&amp;url).await
    })
    .map_err(|e| {
        error!(&quot;DB 연결 풀 생성 실패: {}&quot;, e);
        pyo3::exceptions::PyRuntimeError::new_err(format!(&quot;DB connection failed: {}&quot;, e))
    })?;
&gt;
    Ok(&quot;엔진 초기화 및 DB 연결 성공&quot;.to_string())
}
&gt;
#[pyfunction]
fn shutdown_engine() -&gt; PyResult&lt;()&gt; {
    info!(&quot;엔진 종료 절차 시작&quot;);
&gt;   
    let rt = Runtime::new().unwrap();
    rt.block_on(async {
        db::close().await;
    });
&gt;
    Ok(())
}
&gt;
#[pyfunction]
fn check_connection() -&gt; PyResult&lt;bool&gt; {
    debug!(&quot;DB 연결 상태 확인&quot;);
&gt;
    Ok(db::is_alive())
}
&gt;
#[pymodule]
fn rust_engine(m: &amp;Bound&lt;&#39;_, PyModule&gt;) -&gt; PyResult&lt;()&gt; {
    m.add_function(wrap_pyfunction!(init_engine, m)?)?;
    m.add_function(wrap_pyfunction!(shutdown_engine, m)?)?;
    m.add_function(wrap_pyfunction!(check_connection, m)?)?;
&gt;
    Ok(())
}</code></pre>
<h3 id="python-코드">Python 코드</h3>
<p>Rust 엔진의 수정된 함수명에 따라 Python 코드도 수정한다.</p>
<blockquote>
<p><code>app/main.py</code></p>
</blockquote>
<pre><code class="language-py">from fastapi import FastAPI, HTTPException
from fastapi.responses import ORJSONResponse
from contextlib import asynccontextmanager
import rust_engine
import logging
&gt;
logger = logging.getLogger(&quot;uvicorn.error&quot;)
&gt;
class UTF8ORJSONResponse(ORJSONResponse):
    media_type = &quot;application/json; charset=utf-8&quot;
&gt;
@asynccontextmanager
async def lifespan(app: FastAPI):
    logger.info(&quot;Rust 엔진 초기화 중...&quot;)
    try:
        msg = rust_engine.init_engine()
        logger.info(msg)
    except Exception as e:
        logger.info(f&quot;Rust 엔진 초기화 실패: {e}&quot;)
&gt;
    yield # 앱 가동
&gt;
    # [SHUTDOWN]
&gt;
    logger.info(&quot;서버 종료 감지: 자원 정리 중...&quot;)
    try:
        rust_engine.shutdown_engine()
        logger.info(&quot;모든 연결 안전하게 종료&quot;)
    except Exception as e:
        logger.error(f&quot;종료 중 오류 발생: {e}&quot;)
&gt;
app = FastAPI(default_response_class=UTF8ORJSONResponse, lifespan=lifespan)
&gt;
@app.get(&quot;/&quot;)
def read_root():
    return {
        &quot;status&quot;: &quot;200&quot;,
        &quot;info&quot;: &quot;서버 가동 중입니다.&quot;
    }
&gt;
@app.get(&quot;/db-status&quot;)
def get_db_status():
    logger.info(&quot;DB 상태 체크 요청&quot;)
    is_alive = rust_engine.check_connection()
&gt;
    if is_alive:
        logger.info(&quot;DB 연결 상태 양호&quot;)
        return {
            &quot;status&quot;: &quot;online&quot;,
            &quot;message&quot;: &quot;Rust 엔진이 PostgreSQL을 사용합니다.&quot;
        }
    else:
        logger.error(&quot;DB 연결 끊김 감지&quot;)
        raise HTTPException(
            status_code=500,
            detail=&quot;DB 연결이 끊겼거나 초기화되지 않았습니다.&quot;
        )</code></pre>
<h2 id="빌드-및-실행">빌드 및 실행</h2>
<p>docker 서비스가 내려가 있다면 다시 실행해 준다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/db-connection$ docker compose up -d </code></pre>
<p>docker 서비스를 내리고 싶을 땐 다음 명령어를 사용하면 된다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/db-connection$ docker compose down</code></pre>
<p>Maturin 라이브러리를 통해 Rust 코드를 다시 컴파일한 후
uvicorn 라이브러리를 통해 FastAPI를 실행하면
이전과 동일하게 실행되는 것을 확인할 수 있다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/db-connection$ maturin develop
~/workspace/db-connection$ uvicorn app.main:app --reload</code></pre>
<p>앞으로 데이터베이스를 다루는 예제에서는
이 프로젝트 디렉토리를 복제 후 수정하여 사용할 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[데이터베이스 연결]]></title>
            <link>https://velog.io/@peeeeeter_j/pt-study-20</link>
            <guid>https://velog.io/@peeeeeter_j/pt-study-20</guid>
            <pubDate>Tue, 07 Apr 2026 01:21:35 GMT</pubDate>
            <description><![CDATA[<h1 id="데이터베이스-연결">데이터베이스 연결</h1>
<p>많은 서버는 데이터베이스를 사용한다.
데이터베이스를 어떻게 사용할 수 있는지 알아보자.</p>
<p>여기서는 PostgreSQL을 사용할 것이다.
PostgreSQL은 객체-관계형(ORDBMS) 데이터베이스의 표준이다.
SONB 타입을 지원하여 NoSQL의 장점을 흡수했고,
PostGIS(지리정보), pgvector(AI 벡터 검색) 등 확장이 무궁무진하다.
데이터 무결성에 있어서도 엄격하며 안정적이기에
가장 복잡한 비즈니스 로직을 가장 안정적인 성능으로 처리할 수 있다.</p>
<h2 id="작업공간-생성-및-구조-확인">작업공간 생성 및 구조 확인</h2>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace$ mkdir db-connection &amp;&amp; cd db-connection
~/workspace/db-connection$ python3 -m venv venv
~/workspace/db-connection$ source venv/bin/activate
~/workspace/db-connection$ # 평소에 설치하던 것 외에 추가된 라이브러리를 놓치지 말자
~/workspace/db-connection$ pip install maturin fastapi uvicorn orjson
~/workspace/db-connection$ maturin init
~/workspace/db-connection$ # 선택지 중 기본값인 PyO3 선택
~/workspace/db-connection$ # Cargo.toml과 src/lib.rs가 자동 생성된다
~/workspace/db-connection$ # Python 코드는 직접 생성해 주어야 한다
~/workspace/db-connection$ mkdir app &amp;&amp; touch app/main.py
~/workspace/db-connection$ # ProgreSQL Docker를 위한 파일도 생성한다
~/workspace/db-connection$ touch docker-compose.yml .env
~/workspace/db-connection$ tree -a -I venv
.
├── .env
├── .github
│   └── workflows
│       └── CI.yml
├── .gitignore
├── app
│   └── main.py
├── Cargo.toml
├── docker-compose.yml
├── pyproject.toml
└── src
    └── lib.rs</code></pre>
<p><code>Cargo.toml</code> 파일을 열어 라이브러리 이름을 수정해 주겠다.</p>
<p>데이터베이스 연결을 위해서는 <a href="https://docs.rs/sqlx/latest/sqlx">sqlx</a> 크레이트가 필요하다.
그리고 데이터베이스 입출력은 비동기로 수행하는 게 효율적이므로
비동기 작업을 위한 <a href="https://docs.rs/tokio/latest/tokio">tokio</a> 크레이트도 사용한다.
각 크레이트에는 적절한 <code>features</code> 도 설정해 주겠다.</p>
<p>데이터베이스 설정 파일과 계정 정보를 분리하기 위해
환경변수 사용을 위한 <a href="https://docs.rs/dotenvy/latest/dotenvy">dotenvy</a> 크레이트도 사용한다.</p>
<p>로그를 기록하는 것을 배웠으니 <a href="https://docs.rs/tracing/latest/tracing">tracing</a> 크레이트를 비롯한
로그 크레이트도 추가하여 로그도 남겨 보겠다.</p>
<blockquote>
<p><code>Cargo.toml</code></p>
</blockquote>
<pre><code class="language-toml">[package]
name = &quot;image-processor&quot;
version = &quot;0.1.0&quot;
edition = &quot;2024&quot;
&gt;
[lib]
name = &quot;rust_engine&quot;
crate-type = [&quot;cdylib&quot;]
&gt;
[dependencies]
pyo3 = &quot;0.28.0&quot;
sqlx = { version = &quot;0.8&quot;, features = [&quot;runtime-tokio&quot;, &quot;tls-rustls&quot;, &quot;postgres&quot;, &quot;macros&quot;] }
tokio = { version = &quot;1.43&quot;, features = [&quot;full&quot;] }
tracing-appender = &quot;0.2&quot;
tracing = &quot;0.1&quot;
tracing-subscriber = { version = &quot;0.3&quot;, features = [&quot;env-filter&quot;, &quot;json&quot;] }
dotenvy = &quot;0.15&quot;</code></pre>
<h2 id="코드-작성">코드 작성</h2>
<h3 id="docker-파일">Docker 파일</h3>
<p><code>docker compose</code> 를 통해 데이터베이스를 관리할 것이다.</p>
<p>현 시점 가장 안정적인 PostgreSQL 버전을 사용하며,
성능 모니터링을 위해 유용한 도구들을 함께 띄운다.</p>
<p>계정 정보는 <code>.env</code> 환경변수 파일에 작성하여
하드웨어 자원 할당과 계정 정보를 분리하여 관리할 것이다.</p>
<blockquote>
<p><code>.env</code></p>
</blockquote>
<pre><code class="language-bash"># Docker Compose Project
COMPOSE_PROJECT_NAME=db_template
&gt;
# PostgreSQL
POSTGRES_USER=peter
POSTGRES_PASSWORD=ku201711424
POSTGRES_DB=rust_python_db
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
&gt;
# pgAdmin
PGADMIN_EMAIL=admin@pjos.dev
PGADMIN_PASSWORD=admin201711424
&gt;
# SQLX
DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}</code></pre>
<p>실무에서는 <code>.env</code> 파일의 내용을 절대 어딘가에 업로드하거나 유출하지 말 것.
...이라는 기본 보안 수칙을 인지하지 못한 채 개발을 하는 바이브코더들이 종종 이슈가 되더라.
당장 Gemini에게 설정 파일 작성하라고 해도 <code>.env</code> 파일을 따로 빼지 않고
설정 파일에 보안 관련 정보까지 다 집어넣어 버리더라.</p>
<p>데이터베이스 설정에 <code>io_method=worker</code> 커맨드를 추가하여
PostgreSQL 18의 비동기 IO를 사용한다.
또한 <code>io_workers=8</code> 커맨드로 코어 활용을 최적화한다.</p>
<p>데이터의 변화를 시각적으로 확인하기 위해 pgAdmin을 사용한다.
이를 통해 PostgreSQL 18에서 도입된 UUID v7이
실제 시간 순서대로 정렬되어 인덱스 효율을 높이는지 직접 눈으로 확인할 수 있으며,
복잡한 메타데이터(JSON)를 계층 구조로 편하게 분석할 수 있다.
DB의 세션 상태와 I/O 부하를 실시간 그래프로 보며 성능 모니터링을 할 수 있다.</p>
<p>컨테이너 이름은 임의의 문자열을 사용하면 된다.</p>
<blockquote>
<p><code>docker-compose.yml</code></p>
</blockquote>
<pre><code class="language-yaml">services:
  db:
    image: postgres:18-alpine
    container_name: ${COMPOSE_PROJECT_NAME}_postgres
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    ports:
      - &quot;${POSTGRES_PORT}:5432&quot;
    command:
      - &quot;-c&quot;
      - &quot;io_method=worker&quot;
      - &quot;-c&quot;
      - &quot;io_workers=8&quot;
    volumes:
      - ./postgres_data:/var/lib/postgresql
    restart: always
&gt;
  pgadmin:
    image: dpage/pgadmin4
    container_name: pgadmin_ui
    environment:
      PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL}
      PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD}
    ports:
      - &quot;8080:80&quot;
    depends_on:
      - db
    restart: always
&gt;
volumes:
  db_data:
    name: ${COMPOSE_PROJECT_NAME}_db_data</code></pre>
<h3 id="rust-코드">Rust 코드</h3>
<p>PostgreSQL에 연결된 데이터베이스 커넥션 풀을 미리 생성해 놓고
필요할 때 하나씩 꺼내 사용하는 방식으로 구현한다.
데이터베이스 커넥션 풀은 <code>OnceLock</code> 을 이용하여 전역 선언하여
FastAPI 서버가 가동되는 동안 살아있도록 한다.</p>
<blockquote>
<p><code>src/lib.rs</code></p>
</blockquote>
<pre><code class="language-rust">use pyo3::prelude::*;
use sqlx::postgres::PgPoolOptions;
use sqlx::{Pool, Postgres};
use std::sync::OnceLock;
use tokio::runtime::Runtime;
use dotenvy::dotenv;
use std::env;
use tracing::{info, debug, warn, error, instrument};
use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
&gt;
static DB_POOL: OnceLock&lt;Pool&lt;Postgres&gt;&gt; = OnceLock::new();
static LOG_GUARD: OnceLock&lt;WorkerGuard&gt; = OnceLock::new();
&gt;
fn init_tracing() {
    let filter_appender = tracing_appender::rolling::hourly(&quot;./logs&quot;, &quot;server.log&quot;);
    let (non_blocking, guard) = tracing_appender::non_blocking(filter_appender);
&gt;
    let filter = EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| EnvFilter::new(&quot;info&quot;));
&gt;
    let _ = LOG_GUARD.set(guard);
&gt;
    tracing_subscriber::registry()
        .with(filter)
        .with(fmt::layer().with_thread_ids(true))               // 콘솔 출력
        .with(fmt::layer().with_writer(non_blocking).json())    // 파일 저장
        .init();
}
&gt;
#[pyfunction]
#[instrument]
fn init_database() -&gt; PyResult&lt;String&gt; {
    dotenv().ok();
&gt;
    debug!(&quot;환경 변수 로드&quot;);
    let database_url = env::var(&quot;DATABASE_URL&quot;)
        .map_err(|e| {
            error!(&quot;DATABASE_URL 환경 변수를 찾을 수 없음: {}&quot;, e);
            pyo3::exceptions::PyRuntimeError::new_err(&quot;DATABASE_URL not found in .env&quot;)
        })?;
&gt;
    let rt = Runtime::new().unwrap();
&gt;
    rt.block_on(async {
        info!(&quot;PostgreSQL 연결 풀 생성 시작&quot;);
        let pool = PgPoolOptions::new()
            .max_connections(10)
            .connect(&amp;database_url)
            .await
            .map_err(|e| {
                error!(&quot;데이터베이스 연결 풀 생성 실패: {}&quot;, e);
                pyo3::exceptions::PyRuntimeError::new_err(format!(&quot;DB 연결 실패: {}&quot;, e))
            })?;
&gt;
        if DB_POOL.set(pool).is_ok() {
            info!(&quot;데이터베이스 연결 풀 성공적으로 초기화&quot;);
        } else {
            warn!(&quot;데이터베이스 연결 풀이 이미 초기화되어 있음&quot;);
        }
&gt;
        Ok(&quot;PostgreSQL 연결 성공 및 Pool 초기화 완료&quot;.to_string())
    })
}
&gt;
#[pyfunction]
#[instrument]
fn close_database() -&gt; PyResult&lt;()&gt; {
    if let Some(pool) = DB_POOL.get() {
        info!(&quot;데이터베이스 연결 종료&quot;);
&gt;
        let rt = Runtime::new().unwrap();
&gt;
        rt.block_on(async {
            pool.close().await;
            info!(&quot;PostgreSQL 연결 풀 안전하게 닫음&quot;);
        });
    } else {
        warn!(&quot;정리할 데이터베이스 연결 풀이 존재하지 않음&quot;);
    }
&gt;
    Ok(())
}
&gt;
#[pyfunction]
fn check_connection() -&gt; PyResult&lt;bool&gt; {
    debug!(&quot;데이터베이스 연결 상태 확인&quot;);
&gt;
    if let Some(pool) = DB_POOL.get() {
        let closed = pool.is_closed();
        if closed {
            warn!(&quot;데이터베이스 풀이 닫혀 있음&quot;);
        } else {
            debug!(&quot;데이터베이스 풀 활성화 상태&quot;);
        }
        return Ok(!closed);
    }
&gt;
    error!(&quot;데이터베이스 연결 풀이 존재하지 않음&quot;);
    Ok(false)
}
&gt;
#[pymodule]
fn rust_engine(m: &amp;Bound&lt;&#39;_, PyModule&gt;) -&gt; PyResult&lt;()&gt; {
    init_tracing();
    tracing::info!(&quot;Rust Engine 가동 및 Tracing 시스템 초기화 완료&quot;);
&gt;
    m.add_function(wrap_pyfunction!(init_database, m)?)?;
    m.add_function(wrap_pyfunction!(close_database, m)?)?;
    m.add_function(wrap_pyfunction!(check_connection, m)?)?;
&gt;
    Ok(())
}</code></pre>
<h3 id="python-코드">Python 코드</h3>
<p>서버 시작 시 데이터베이스 연결을 자동으로 수행하도록 설정한다.
서버 중단 시 데이터베이스 연결을 안전하게 종료하도록 설정한다.
이 작업은 <code>lifespan</code> 을 통해 작성할 수 있다.</p>
<blockquote>
<p><code>app/main.py</code></p>
</blockquote>
<pre><code class="language-py">from fastapi import FastAPI, HTTPException
from fastapi.responses import ORJSONResponse
from contextlib import asynccontextmanager
import rust_engine
import logging
&gt;
logger = logging.getLogger(&quot;uvicorn.error&quot;)
&gt;
class UTF8ORJSONResponse(ORJSONResponse):
    media_type = &quot;application/json; charset=utf-8&quot;
&gt;
@asynccontextmanager
async def lifespan(app: FastAPI):
    logger.info(&quot;DB 연결 초기화 중...&quot;)
    try:
        msg = rust_engine.init_database()
        logger.info(msg)
    except Exception as e:
        logger.info(f&quot;DB 연결 오류: {e}&quot;)
&gt;
    yield # 앱 가동
&gt;
    # [SHUTDOWN]
    logger.info(&quot;서버 종료 감지&quot;)
    try:
        rust_engine.close_database()
        logger.info(&quot;모든 연결 안전하게 종료&quot;)
    except Exception as e:
        logger.error(f&quot;종료 중 오류 발생: {e}&quot;)
&gt;
app = FastAPI(default_response_class=UTF8ORJSONResponse, lifespan=lifespan)
&gt;
@app.get(&quot;/&quot;)
def read_root():
    return {
        &quot;status&quot;: &quot;200&quot;,
        &quot;info&quot;: &quot;서버 가동 중입니다.&quot;
    }
&gt;
@app.get(&quot;/db-status&quot;)
def get_db_status():
    logger.info(&quot;DB 상태 체크 요청&quot;)
    is_alive = rust_engine.check_connection()
    if is_alive:
        logger.info(&quot;DB 연결 상태 양호&quot;)
        return {
            &quot;status&quot;: &quot;online&quot;,
            &quot;message&quot;: &quot;Rust 엔진이 PostgreSQL을 사용합니다.&quot;
        }
    else:
        logger.error(&quot;DB 연결 끊김 감지&quot;)
        raise HTTPException(
            status_code=500,
            detail=&quot;DB 연결이 끊겼거나 초기화되지 않았습니다.&quot;
        )</code></pre>
<h2 id="빌드-및-실행">빌드 및 실행</h2>
<p>도커 서비스를 실행한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/db-connection$ docker compose up -d 
[+] up 31/31
 ✔ Image dpage/pgadmin4          Pulled                                                84.6s
 ✔ Image postgres:18-alpine      Pulled                                                95.9s
 ✔ Network db-connection_default Created                                               0.0s
 ✔ Container rust_progres_18     Created                                               0.3s
 ✔ Container pgadmin_ui          Created                                               0.0s</code></pre>
<p>브라우저를 통해 다음 URL로 접속하여
환경변수에 작성한 pgAdmin 계정으로 로그인하면
데이터베이스 관리를 위한 웹 인터페이스에 접속할 수 있다.</p>
<ul>
<li><code>http://localhost:8080</code></li>
</ul>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/b0ab28ff-32cc-4238-9ba2-1be1c7481d7e/image.png" alt=""></p>
<p>여기서 [Add New Server] 혹은 [새 서버 추가] 버튼을 눌러 데이터베이스 정보를 입력한다.</p>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/ade5159b-16dd-4347-bad8-1290634fbaac/image.png" alt=""></p>
<p>Maturin 라이브러리를 통해 Rust 코드를 Python에서 호출 가능한 형태로 컴파일한다.
병렬 처리가 포함된 코드는 성능 최적화를 위해 <code>--release</code> 를 붙여 컴파일한다.
컴파일 후 <code>pip list</code> 명령어를 사용해 보면 <code>Cargo.toml</code> 파일에 작성한 패키지 이름을 확인할 수 있다.</p>
<p>uvicorn 라이브러리를 통해 FastAPI를 실행한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/db-connection$ maturin develop
~/workspace/db-connection$ uvicorn app.main:app --reload</code></pre>
<p>curl 명령어 또는 브라우저를 통해 다음과 같은 테스트를 해볼 수 있다.</p>
<ul>
<li><code>http://127.0.0.1:8000/db-status</code></li>
</ul>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ curl -i http://127.0.0.1:8000          
HTTP/1.1 200 OK
date: Tue, 07 Apr 2026 01:09:36 GMT
server: uvicorn
content-length: 53
content-type: application/json; charset=utf-8
&gt;
{&quot;status&quot;:&quot;200&quot;,&quot;info&quot;:&quot;서버 가동 중입니다.&quot;}% </code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ curl -i http://127.0.0.1:8000/db-status
HTTP/1.1 200 OK
date: Tue, 07 Apr 2026 01:09:39 GMT
server: uvicorn
content-length: 77
content-type: application/json; charset=utf-8
&gt;
{&quot;status&quot;:&quot;online&quot;,&quot;message&quot;:&quot;Rust 엔진이 PostgreSQL을 사용합니다.&quot;}%</code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">INFO:     Started reloader process [85335] using StatReload
2026-04-07T01:09:29.646312Z  INFO ThreadId(02) rust_engine: Rust Engine 가동 및 Tracing 시스템 초기화 완료
INFO:     Started server process [85337]
INFO:     Waiting for application startup.
INFO:     DB 연결 초기화 중...
2026-04-07T01:09:29.648668Z  INFO ThreadId(02) init_database: rust_engine: PostgreSQL 연결 풀 생성 시작
2026-04-07T01:09:29.688320Z  INFO ThreadId(02) init_database: rust_engine: 데이터베이스 연결 풀 성공적으로 초기화
INFO:     PostgreSQL 연결 성공 및 Pool 초기화 완료
INFO:     Application startup complete.
INFO:     127.0.0.1:55419 - &quot;GET / HTTP/1.1&quot; 200 OK
INFO:     DB 상태 체크 요청
INFO:     DB 연결 상태 양호
INFO:     127.0.0.1:55420 - &quot;GET /db-status HTTP/1.1&quot; 200 OK
^CINFO:     Shutting down
INFO:     Waiting for application shutdown.
INFO:     서버 종료 감지
2026-04-07T01:09:46.667943Z  INFO ThreadId(02) close_database: rust_engine: 데이터베이스 연결 종료
2026-04-07T01:09:46.669614Z  INFO ThreadId(02) close_database: rust_engine: PostgreSQL 연결 풀 안전하게 닫음
INFO:     모든 연결 안전하게 종료
INFO:     Application shutdown complete.
INFO:     Finished server process [85337]
INFO:     Stopping reloader process [85335]</code></pre>
<blockquote>
<p><code>RUST_LOG=debug</code> 일 때</p>
</blockquote>
<pre><code class="language-bash">~$ curl -i http://127.0.0.1:8000                  
HTTP/1.1 200 OK
date: Tue, 07 Apr 2026 01:11:16 GMT
server: uvicorn
content-length: 53
content-type: application/json; charset=utf-8
&gt;
{&quot;status&quot;:&quot;200&quot;,&quot;info&quot;:&quot;서버 가동 중입니다.&quot;}%</code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ curl -i http://127.0.0.1:8000/db-status
HTTP/1.1 200 OK
date: Tue, 07 Apr 2026 01:11:18 GMT
server: uvicorn
content-length: 77
content-type: application/json; charset=utf-8
&gt;
{&quot;status&quot;:&quot;online&quot;,&quot;message&quot;:&quot;Rust 엔진이 PostgreSQL을 사용합니다.&quot;}%</code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">INFO:     Started reloader process [85375] using StatReload
2026-04-07T01:11:10.113858Z  INFO ThreadId(02) rust_engine: Rust Engine 가동 및 Tracing 시스템 초기화 완료
INFO:     Started server process [85377]
INFO:     Waiting for application startup.
INFO:     DB 연결 초기화 중...
2026-04-07T01:11:10.115043Z DEBUG ThreadId(02) init_database: rust_engine: 환경 변수 로드
2026-04-07T01:11:10.115417Z  INFO ThreadId(02) init_database: rust_engine: PostgreSQL 연결 풀 생성 시작
2026-04-07T01:11:10.154654Z  INFO ThreadId(02) init_database: rust_engine: 데이터베이스 연결 풀 성공적으로 초기화
INFO:     PostgreSQL 연결 성공 및 Pool 초기화 완료
INFO:     Application startup complete.
INFO:     127.0.0.1:55422 - &quot;GET / HTTP/1.1&quot; 200 OK
INFO:     DB 상태 체크 요청
2026-04-07T01:11:18.652556Z DEBUG ThreadId(18) rust_engine: 데이터베이스 연결 상태 확인
2026-04-07T01:11:18.652603Z DEBUG ThreadId(18) rust_engine: 데이터베이스 풀 활성화 상태
INFO:     DB 연결 상태 양호
INFO:     127.0.0.1:55423 - &quot;GET /db-status HTTP/1.1&quot; 200 OK
^CINFO:     Shutting down
INFO:     Waiting for application shutdown.
INFO:     서버 종료 감지
2026-04-07T01:11:25.215181Z  INFO ThreadId(02) close_database: rust_engine: 데이터베이스 연결 종료
2026-04-07T01:11:25.215891Z  INFO ThreadId(02) close_database: rust_engine: PostgreSQL 연결 풀 안전하게 닫음
INFO:     모든 연결 안전하게 종료
INFO:     Application shutdown complete.
INFO:     Finished server process [85377]
INFO:     Stopping reloader process [85375]</code></pre>
<p>이어서 스키마 설계 및 CRUB 단계로 넘어갈 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[로그 아카이빙]]></title>
            <link>https://velog.io/@peeeeeter_j/pt-study-19</link>
            <guid>https://velog.io/@peeeeeter_j/pt-study-19</guid>
            <pubDate>Sun, 05 Apr 2026 23:04:32 GMT</pubDate>
            <description><![CDATA[<h1 id="로그-아카이빙">로그 아카이빙</h1>
<p>로그를 터미널에 출력하고 넘어가면
실시간으로 관측하고 있지 않은 이상 문제를 파악하기 어렵다.
따라서 로그를 파일의 형태로 아카이빙할 필요가 있다.</p>
<p>여기서는 조금 더 다양한 상황을 연출하기 위해
병렬 처리뿐만 아니라 비동기 처리에 대한 시뮬레이션을 추가했다.</p>
<h2 id="작업공간-생성-및-구조-확인">작업공간 생성 및 구조 확인</h2>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace$ mkdir log-archiving &amp;&amp; cd log-archiving
~/workspace/log-archiving$ python3 -m venv venv
~/workspace/log-archiving$ source venv/bin/activate
~/workspace/log-archiving$ # 평소에 설치하던 것 외에 추가된 라이브러리를 놓치지 말자
~/workspace/log-archiving$ pip install maturin fastapi uvicorn orjson
~/workspace/log-archiving$ maturin init
~/workspace/log-archiving$ # 선택지 중 기본값인 PyO3 선택
~/workspace/log-archiving$ # Cargo.toml과 src/lib.rs가 자동 생성된다
~/workspace/log-archiving$ # Python 코드는 직접 생성해 주어야 한다
~/workspace/log-archiving$ mkdir app &amp;&amp; touch app/main.py
~/workspace/log-archiving$ tree -I venv
.
├── app
│   └── main.py
├── Cargo.toml
├── pyproject.toml
└── src
    └── lib.rs</code></pre>
<p><code>Cargo.toml</code> 파일을 열어 라이브러리 이름을 수정해 주겠다.</p>
<p>로그 추적을 위한 <a href="https://docs.rs/tracing/latest/tracing">tracing</a> 크레이트와
로그를 필터링하기 위한 <a href="https://docs.rs/tracing-subscriber/latest/tracing_subscriber">tracing-subscriber</a> 크레이트,
로그를 파일에 기록하기 위한 <a href="https://docs.rs/tracing-appender/latest/tracing_appender">tracing-appender</a> 크레이트를 사용한다.
<code>features</code> 에 <code>&quot;json&quot;</code> 을 추가해 주어야
데이터 분석 도구에서 사용하기 수월한 JSON 형태로 저장할 수 있다.</p>
<p>병렬 처리를 위한 <a href="https://docs.rs/rayon/latest/rayon">rayon</a> 크레이트와 더불어
비동기 처리를 위한 <a href="https://docs.rs/tokio/latest/tokio">tokio</a> 크레이트도 추가한다.</p>
<blockquote>
<p><code>Cargo.toml</code></p>
</blockquote>
<pre><code class="language-toml">[package]
name = &quot;log-archiving&quot;
version = &quot;0.1.0&quot;
edition = &quot;2024&quot;
&gt;
[lib]
name = &quot;rust_engine&quot;
crate-type = [&quot;cdylib&quot;]
&gt;
[dependencies]
pyo3 = &quot;0.28.0&quot;
rayon = &quot;1.11&quot;
tokio = { version = &quot;1.50&quot;, features = [&quot;full&quot;] }
tracing-appender = &quot;0.2&quot;
tracing = &quot;0.1&quot;
tracing-subscriber = { version = &quot;0.3&quot;, features = [&quot;env-filter&quot;, &quot;json&quot;] }</code></pre>
<h2 id="코드-작성">코드 작성</h2>
<h3 id="rust-코드">Rust 코드</h3>
<p><code>tracing_appender::non_blocking</code> 을 사용하여
별도의 스레드가 매 시간마다 새로운 로그 파일을 생성하고
<code>logs</code> 폴더에 저장하도록 한다.</p>
<p><code>guard</code> 는 프로그램이 갑자기 종료될 때
버퍼에 남아있던 로그들을 파일에 마저 쓰고 닫는 역할을 하는데,
이를 전역 변수에 저장하여 프로그램 종료 시까지 살려두어야
함수가 끝나는 즉시 사라지지 않고
프로그램이 끝날 때까지 살아 있을 수 있다.</p>
<p><code>std::sync::OnceLock</code> 을 사용하면
Python이 실행되는 동안 로그 시스템이 죽지 않고 살아있게 된다.</p>
<blockquote>
<p><code>src/lib.rs</code></p>
</blockquote>
<pre><code class="language-rust">use pyo3::prelude::*;
use rayon::prelude::*;
use tracing::{info, debug, warn, error, instrument, span, Level};
use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
use std::sync::OnceLock;
use std::time::Duration;
&gt;
static LOG_GUARD: OnceLock&lt;WorkerGuard&gt; = OnceLock::new();
&gt;
fn init_tracing() {
    let filter_appender = tracing_appender::rolling::hourly(&quot;./logs&quot;, &quot;server.log&quot;);
    let (non_blocking, guard) = tracing_appender::non_blocking(filter_appender);
&gt;
    let filter = EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| EnvFilter::new(&quot;info&quot;));
&gt;
    let _ = LOG_GUARD.set(guard);
&gt;
    tracing_subscriber::registry()
        .with(filter)
        .with(fmt::layer().with_thread_ids(true))               // 콘솔 출력
        .with(fmt::layer().with_writer(non_blocking).json())    // 파일 저장
        .init();
}
&gt;
/// 비동기 IO 시뮬레이션 함수
async fn mock_db_call(id: u32) {
    let _span = span!(Level::INFO, &quot;db_query&quot;, id = id).entered();
    debug!(&quot;DB 데이터 조회 중...&quot;);
    tokio::time::sleep(Duration::from_millis(50)).await;
    info!(&quot;DB 조회 완료&quot;);
}
&gt;
#[pyfunction]
#[instrument]
fn complex_task_runner(events: Vec&lt;String&gt;) -&gt; PyResult&lt;String&gt; {
    info!(&quot;통합 태스크 러너 가동&quot;);
&gt;
    // 비동기 처리
    let rt = tokio::runtime::Runtime::new().unwrap();
    rt.block_on(async {
        mock_db_call(101).await;
    });
&gt;
    // 병렬 처리
    events.into_par_iter()
        .enumerate()
        .for_each(|(idx, name)| {
            let _sapn = span!(Level::DEBUG, &quot;worker&quot;, id = idx).entered();
&gt;
            if name.contains(&quot;error&quot;) {
                error!(&quot;심각한 페이로드 발견: {}&quot;, name);
            } else if name.len() &gt; 20 {
                warn!(&quot;비정상적으로 긴 이벤트명 감지: {}&quot;, name);
            } else {
                // 이벤트 처리에 걸리는 시간 시뮬레이션
                std::thread::sleep(Duration::from_millis(10));
                debug!(&quot;일반 태스크 처리 중: {}&quot;, name);
            }
        });
&gt;
    Ok(&quot;모든 작업 수행 및 로깅 완료&quot;.to_string())
}
&gt;
#[pymodule]
fn rust_engine(m: &amp;Bound&lt;&#39;_, PyModule&gt;) -&gt; PyResult&lt;()&gt; {
    init_tracing();
    tracing::info!(&quot;Rust Engine 가동 및 Tracing 시스템 초기화 완료&quot;);
&gt;
    m.add_function(wrap_pyfunction!(complex_task_runner, m)?)?;
&gt;
    Ok(())
}</code></pre>
<h3 id="python-코드">Python 코드</h3>
<blockquote>
<p><code>app/main.py</code></p>
</blockquote>
<pre><code class="language-py">import os
import time
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
&gt;
os.environ[&quot;RUST_LOG&quot;] = &quot;debug&quot;
&gt;
import rust_engine
&gt;
class UTF8ORJSONResponse(ORJSONResponse):
    media_type = &quot;application/json; charset=utf-8&quot;
&gt;
app = FastAPI(default_response_class=UTF8ORJSONResponse)
&gt;
@app.get(&quot;/&quot;)
def read_root():
    return {
        &quot;status&quot;: &quot;200&quot;,
        &quot;info&quot;: &quot;서버 가동 중입니다.&quot;
    }
&gt;
@app.get(&quot;/run-complex-task&quot;)
async def run_task():
    events = [
        &quot;normal_event_1&quot;,
        &quot;critical_error_payload&quot;,
        &quot;short&quot;,
        &quot;very_long_event_name_for_warning&quot;,
        &quot;normal_event_2&quot;
    ]
&gt;
    start = time.perf_counter()
    result = rust_engine.complex_task_runner(events)
    end = time.perf_counter()
&gt;
    duration = end - start
&gt;
    return {
        &quot;status&quot;: &quot;ok&quot;,
        &quot;rust_result&quot;: result,
        &quot;rust_pure_time&quot;: f&quot;{duration:.4f} sec&quot;,
        &quot;log_check&quot;: &quot;프로젝트 루트의 /logs 디렉토리에서 확인&quot;
    }</code></pre>
<h2 id="빌드-및-실행">빌드 및 실행</h2>
<p>Maturin 라이브러리를 통해 Rust 코드를 Python에서 호출 가능한 형태로 컴파일한다.
병렬 처리가 포함된 코드는 성능 최적화를 위해 <code>--release</code> 를 붙여 컴파일한다.
컴파일 후 <code>pip list</code> 명령어를 사용해 보면 <code>Cargo.toml</code> 파일에 작성한 패키지 이름을 확인할 수 있다.</p>
<p>uvicorn 라이브러리를 통해 FastAPI를 실행한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/log-archiving$ maturin develop --release
~/workspace/log-archiving$ uvicorn app.main:app --reload</code></pre>
<p>curl 명령어 또는 브라우저를 통해 다음과 같은 테스트를 해볼 수 있다.</p>
<ul>
<li><code>http://127.0.0.1:8000/run-complex-task</code></li>
</ul>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ curl -i http://127.0.0.1:8000/run-complex-task 
HTTP/1.1 200 OK
date: Sun, 05 Apr 2026 22:48:13 GMT
server: uvicorn
content-length: 169
content-type: application/json; charset=utf-8
&gt;
{&quot;status&quot;:&quot;ok&quot;,&quot;rust_result&quot;:&quot;모든 작업 수행 및 로깅 완료&quot;,&quot;rust_pure_time&quot;:&quot;0.0635 sec&quot;,&quot;log_check&quot;:&quot;프로젝트 루트의 /logs 디렉토리에서 확인&quot;}% </code></pre>
<pre><code class="language-bash">INFO:     Started reloader process [75975] using StatReload
2026-04-05T22:48:07.792253Z  INFO ThreadId(02) rust_engine: Rust Engine 가동 및 Tracing 시스템 초기화 완료
INFO:     Started server process [75977]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
2026-04-05T22:48:14.300101Z  INFO ThreadId(02) complex_task_runner{events=[&quot;normal_event_1&quot;, &quot;critical_error_payload&quot;, &quot;short&quot;, &quot;very_long_event_name_for_warning&quot;, &quot;normal_event_2&quot;]}: rust_engine: 통합 태스크 러너 가동
2026-04-05T22:48:14.300434Z DEBUG ThreadId(02) complex_task_runner{events=[&quot;normal_event_1&quot;, &quot;critical_error_payload&quot;, &quot;short&quot;, &quot;very_long_event_name_for_warning&quot;, &quot;normal_event_2&quot;]}:db_query{id=101}: rust_engine: DB 데이터 조회 중...
2026-04-05T22:48:14.352516Z  INFO ThreadId(02) complex_task_runner{events=[&quot;normal_event_1&quot;, &quot;critical_error_payload&quot;, &quot;short&quot;, &quot;very_long_event_name_for_warning&quot;, &quot;normal_event_2&quot;]}:db_query{id=101}: rust_engine: DB 조회 완료
2026-04-05T22:48:14.352827Z  WARN ThreadId(26) worker{id=3}: rust_engine: 비정상적으로 긴 이벤트명 감지: very_long_event_name_for_warning
2026-04-05T22:48:14.352837Z ERROR ThreadId(22) worker{id=1}: rust_engine: 심각한 페이로드 발견: critical_error_payload
2026-04-05T22:48:14.362937Z DEBUG ThreadId(29) worker{id=0}: rust_engine: 일반 태스크 처리 중: normal_event_1
2026-04-05T22:48:14.362966Z DEBUG ThreadId(27) worker{id=4}: rust_engine: 일반 태스크 처리 중: normal_event_2
2026-04-05T22:48:14.363032Z DEBUG ThreadId(18) worker{id=2}: rust_engine: 일반 태스크 처리 중: short
INFO:     127.0.0.1:52324 - &quot;GET /run-complex-task HTTP/1.1&quot; 200 OK</code></pre>
<p>로그에 출력된 <code>events</code> 인자가 길어 생략하고 싶다면
<a href="pt-study-18">저번 실습</a>에서처럼 <code>#[instrument(skip(events))]</code> 를 사용하면 되지만
어떤 인자가 전달되었는지도 로그 분석에 필요할 수 있으니 여기서는 남겨두는 방식으로 작성했다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/log-archiving$ tree -I venv -I target
.
├── app
│   └── main.py
├── Cargo.lock
├── Cargo.toml
├── logs
│   └── server.log.2026-04-05-22
├── pyproject.toml
└── src
    └── lib.rs
&gt;
~/workspace/log-archiving$ cat logs/server.log.2026-04-05-22
{&quot;timestamp&quot;:&quot;2026-04-05T22:48:07.792269Z&quot;,&quot;level&quot;:&quot;INFO&quot;,&quot;fields&quot;:{&quot;message&quot;:&quot;Rust Engine 가동 및 Tracing 시스템 초기화 완료&quot;},&quot;target&quot;:&quot;rust_engine&quot;}
{&quot;timestamp&quot;:&quot;2026-04-05T22:48:14.300122Z&quot;,&quot;level&quot;:&quot;INFO&quot;,&quot;fields&quot;:{&quot;message&quot;:&quot;통합 태스크 러너 가동&quot;},&quot;target&quot;:&quot;rust_engine&quot;,&quot;span&quot;:{&quot;events&quot;:&quot;[\&quot;normal_event_1\&quot;, \&quot;critical_error_payload\&quot;, \&quot;short\&quot;, \&quot;very_long_event_name_for_warning\&quot;, \&quot;normal_event_2\&quot;]&quot;,&quot;name&quot;:&quot;complex_task_runner&quot;},&quot;spans&quot;:[{&quot;events&quot;:&quot;[\&quot;normal_event_1\&quot;, \&quot;critical_error_payload\&quot;, \&quot;short\&quot;, \&quot;very_long_event_name_for_warning\&quot;, \&quot;normal_event_2\&quot;]&quot;,&quot;name&quot;:&quot;complex_task_runner&quot;}]}
{&quot;timestamp&quot;:&quot;2026-04-05T22:48:14.300449Z&quot;,&quot;level&quot;:&quot;DEBUG&quot;,&quot;fields&quot;:{&quot;message&quot;:&quot;DB 데이터 조회 중...&quot;},&quot;target&quot;:&quot;rust_engine&quot;,&quot;span&quot;:{&quot;id&quot;:101,&quot;name&quot;:&quot;db_query&quot;},&quot;spans&quot;:[{&quot;events&quot;:&quot;[\&quot;normal_event_1\&quot;, \&quot;critical_error_payload\&quot;, \&quot;short\&quot;, \&quot;very_long_event_name_for_warning\&quot;, \&quot;normal_event_2\&quot;]&quot;,&quot;name&quot;:&quot;complex_task_runner&quot;},{&quot;id&quot;:101,&quot;name&quot;:&quot;db_query&quot;}]}
{&quot;timestamp&quot;:&quot;2026-04-05T22:48:14.352541Z&quot;,&quot;level&quot;:&quot;INFO&quot;,&quot;fields&quot;:{&quot;message&quot;:&quot;DB 조회 완료&quot;},&quot;target&quot;:&quot;rust_engine&quot;,&quot;span&quot;:{&quot;id&quot;:101,&quot;name&quot;:&quot;db_query&quot;},&quot;spans&quot;:[{&quot;events&quot;:&quot;[\&quot;normal_event_1\&quot;, \&quot;critical_error_payload\&quot;, \&quot;short\&quot;, \&quot;very_long_event_name_for_warning\&quot;, \&quot;normal_event_2\&quot;]&quot;,&quot;name&quot;:&quot;complex_task_runner&quot;},{&quot;id&quot;:101,&quot;name&quot;:&quot;db_query&quot;}]}
{&quot;timestamp&quot;:&quot;2026-04-05T22:48:14.352843Z&quot;,&quot;level&quot;:&quot;WARN&quot;,&quot;fields&quot;:{&quot;message&quot;:&quot;비정상적으로 긴 이벤트명 감지: very_long_event_name_for_warning&quot;},&quot;target&quot;:&quot;rust_engine&quot;,&quot;span&quot;:{&quot;id&quot;:3,&quot;name&quot;:&quot;worker&quot;},&quot;spans&quot;:[{&quot;id&quot;:3,&quot;name&quot;:&quot;worker&quot;}]}
{&quot;timestamp&quot;:&quot;2026-04-05T22:48:14.352847Z&quot;,&quot;level&quot;:&quot;ERROR&quot;,&quot;fields&quot;:{&quot;message&quot;:&quot;심각한 페이로드 발견: critical_error_payload&quot;},&quot;target&quot;:&quot;rust_engine&quot;,&quot;span&quot;:{&quot;id&quot;:1,&quot;name&quot;:&quot;worker&quot;},&quot;spans&quot;:[{&quot;id&quot;:1,&quot;name&quot;:&quot;worker&quot;}]}
{&quot;timestamp&quot;:&quot;2026-04-05T22:48:14.362949Z&quot;,&quot;level&quot;:&quot;DEBUG&quot;,&quot;fields&quot;:{&quot;message&quot;:&quot;일반 태스크 처리 중: normal_event_1&quot;},&quot;target&quot;:&quot;rust_engine&quot;,&quot;span&quot;:{&quot;id&quot;:0,&quot;name&quot;:&quot;worker&quot;},&quot;spans&quot;:[{&quot;id&quot;:0,&quot;name&quot;:&quot;worker&quot;}]}
{&quot;timestamp&quot;:&quot;2026-04-05T22:48:14.362974Z&quot;,&quot;level&quot;:&quot;DEBUG&quot;,&quot;fields&quot;:{&quot;message&quot;:&quot;일반 태스크 처리 중: normal_event_2&quot;},&quot;target&quot;:&quot;rust_engine&quot;,&quot;span&quot;:{&quot;id&quot;:4,&quot;name&quot;:&quot;worker&quot;},&quot;spans&quot;:[{&quot;id&quot;:4,&quot;name&quot;:&quot;worker&quot;}]}
{&quot;timestamp&quot;:&quot;2026-04-05T22:48:14.363044Z&quot;,&quot;level&quot;:&quot;DEBUG&quot;,&quot;fields&quot;:{&quot;message&quot;:&quot;일반 태스크 처리 중: short&quot;},&quot;target&quot;:&quot;rust_engine&quot;,&quot;span&quot;:{&quot;id&quot;:2,&quot;name&quot;:&quot;worker&quot;},&quot;spans&quot;:[{&quot;id&quot;:2,&quot;name&quot;:&quot;worker&quot;}]}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[로그 남기기]]></title>
            <link>https://velog.io/@peeeeeter_j/pt-study-18</link>
            <guid>https://velog.io/@peeeeeter_j/pt-study-18</guid>
            <pubDate>Thu, 02 Apr 2026 23:40:02 GMT</pubDate>
            <description><![CDATA[<h1 id="로그-남기기">로그 남기기</h1>
<p>서버를 가동하다가 문제가 발생했을 때
원인을 찾고 문제를 해결하기 위해서는
어디서 어떤 문제가 발생했는지 알아야 한다.</p>
<p>이를 위해 정상 작동과 그렇지 않은 상황에 대한
로그를 남겨 두는 것이 좋다.</p>
<p>우선 터미널에 로그를 찍는 것을 알아보자.</p>
<h2 id="작업공간-생성-및-구조-확인">작업공간 생성 및 구조 확인</h2>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace$ mkdir log-tracing &amp;&amp; cd log-tracing
~/workspace/log-tracing$ python3 -m venv venv
~/workspace/log-tracing$ source venv/bin/activate
~/workspace/log-tracing$ # 평소에 설치하던 것 외에 추가된 라이브러리를 놓치지 말자
~/workspace/log-tracing$ pip install maturin fastapi uvicorn orjson
~/workspace/log-tracing$ maturin init
~/workspace/log-tracing$ # 선택지 중 기본값인 PyO3 선택
~/workspace/log-tracing$ # Cargo.toml과 src/lib.rs가 자동 생성된다
~/workspace/log-tracing$ # Python 코드는 직접 생성해 주어야 한다
~/workspace/log-tracing$ mkdir app &amp;&amp; touch app/main.py
~/workspace/log-tracing$ tree -I venv
.
├── app
│   └── main.py
├── Cargo.toml
├── pyproject.toml
└── src
    └── lib.rs</code></pre>
<p><code>Cargo.toml</code> 파일을 열어 라이브러리 이름을 수정해 주겠다.</p>
<p>Rust에서 로그를 남기기 위해서는 <a href="https://docs.rs/tracing/latest/tracing">tracing</a> 크레이트를 사용해야 한다.
그리고 로그를 출력하고 필터링하기 위해 <a href="https://docs.rs/tracing-subscriber/latest/tracing_subscriber">tracing-subscriber</a>를 사용한다.
<code>features = [&quot;env-filter&quot;]</code> 를 명시해 주면
다시 컴파일할 필요 없이 환경변수만으로 필터를 조정할 수 있다.</p>
<p>유의미한 로그가 나오는 환경을 시뮬레이션하기 위해
병렬 처리를 위한 <a href="https://docs.rs/rayon/latest/rayon">rayon</a>도 사용한다.</p>
<blockquote>
<p><code>Cargo.toml</code></p>
</blockquote>
<pre><code class="language-toml">[package]
name = &quot;log-tracing&quot;
version = &quot;0.1.0&quot;
edition = &quot;2024&quot;
&gt;
[lib]
name = &quot;rust_engine&quot;
crate-type = [&quot;cdylib&quot;]
&gt;
[dependencies]
pyo3 = &quot;0.28.0&quot;
rayon = &quot;1.11&quot;
tracing = &quot;0.1&quot;
tracing-subscriber = { version = &quot;0.3&quot;, features = [&quot;env-filter&quot;] }</code></pre>
<h2 id="코드-작성">코드 작성</h2>
<h3 id="rust-코드">Rust 코드</h3>
<p>Python에서 <code>rust_engine</code> 모듈을 가져올 때 최초로 한 번
로그에 어떤 스레드가 작업하는지 표시하도록 하며
<code>EnvFilter</code> 를 통해 환경변수에서 가져온 로그 수준을 반영하는
초기화 과정을 거친다.</p>
<p><code>#[instrument]</code> 속성을 사용하면 함수 호출 시
자동으로 맥락(Span)을 생성하며
함수의 인자로 전달된 <code>task_id</code> 가 로그에 자동으로 찍힌다.</p>
<p><code>span!()</code> 매크로를 사용하면
로그 수준에 따라 로그 앞에 부가적인 정보를 붙일 수도 있다.</p>
<p>지연 및 조건부 경고/에러에 대한 시뮬레이션을 작성한다.</p>
<blockquote>
<p><code>src/lib.rs</code></p>
</blockquote>
<pre><code class="language-rust">use pyo3::prelude::*;
use rayon::prelude::*;
use tracing::{info, debug, warn, error, instrument, span, Level};
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
use std::time::Duration;
&gt;
fn init_tracing() {
    let filter = EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| EnvFilter::new(&quot;info&quot;);
&gt;
    let _ = tracing_subscriber::registry()
        .with(fmt::layer().with_thread_ids(true))
        .with(filter)
        .try_init();
}
&gt;
#[pyfunction]
#[instrument(skip(events))] // 인자로 전달된 `events` 자체에 대한 건 생략
fn process_events_parallel(events: Vec&lt;String&gt;) -&gt; PyResult&lt;String&gt; {
    info!(&quot;총 {}개의 이벤트 병렬 처리 시작&quot;, events.len());
&gt;
    let results: Vec&lt;bool&gt; = events
        .into_par_iter()
        .enumerate()
        .map(|(idx, event_name)| {
            let event_span = span!(Level::DEBUG, &quot;event_worker&quot;, id = idx);
            let _enter = event_span.enter();
&gt;
            debug!(&quot;데이터 검증 중: {}&quot;, event_name);
&gt;
            if event_name.contains(&quot;error&quot;) {
                error!(&quot;심각한 페이로드 발견: {}&quot;, event_name);
                false
            } else if event_name.len() &gt; 10 {
                warn!(&quot;비정상적으로 긴 이벤트명 감지&quot;);
                true
            } else {
                // 이벤트 처리에 걸리는 시간 시뮬레이션
                std::thread::sleep(Duration::from_millis(10));
                true
            }
        })
        .collect();
&gt;
    let success_count = results.iter().filter(|&amp;&amp;r| r).count();
&gt;
    if success_count &lt; results.len() {
        warn!(&quot;일부 작업이 실패했습니다. (성공: {} / 전체: {})&quot;, success_count, results.len());
    } else {
        info!(&quot;{}개의 모든 이벤트가 성공적으로 처리되었습니다.&quot;, results.len());
    }
&gt;
    Ok(format!(&quot;처리 완료: {}건 성공&quot;, success_count))
}
&gt;
#[pymodule]
fn rust_engine(m: &amp;Bound&lt;&#39;_, PyModule&gt;) -&gt; PyResult&lt;()&gt; {
    init_tracing();
    tracing::info!(&quot;Rust Engine 가동 및 Tracing 시스템 초기화 완료&quot;);
&gt;
    m.add_function(wrap_pyfunction!(process_events_parallel, m)?)?;
&gt;
    Ok(())
}</code></pre>
<h3 id="python-코드">Python 코드</h3>
<p>Rust로 작성한 모듈을 불러오기 전에 환경 변수를 설정한다.</p>
<blockquote>
</blockquote>
<p>여기서 설정해주지 않을 경우 서버를 실행할 때
다음과 같이 환경 변수를 설정해 주어도 된다.</p>
<pre><code class="language-bash">~/workspace/log-tracing$ RUST_LOG=debug uvicorn app.main:app --reload</code></pre>
<blockquote>
<p><code>app/main.py</code></p>
</blockquote>
<pre><code class="language-py">import os
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
&gt;
# rust_engine을 로드하기 전에 로그 수준 설정
# debug: 각 스레드의 상세 log 확인 가능
# info: 요약 정보만 확인 가능
os.environ[&quot;RUST_LOG&quot;] = &quot;debug&quot;
&gt;
import rust_engine
&gt;
class UTF8ORJSONResponse(ORJSONResponse):
    media_type = &quot;application/json; charset=utf-8&quot;
&gt;
app = FastAPI(default_response_class=UTF8ORJSONResponse)
&gt;
@app.get(&quot;/&quot;)
def read_root():
    return {
        &quot;status&quot;: &quot;200&quot;,
        &quot;info&quot;: &quot;서버 가동 중입니다.&quot;
    }
&gt;
@app.get(&quot;/process&quot;)
def process():
    test_events = [
        &quot;login_event&quot;,
        &quot;purchase_event_with_long_name&quot;,
        &quot;system_error_critical&quot;,
        &quot;logout_event&quot;,
        &quot;something&quot;
    ]
&gt;
    result = rust_engine.process_events_parallel(test_events)
&gt;    
    return {
        &quot;status&quot;: &quot;ok&quot;,
        &quot;message&quot;: result
    }</code></pre>
<h2 id="빌드-및-실행">빌드 및 실행</h2>
<p>Maturin 라이브러리를 통해 Rust 코드를 Python에서 호출 가능한 형태로 컴파일한다.
병렬 처리가 포함된 코드는 성능 최적화를 위해 <code>--release</code> 를 붙여 컴파일한다.
컴파일 후 <code>pip list</code> 명령어를 사용해 보면 <code>Cargo.toml</code> 파일에 작성한 패키지 이름을 확인할 수 있다.</p>
<p>uvicorn 라이브러리를 통해 FastAPI를 실행한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/log-tracing$ maturin develop --release
~/workspace/log-tracing$ uvicorn app.main:app --reload</code></pre>
<p>curl 명령어 또는 브라우저를 통해 다음과 같은 테스트를 해볼 수 있다.</p>
<ul>
<li><code>http://127.0.0.1:8000/process</code></li>
</ul>
<blockquote>
</blockquote>
<p>로그 수준이 DEBUG일 때</p>
<pre><code class="language-bash">~$ curl -i http://127.0.0.1:8000/process
HTTP/1.1 200 OK
date: Thu, 02 Apr 2026 23:21:58 GMT
server: uvicorn
content-length: 54
content-type: application/json; charset=utf-8
&gt;
{&quot;status&quot;:&quot;ok&quot;,&quot;message&quot;:&quot;처리 완료: 4건 성공&quot;}% </code></pre>
<pre><code class="language-bash">INFO:     Started reloader process [65590] using StatReload
2026-04-02T23:21:55.889185Z  INFO ThreadId(01) rust_engine: Rust Engine 가동 및 Tracing 시스템 초기화 완료
INFO:     Started server process [65592]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
2026-04-02T23:21:59.932540Z  INFO ThreadId(02) process_events_parallel: rust_engine: 총 5개의 이벤트 병렬 처리 시작
2026-04-02T23:21:59.932686Z DEBUG ThreadId(16) event_worker{id=1}: rust_engine: 데이터 검증 중: purchase_event_with_long_name
2026-04-02T23:21:59.932693Z DEBUG ThreadId(13) event_worker{id=0}: rust_engine: 데이터 검증 중: login_event
2026-04-02T23:21:59.932686Z DEBUG ThreadId(15) event_worker{id=2}: rust_engine: 데이터 검증 중: system_error_critical
2026-04-02T23:21:59.932699Z DEBUG ThreadId(06) event_worker{id=3}: rust_engine: 데이터 검증 중: logout_event
2026-04-02T23:21:59.932721Z ERROR ThreadId(15) event_worker{id=2}: rust_engine: 심각한 페이로드 발견: system_error_critical
2026-04-02T23:21:59.932722Z  WARN ThreadId(06) event_worker{id=3}: rust_engine: 비정상적으로 긴 이벤트명 감지
2026-04-02T23:21:59.932733Z  WARN ThreadId(13) event_worker{id=0}: rust_engine: 비정상적으로 긴 이벤트명 감지
2026-04-02T23:21:59.932722Z  WARN ThreadId(16) event_worker{id=1}: rust_engine: 비정상적으로 긴 이벤트명 감지
2026-04-02T23:21:59.932737Z DEBUG ThreadId(05) event_worker{id=4}: rust_engine: 데이터 검증 중: something
2026-04-02T23:21:59.945342Z  WARN ThreadId(02) process_events_parallel: rust_engine: 일부 작업이 실패했습니다. (성공: 4 / 전체: 5)
INFO:     127.0.0.1:49647 - &quot;GET /process HTTP/1.1&quot; 200 OK</code></pre>
<blockquote>
</blockquote>
<p>로그 수준이 INFO일 때 혹은 로그 수준을 명시하지 않았을 때</p>
<pre><code class="language-bash">~$ curl -i http://127.0.0.1:8000/process
HTTP/1.1 200 OK
date: Thu, 02 Apr 2026 23:22:29 GMT
server: uvicorn
content-length: 54
content-type: application/json; charset=utf-8
&gt;
{&quot;status&quot;:&quot;ok&quot;,&quot;message&quot;:&quot;처리 완료: 4건 성공&quot;}% </code></pre>
<pre><code class="language-bash">INFO:     Started reloader process [65603] using StatReload
2026-04-02T23:22:27.262593Z  INFO ThreadId(01) rust_engine: Rust Engine 가동 및 Tracing 시스템 초기화 완료
INFO:     Started server process [65605]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
2026-04-02T23:22:30.232762Z  INFO ThreadId(02) process_events_parallel: rust_engine: 총 5개의 이벤트 병렬 처리 시작
2026-04-02T23:22:30.232913Z ERROR ThreadId(04) rust_engine: 심각한 페이로드 발견: system_error_critical
2026-04-02T23:22:30.232929Z  WARN ThreadId(15) rust_engine: 비정상적으로 긴 이벤트명 감지
2026-04-02T23:22:30.232932Z  WARN ThreadId(14) rust_engine: 비정상적으로 긴 이벤트명 감지
2026-04-02T23:22:30.232950Z  WARN ThreadId(16) rust_engine: 비정상적으로 긴 이벤트명 감지
2026-04-02T23:22:30.245526Z  WARN ThreadId(02) process_events_parallel: rust_engine: 일부 작업이 실패했습니다. (성공: 4 / 전체: 5)
INFO:     127.0.0.1:49648 - &quot;GET /process HTTP/1.1&quot; 200 OK</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[CSV 파일 다루기 — 행 간 연산]]></title>
            <link>https://velog.io/@peeeeeter_j/pt-study-17</link>
            <guid>https://velog.io/@peeeeeter_j/pt-study-17</guid>
            <pubDate>Thu, 02 Apr 2026 02:00:03 GMT</pubDate>
            <description><![CDATA[<h1 id="csv-파일-다루기--행-간-연산">CSV 파일 다루기 — 행 간 연산</h1>
<p>단순히 개수를 세거나 총합을 구하는 건 비교적 쉽지만
이전 행이나 다음 행과 비교하거나
몇 개 행 단위로 묶어서 연산을 수행할 경우
병렬 처리를 위해 구간을 나누면서 문제가 발생할 수 있다.</p>
<p>몇 개 행 단위로 묶어서 연산을 하는 것은
구간을 나눌 때 기준 행 수의 배수로 나누면 되니
비교적 수월하게 구현할 수 있지만
이전 행과의 비교는 어떻게든 다른 구간과의 비교가 필요하다.</p>
<p>이런 상황에서 어떻게 처리할 수 있는지 알아보기 위해
현재 행의 <code>value</code> 값이 이전 행의 <code>value</code> 값보다 큰지 비교하여
그 결과를 저장하는 예제를 살펴보도록 하겠다.</p>
<p>이번에는 결과를 전송하지 않고
비교 결과 항목이 추가된 새 CSV 파일을 생성해 볼 것이다.</p>
<p>이 실습은 <a href="pt-study-16">이전 실습</a>에서 이어서 진행한다.
따라서 작업공간 생성 및 구조 확인은 생략한다.</p>
<h2 id="코드-작성">코드 작성</h2>
<h3 id="rust-코드">Rust 코드</h3>
<p>연산 결과를 새 CSV 파일로 저장할 것이므로
CSV 출력에 필요한 크레이트를 추가로 불러와야 한다.</p>
<p>병렬 처리를 위해 구간을 나누는 함수는 그대로 재사용한다.</p>
<p>맨 앞이 아니라면 이전 청크의 마지막 값을 탐색해야
병렬 처리를 해도 연속적인 결과를 확인할 수 있다.</p>
<p><code>wtr</code> 를 사용하는 영역을 블록 스코프로 묶었는데
그렇게 하지 않으면 <code>buffer</code> 를 반환할 때 소유권 문제가 생긴다.
블록 스코프로 묶지 않고 <code>drop(wtr);</code> 하는 것도 방법이다.</p>
<blockquote>
<p><code>src/lib.rs</code></p>
</blockquote>
<pre><code class="language-rust">use pyo3::prelude::*;
use memmap2::Mmap;
use std::fs::File;
use std::io::{BufWriter, Write}; // NEW!
use rayon::prelude::*;
use csv::{ReaderBuilder, WriterBuilder, ByteRecord}; // MODIFIED!
&gt;
/// 청크가 줄 중간에서 잘리지 않도록
/// 파일을 코어 수에 맞춰 최적의 경계로 분리
fn get_split_points(bytes: &amp;[u8], num_chunks: usize) -&gt; Vec&lt;usize&gt; {
    let mut points = vec![0];
    let base_size = bytes.len() / num_chunks;
&gt;
    for i in 1..num_chunks {
        let target = i * base_size;
        let pos = bytes[target..].iter()
            .position(|&amp;b| b == b&#39;\n&#39;)
            .map(|p| target + p + 1)
            .unwrap_or(bytes.len());
&gt;
        if pos &lt; bytes.len() &amp;&amp; !points.contains(&amp;pos) {
            points.push(pos);
        }
    }
 &gt;   
    points.push(bytes.len());
&gt;
    points
}
&gt;
#[pyfunction]
fn process_large_csv(file_path: String) -&gt; PyResult&lt;(f64, usize)&gt; {
    let file = File::open(file_path)?;
    let mmap = unsafe {
        Mmap::map(&amp;file)?
    };
    let bytes = &amp;mmap[..];
&gt;
    let split_points = get_split_points(bytes, rayon::current_num_threads());
&gt;
    let result = split_points.windows(2) // (start, end) 쌍으로 사용
        .enumerate() // 인덱스와 요소를 튜플로 받아 사용: (index, (start, end))
        .collect::&lt;Vec&lt;_&gt;&gt;()
        .into_par_iter()
        .map(|(i, window)| {
            let (start, end) = (window[0], window[1]);
            let mut rdr = ReaderBuilder::new()
                .has_headers(i == 0)
                .from_reader(&amp;bytes[start..end]);
&gt;
            let mut record = ByteRecord::new();
            let (mut l_sum, mut l_count) = (0.0, 0);
&gt;
            while rdr.read_byte_record(&amp;mut record).unwrap_or(false) {
                // 0:id, 1:value, 2:category
                if record.get(2) == Some(b&quot;A&quot;) {
                    if let Some(val_bytes) = record.get(1) {
                        if let Ok(s) = std::str::from_utf8(val_bytes) {
                            if let Ok(val) = s.parse::&lt;f64&gt;() {
                                l_sum += val;
                                l_count += 1;
                            }
                        }
                    }
                }
            }
&gt;
            (l_sum, l_count)
        })
        .reduce(
            || (0.0, 0),
            |(s1, c1), (s2, c2)| (s1 + s2, c1 + c2)
        );
&gt;
    Ok(result)
}
&gt;
// 이상 기존 코드
&gt;
#[pyfunction]
fn compare_and_write(input_path: String, output_path: String) -&gt; PyResult&lt;usize&gt; {
    let file = File::open(&amp;input_path)?;
    let mmap = unsafe {
        Mmap::map(&amp;file)?
    };
    let bytes = &amp;mmap[..];
&gt;
    let split_points = get_split_points(bytes, rayon::current_num_threads());
&gt;
    let processed_chunks: Vec&lt;Vec&lt;u8&gt;&gt; = split_points.windows(2) // (start, end) 쌍으로 사용
        .enumerate() // 인덱스와 요소를 튜플로 받아 사용: (index, (start, end))
        .collect::&lt;Vec&lt;_&gt;&gt;()
        .into_par_iter()
        .map(|(i, window)| {
            let (start, end) = (window[0], window[1]);
            let mut buffer = Vec::new();
            {
                let mut wtr = WriterBuilder::new()
                    .has_headers(false)
                    .from_writer(&amp;mut buffer);
&gt;
                let mut prev_value: Option&lt;f64&gt; = None;
&gt;
                // Look-back: 이전 청크의 마지막 값 가져오기
                if i &gt; 0 {
                    let search_start = if start &gt; 1024 { start - 1024 } else { 0 };
                    let mut rdr_prev = ReaderBuilder::new()
                        .has_headers(false)
                        .from_reader(&amp;bytes[search_start..start]); // 이전 청크
&gt;
                    let mut last_rec = ByteRecord::new();
                    let mut last_val = None;
&gt;
                    // 마지막 값 찾기
                    while rdr_prev.read_byte_record(&amp;mut last_rec).unwrap_or(false) {
                        // 0:id, 1:value, 2:category
                        if let Ok(s) = std::str::from_utf8(last_rec.get(1).unwrap_or(b&quot;0&quot;)) {
                            last_val = s.parse::&lt;f64&gt;().ok();
                        }
                    }
                    prev_value = last_val;
                }
&gt;
                // 본격적인 작업 시작
&gt;           
                let mut rdr = ReaderBuilder::new()
                    .has_headers(false)
                    .from_reader(&amp;bytes[start..end]);
                let mut record = ByteRecord::new();
&gt;
                while rdr.read_byte_record(&amp;mut record).unwrap_or(false) {
                    // 0:id, 1:value, 2:category
                    let current_value = std::str::from_utf8(record.get(1).unwrap_or(b&quot;0&quot;))
                        .unwrap_or(&quot;0&quot;).parse::&lt;f64&gt;().unwrap_or(0.0);
&gt;
                    let is_higher = match prev_value {
                        Some(v) if current_value &gt; v =&gt; &quot;1&quot;,
                        _ =&gt; &quot;0&quot;,
                    };
&gt;
                    record.push_field(is_higher.as_bytes());
                    wtr.write_byte_record(&amp;record).unwrap();
&gt;
                    prev_value = Some(current_value);
                }
&gt;
                wtr.flush().unwrap();
            }
&gt;
            buffer
        })
        .collect();
&gt;
    let out_file = File::create(output_path)?;
    let mut buffered_writer = BufWriter::with_capacity(1024 * 1024, out_file);
    buffered_writer.write_all(b&quot;id,value,category,is_higher\n&quot;)?;
    for chunk in processed_chunks {
        buffered_writer.write_all(&amp;chunk)?;
    }
&gt;
    Ok(mmap.len())
}
&gt;
#[pymodule]
fn rust_engine(m: &amp;Bound&lt;&#39;_, PyModule&gt;) -&gt; PyResult&lt;()&gt; {
    m.add_function(wrap_pyfunction!(process_large_csv, m)?)?;
    m.add_function(wrap_pyfunction!(compare_and_write, m)?)?; // NEW!
&gt;
    Ok(())
}</code></pre>
<h3 id="python-코드">Python 코드</h3>
<blockquote>
<p><code>app/main.py</code></p>
</blockquote>
<pre><code class="language-py">import pandas as pd
import numpy as np
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
import rust_engine
import time
import os
&gt;
class UTF8ORJSONResponse(ORJSONResponse):
    media_type = &quot;application/json; charset=utf-8&quot;
&gt;
app = FastAPI(default_response_class=UTF8ORJSONResponse)
&gt;
@app.get(&quot;/&quot;)
def read_root():
    return {
        &quot;status&quot;: &quot;200&quot;,
        &quot;info&quot;: &quot;서버 가동 중입니다.&quot;
    }
&gt;
@app.get(&quot;/setup-csv/{rows}&quot;)
def setup_csv(rows: int):
    file_path = &quot;large_data.csv&quot;
&gt;
    df = pd.DataFrame({
        &quot;id&quot;: range(rows),
        &quot;value&quot;: np.random.rand(rows),
        &quot;category&quot;: np.random.choice([&quot;A&quot;, &quot;B&quot;, &quot;C&quot;, &quot;D&quot;], rows)
    })
&gt;
    df.to_csv(file_path, index=False)
&gt;
    return {
        &quot;message&quot;: f&quot;{rows:,}줄 CSV 파일 생성 완료&quot;
    }
&gt;
@app.get(&quot;/process-csv&quot;)
def process_csv():
    file_path = &quot;large_data.csv&quot;
&gt;
    if not os.path.exists(file_path):
        return {
            &quot;error&quot;: &quot;파일이 없습니다. /setup-csv 라우트를 먼저 호출해주세요.&quot;
        }
&gt;
    start_rust = time.perf_counter()
    rust_sum, rust_count = rust_engine.process_large_csv(file_path)
    end_rust = time.perf_counter()
&gt;
    rust_duration = end_rust - start_rust
&gt;
    start_pd = time.perf_counter()
    df = pd.read_csv(file_path)
    pd_sum = df[df[&quot;category&quot;] == &quot;A&quot;][&quot;value&quot;].sum()
    end_pd = time.perf_counter()
&gt;
    pd_duration = end_pd - start_pd
&gt;
    return {
        &quot;total_rows&quot;: rust_count,
        &quot;rust_time&quot;: f&quot;{rust_duration:.4f} sec&quot;,
        &quot;pandas_time&quot;: f&quot;{pd_duration:.4f} sec&quot;,
        &quot;speed_up&quot;: f&quot;{pd_duration / rust_duration:.1f}x&quot;,
        &quot;results&quot;: {
            &quot;rust_sum&quot;: rust_sum,
            &quot;pandas_sum&quot;: pd_sum
        }
    }
&gt;
# 이상 기존 코드
&gt;
@app.get(&quot;/compare-and-write&quot;)
def compare_and_write():
    input_file = &quot;large_data.csv&quot;
    output_file = &quot;processed_data.csv&quot;
&gt;
    if not os.path.exists(input_file):
        return {
            &quot;error&quot;: &quot;파일이 없습니다. /setup-csv 라우트를 먼저 호출해주세요.&quot;
        }
&gt;
    start = time.perf_counter()
    processed_data = rust_engine.compare_and_write(input_file, output_file)
    end = time.perf_counter()
&gt;
    duration = end - start
&gt;
    return {
        &quot;output_file&quot;: output_file,
        &quot;rust_pure_time&quot;: f&quot;{duration:.4f} sec&quot;,
        &quot;file_size&quot;: f&quot;{os.path.getsize(output_file) / (1024 * 1024):.2f} MB&quot;
    }</code></pre>
<h2 id="빌드-및-실행">빌드 및 실행</h2>
<p>Maturin 라이브러리를 통해 Rust 코드를 Python에서 호출 가능한 형태로 컴파일한다.
병렬 처리가 포함된 코드는 성능 최적화를 위해 <code>--release</code> 를 붙여 컴파일한다.
컴파일 후 <code>pip list</code> 명령어를 사용해 보면 <code>Cargo.toml</code> 파일에 작성한 패키지 이름을 확인할 수 있다.</p>
<p>uvicorn 라이브러리를 통해 FastAPI를 실행한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/csv-processor$ maturin develop --release
~/workspace/csv-processor$ uvicorn app.main:app --reload</code></pre>
<p>curl 명령어 또는 브라우저를 통해 다음과 같은 테스트를 해볼 수 있다.</p>
<ul>
<li><code>http://127.0.0.1:8000/setup-csv/50000000</code></li>
<li><code>http://127.0.0.1:8000/compare-and-write</code></li>
</ul>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ column -s, -t &lt; workspace/csv-processor/large_data.csv | head -n 20
id        value                   category
0         0.40051801434356893     B
1         0.2546372973392159      B
2         0.8411876220186603      A
3         0.22976716598349933     C
4         0.2205761919396625      C
5         0.8969404327142123      C
6         0.24805162492147237     C
7         0.6157544951301185      B
8         0.37118755341262744     D
9         0.20077848283153787     C
10        0.7006307580777321      A
11        0.6289105208867691      C
12        0.5645651105257122      A
13        0.07251456319471494     C
14        0.4878721859338948      B
15        0.41749333189497495     B
16        0.793024114133093       B
17        0.684794684186633       C
18        0.7427233817197105      A</code></pre>
<pre><code class="language-bash">~$ curl -i http://127.0.0.1:8000/compare-and-write           
HTTP/1.1 200 OK
date: Thu, 02 Apr 2026 01:41:21 GMT
server: uvicorn
content-length: 91
content-type: application/json; charset=utf-8
&gt;
{&quot;output_file&quot;:&quot;processed_data.csv&quot;,&quot;rust_pure_time&quot;:&quot;0.7453 sec&quot;,&quot;file_size&quot;:&quot;1528.15 MB&quot;}%</code></pre>
<pre><code class="language-bash">~$ column -s, -t &lt; workspace/csv-processor//processed_data.csv | head -n 20
id        value                   category  is_higher
0         0.40051801434356893     B         0
1         0.2546372973392159      B         0
2         0.8411876220186603      A         1
3         0.22976716598349933     C         0
4         0.2205761919396625      C         0
5         0.8969404327142123      C         1
6         0.24805162492147237     C         0
7         0.6157544951301185      B         1
8         0.37118755341262744     D         0
9         0.20077848283153787     C         0
10        0.7006307580777321      A         1
11        0.6289105208867691      C         0
12        0.5645651105257122      A         0
13        0.07251456319471494     C         0
14        0.4878721859338948      B         1
15        0.41749333189497495     B         0
16        0.793024114133093       B         1
17        0.684794684186633       C         0
18        0.7427233817197105      A         1</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[CSV 파일 다루기 — 행 단위 독립 연산]]></title>
            <link>https://velog.io/@peeeeeter_j/pt-study-16</link>
            <guid>https://velog.io/@peeeeeter_j/pt-study-16</guid>
            <pubDate>Wed, 01 Apr 2026 05:29:47 GMT</pubDate>
            <description><![CDATA[<h1 id="csv-파일-다루기--행-단위-독립-연산">CSV 파일 다루기 — 행 단위 독립 연산</h1>
<p>CSV 파일은 DBMS를 사용할 정도의 규모와 복잡도를 갖지 않은
간단하지만 많은 양의 데이터를 다룰 때 유용하게 사용된다.</p>
<p>CSV 파일의 행 단위 독립 연산을 빠르게 수행하는 방법을 알아보자.</p>
<h2 id="작업공간-생성-및-구조-확인">작업공간 생성 및 구조 확인</h2>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace$ mkdir csv-processor &amp;&amp; cd csv-processor
~/workspace/csv-processor$ python3 -m venv venv
~/workspace/csv-processor$ source venv/bin/activate
~/workspace/csv-processor$ # 평소에 설치하던 것 외에 추가된 라이브러리를 놓치지 말자
~/workspace/csv-processor$ pip install maturin fastapi uvicorn orjson numpy pandas
~/workspace/csv-processor$ maturin init
~/workspace/csv-processor$ # 선택지 중 기본값인 PyO3 선택
~/workspace/csv-processor$ # Cargo.toml과 src/lib.rs가 자동 생성된다
~/workspace/csv-processor$ # Python 코드는 직접 생성해 주어야 한다
~/workspace/csv-processor$ mkdir app &amp;&amp; touch app/main.py
~/workspace/csv-processor$ tree -I venv
.
├── app
│   └── main.py
├── Cargo.toml
├── pyproject.toml
└── src
    └── lib.rs</code></pre>
<p><code>Cargo.toml</code> 파일을 열어 라이브러리 이름을 수정해 주겠다.</p>
<p>CSV 처리를 위한 Rust 생태계의 표준인 <a href="https://docs.rs/csv/latest/csv">csv</a> 크레이트와
데이터 직렬화를 담당하는 <a href="https://docs.rs/serde/latest/serde">serde</a> 크레이트를 추가한다.</p>
<p><code>features = [&quot;derive&quot;]</code> 를 명시해 주어야
serde 크레이트의 매크로를 사용할 수 있음을 유의하자.</p>
<p>CSV 파일의 크기가 클 경우 MMAP을 사용하는 게 효율적이다.
여기서는 대용량 CSV 파일을 상정하고 실습을 진행할 것이므로
MMAP을 사용하기 위해 <a href="https://docs.rs/memmap2/latest/memmap2">memmap2</a> 크레이트도 사용한다.</p>
<p>CSV 파일 다루는 것 자체는 순차적으로 진행되지만
데이터를 파싱하고 계산하는 과정은 병렬화가 가능하므로 <a href="https://docs.rs/rayon/latest/rayon">rayon</a>도 사용한다.</p>
<blockquote>
<p><code>Cargo.toml</code></p>
</blockquote>
<pre><code class="language-toml">[package]
name = &quot;csv-processor&quot;
version = &quot;0.1.0&quot;
edition = &quot;2024&quot;
&gt;
[lib]
name = &quot;rust_engine&quot;
crate-type = [&quot;cdylib&quot;]
&gt;
[dependencies]
pyo3 = &quot;0.28.0&quot;
rayon = &quot;1.11&quot;
memmap2 = &quot;0.9&quot;
csv = &quot;1.4&quot;
serde = { version = &quot;1.0&quot;, features = [&quot;derive&quot;] }</code></pre>
<h2 id="코드-작성">코드 작성</h2>
<h3 id="rust-코드">Rust 코드</h3>
<p>연습 수준에서는 모든 데이터를 벡터로 수집하여 처리해도 괜찮지만
데이터 양이 많아질 경우에는 메모리 이슈가 발생하므로
데이터를 메모리에 쌓지 않고 읽는 즉시 병렬 파이프라인으로 던지는 방식으로
스트리밍 병렬 처리를 하는 것이 효율적이다.</p>
<p>코어 수에 맞게 청크를 나눌 때 라인 중간에서 끊기면 안 되므로
최적의 경계로 분리하는 기준점을 제시하는 헬퍼 함수를 따로 작성한다.</p>
<p>Rust의 <code>csv</code> 생태계에서는 관습적으로
Reader를 <code>rdr</code>, Writer를 <code>wtr</code> 라는 약어로 사용한다는 건 여담.</p>
<blockquote>
<p><code>src/lib.rs</code></p>
</blockquote>
<pre><code class="language-rust">use pyo3::prelude::*;
use memmap2::Mmap;
use std::fs::File;
use rayon::prelude::*;
use csv::{ReaderBuilder, ByteRecord};
&gt;
/// 청크가 줄 중간에서 잘리지 않도록 파일을 코어 수에 맞춰 최적의 경계로 분리
fn get_split_points(bytes: &amp;[u8], num_chunks: usize) -&gt; Vec&lt;usize&gt; {
    let mut points = vec![0];
    let base_size = bytes.len() / num_chunks;
&gt;
    for i in 1..num_chunks {
        let target = i * base_size;
        let pos = bytes[target..].iter()
            .position(|&amp;b| b == b&#39;\n&#39;)
            .map(|p| target + p + 1)
            .unwrap_or(bytes.len());
&gt;
        if pos &lt; bytes.len() &amp;&amp; !points.contains(&amp;pos) {
            points.push(pos);
        }
    }
 &gt;   
    points.push(bytes.len());
&gt;
    points
}
&gt;
#[pyfunction]
fn process_large_csv(file_path: String) -&gt; PyResult&lt;(f64, usize)&gt; {
    let file = File::open(file_path)?;
    let mmap = unsafe {
        Mmap::map(&amp;file)?
    };
    let bytes = &amp;mmap[..];
&gt;
    let split_points = get_split_points(bytes, rayon::current_num_threads());
&gt;
    let result = split_points.windows(2) // (start, end) 쌍으로 사용
        .enumerate() // 인덱스와 요소를 튜플로 받아 사용: (index, (start, end))
        .collect::&lt;Vec&lt;_&gt;&gt;()
        .into_par_iter()
        .map(|(i, window)| {
            let (start, end) = (window[0], window[1]);
            let mut rdr = ReaderBuilder::new()
                .has_headers(i == 0)
                .from_reader(&amp;bytes[start..end]);
&gt;
            let mut record = ByteRecord::new();
            let (mut l_sum, mut l_count) = (0.0, 0);
&gt;
            while rdr.read_byte_record(&amp;mut record).unwrap_or(false) {
                // 0:id, 1:value, 2:category
                if record.get(2) == Some(b&quot;A&quot;) {
                    if let Some(val_bytes) = record.get(1) {
                        if let Ok(s) = std::str::from_utf8(val_bytes) {
                            if let Ok(val) = s.parse::&lt;f64&gt;() {
                                l_sum += val;
                                l_count += 1;
                            }
                        }
                    }
                }
            }
&gt;
            (l_sum, l_count)
        })
        .reduce(
            || (0.0, 0),
            |(s1, c1), (s2, c2)| (s1 + s2, c1 + c2)
        );
&gt;
    Ok(result)
}
&gt;
#[pymodule]
fn rust_engine(m: &amp;Bound&lt;&#39;_, PyModule&gt;) -&gt; PyResult&lt;()&gt; {
    m.add_function(wrap_pyfunction!(process_large_csv, m)?)?;
&gt;
    Ok(())
}</code></pre>
<h3 id="python-코드">Python 코드</h3>
<p>C언어 기반으로 최적화되어 있는 Pandas도 충분히 빠르지만
우리가 작성한 Rust 코드를 사용하여 데이터를 처리하는 것과
Pandas를 사용하여 데이터를 처리하는 것의 시간을 비교해 보자.</p>
<blockquote>
<p><code>app/main.py</code></p>
</blockquote>
<pre><code class="language-py">import pandas as pd
import numpy as np
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
import rust_engine
import time
import os
&gt;
class UTF8ORJSONResponse(ORJSONResponse):
    media_type = &quot;application/json; charset=utf-8&quot;
&gt;
app = FastAPI(default_response_class=UTF8ORJSONResponse)
&gt;
@app.get(&quot;/&quot;)
def read_root():
    return {
        &quot;status&quot;: &quot;200&quot;,
        &quot;info&quot;: &quot;서버 가동 중입니다.&quot;
    }
&gt;
@app.get(&quot;/setup-csv/{rows}&quot;)
def setup_csv(rows: int):
    file_path = &quot;large_data.csv&quot;
&gt;
    df = pd.DataFrame({
        &quot;id&quot;: range(rows),
        &quot;value&quot;: np.random.rand(rows),
        &quot;category&quot;: np.random.choice([&quot;A&quot;, &quot;B&quot;, &quot;C&quot;, &quot;D&quot;], rows)
    })
&gt;
    df.to_csv(file_path, index=False)
&gt;
    return {
        &quot;message&quot;: f&quot;{rows:,}줄 CSV 파일 생성 완료&quot;
    }
&gt;
@app.get(&quot;/process-csv&quot;)
def process_csv():
    file_path = &quot;large_data.csv&quot;
&gt;
    if not os.path.exists(file_path):
        return {
            &quot;error&quot;: &quot;파일이 없습니다. /setup-csv 라우트를 먼저 호출해주세요.&quot;
        }
&gt;
    start_rust = time.perf_counter()
    rust_sum, rust_count = rust_engine.process_large_csv(file_path)
    end_rust = time.perf_counter()
&gt;
    rust_duration = end_rust - start_rust
&gt;
    start_pd = time.perf_counter()
    df = pd.read_csv(file_path)
    pd_sum = df[df[&quot;category&quot;] == &quot;A&quot;][&quot;value&quot;].sum()
    end_pd = time.perf_counter()
&gt;
    pd_duration = end_pd - start_pd
&gt;
    return {
        &quot;total_rows&quot;: rust_count,
        &quot;rust_time&quot;: f&quot;{rust_duration:.4f} sec&quot;,
        &quot;pandas_time&quot;: f&quot;{pd_duration:.4f} sec&quot;,
        &quot;speed_up&quot;: f&quot;{pd_duration / rust_duration:.1f}x&quot;,
        &quot;results&quot;: {
            &quot;rust_sum&quot;: rust_sum,
            &quot;pandas_sum&quot;: pd_sum
        }
    }</code></pre>
<h2 id="빌드-및-실행">빌드 및 실행</h2>
<p>Maturin 라이브러리를 통해 Rust 코드를 Python에서 호출 가능한 형태로 컴파일한다.
병렬 처리가 포함된 코드는 성능 최적화를 위해 <code>--release</code> 를 붙여 컴파일한다.
컴파일 후 <code>pip list</code> 명령어를 사용해 보면 <code>Cargo.toml</code> 파일에 작성한 패키지 이름을 확인할 수 있다.</p>
<p>uvicorn 라이브러리를 통해 FastAPI를 실행한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/csv-processor$ maturin develop --release
~/workspace/csv-processor$ uvicorn app.main:app --reload</code></pre>
<p>curl 명령어 또는 브라우저를 통해 다음과 같은 테스트를 해볼 수 있다.</p>
<ul>
<li><code>http://127.0.0.1:8000/setup-csv/50000000</code></li>
<li><code>http://127.0.0.1:8000/process-csv</code></li>
</ul>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ curl -i http://127.0.0.1:8000/setup-csv/50000000
HTTP/1.1 200 OK
date: Wed, 01 Apr 2026 05:11:26 GMT
server: uvicorn
content-length: 52
content-type: application/json; charset=utf-8
&gt;
{&quot;message&quot;:&quot;50,000,000줄 CSV 파일 생성 완료&quot;}%</code></pre>
<pre><code class="language-bash">~$ curl -i http://127.0.0.1:8000/process-csv       
HTTP/1.1 200 OK
date: Wed, 01 Apr 2026 05:12:02 GMT
server: uvicorn
content-length: 166
content-type: application/json; charset=utf-8
&gt;
{&quot;total_rows&quot;:12502433,&quot;rust_time&quot;:&quot;0.2692 sec&quot;,&quot;pandas_time&quot;:&quot;4.8297 sec&quot;,&quot;speed_up&quot;:&quot;17.9x&quot;,&quot;results&quot;:{&quot;rust_sum&quot;:6252556.523183651,&quot;pandas_sum&quot;:6252556.523183713}}%</code></pre>
<p>부동소수점 연산 특성 상 연산 순서에 따라 소숫점 아래 낮은 자리 값은 오차가 있을 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[행렬 연산]]></title>
            <link>https://velog.io/@peeeeeter_j/pt-study-15</link>
            <guid>https://velog.io/@peeeeeter_j/pt-study-15</guid>
            <pubDate>Mon, 30 Mar 2026 22:06:00 GMT</pubDate>
            <description><![CDATA[<h1 id="행렬-연산">행렬 연산</h1>
<p>제로 카피 연산에서 단순히 더하고 곱하는 건 메모리 대역폭 싸움이었다면,
행렬 곱셈은 CPU 연산 능력과 데이터 재사용의 싸움이다.</p>
<p>64GB 램 안에서 가장 거대하고 빠른 행렬 연산을 구현해 보도록 하자.</p>
<h2 id="작업공간-생성-및-구조-확인">작업공간 생성 및 구조 확인</h2>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace$ mkdir matrix-multiply &amp;&amp; cd matrix-multiply
~/workspace/matrix-multiply$ python3 -m venv venv
~/workspace/matrix-multiply$ source venv/bin/activate
~/workspace/matrix-multiply$ # 평소에 설치하던 것 외에 추가된 라이브러리를 놓치지 말자
~/workspace/matrix-multiply$ pip install maturin fastapi uvicorn orjson numpy
~/workspace/matrix-multiply$ maturin init
~/workspace/matrix-multiply$ # 선택지 중 기본값인 PyO3 선택
~/workspace/matrix-multiply$ # Cargo.toml과 src/lib.rs가 자동 생성된다
~/workspace/matrix-multiply$ # Python 코드는 직접 생성해 주어야 한다
~/workspace/matrix-multiply$ mkdir app &amp;&amp; touch app/main.py
~/workspace/matrix-multiply$ tree -I venv
.
├── app
│   └── main.py
├── Cargo.toml
├── pyproject.toml
└── src
    └── lib.rs</code></pre>
<p><code>Cargo.toml</code> 파일을 열어 라이브러리 이름을 수정해 주겠다.</p>
<p>이번 실습에서는 <a href="https://docs.rs/numpy/latest/numpy">numpy</a> 크레이트와 함께
<a href="https://docs.rs/ndarray/latest/ndarray">ndarray</a> 크레이트를 사용한다고 명시해 둔다.
numpy 크레이트가 내부적으로 ndarray 크레이트를 가지고 사용하고 있어도
Rust의 의존성 관리 규칙에 의해
다음과 같은 이유로 명시적으로 작성해주는 것을 권장한다.</p>
<table>
<thead>
<tr>
<th align="center">구분</th>
<th align="center">묵시적 사용 (Implicit)</th>
<th align="center">명시적 사용 (Explicit)</th>
</tr>
</thead>
<tbody><tr>
<td align="center">버전 제어</td>
<td align="center">numpy가 업데이트될 때 ndarray 버전도 내 의사와 상관없이 바뀜.</td>
<td align="center">내가 원하는 0.17.2 버전을 명시적으로 고정할 수 있음.</td>
</tr>
<tr>
<td align="center">타입 안정성</td>
<td align="center">나중에 다른 라이브러리가 다른 버전의 ndarray를 가져오면 충돌 날 확률 높음.</td>
<td align="center">프로젝트 전체에서 ndarray 버전을 통일시키기 쉬움.</td>
</tr>
<tr>
<td align="center">명시성</td>
<td align="center">이 프로젝트가 ndarray 기능을 직접 쓰는지 알기 어려움.</td>
<td align="center">Cargo.toml만 봐도 &quot;아, 이건 행렬 연산을 직접 하는구나&quot;라고 알 수 있음.</td>
</tr>
</tbody></table>
<blockquote>
<p><code>Cargo.toml</code></p>
</blockquote>
<pre><code class="language-toml">[package]
name = &quot;matrix-multiply&quot;
version = &quot;0.1.0&quot;
edition = &quot;2024&quot;
&gt;
[lib]
name = &quot;rust_engine&quot;
crate-type = [&quot;cdylib&quot;]
&gt;
[dependencies]
pyo3 = &quot;0.28.0&quot;
numpy = &quot;0.28&quot;
ndarray = &quot;0.17&quot;</code></pre>
<h2 id="코드-작성">코드 작성</h2>
<h3 id="rust-코드">Rust 코드</h3>
<p>지금까지는 1차원 배열을 사용했지만
이번에는 행렬 연산을 수행할 것이기에 2차원 배열을 가져온다.</p>
<blockquote>
<p><code>src/lib.rs</code></p>
</blockquote>
<pre><code class="language-rust">use pyo3::prelude::*;
use numpy::{PyReadonlyArray2, PyArray2};
&gt;
#[pyfunction]
fn fast_matrix_multiply(
    py: Python&lt;&#39;_&gt;,
    mat_a: PyReadonlyArray2&lt;f64&gt;,
    mat_b: PyReadonlyArray2&lt;f64&gt;
) -&gt; Py&lt;PyArray2&lt;f64&gt;&gt; {
    let a = mat_a.as_array();
    let b = mat_b.as_array();
&gt;
    let result = a.dot(&amp;b);
&gt;
    PyArray2::from_owned_array(py, result).unbind()
}
&gt;
#[pymodule]
fn rust_engine(m: &amp;Bound&lt;&#39;_, PyModule&gt;) -&gt; PyResult&lt;()&gt; {
    m.add_function(wrap_pyfunction!(fast_matrix_multiply, m)?)?;
&gt;
    Ok(())
}</code></pre>
<h3 id="python-코드">Python 코드</h3>
<blockquote>
<p><code>app/main.py</code></p>
</blockquote>
<pre><code class="language-py">import numpy as np
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
import rust_engine
import time
&gt;
class UTF8ORJSONResponse(ORJSONResponse):
    media_type = &quot;application/json; charset=utf-8&quot;
&gt;
app = FastAPI(default_response_class=UTF8ORJSONResponse)
&gt;
@app.get(&quot;/&quot;)
def read_root():
    return {
        &quot;status&quot;: &quot;200&quot;,
        &quot;info&quot;: &quot;서버 가동 중입니다.&quot;
    }
&gt;
@app.get(&quot;/matrix/{dim}&quot;)
def matrix_test(dim: int):
    a = np.random.rand(dim, dim).astype(np.float64)
    b = np.random.rand(dim, dim).astype(np.float64)
&gt;
    start = time.perf_counter()
    result = rust_engine.fast_matrix_multiply(a, b)
    end = time.perf_counter()
&gt;
    rust_duration = end - start
&gt;
    return {
        &quot;dimension&quot;: f&quot;{dim}x{dim}&quot;,
        &quot;total_elements&quot;: dim*dim,
        &quot;rust_pure_time&quot;: f&quot;Rust 연산에 걸린 시간: {rust_duration:.4f} sec&quot;,
        &quot;result_sample&quot;: result[0, :3].tolist()
    }</code></pre>
<h2 id="빌드-및-실행">빌드 및 실행</h2>
<p>Maturin 라이브러리를 통해 Rust 코드를 Python에서 호출 가능한 형태로 컴파일한다.
ndarray 크레이트의 <code>dot()</code> 연산이 내부적으로 병렬 처리를 지원하므로
성능 최적화를 위해 <code>--release</code> 를 붙여 컴파일한다.
컴파일 후 <code>pip list</code> 명령어를 사용해 보면 <code>Cargo.toml</code> 파일에 작성한 패키지 이름을 확인할 수 있다.</p>
<p>uvicorn 라이브러리를 통해 FastAPI를 실행한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/matrix-multiply$ maturin develop --release
~/workspace/matrix-multiply$ uvicorn app.main:app --reload</code></pre>
<p>curl 명령어 또는 브라우저를 통해 다음과 같은 테스트를 해볼 수 있다.</p>
<ul>
<li><code>http://127.0.0.1:8000/matrix/10000</code></li>
</ul>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ curl -i http://127.0.0.1:8000/matrix/10000
HTTP/1.1 200 OK
date: Mon, 30 Mar 2026 21:52:19 GMT
server: uvicorn
content-length: 190
content-type: application/json; charset=utf-8
&gt;
{&quot;dimension&quot;:&quot;10000x10000&quot;,&quot;total_elements&quot;:100000000,&quot;rust_pure_time&quot;:&quot;Rust 연산에 걸린 시간: 37.1463 sec&quot;,&quot;result_sample&quot;:[2493.7760400667707,2484.6190913959954,2469.6911490233115]}%   </code></pre>
<p>행렬 연산은 O(N³) 연산이므로 데이터 크기가 늘어날수록 세제곱에 비례하게 속도가 느려진다.
이 테스트는 약 2.4GB의 데이터를 다루며 1조 번의 곱셈과 덧셈을 수행한다.
데이터가 큰 만큼 오래 걸린 것 같아 보이지만 초당 약 538억 번의 부동 소수점 연산이 있었다.
Accelerate 프레임워크 같은 하드웨어 가속 라이브러리를 연결하여
하드웨어 최적화를 하면 속도를 훨씬 더 줄일 수 있겠지만 여기서는 다루지 않겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[MMAP(Memory-MAPping)]]></title>
            <link>https://velog.io/@peeeeeter_j/pt-study-14</link>
            <guid>https://velog.io/@peeeeeter_j/pt-study-14</guid>
            <pubDate>Mon, 30 Mar 2026 01:37:28 GMT</pubDate>
            <description><![CDATA[<h1 id="mmapmemory-mapping">MMAP(Memory-MAPping)</h1>
<p><a href="pt-study-11">가변카피 첫 예제</a>에서 데이터 양이 너무 많아지면
SWAP 공간에 대한 오버헤드가 발생한다는 사실을 확인해 보았다.
파일을 마치 메모리인 것처럼 다루는 MMAP을 사용하면
그런 오버헤드를 완화할 수 있다.</p>
<p>일반적인 파일 읽기는 File -&gt; OS Buffer -&gt; User Buffer 순으로
데이터를 복사하여 사용하는 반면,
MMAP은 파일을 프로세스의 가상 주소 공간에 직접 매핑한다.</p>
<p>데이터를 명시적으로 <code>read()</code> 하지 않아도 되며
RAM 용량보다 훨씬 큰 파일을 읽는 경우에도
파일 크기만큼의 메모리 공간이 있는 것처럼 사용할 수 있다.</p>
<h2 id="작업공간-생성-및-구조-확인">작업공간 생성 및 구조 확인</h2>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace$ mkdir memory-mapping &amp;&amp; cd memory-mapping
~/workspace/memory-mapping$ python3 -m venv venv
~/workspace/memory-mapping$ source venv/bin/activate
~/workspace/memory-mapping$ # 평소에 설치하던 것 외에 추가된 라이브러리를 놓치지 말자
~/workspace/memory-mapping$ pip install maturin fastapi uvicorn orjson numpy
~/workspace/memory-mapping$ maturin init
~/workspace/memory-mapping$ # 선택지 중 기본값인 PyO3 선택
~/workspace/memory-mapping$ # Cargo.toml과 src/lib.rs가 자동 생성된다
~/workspace/memory-mapping$ # Python 코드는 직접 생성해 주어야 한다
~/workspace/memory-mapping$ mkdir app &amp;&amp; touch app/main.py
~/workspace/memory-mapping$ tree -I venv
.
├── app
│   └── main.py
├── Cargo.toml
├── pyproject.toml
└── src
    └── lib.rs</code></pre>
<p><code>Cargo.toml</code> 파일을 열어 라이브러리 이름을 수정해 주겠다.</p>
<p>MMAP을 위해서는 <a href="https://docs.rs/memmap2/latest/memmap2/">memmap2</a> 크레이트를 불러와야 한다.</p>
<blockquote>
<p><code>Cargo.toml</code></p>
</blockquote>
<pre><code class="language-toml">[package]
name = &quot;memory-mapping&quot;
version = &quot;0.1.0&quot;
edition = &quot;2024&quot;
&gt;
[lib]
name = &quot;rust_engine&quot;
crate-type = [&quot;cdylib&quot;]
&gt;
[dependencies]
pyo3 = &quot;0.28.0&quot;
numpy = &quot;0.28&quot;
rayon = &quot;1.11&quot;
memmap2 = &quot;0.9&quot;</code></pre>
<h2 id="코드-작성">코드 작성</h2>
<h3 id="rust-코드">Rust 코드</h3>
<p>파일 경로를 받아 그 파일을 MMAP으로 열고,
안에 들어있는 <code>f64</code> 데이터를 제로카피로 읽어 합계를 계산한다.</p>
<p>바이트 데이터를 <code>f64</code> 슬라이스로 변환하여 사용할 것이다.
<code>f64</code>는 8바이트이므로, 전체 바이트를 8로 나눈 만큼의 슬라이스를 만들고
각 슬라이스의 합을 Rayon을 통해 병렬 연산한다.</p>
<blockquote>
<p><code>src/lib.rs</code></p>
</blockquote>
<pre><code class="language-rust">use pyo3::prelude::*;
use memmap2::Mmap;
use std::fs::File;
use rayon::prelude::*;
&gt;
#[pyfunction]
fn sum_mmap_file(file_path: String) -&gt; PyResult&lt;f64&gt; {
    let file = File::open(file_path)?;
&gt;
    let mmap = unsafe {
        Mmap::map(&amp;file)?
    };
&gt;
    let raw_ptr = mmap.as_ptr() as *const f64;
    let len = mmap.len() / 8;
&gt;
    let slice = unsafe {
        std::slice::from_raw_parts(raw_ptr, len)
    };
&gt;
    let total_sum = slice.par_iter().sum();
&gt;
    Ok(total_sum)
}
&gt;
#[pymodule]
fn rust_engine(m: &amp;Bound&lt;&#39;_, PyModule&gt;) -&gt; PyResult&lt;()&gt; {
    m.add_function(wrap_pyfunction!(sum_mmap_file, m)?)?;
&gt;
    Ok(())
}</code></pre>
<h3 id="python-코드">Python 코드</h3>
<p>MMAP 테스트를 위해 거대한 바이너리 파일을 만드는 함수와
실제 MMAP 연산을 Rust에게 요청하는 함수를 각각 작성한다.</p>
<p>여기서는 전부 <code>1.0</code> 으로 채워진 GB 단위의 바이너리 파일을 사용하겠다.</p>
<blockquote>
<p><code>app/main.py</code></p>
</blockquote>
<pre><code class="language-py">import numpy as np
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
import rust_engine
import os
import time
&gt;
class UTF8ORJSONResponse(ORJSONResponse):
    media_type = &quot;application/json; charset=utf-8&quot;
&gt;
app = FastAPI(default_response_class=UTF8ORJSONResponse)
&gt;
@app.get(&quot;/&quot;)
def read_root():
    return {
        &quot;status&quot;: &quot;200&quot;,
        &quot;info&quot;: &quot;서버 가동 중입니다.&quot;
    }
&gt;
@app.get(&quot;/setup-mmap-file/{gb}&quot;)
def setup_file(gb: int):
    file_path = &quot;huge_data.bin&quot;
    size = gb * 1024 * 1024 * 1024
    elements = size // 8
&gt;
    data = np.ones(elements, dtype=np.float64)
    data.tofile(file_path)
&gt;
    return {
        &quot;message&quot;: f&quot;{gb}GB 파일 생성 완료&quot;,
        &quot;path&quot;: file_path
    }
&gt;
@app.get(&quot;/sum-mmap&quot;)
def sum_mmap():
    file_path = &quot;huge_data.bin&quot;
    if not os.path.exists(file_path):
        return {
            &quot;error&quot;: &quot;파일이 없습니다. /setup-mmap-file 라우트를 먼저 호출하세요.&quot;
        }
&gt;
    start = time.perf_counter()
    result = rust_engine.sum_mmap_file(file_path)
    end = time.perf_counter()
&gt;   
    rust_duration = end - start
&gt;
    return {
        &quot;file_size&quot;: f&quot;{os.path.getsize(file_path) / (1024**3)} GB&quot;,
        &quot;result&quot;: result,
        &quot;rust_pure_time&quot;: f&quot;Rust 연산에 걸린 시간: {rust_duration:.4f} sec&quot;
    }</code></pre>
<h2 id="빌드-및-실행">빌드 및 실행</h2>
<p>Maturin 라이브러리를 통해 Rust 코드를 Python에서 호출 가능한 형태로 컴파일한다.
병렬 처리가 포함된 코드는 성능 최적화를 위해 <code>--release</code> 를 붙여 컴파일한다.
컴파일 후 <code>pip list</code> 명령어를 사용해 보면 <code>Cargo.toml</code> 파일에 작성한 패키지 이름을 확인할 수 있다.</p>
<p>uvicorn 라이브러리를 통해 FastAPI를 실행한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/memory-mapping$ maturin develop --release
~/workspace/memory-mapping$ uvicorn app.main:app --reload</code></pre>
<p>curl 명령어 또는 브라우저를 통해 다음과 같은 테스트를 해볼 수 있다.</p>
<ul>
<li><code>http://127.0.0.1:8000/setup-mmap-file/8</code></li>
<li><code>http://127.0.0.1:8000/sum-mmap</code></li>
</ul>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ curl -i http://127.0.0.1:8000/setup-mmap-file/8
HTTP/1.1 200 OK
date: Mon, 30 Mar 2026 01:19:13 GMT
server: uvicorn
content-length: 61
content-type: application/json; charset=utf-8
&gt;
{&quot;message&quot;:&quot;8GB 파일 생성 완료&quot;,&quot;path&quot;:&quot;huge_data.bin&quot;}%
&gt;
~$ curl -i http://127.0.0.1:8000/sum-mmap         
HTTP/1.1 200 OK
date: Mon, 30 Mar 2026 01:19:18 GMT
server: uvicorn
content-length: 104
content-type: application/json; charset=utf-8
&gt;
{&quot;file_size&quot;:&quot;8.0 GB&quot;,&quot;result&quot;:1073741824.0,&quot;rust_pure_time&quot;:&quot;Rust 연산에 걸린 시간: 0.5097 sec&quot;}%</code></pre>
<p>데이터 크기가 작으면 OS 페이지 캐시의 도움으로 매우 빠르게 완료된다. (약 15.6 GB/s)</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ curl -i http://127.0.0.1:8000/setup-mmap-file/16
HTTP/1.1 200 OK
date: Mon, 30 Mar 2026 01:19:29 GMT
server: uvicorn
content-length: 62
content-type: application/json; charset=utf-8
&gt;
{&quot;message&quot;:&quot;16GB 파일 생성 완료&quot;,&quot;path&quot;:&quot;huge_data.bin&quot;}%
&gt;
~$ curl -i http://127.0.0.1:8000/sum-mmap          
HTTP/1.1 200 OK
date: Mon, 30 Mar 2026 01:19:34 GMT
server: uvicorn
content-length: 105
content-type: application/json; charset=utf-8
&gt;
{&quot;file_size&quot;:&quot;16.0 GB&quot;,&quot;result&quot;:2147483648.0,&quot;rust_pure_time&quot;:&quot;Rust 연산에 걸린 시간: 2.8441 sec&quot;}%</code></pre>
<p>캐시의 도움을 받지 못하는 순수 SSD 읽기 속도로 수행된다. (약 5.6 GB/s)</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ curl -i http://127.0.0.1:8000/setup-mmap-file/80
HTTP/1.1 200 OK
date: Mon, 30 Mar 2026 01:20:02 GMT
server: uvicorn
content-length: 62
content-type: application/json; charset=utf-8
&gt;
{&quot;message&quot;:&quot;80GB 파일 생성 완료&quot;,&quot;path&quot;:&quot;huge_data.bin&quot;}%
&gt;
~$ curl -i http://127.0.0.1:8000/sum-mmap          
HTTP/1.1 200 OK
date: Mon, 30 Mar 2026 01:20:51 GMT
server: uvicorn
content-length: 107
content-type: application/json; charset=utf-8
&gt;
{&quot;file_size&quot;:&quot;80.0 GB&quot;,&quot;result&quot;:10737418240.0,&quot;rust_pure_time&quot;:&quot;Rust 연산에 걸린 시간: 34.3357 sec&quot;}%</code></pre>
<p>RAM(64GB) 크기를 초과하여 시간이 많이 걸린다. (약 2.3 GB/s)
100억 개의 데이터를 약 55초만에 처리했던 것(약 1.44 GB/s)에 비하면 많이 빨라졌지만.</p>
<blockquote>
</blockquote>
<p>크기가 작은 데이터는 메모리에 먼저 올려 놓고 사용하는
일반적인 제로카피가 더 빠른 연산이 가능하지만
데이터 크기가 커질수록 MMAP을 사용하는 게 이득이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[제로카피 필터링]]></title>
            <link>https://velog.io/@peeeeeter_j/pt-study-13</link>
            <guid>https://velog.io/@peeeeeter_j/pt-study-13</guid>
            <pubDate>Sat, 28 Mar 2026 00:16:23 GMT</pubDate>
            <description><![CDATA[<h1 id="제로카피-필터링">제로카피 필터링</h1>
<p>이 실습은 <a href="pt-study-12">이전 실습</a>에서 이어서 진행한다.
따라서 작업공간 생성 및 구조 확인은 생략한다.</p>
<p>조건에 맞는 데이터의 인덱스를 뽑아내는 실습을 해보겠다.</p>
<h2 id="코드-작성">코드 작성</h2>
<h3 id="rust-코드">Rust 코드</h3>
<p>Rayon으로 병렬 필터링을 진행하며
<code>enumerate()</code> 로 인덱스와 데이터 쌍을 만들고
<code>filter_map()</code> 으로 조건에 맞는 인덱스만 수집한 뒤
결과 인덱스 배열을 Python 객체로 변환하여 반환한다.</p>
<blockquote>
<p><code>src/lib.rs</code></p>
</blockquote>
<pre><code class="language-rust">use pyo3::prelude::*;
use numpy::{PyArray1, PyReadonlyArray1, PyArrayMethods};
use rayon::prelude::*;
&gt;
#[pyfunction]
fn zero_copy_sum(array: PyReadonlyArray1&lt;f64&gt;) -&gt; f64 {
    let slice = array.as_slice().expect(&quot;Numpy 슬라이스를 가져오는 데 실패했습니다.&quot;);
&gt;
    slice.par_iter().sum()
}
&gt;
#[pyfunction]
fn zero_copy_multiply(array: &amp;Bound&lt;&#39;_, PyArray1&lt;f64&gt;&gt;, factor: f64) {
    unsafe {
        let slice = array.as_slice_mut().expect(&quot;가변 슬라이스를 가져오는 데 실패했습니다.&quot;);
&gt;
        slice.par_iter_mut().for_each(|x| *x *= factor);
    }
}
&gt;
// 이상 기존 코드
&gt;
#[pyfunction]
fn zero_copy_filter(py: Python&lt;&#39;_&gt;, array: PyReadonlyArray1&lt;f64&gt;, threshold: f64) -&gt;Py&lt;PyArray1&lt;u64&gt;&gt; {
    let slice = array.as_slice().expect(&quot;Numpy 슬라이스를 가져오는 데 실패했습니다.&quot;);
&gt;
    let indices: Vec&lt;u64&gt; = slice
        .par_iter()
        .enumerate()
        .filter_map(|(idx, &amp;val)| {
            if val &gt; threshold {
                Some(idx as u64)
            } else {
                None
            }
        }).collect();
&gt;
    PyArray1::from_vec(py, indices).unbind()
}
&gt;
#[pymodule]
fn rust_engine(m: &amp;Bound&lt;&#39;_, PyModule&gt;) -&gt; PyResult&lt;()&gt; {
    m.add_function(wrap_pyfunction!(zero_copy_sum, m)?)?;
    m.add_function(wrap_pyfunction!(zero_copy_multiply, m)?)?;
    m.add_function(wrap_pyfunction!(zero_copy_filter, m)?)?; // NEW!
&gt;
    Ok(())
}</code></pre>
<h3 id="python-코드">Python 코드</h3>
<blockquote>
<p><code>app/main.py</code></p>
</blockquote>
<pre><code class="language-py">import numpy as np
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
import rust_engine
import time
&gt;
class UTF8ORJSONResponse(ORJSONResponse):
    media_type = &quot;application/json; charset=utf-8&quot;
&gt;
app = FastAPI(default_response_class=UTF8ORJSONResponse)
&gt;
@app.get(&quot;/&quot;)
def read_root():
    return {
        &quot;status&quot;: &quot;200&quot;,
        &quot;info&quot;: &quot;서버 가동 중입니다.&quot;
    }
&gt;
@app.get(&quot;/compute/{size}&quot;)
def compute(size: int):
    data = np.random.rand(size)
&gt;
    start = time.perf_counter()
    result = rust_engine.zero_copy_sum(data)
    end = time.perf_counter()
&gt;
    rust_duration = end - start
&gt;
    return {
        &quot;size&quot;: len(data),
        &quot;result&quot;: result,
        &quot;rust_pure_time&quot;: f&quot;Rust 연산에 걸린 시간: {rust_duration:.4f} sec&quot;
    }
&gt;
@app.get(&quot;/multiply/{size}/{factor}&quot;)
def multiply_inplace(size: int, factor: float):
    data = np.random.rand(size)
&gt;
    start = time.perf_counter()
    result = rust_engine.zero_copy_multiply(data, factor)
    end = time.perf_counter()
&gt;
    rust_duration = end - start
&gt;
    return {
        &quot;size&quot;: len(data),
        &quot;factor&quot;: factor,
        &quot;result_sample&quot;: data[:3].tolist(),
        &quot;rust_pure_time&quot;: f&quot;Rust 연산에 걸린 시간: {rust_duration:.4f} sec&quot;
    }
&gt;
# 이상 기존 코드
&gt;
@app.get(&quot;/filter/{size}/{threshold}&quot;)
def filter_data(size: int, threshold: float):
    data = np.random.rand(size)
&gt;
    start = time.perf_counter()
    indices = rust_engine.zero_copy_filter(data, threshold)
    end = time.perf_counter()
&gt;
    rust_duration = end - start
&gt;
    return {
        &quot;original size&quot;: len(data),
        &quot;filtered count&quot;: len(indices),
        &quot;threshold&quot;: threshold,
        &quot;sample_indices&quot;: indices[:5].tolist(),
        &quot;rust_pure_time&quot;: f&quot;Rust 연산에 걸린 시간: {rust_duration:.4f} sec&quot;
    }</code></pre>
<h2 id="빌드-및-실행">빌드 및 실행</h2>
<p>Maturin 라이브러리를 통해 Rust 코드를 Python에서 호출 가능한 형태로 컴파일한다.
병렬 처리가 포함된 코드는 성능 최적화를 위해 <code>--release</code> 를 붙여 컴파일한다.
컴파일 후 <code>pip list</code> 명령어를 사용해 보면 <code>Cargo.toml</code> 파일에 작성한 패키지 이름을 확인할 수 있다.</p>
<p>uvicorn 라이브러리를 통해 FastAPI를 실행한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/zero-copy$ maturin develop --release
~/workspace/zero-copy$ uvicorn app.main:app --reload</code></pre>
<p>curl 명령어 또는 브라우저를 통해 다음과 같은 테스트를 해볼 수 있다.</p>
<ul>
<li><code>http://127.0.0.1:8000/filter/1000000000/0.9</code></li>
</ul>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ curl -i http://127.0.0.1:8000/filter/1000000000/0.9
HTTP/1.1 200 OK
date: Sat, 28 Mar 2026 00:09:11 GMT
server: uvicorn
content-length: 163
content-type: application/json; charset=utf-8
&gt;
{&quot;original size&quot;:1000000000,&quot;filtered count&quot;:99997758,&quot;threshold&quot;:0.9,&quot;sample_indices&quot;:[8,36,39,56,57],&quot;rust_pure_time&quot;:&quot;Rust 연산에 걸린 시간: 0.1645 sec&quot;}%</code></pre>
<p>값이 0.9보다 큰 데이터만 뽑아낼 때 평균적으로 전체의 10% 정도의 데이터가 추출된다.
10억 번의 조건 분기가 0.1초대에 완료되었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[가변 제로카피]]></title>
            <link>https://velog.io/@peeeeeter_j/pt-study-12</link>
            <guid>https://velog.io/@peeeeeter_j/pt-study-12</guid>
            <pubDate>Fri, 27 Mar 2026 01:11:48 GMT</pubDate>
            <description><![CDATA[<h1 id="가변-제로카피">가변 제로카피</h1>
<p>이 실습은 <a href="pt-study-11">이전 실습</a>에서 이어서 진행한다.
따라서 작업공간 생성 및 구조 확인은 생략한다.</p>
<p>이전 실습에서는 Python의 Numpy 배열을 읽어서 사용하는 것만 다뤘다.
하지만 때로는 새로 생성하거나 수정하는 일도 있을 수 있다.</p>
<p>기존에 사용했던 <code>PyReadonlyArray1</code> 는
쓰기에 대한 고려를 하지 않아 빠르게 작업을 처리할 수 있지만
생성 및 수정 연산을 수행할 수 없다.
따라서 <code>PyArray1</code> 을 사용해야 한다.</p>
<h2 id="코드-작성">코드 작성</h2>
<h3 id="rust-코드">Rust 코드</h3>
<p>원본 Numpy 배열을 복사하지 않고 곱셈 연산을 하는 코드를 추가한다.
<code>&amp;Bound</code> 를 사용하여 Python 객체에 직접 접근할 수 있다.</p>
<p>컴파일러가 메모리 안전성을 보장할 수 없는
로우 레벨 작업을 할 때 사용되는 <code>unsafe</code> 블록 내부에서
Python 메모리 버퍼에 직접 가변 슬라이스로 접근한다.</p>
<p>호출 시점에 GIL(Global Interpreter Lock)이 보장되므로
Rust의 안전 가이드라인 안에서 Python 메모리를 직접 건드릴 수 있다.</p>
<p><code>as_slice_mut()</code> 메서드를 사용하기 위해서는
<code>PyArrayMethods</code> 도 같이 불러와야 한다.</p>
<blockquote>
<p><code>src/lib.rs</code></p>
</blockquote>
<pre><code class="language-rust">use pyo3::prelude::*;
use numpy::{PyArray1, PyReadonlyArray1, PyArrayMethods}; // MODIFIED!
use rayon::prelude::*;
&gt;
#[pyfunction]
fn zero_copy_sum(array: PyReadonlyArray1&lt;f64&gt;) -&gt; f64 {
    let slice = array.as_slice().expect(&quot;Numpy 슬라이스를 가져오는 데 실패했습니다.&quot;);
&gt;
    slice.par_iter().sum()
}
&gt;
// 이상 기존 코드
&gt;
#[pyfunction]
fn zero_copy_multiply(array: &amp;Bound&lt;&#39;_, PyArray1&lt;f64&gt;&gt;, factor: f64) {
    unsafe {
        let slice = array.as_slice_mut().expect(&quot;가변 슬라이스를 가져오는 데 실패했습니다.&quot;);
&gt;
        slice.par_iter_mut().for_each(|x| *x *= factor);
    }
}
&gt;
#[pymodule]
fn rust_engine(m: &amp;Bound&lt;&#39;_, PyModule&gt;) -&gt; PyResult&lt;()&gt; {
    m.add_function(wrap_pyfunction!(zero_copy_sum, m)?)?;
    m.add_function(wrap_pyfunction!(zero_copy_multiply, m)?)?; // NEW!
&gt;
    Ok(())
}</code></pre>
<h3 id="python-코드">Python 코드</h3>
<blockquote>
<p><code>app/main.py</code></p>
</blockquote>
<pre><code class="language-py">import numpy as np
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
import rust_engine
import time
&gt;
class UTF8ORJSONResponse(ORJSONResponse):
    media_type = &quot;application/json; charset=utf-8&quot;
&gt;
app = FastAPI(default_response_class=UTF8ORJSONResponse)
&gt;
@app.get(&quot;/&quot;)
def read_root():
    return {
        &quot;status&quot;: &quot;200&quot;,
        &quot;info&quot;: &quot;서버 가동 중입니다.&quot;
    }
&gt;
@app.get(&quot;/compute/{size}&quot;)
def compute(size: int):
    data = np.random.rand(size)
&gt;
    start = time.perf_counter()
    result = rust_engine.zero_copy_sum(data)
    end = time.perf_counter()
&gt;
    rust_duration = end - start
&gt;
    return {
        &quot;size&quot;: len(data),
        &quot;result&quot;: result,
        &quot;rust_pure_time&quot;: f&quot;Rust 연산에 걸린 시간: {rust_duration:.4f} sec&quot;
    }
&gt;
# 이상 기존 코드
&gt;
@app.get(&quot;/multiply/{size}/{factor}&quot;)
def multiply_inplace(size: int, factor: float):
    data = np.random.rand(size)
&gt;
    start = time.perf_counter()
    result = rust_engine.zero_copy_multiply(data, factor)
    end = time.perf_counter()
&gt;
    rust_duration = end - start
&gt;
    return {
        &quot;size&quot;: len(data),
        &quot;factor&quot;: factor,
        &quot;result_sample&quot;: data[:3].tolist(),
        &quot;rust_pure_time&quot;: f&quot;Rust 연산에 걸린 시간: {rust_duration:.4f} sec&quot;
    }</code></pre>
<h2 id="빌드-및-실행">빌드 및 실행</h2>
<p>Maturin 라이브러리를 통해 Rust 코드를 Python에서 호출 가능한 형태로 컴파일한다.
병렬 처리가 포함된 코드는 성능 최적화를 위해 <code>--release</code> 를 붙여 컴파일한다.
컴파일 후 <code>pip list</code> 명령어를 사용해 보면 <code>Cargo.toml</code> 파일에 작성한 패키지 이름을 확인할 수 있다.</p>
<p>uvicorn 라이브러리를 통해 FastAPI를 실행한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/zero-copy$ maturin develop --release
~/workspace/zero-copy$ uvicorn app.main:app --reload</code></pre>
<p>curl 명령어 또는 브라우저를 통해 다음과 같은 테스트를 해볼 수 있다.</p>
<ul>
<li><code>http://127.0.0.1:8000/multiply/1000000000/0.5</code></li>
</ul>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ curl -i http://127.0.0.1:8000/compute/1000000000
HTTP/1.1 200 OK
date: Fri, 27 Mar 2026 00:54:42 GMT
server: uvicorn
content-length: 107
content-type: application/json; charset=utf-8
&gt;
{&quot;size&quot;:1000000000,&quot;result&quot;:499981729.21686363,&quot;rust_pure_time&quot;:&quot;Rust 연산에 걸린 시간: 0.0686 sec&quot;}%</code></pre>
<pre><code class="language-bash">~$ curl -i http://127.0.0.1:8000/multiply/1000000000/0.5
HTTP/1.1 200 OK
date: Fri, 27 Mar 2026 00:55:05 GMT
server: uvicorn
content-length: 169
content-type: application/json; charset=utf-8
&gt;
{&quot;size&quot;:1000000000,&quot;factor&quot;:0.5,&quot;result_sample&quot;:[0.48982013585054657,0.42512184525374297,0.2544803303002875],&quot;rust_pure_time&quot;:&quot;Rust 연산에 걸린 시간: 0.0833 sec&quot;}% </code></pre>
<p>값을 수정할 때는 CPU 캐시와 실제 RAM 사이의 데이터를 동기화하는 오버헤드가 발생하여
같은 크기의 데이터에 대해 읽기 연산보다 조금 더 오래 걸리는 걸 확인할 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[메모리 복사 생략]]></title>
            <link>https://velog.io/@peeeeeter_j/pt-study-11</link>
            <guid>https://velog.io/@peeeeeter_j/pt-study-11</guid>
            <pubDate>Thu, 26 Mar 2026 01:07:00 GMT</pubDate>
            <description><![CDATA[<h1 id="메모리-복사-생략">메모리 복사 생략</h1>
<p>큰 데이터를 다루게 될 경우
Python에서 Rust로 데이터를 옮겨 오는 게 병목 현상을 야기할 수 있다.</p>
<p>따라서 큰 데이터를 다룰 때는 데이터 자체를 복사하는 과정을 생략하고
메모리 주소만 넘겨 받아 사용하는 편이 효율적이다.</p>
<p>Rust의 <code>rust-numpy</code> 를 사용하면 Python의 <code>Numpy</code> 배열을
복사 없이 직접 읽고 쓸 수 있다.</p>
<h2 id="작업공간-생성-및-구조-확인">작업공간 생성 및 구조 확인</h2>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace$ mkdir zero-copy &amp;&amp; cd zero-copy
~/workspace/zero-copy$ python3 -m venv venv
~/workspace/zero-copy$ source venv/bin/activate
~/workspace/zero-copy$ # 평소에 설치하던 것 외에 추가된 라이브러리를 놓치지 말자
~/workspace/zero-copy$ pip install maturin fastapi uvicorn orjson numpy
~/workspace/zero-copy$ maturin init
~/workspace/zero-copy$ # 선택지 중 기본값인 PyO3 선택
~/workspace/zero-copy$ # Cargo.toml과 src/lib.rs가 자동 생성된다
~/workspace/zero-copy$ # Python 코드는 직접 생성해 주어야 한다
~/workspace/zero-copy$ mkdir app &amp;&amp; touch app/main.py
~/workspace/zero-copy$ tree -I venv
.
├── app
│   └── main.py
├── Cargo.toml
├── pyproject.toml
└── src
    └── lib.rs</code></pre>
<p><code>Cargo.toml</code> 파일을 열어 라이브러리 이름을 수정해 주겠다.</p>
<p>Python의 Numpy 배열을 가져다 쓰기 위한 <a href="https://docs.rs/numpy/latest/numpy">numpy</a> 크레이트를 추가하고,
큰 데이터를 빠르게 처리할 수 있도록 병렬 처리를 위한 <a href="https://docs.rs/rayon/latest/rayon">Rayon</a> 크레이트,
그리고 데이터를 처리하는 시간동안 다른 작업이 멈추어 있지 않도록
비동기 처리를 위한 <a href="https://docs.rs/tokio/latest/tokio">takio</a> 크레이트와 <a href="https://docs.rs/pyo3-async-runtimes/latest/pyo3_async_runtimes">pyo3_async_runtimes</a> 크레이트도 추가해 준다.</p>
<p>여담이지만 numpy, pyo3_async_runtimes 와 같이
Python과 연관된 크레이트의 경우 대체로 PyO3와 버전이 함께 올라간다.
PyO3의 버전이 올라감에 따라 변경점에 최적화하여 같이 변경되기 때문에
되도록이면 이들끼리 버전을 맞춰주는 게 좋다.</p>
<blockquote>
<p><code>Cargo.toml</code></p>
</blockquote>
<pre><code class="language-toml">[package]
name = &quot;zero-copy&quot;
version = &quot;0.1.0&quot;
edition = &quot;2024&quot;
&gt;
[lib]
name = &quot;rust_engine&quot;
crate-type = [&quot;cdylib&quot;]
&gt;
[dependencies]
pyo3 = &quot;0.28.0&quot;
numpy = &quot;0.28&quot;
rayon = &quot;1.11&quot;
tokio = { version = &quot;1.50&quot;, features = [&quot;full&quot;] }
pyo3-async-runtimes = { version = &quot;0.28&quot;, features = [&quot;tokio-runtime&quot;] }</code></pre>
<h2 id="코드-작성">코드 작성</h2>
<h3 id="rust-코드">Rust 코드</h3>
<p>Python의 1차원 Numpy 배열을 사용하기 위한 자료형을 가져온다.
실무에서는 <code>PyArray1</code> 과 <code>PyReadonlyArray1</code> 을 세트로 가져오는 경우가 많지만
우리는 배열을 생성하지는 않고 사용만 할 것이므로 후자만 가져와도 충분하다.</p>
<p>Numpy 배열을 복사 없이 그대로 사용하여 병렬 연산을 수행할 것이다.</p>
<blockquote>
<p><code>src/lib.rs</code></p>
</blockquote>
<pre><code class="language-rust">use pyo3::prelude::*;
use numpy::PyReadonlyArray1;
use rayon::prelude::*;
&gt;
#[pyfunction]
fn zero_copy_sum(array: PyReadonlyArray1&lt;f64&gt;) -&gt; f64 {
    let slice = array.as_slice().expect(&quot;Numpy 슬라이스를 가져오는 데 실패했습니다.&quot;);
&gt;
    slice.par_iter().sum()
}
&gt;
#[pymodule]
fn rust_engine(m: &amp;Bound&lt;&#39;_, PyModule&gt;) -&gt; PyResult&lt;()&gt; {
    m.add_function(wrap_pyfunction!(zero_copy_sum, m)?)?;
&gt;
    Ok(())
}</code></pre>
<h3 id="python-코드">Python 코드</h3>
<p>사용자로부터 입력받은 크기의 Numpy 배열을 임의로 생성하여
Rust에게 모든 배열 원소의 합을 구하라고 던져 준다.</p>
<p>데이터 생성 등으로 인한 오버헤드를 제외하고
순수 Rust 연산 시간을 알아보기 위해 <code>time</code> 라이브러리를 사용한다.</p>
<blockquote>
<p><code>app/main.py</code></p>
</blockquote>
<pre><code class="language-py">import numpy as np
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
import rust_engine
import time
&gt;
class UTF8ORJSONResponse(ORJSONResponse):
    media_type = &quot;application/json; charset=utf-8&quot;
&gt;
app = FastAPI(default_response_class=UTF8ORJSONResponse)
&gt;
@app.get(&quot;/&quot;)
def read_root():
    return {
        &quot;status&quot;: &quot;200&quot;,
        &quot;info&quot;: &quot;서버 가동 중입니다.&quot;
    }
&gt;
@app.get(&quot;/compute/{size}&quot;)
def compute(size: int):
    data = np.random.rand(size)
&gt;
    start = time.perf_counter()
    result = rust_engine.zero_copy_sum(data)
    end = time.perf_counter()
&gt;
    rust_duration = end - start
&gt;
    return {
        &quot;size&quot;: len(data),
        &quot;result&quot;: result,
        &quot;rust_pure_time&quot;: f&quot;Rust 연산에 걸린 시간: {rust_duration:.4f} sec&quot;
    }</code></pre>
<h2 id="빌드-및-실행">빌드 및 실행</h2>
<p>Maturin 라이브러리를 통해 Rust 코드를 Python에서 호출 가능한 형태로 컴파일한다.
병렬 처리가 포함된 코드는 성능 최적화를 위해 <code>--release</code> 를 붙여 컴파일한다.
컴파일 후 <code>pip list</code> 명령어를 사용해 보면 <code>Cargo.toml</code> 파일에 작성한 패키지 이름을 확인할 수 있다.</p>
<p>uvicorn 라이브러리를 통해 FastAPI를 실행한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/zero-copy$ maturin develop --release
~/workspace/zero-copy$ uvicorn app.main:app --reload</code></pre>
<p>curl 명령어 또는 브라우저를 통해 다음과 같은 테스트를 해볼 수 있다.</p>
<ul>
<li><code>http://127.0.0.1:8000/compute/100000000</code></li>
</ul>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ curl -i http://127.0.0.1:8000/compute/100000000 
HTTP/1.1 200 OK
date: Thu, 26 Mar 2026 00:49:03 GMT
server: uvicorn
content-length: 105
content-type: application/json; charset=utf-8
&gt;
{&quot;size&quot;:100000000,&quot;result&quot;:50003983.53816322,&quot;rust_pure_time&quot;:&quot;Rust 연산에 걸린 시간: 0.0074 sec&quot;}%    </code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ curl -i http://127.0.0.1:8000/compute/1000000000
HTTP/1.1 200 OK
date: Thu, 26 Mar 2026 00:49:08 GMT
server: uvicorn
content-length: 107
content-type: application/json; charset=utf-8
&gt;
{&quot;size&quot;:1000000000,&quot;result&quot;:499998781.80647945,&quot;rust_pure_time&quot;:&quot;Rust 연산에 걸린 시간: 0.0681 sec&quot;}%</code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ curl -i http://127.0.0.1:8000/compute/10000000000
HTTP/1.1 200 OK
date: Thu, 26 Mar 2026 00:49:22 GMT
server: uvicorn
content-length: 108
content-type: application/json; charset=utf-8
&gt;
{&quot;size&quot;:10000000000,&quot;result&quot;:5000036618.422572,&quot;rust_pure_time&quot;:&quot;Rust 연산에 걸린 시간: 55.5093 sec&quot;}% </code></pre>
<p>데이터 양이 너무 많아지면 SWAP 공간에 대한 오버헤드가 발생한다.
그런 경우에는 MMAP을 사용할 수 있는데 그것에 대해서는 이후에 알아보도록 하자.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[(여담) 인코딩 이슈]]></title>
            <link>https://velog.io/@peeeeeter_j/py-study-10</link>
            <guid>https://velog.io/@peeeeeter_j/py-study-10</guid>
            <pubDate>Tue, 24 Mar 2026 23:23:11 GMT</pubDate>
            <description><![CDATA[<h1 id="인코딩-이슈">인코딩 이슈</h1>
<p>FastAPI로부터 응답을 받았을 때 웹 브라우저에서 한글이 깨지는 경우
어떻게 해결할 수 있는지 알아보자.</p>
<h2 id="원인">원인</h2>
<p>지금까지 우리가 작성한 코드는 텍스트 인코딩 방식을 명시하지 않았다.</p>
<p>보통의 웹사이트는 헤더 부분에 <code>charset=utf-8</code> 라는 메타 데이터를 가지고 있어
웹 브라우저가 웹사이트 정보를 받았을 때
UTF-8 방식으로 인코딩되어 있음을 인지할 수 있지만
그것이 명시되어 있지 않을 경우 시스템 기본 인코딩으로 보여준다.</p>
<p>이 &#39;시스템 기본 인코딩&#39;이 UTF-8이 아닌 다른 방식이었을 경우
인코딩 방식이 맞지 않아 잘못 해석된 텍스트가 출력된다.</p>
<h2 id="해결책">해결책</h2>
<p>어차피 개발 단계가 아닌 프로젝트 완성 단계에서는
백엔드의 출력을 웹 브라우저에서 직접 보여줄 일이 없으니
프론트엔드에서 메타 데이터를 제대로 명시하면 문제 없긴 하다.</p>
<p>하지만 개발 단계에서 출력된 데이터를 확인할 수 있어야
정상적으로 작동하는지 원활하게 확인할 수 있으니 적절한 코드로 변경한다.</p>
<p>우리의 <a href="pt-study-9">이전</a> 실습에서 한글 텍스트를 반환했으니
그 코드를 기준으로 코드를 수정해 보겠다.</p>
<h3 id="수정-전">수정 전</h3>
<blockquote>
<p><code>app/main.py</code> (수정 전)</p>
</blockquote>
<pre><code class="language-py">from fastapi import FastAPI
import rust_engine
import time
&gt;
app = FastAPI()
&gt;
@app.get(&quot;/&quot;)
def read_root():
    return {
        &quot;status&quot;: &quot;200&quot;,
        &quot;info&quot;: &quot;비동기 작업 중에도 응답할 수 있습니다.&quot;
    }
&gt;
@app.get(&quot;/async-rust/{seconds}&quot;)
async def run_rust_task(seconds: int):
    start = time.time()
&gt;
    message = await rust_engine.async_compute(seconds)
&gt;
    end = time.time()
&gt;
    return {
        &quot;result&quot;: message,
        &quot;duration&quot;: f&quot;{end - start:.2f}s&quot;,
        &quot;engine&quot;: &quot;Rust (via pyo3-async-runtimes)&quot;
    }</code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/asynchronous$ curl -i http://127.0.0.1:8000/
HTTP/1.1 200 OK
date: Tue, 24 Mar 2026 23:01:06 GMT
server: uvicorn
content-length: 80
content-type: application/json
&gt;
{&quot;status&quot;:&quot;200&quot;,&quot;info&quot;:&quot;비동기 작업 중에도 응답할 수 있습니다.&quot;}% </code></pre>
<h3 id="수정-후">수정 후</h3>
<p>ORJSONResponse를 불러온다.
FastAPI의 기본 응답을 ORJSONResponse로 바꿔주기만 하면 된다.</p>
<p>이를 위해 orjson 라이브러리를 설치해야 한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/asynchronous$ pip install orjson</code></pre>
<p>기본 응답을 바꾸지 않고 각 함수의 반환값을 일일이</p>
<pre><code class="language-py">return ORJSONResponse(
    content=content,
    media_type=&quot;application/json; charset=utf-8&quot;)</code></pre>
<p>와 같이 변경할 수도 있지만 기본 응답 자체를 바꿔주는 편이 효율적이다.</p>
<p><code>ORJSONResponse</code> 클래스를 상속받아
<code>media_type</code> 값을 덮어씌운 클래스를 생성하고
FastAPI의 기본 응답을 그것으로 변경해 준다.</p>
<blockquote>
<p><code>app/main.py</code> (수정 후)</p>
</blockquote>
<pre><code class="language-py">from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
import rust_engine
import time
&gt;
class UTF8ORJSONResponse(ORJSONResponse):
    media_type = &quot;application/json; charset=utf-8&quot;
&gt;
app = FastAPI(default_response_class=UTF8ORJSONResponse)
&gt;
@app.get(&quot;/&quot;)
def read_root():
    return {
        &quot;status&quot;: &quot;200&quot;,
        &quot;info&quot;: &quot;비동기 작업 중에도 응답할 수 있습니다.&quot;
    }
&gt;
@app.get(&quot;/async-rust/{seconds}&quot;)
async def run_rust_task(seconds: int):
    start = time.time()
&gt;
    message = await rust_engine.async_compute(seconds)
&gt;
    end = time.time()
&gt;
    return {
        &quot;result&quot;: message,
        &quot;duration&quot;: f&quot;{end - start:.2f}s&quot;,
        &quot;engine&quot;: &quot;Rust (via pyo3-async-runtimes)&quot;
    }</code></pre>
<blockquote>
</blockquote>
<pre><code class="language-bash">~$ curl -i http://127.0.0.1:8000/
HTTP/1.1 200 OK
date: Tue, 24 Mar 2026 23:04:15 GMT
server: uvicorn
content-length: 80
content-type: application/json; charset=utf-8
&gt;
{&quot;status&quot;:&quot;200&quot;,&quot;info&quot;:&quot;비동기 작업 중에도 응답할 수 있습니다.&quot;}%   </code></pre>
<p>응답의 <code>content-type</code> 에 인코딩 정보가 추가되었다.
웹 브라우저로 테스트 해보아도 한글이 정상 출력되는 것을 확인할 수 있을 것이다.</p>
<h3 id="비교">비교</h3>
<table>
<thead>
<tr>
<th align="center">구분</th>
<th align="center">표준 JSONResponse</th>
<th align="center">ORJSONResponse</th>
</tr>
</thead>
<tbody><tr>
<td align="center">직렬화 방식</td>
<td align="center">Python <code>json.dumps</code> 사용</td>
<td align="center"><code>orjson.dumps</code> (Rust/C) 사용</td>
</tr>
<tr>
<td align="center">한글 처리</td>
<td align="center"><code>\uXXXX</code> 로 변환 (이스케이프)</td>
<td align="center">UTF-8 바이트 그대로 유지</td>
</tr>
<tr>
<td align="center">헤더 처리</td>
<td align="center">유저가 직접 명시해야 함</td>
<td align="center">자동으로 UTF-8로 전송</td>
</tr>
<tr>
<td align="center">성능</td>
<td align="center">보통</td>
<td align="center">매우 빠름</td>
</tr>
</tbody></table>
<p>실습의 단순화를 위해 기본값을 사용했지만 사실 성능 측면에서도
ORJSONReponse를 사용하는 편이 이득이다.</p>
<h2 id="여담">여담</h2>
<p>코드를 수정하기 전에도 웹 브라우저에는 한글이 깨져도
Swagger UI로 실행하면 응답의 한글이 잘 나온다.</p>
<p>이는 Swaager UI가 웹 브라우저 위에서 실행되는 웹앱인데
내부적으로 <code>fetch()</code> API를 사용하여 데이터를 가져온 뒤
UTF-8 기반으로 HTML 문서 안에 보기 좋게 출력해주기 때문이다.</p>
<p>Swagger UI의 기본 인코딩이 UTF-8이니
따로 명시해주지 않았어도 UTF-8로 해석하고 보여준 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[비동기 처리]]></title>
            <link>https://velog.io/@peeeeeter_j/pt-study-9</link>
            <guid>https://velog.io/@peeeeeter_j/pt-study-9</guid>
            <pubDate>Tue, 24 Mar 2026 01:38:35 GMT</pubDate>
            <description><![CDATA[<h1 id="비동기-처리">비동기 처리</h1>
<p>지금까지 우리가 작성했던 예제 코드는
비동기 처리를 하는 FastAPI에서 동기 처리를 하는 Rust 코드를 호출해
Rust 연산이 끝날 때까지 Python 이벤트 루프가 멈출 위험이 있다.</p>
<p>Rust에서 무거운 작업을 실행할 경우 <code>async</code> 로 비동기로 처리하고
이를 Python의 <code>await</code> 와 연결하여 이 문제를 해결할 수 있다.</p>
<h2 id="작업공간-생성-및-구조-확인">작업공간 생성 및 구조 확인</h2>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace$ mkdir asynchronous &amp;&amp; cd asynchronous
~/workspace/asynchronous$ python3 -m venv venv
~/workspace/asynchronous$ source venv/bin/activate
~/workspace/asynchronous$ pip install maturin fastapi uvicorn
~/workspace/asynchronous$ maturin init
~/workspace/asynchronous$ # 선택지 중 기본값인 PyO3 선택
~/workspace/asynchronous$ # Cargo.toml과 src/lib.rs가 자동 생성된다
~/workspace/asynchronous$ # Python 코드는 직접 생성해 주어야 한다
~/workspace/asynchronous$ mkdir app &amp;&amp; touch app/main.py
~/workspace/asynchronous$ tree -I venv
.
├── app
│   └── main.py
├── Cargo.toml
├── pyproject.toml
└── src
    └── lib.rs</code></pre>
<p><code>Cargo.toml</code> 파일을 열어 라이브러리 이름을 수정해 주겠다.
비동기 처리를 위한 <a href="https://docs.rs/tokio/latest/tokio">takio</a> 크레이트와 <a href="https://docs.rs/pyo3-async-runtimes/latest/pyo3_async_runtimes">pyo3_async_runtimes</a> 크레이트도 추가해 준다.</p>
<p>가장 최신 takio 크레이트는 1.50 버전인데 1.0으로 작성해도 충분하다.
<code>Cargo.toml</code> 는 1.0이라고 적어놓은 버전을 &quot;1.0 이상 2.0 미만의 최신 버전&quot;으로 인식한다.
takio는 1.0 버전 이후로 매우 엄격한 하위 호환성을 지키고 있는 크레이트이므로
1.0 버전 이후의 어떤 버전을 내려받더라도 문제 없이 작동할 것이다.</p>
<p>pyo3_async_runtimes 크레이트는 pyo3와 tokio 사이의 다리 역할을 하는데
pyo3 버전이 올라갈 때마다 그것에 맞춰 변경점을 반영하기 때문에
pyo3와 버전을 맞춰주는 게 좋다.</p>
<p>pyo3의 <code>features</code> 항목에는 &quot;extension-module&quot;을 추가하지 않아도
Maturin이 자동으로 추가해서 Python 모듈로 생성할 수 있지만
비동기 크레이트들에는 <code>features</code> 항목을 적어주어야 한다.</p>
<blockquote>
<p><code>Cargo.toml</code></p>
</blockquote>
<pre><code class="language-toml">[package]
name = &quot;asynchronous&quot;
version = &quot;0.1.0&quot;
edition = &quot;2024&quot;
&gt;
[lib]
name = &quot;rust_engine&quot;
crate-type = [&quot;cdylib&quot;]
&gt;
[dependencies]
pyo3 = &quot;0.28.0&quot;
tokio = { version = &quot;1.50&quot;, features = [&quot;full&quot;] }
pyo3-async-runtimes = { version = &quot;0.28&quot;, features = [&quot;tokio-runtime&quot;] }</code></pre>
<h2 id="코드-작성">코드 작성</h2>
<h3 id="rust-코드">Rust 코드</h3>
<p>pyo3_async_runtimes를 사용하여 Rust의 <code>Future</code>를 
Python이 이해할 수 있는 Awaitable 객체(<code>Coroutine</code>)로 변환한다.</p>
<p><code>future_into_py</code> 함수가 Rust의 tokio 런타임에서 실행되는 <code>Future</code>를 
Python의 <code>asyncio</code> 루프로 던져주기 때문에 Python은
Rust가 일을 끝낼 때까지 기다리지 않고 다른 HTTP 요청을 받을 수 있다.</p>
<p><code>async</code> 블록 내에서 <code>await</code> 을 사용하면 비동기 처리를 할 수 있다.</p>
<blockquote>
<p><code>src/lib.rs</code></p>
</blockquote>
<pre><code class="language-rust">use pyo3::prelude::*;
use std::time::Duration;
use tokio::time::sleep;
&gt;
#[pyfunction]
fn async_compute(py: Python&lt;&#39;_&gt;, seconds: u64) -&gt; PyResult&lt;Bound&lt;&#39;_, PyAny&gt;&gt; {
    pyo3_async_runtimes::tokio::future_into_py(py, async move {
        sleep(Duration::from_secs(seconds)).await;
&gt;
        let result = format!(&quot;코어에서 {}초간 비동기 연산을 무사히 마쳤습니다!&quot;, seconds);
&gt;       
        Ok(result)
    })
}
&gt;
#[pymodule]
fn rust_engine(m: &amp;Bound&lt;&#39;_, PyModule&gt;) -&gt; PyResult&lt;()&gt; {
    m.add_function(wrap_pyfunction!(async_compute, m)?)?;
&gt;
    Ok(())
}</code></pre>
<h3 id="python-코드">Python 코드</h3>
<p>Rust에서 작성한 비동기 함수를
Python에서 <code>async def</code> 를 통해 정의한 함수처럼 사용할 수 있다.</p>
<blockquote>
<p><code>app/main.py</code></p>
</blockquote>
<pre><code class="language-py">from fastapi import FastAPI
import rust_engine
import time
&gt;
app = FastAPI()
&gt;
@app.get(&quot;/&quot;)
def read_root():
    return {
        &quot;status&quot;: &quot;200&quot;,
        &quot;info&quot;: &quot;비동기 작업 중에도 응답할 수 있습니다.&quot;
    }
&gt;
@app.get(&quot;/async-rust/{seconds}&quot;)
async def run_rust_task(seconds: int):
    start = time.time()
&gt;
    message = await rust_engine.async_compute(seconds)
&gt;
    end = time.time()
&gt;
    return {
        &quot;result&quot;: message,
        &quot;duration&quot;: f&quot;{end - start:.2f}s&quot;,
        &quot;engine&quot;: &quot;Rust (via pyo3-async-runtimes)&quot;
    }</code></pre>
<h2 id="빌드-및-실행">빌드 및 실행</h2>
<p>Maturin 라이브러리를 통해 Rust 코드를 Python에서 호출 가능한 형태로 컴파일한다.
컴파일 후 <code>pip list</code> 명령어를 사용해 보면 <code>Cargo.toml</code> 파일에 작성한 패키지 이름을 확인할 수 있다.</p>
<p>uvicorn 라이브러리를 통해 FastAPI를 실행한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/asynchronous$ maturin develop
~/workspace/asynchronous$ uvicorn app.main:app --reload</code></pre>
<p>curl 명령어 또는 브라우저를 통해 다음과 같은 테스트를 해볼 수 있다.</p>
<ul>
<li><code>http://127.0.0.1:8000/async-rust/10</code></li>
</ul>
<p>전달한 숫자만큼의 시간이 지나기 전에 다른 창을 열고 다시 접속해 보면
대기 없이 접속되는 것을 확인할 수 있다.</p>
<p>Swagger UI를 통해 <code>/async-rust</code> 를 실행한 후
로딩 중인 동안 <code>/</code> 를 실행해 보는 식으로 테스트할 수도 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[모듈화]]></title>
            <link>https://velog.io/@peeeeeter_j/pt-study-8</link>
            <guid>https://velog.io/@peeeeeter_j/pt-study-8</guid>
            <pubDate>Sun, 22 Mar 2026 23:42:32 GMT</pubDate>
            <description><![CDATA[<h1 id="모듈화">모듈화</h1>
<p>프로젝트 규모가 커졌을 때
모든 Rust 코드를 <code>src/lib.rs</code> 파일 하나에서 관리하게 될 경우
스파게티 코드가 될 확률이 높으며 관리하기 어려워진다.</p>
<p>따라서 기능에 따라 파일을 나누어 모듈 단위로 관리할 필요가 있다.</p>
<h2 id="작업공간-생성-및-구조-확인">작업공간 생성 및 구조 확인</h2>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace$ mkdir modularization &amp;&amp; cd modularization
~/workspace/modularization$ python3 -m venv venv
~/workspace/modularization$ source venv/bin/activate
~/workspace/modularization$ pip install maturin fastapi uvicorn
~/workspace/modularization$ maturin init
~/workspace/modularization$ # 선택지 중 기본값인 PyO3 선택
~/workspace/modularization$ # Cargo.toml과 src/lib.rs가 자동 생성된다
~/workspace/modularization$ # Python 코드는 직접 생성해 주어야 한다
~/workspace/modularization$ mkdir app &amp;&amp; touch app/main.py
~/workspace/modularization$ # Rust 모듈을 작성할 파일도 생성해 주어야 한다
~/workspace/modularization$ touch src/math_ops.rs src/data_ops.rs
~/workspace/modularization$ tree -I venv
.
├── app
│   └── main.py
├── Cargo.toml
├── pyproject.toml
└── src
    ├── data_ops.rs
    ├── lib.rs
    └── math_ops.rs</code></pre>
<p><code>Cargo.toml</code> 파일을 열어 라이브러리 이름을 수정해 주겠다.
우리가 작성할 모듈 중 하나는 병렬 처리 연산을 수행할 것이므로
병렬 처리를 위한 <a href="https://docs.rs/rayon/latest/rayon">Rayon</a> 크레이트도 추가해 준다.</p>
<blockquote>
<p><code>Cargo.toml</code></p>
</blockquote>
<pre><code class="language-toml">[package]
name = &quot;modularization&quot;
version = &quot;0.1.0&quot;
edition = &quot;2024&quot;
&gt;
[lib]
name = &quot;rust_engine&quot;
crate-type = [&quot;cdylib&quot;]
&gt;
[dependencies]
pyo3 = &quot;0.28.0&quot;
rayon = &quot;1.11&quot;</code></pre>
<h2 id="코드-작성">코드 작성</h2>
<h3 id="rust-코드">Rust 코드</h3>
<p>다른 파일에 작성된 함수는 <code>#[pyfunction]</code> 속성뿐만 아니라
<code>pub</code> 키워드도 있어야 <code>src/lib.rs</code> 에서 정상적으로 등록할 수 있다.
그 외에는 <code>src/lib.rs</code> 에서 작성하던 것과 크게 다르지 않다.</p>
<blockquote>
<p><code>src/math_ops.rs</code></p>
</blockquote>
<pre><code class="language-rust">use pyo3::prelude::*;
use rayon::prelude::*;
&gt;
#[pyfunction]
pub fn parallel_sum(data: Vec&lt;i32&gt;) -&gt; PyResult&lt;i64&gt; {
    Ok(data.par_iter().map(|&amp;x| x as i64).sum())
}</code></pre>
<blockquote>
<p><code>src/data_ops.rs</code></p>
</blockquote>
<pre><code class="language-rust">use pyo3::prelude::*;
use pyo3::exceptions::PyValueError;
use std::collections::HashMap;
&gt;
#[pyfunction]
pub fn get_stats(data: Vec&lt;i32&gt;) -&gt; PyResult&lt;HashMap&lt;String, i64&gt;&gt; {
    if data.is_empty() {
        return Err(PyValueError::new_err(&quot;데이터 비어 있습니다.&quot;));
    }
&gt;
    let sum: i32 = data.iter().sum();
    let avg = sum as i64 / data.len() as i64;
&gt;
    let mut map = HashMap::new();
    map.insert(&quot;sum&quot;.to_string(), sum as i64);
    map.insert(&quot;average&quot;.to_string(), avg);
&gt;
    Ok(map)
}</code></pre>
<p>다른 파일에서 작성한 코드를 <code>src/lib.rs</code> 에서 모듈로 불러올 땐
불러오고자 하는 파일 경로 앞에 <code>mod</code> 키워드를 사용한다.</p>
<p>모듈 이름과 함수 이름 사이에 <code>::</code> 을 붙여
해당 모듈 내에 정의된 함수를 사용할 수 있다.</p>
<blockquote>
<p><code>src/lib.rs</code></p>
</blockquote>
<pre><code class="language-rust">use pyo3::prelude::*;
&gt;
mod math_ops;
mod data_ops;
&gt;
#[pymodule]
fn rust_engine(m: &amp;Bound&lt;&#39;_, PyModule&gt;) -&gt; PyResult&lt;()&gt; {
    m.add_function(wrap_pyfunction!(math_ops::parallel_sum, m)?)?;
    m.add_function(wrap_pyfunction!(data_ops::get_stats, m)?)?;
&gt;
    Ok(())
}</code></pre>
<h3 id="python-코드">Python 코드</h3>
<p>이번에도 정수 값을 담는 <code>List</code> 를 속성으로 가진 <code>DataInput</code> 클래스를 만들어 사용할 것이다.
이것은 데이터 검증 라이브러리 <code>pydantic</code> 의 <code>BaseModel</code> 클래스를 상속받아 생성한다.</p>
<blockquote>
<p><code>app/main.py</code></p>
</blockquote>
<pre><code class="language-py">from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import rust_engine
&gt;
app = FastAPI()
&gt;
class NumbersInput(BaseModel):
    values: list[int]
&gt;
@app.post(&quot;/compute&quot;)
def run_all(data: NumbersInput):
    try:
        p_sum = rust_engine.parallel_sum(data.values)
        stats = rust_engine.get_stats(data.values)
&gt;
        return {
            &quot;parallel_sum&quot;: p_sum,
            &quot;statistics&quot;: stats
        }
&gt;
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))</code></pre>
<h2 id="빌드-및-실행">빌드 및 실행</h2>
<p>Maturin 라이브러리를 통해 Rust 코드를 Python에서 호출 가능한 형태로 컴파일한다.
병렬 처리가 포함된 코드는 성능 최적화를 위해 <code>--release</code> 를 붙여 컴파일한다.
컴파일 후 <code>pip list</code> 명령어를 사용해 보면 <code>Cargo.toml</code> 파일에 작성한 패키지 이름을 확인할 수 있다.</p>
<p>uvicorn 라이브러리를 통해 FastAPI를 실행한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-bash">~/workspace/modularization$ maturin develop --release
~/workspace/modularization$ uvicorn app.main:app --reload</code></pre>
<p>curl 명령어 또는 브라우저를 통해 테스트를 해볼 수 있다.</p>
<p>이 예제의 경우 GET 메서드가 아닌 POST 메서드로 통신하므로
브라우저를 통한 테스트 시 Swagger UI를 사용해야 한다.
FastAPI의 경우 다음과 같은 주소로 Swagger UI가 내장되어 있다.</p>
<ul>
<li><code>http://127.0.0.1:8000/docs</code></li>
</ul>
<p>이 예제는 병렬 처리 연산이 핵심은 아니므로
따로 클라이언트 파일은 생성하지 않고
Swagger UI를 통해 테스트를 진행해도 충분하다.</p>
]]></description>
        </item>
    </channel>
</rss>