계층 구조를 재귀적으로 탐색하는 데이터베이스 (1) 환경 구축 및 통신 규약 설정

Pt J·2026년 5월 27일
post-thumbnail

계층 구조를 재귀적으로 탐색하는 데이터베이스 (1) 환경 구축 및 통신 규약 설정

나는 기본적으로 잡지식에 관심이 많다.
DB 실습에 사주 분석 같은 걸 넣은 것도 그런 이유다.
그리고 이번에는 좀 더 잡지식스러운 것을 준비해 보았다.

오컬트, 좋아하세요?

🤖 AI AGENT | INTRO

오컬트 지식 그래프라는 도메인은 관계형 데이터베이스에서 Recursive CTE(재귀 공통 테이블 식)와 그래프 탐색(Graph Traversal)을 깊이 있게 다루기에 더할 나위 없이 완벽한 주제입니다. 오컬트 지식은 그 자체로 방대한 계층 구조(Hierarchy)와 복잡한 상호 참조(Cross-reference)를 가지기 때문입니다.

첫 번째 단계인 전체적인 설계 및 도메인 범위 설정부터 시작해 보겠습니다.


1. 오컬트 지식 그래프: 도메인 스코프 설정

오컬트 분야의 다양한 정보를 포함하려면, 특정 오컬트 분야(예: 타로카드, 점성술)에 종속된 테이블(예: tarot_cards, zodiac_signs 등)을 만드는 것은 피해야 합니다. 대신, 모든 지식을 노드(Node)와 간선(Edge)으로 취급하는 범용적인 그래프 데이터베이스 스키마를 설계해야 합니다.

실습의 재미와 쿼리의 복잡도를 높이기 위해, 다음과 같은 4가지 핵심 지식 체계를 그래프에 통합하는 것을 제안합니다.

  1. 카발라 (Kabbalah): 생명나무의 10개 세피로트(Sephiroth)와 이들을 잇는 22개의 길(Paths). (가장 완벽한 트리/그래프 구조)
  2. 점성술 (Astrology): 7행성, 12황도대, 4원소. (카발라의 길 및 타로와의 교차 참조용)
  3. 연금술 (Alchemy): 3원리(수은, 유황, 소금), 4원소, 변성 단계(흑화, 백화, 적화).
  4. 악마학/천사학 (Demonology/Angelology): 솔로몬의 72 악마(Ars Goetia)의 계급 구조(왕, 공작, 백작 등)와 지배 천사.

이렇게 구성하면 다음과 같은 고난도 재귀 쿼리가 가능해집니다.

  • "특정 악마가 속한 군단에서 상위 계급으로 역추적하여, 그 악마를 통제하는 데 필요한 연금술 원소와 카발라 세피라를 재귀적으로 모두 가져오시오."

2. 전체 시스템 아키텍처 설계

실무 환경을 가정하여, 무거운 그래프 연산과 DB 통신은 Rust가 전담하고, 외부 클라이언트와의 유연한 통신 및 데이터 조립은 Python이 담당하는 마이크로서비스 아키텍처(MSA)를 구성합니다.

  • Database: PostgreSQL 18 (Docker, arm64 네이티브 이미지 사용)
  • 스키마는 scripts/database/ 디렉토리에 분리.
  • COMMENT ON을 사용한 철저한 메타데이터 관리.
  • Rust Engine (gRPC Server):
  • 역할: 고성능 그래프 탐색, Recursive CTE 쿼리 실행.
  • 스택: Rust 2024 Edition, tonic (>= 0.14), sqlx (컴파일 타임 쿼리 검증), tokio (비동기 런타임), tracing (구조화된 로깅).
  • 특징: Ctrl+C (SIGINT) 수신 시 Graceful Shutdown 구현, DB 커넥션 풀 안전 종료.
  • Python Gateway (API Server):
  • 역할: gRPC로 Rust 엔진과 통신하여 데이터를 받아오고, 클라이언트에게 RESTful(혹은 GraphQL) API 제공.
  • 스택: Python 3.12, uv (패키지 관리), FastAPI, grpcio.

3. 데이터베이스 스키마 초안 (Adjacency List 모델)

재귀적 탐색을 위해 두 개의 핵심 테이블로 모든 것을 표현합니다.

  • nodes (지식의 주체): id, name(예: '루시퍼', '불의 원소'), category(예: 'Demon', 'Element'), description.
  • edges (지식 간의 관계): parent_node_id, child_node_id, relation_type(예: 'COMMANDS', 'CORRESPONDS_TO', 'EMANATES_FROM'), weight.

데이터베이스 환경 구축

데이터베이스 스키마

  • 노드 테이블
컬럼명데이터 타입설명 (목적)
idUUID각 노드의 고유 식별자 (Primary Key)
nameVARCHAR(225)개별 오컬트 지식 엔티티의 이름
entity_typeVARCHAR(100)해당 오컬트 지식 엔티티가 속해 있는 분류
attributesJSONB각 분류별로 상이한 세부 속성을 담는 JSON
  • 엣지 테이블
컬럼명데이터 타입설명 (목적)
parent_node_idUUID관계의 시작점이 되는 노드 ID (Primary Key)
child_node_idUUID관계의 도착점이 되는 노드 ID (Primary Key)
relation_typeVARCHAR(100)관계의 성격 (어떤 관계인지) (Primary Key)
weightREAL탐색 가중치 (기본값 1.0)

compose.yaml

환경변수를 사용하여 보안과 관련된 값을 지정할 때,
:? 을 사용하면 해당 환경변수가 설정되어 있지 않을 경우의 오류 메시지를 지정할 수 있다.

compose.yaml

services:
  postgres:
    image: postgres:18-alpine
    container_name: occult_graph_db
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER 환경변수가 설정되지 않았습니다.}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD 환경변수가 설정되지 않았습니다.}
      POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB 환경변수가 설정되지 않았습니다.}
    ports:
      - "5432:5432"
    volumes:
      - ./scripts/database:/docker-entrypoint-initdb.d
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d occult_knowledge"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:

.env

보안과 유연성을 위해 프로젝트 루트에 환경변수 파일을 작성한다.
늘 이야기하는 거지만 이것은 공부 기록용이라 이렇게 올려 놓는 거지
실무에서는 환경변수 파일을 어딘가에 업로드하거나 유출하지 않도록 주의하자.

.env

POSTGRES_USER=admin
POSTGRES_PASSWORD=ku201711424
POSTGRES_DB=occult_knowledge
POSTGRES_PORT=5432

DATABASE_URL=postgres://admin:ku201711424@localhost:5432/occult_knowledge

01_init.sql

데이터 스키마를 기반으로 메타데이터와 제약조건을 포함하여 작성한다.

scripts/database/01_init.sql

-- 자동 업데이트 타임스탬프를 위한 트리거 함수
CREATE OR REPLACE FUNCTION update_modified_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = CURRENT_TIMESTAMP;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

COMMENT ON FUNCTION update_modified_column() IS '레코드 수정 시 updated_at 칼럼을 자동으로 갱신하는 트리거 함수입니다.';

--------------------------------------------------
-- NODES 테이블: 지식 그래프의 정점
--------------------------------------------------
CREATE TABLE nodes (
    id UUID PRIMARY KEY DEFAULT uuidv7(),
    name VARCHAR(255) NOT NULL,
    entity_type VARCHAR(100) NOT NULL,
    attributes JSONB DEFAULT '{}'::jsonb,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT unique_name_per_type UNIQUE (name, entity_type)
);

CREATE TRIGGER update_nodes_modtime
    BEFORE UPDATE ON nodes
    FOR EACH ROW EXECUTE FUNCTION update_modified_column();

COMMENT ON TABLE nodes IS '오컬트 지식 그래프의 기본 엔티티(노드)를 저장하는 테이블입니다.';
COMMENT ON COLUMN nodes.id IS '노드의 고유 식별자 (UUID v4)';
COMMENT ON COLUMN nodes.name IS '엔티티의 이름 (예: 루시퍼, 불, 비나)';
COMMENT ON COLUMN nodes.entity_type IS '엔티티의 분류 (예: Demon, Element, Sephirah, Tarot)';
COMMENT ON COLUMN nodes.attributes IS '각 분류별로 상이한 세부 속성을 담는 JSONB 칼럼 (예: 악마의 계급, 행성의 기호 등)';

-- JSONB 속성 검색 성능을 위한 GIN 인덱스
CREATE INDEX idx_nodes_attributes ON nodes USING GIN (attributes);
CREATE INDEX idx_nodes_entity_type ON nodes (entity_type);

--------------------------------------------------
-- EDGES 테이블: 지식 그래프의 간선
--------------------------------------------------
CREATE TABLE edges (
    parent_node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
    child_node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
    relation_type VARCHAR(100) NOT NULL,
    weight REAL DEFAULT 1.0,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (parent_node_id, child_node_id, relation_type),
    CONSTRAINT prevent_self_loop CHECK (parent_node_id != child_node_id)
);

CREATE TRIGGER update_edges_modtime
    BEFORE UPDATE ON edges
    FOR EACH ROW EXECUTE FUNCTION update_modified_column();

COMMENT ON TABLE edges IS '노드 간의 관계와 방향성을 정의하는 테이블입니다. 재귀 CTE의 핵심입니다.';
COMMENT ON COLUMN edges.parent_node_id IS '관계의 시작점이 되는 노드 ID';
COMMENT ON COLUMN edges.child_node_id IS '관계의 도착점이 되는 노드 ID';
COMMENT ON COLUMN edges.relation_type IS '관계의 성격 (예: COMMANDS, EMANATES_FROM, CORRESPONDS_TO)';
COMMENT ON COLUMN edges.weight IS '탐색 가중치 (기본값 1.0, 최단 거리나 연관성 강도 계산에 활용)';

-- 재귀 CTE 성능을 위한 양방향 인덱스
-- Top-Down 탐색용 인덱스 (부모에서 자식으로)
CREATE INDEX idx_edges_parent ON edges (parent_node_id);
-- Bottom-Up 탐색용 인덱스 (자식에서 부모로 역추적)
CREATE INDEX idx_edges_child ON edges (child_node_id);
-- 특정 관계 타입 필터링 성능을 위한 인덱스
CREATE INDEX idx_edges_relation ON edges (relation_type);

02_data.py

미리 준비해 놓은 data/edges.csvdata/nodes.csv 파일로부터
각각의 데이터를 읽어와 DB에 넣는 코드를 작성한다.

scripts/database/02_data.py

# /// script
# dependencies = [
#     "psycopg[binary]",
#     "python-dotenv",
# ]
# ///

"""
오컬트 지식 그래프 대용량 데이터 인제스션 스크립트.
CSV 파일들로부터 데이터를 스트리밍하여 PostgreSQL 18에 트랜잭션 안전하게 벌크 삽입합니다.
OS 시그널을 감지하여 Ctrl+C 중단 시에도 자원을 Graceful하게 정리합니다.
"""

import os
import sys
import csv
import json
import signal
import logging
from pathlib import Path
from typing import Dict, Tuple
import psycopg  # modern psycopg3 library
from dotenv import load_dotenv

load_dotenv()

# 1. 로깅 구성 (실무급 정형화 포맷)
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger("ingestion_pipeline")

# 전역 커넥션 객체 (시그널 핸들러 참조용)
conn = None

def get_db_connection():
    """환경 변수에서 설정을 읽어 PostgreSQL 커넥션을 생성합니다. 필수 변수 누락 시 Fail-fast합니다."""
    required_env = ["POSTGRES_USER", "POSTGRES_PASSWORD", "POSTGRES_DB"]
    for env in required_env:
        if env not in os.environ:
            logger.critical(f"필수 환경 변수 {env}가 누락되었습니다. 실행을 중단합니다.")
            sys.exit(1)

    user = os.environ["POSTGRES_USER"]
    password = os.environ["POSTGRES_PASSWORD"]
    db = os.environ["POSTGRES_DB"]
    host = os.environ.get("POSTGRES_HOST", "localhost")
    port = os.environ.get("POSTGRES_PORT", "5432")

    conn_str = f"host={host} port={port} dbname={db} user={user} password={password}"
    logger.info(f"데이터베이스 연결 시도 중... (Host: {host}:{port}, DB: {db})")
    return psycopg.connect(conn_str)

def graceful_shutdown(signum, frame):
    """Ctrl+C 등 중단 시그널 발생 시 자원을 안전하게 정리하는 핸들러"""
    global conn
    logger.warning(f"시그널 {signum} 수신. 인제스션을 안전하게 중단하고 자원을 해제합니다.")
    if conn:
        try:
            conn.rollback()
            logger.info("진행 중이던 트랜잭션을 롤백했습니다.")
            conn.close()
            logger.info("데이터베이스 커넥션을 안전하게 닫았습니다.")
        except Exception as e:
            logger.error(f"자원 해제 중 에러 발생: {e}")
    logger.info("프로세스를 종료합니다.")
    sys.exit(128 + signum)

# 시그널 등록 (SIGINT: Ctrl+C, SIGTERM: 프로세스 종료)
signal.signal(signal.SIGINT, graceful_shutdown)
signal.signal(signal.SIGTERM, graceful_shutdown)

def ingest_data(nodes_path: str, edges_path: str):
    """CSV 파일들을 읽어 트랜잭션 단위로 Bulk Ingestion을 수행합니다."""
    global conn

    if not os.path.exists(nodes_path) or not os.path.exists(edges_path):
        logger.error(f"데이터 파일 경로가 올바르지 않습니다. (Nodes: {nodes_path}, Edges: {edges_path})")
        return

    try:
        conn = get_db_connection()

        # 전체 작업을 단일 원자적 트랜잭션으로 처리하기 위해autocommit 거부
        with conn.cursor() as cur:
            logger.info("--- 1단계: Nodes 데이터 벌크 인서트 시작 ---")

            # 후속 에지 매핑을 위해 (name, entity_type) -> uuid 매핑 딕셔너리 구축 예정
            # 우선 데이터를 효율적으로 넣기 위해 psycopg3의 COPY 기능 또는 executemany 사용
            inserted_nodes_count = 0

            with open(nodes_path, mode='r', encoding='utf-8') as f:
                reader = csv.DictReader(f)

                # 대량의 데이터를 메모리에 모두 올리지 않고 스트리밍 방식으로 쿼리 실행
                for row in reader:
                    # 명시적으로 JSON 데이터 파싱 유효성 검증
                    try:
                        attr_json = json.loads(row['attributes'])
                    except json.JSONDecodeError:
                        logger.warning(f"잘못된 JSON 포맷 스킵: {row['name']}")
                        continue

                    cur.execute(
                        """
                        INSERT INTO nodes (name, entity_type, attributes)
                        VALUES (%s, %s, %s)
                        ON CONFLICT (name, entity_type) DO UPDATE 
                        SET attributes = EXCLUDED.attributes
                        RETURNING id;
                        """,
                        (row['name'], row['entity_type'], json.dumps(attr_json))
                    )
                    inserted_nodes_count += 1

            logger.info(f"Nodes 벌크 인서트 완료. 총 {inserted_nodes_count}개 레코드 반영.")

            # 메모리에 이름 기반 UUID 매핑 테이블 구축 (Edges ID 변환용)
            logger.info("메모리 내 노드 매핑 인덱스 캐싱 중...")
            cur.execute("SELECT id, name, entity_type FROM nodes;")
            node_map: Dict[Tuple[str, str], str] = {
                (row[1], row[2]): str(row[0]) for row in cur.fetchall()
            }

            logger.info("--- 2단계: Edges 관계 데이터 벌크 인서트 시작 ---")
            inserted_edges_count = 0

            with open(edges_path, mode='r', encoding='utf-8') as f:
                reader = csv.DictReader(f)

                for row in reader:
                    parent_key = (row['parent_name'], row['parent_type'])
                    child_key = (row['child_name'], row['child_type'])

                    # 무결성 검증: CSV 내에 정의된 관계의 노드가 실제 존재하는지 확인
                    if parent_key not in node_map or child_key not in node_map:
                        logger.warning(
                            f"에지 무결성 위배 스킵: {parent_key} -> {child_key} (노드를 찾을 수 없음)"
                        )
                        continue
  
                    parent_uuid = node_map[parent_key]
                    child_uuid = node_map[child_key]
                    weight = float(row.get('weight', 1.0))

                    cur.execute(
                        """
                        INSERT INTO edges (parent_node_id, child_node_id, relation_type, weight)
                        VALUES (%s, %s, %s, %s)
                        ON CONFLICT (parent_node_id, child_node_id, relation_type) DO UPDATE
                        SET weight = EXCLUDED.weight;
                        """,
                        (parent_uuid, child_uuid, row['relation_type'], weight)
                    )
                    inserted_edges_count += 1
   
            logger.info(f"Edges 벌크 인서트 완료. 총 {inserted_edges_count}개 관계 반영.")

            # 모든 작업 성공 시 최종 커밋
            conn.commit()
            logger.info("모든 데이터 가 정상적으로 데이터베이스에 커밋되었습니다. 파이프라인 종료.")
 
    except Exception as e:
        logger.error(f"인제스션 중 치명적 예외 발생. 롤백을 수행합니다. 에러: {e}")
        if conn:
            conn.rollback()
    finally:
        if conn:
            conn.close()
            logger.info("데이터베이스 커넥션 풀을 반환했습니다.")

if __name__ == "__main__":
    # 실행 경로 설정
    BASE_DIR = Path(__file__).resolve().parents[2]

    nodes_csv = BASE_DIR / "data" / "nodes.csv"
    edges_csv = BASE_DIR / "data" / "edges.csv"

    logger.info("오컬트 지식 그래프 ETL 파이프라인 가동")
    ingest_data(nodes_csv, edges_csv)

스크립트 실행

~/workspace/occult-graph$ docker compose up -d
~/workspace/occult-graph$ docker exec -i occult_graph_db psql -U admin -d occult_knowledge < scripts/database/01_init.sql
~/workspace/occult-graph$ docker exec -it occult_graph_db psql -U admin -d occult_knowledge -c "\dt"                  

List of tables
 Schema | Name  | Type  | Owner 
--------+-------+-------+-------
 public | edges | table | admin
 public | nodes | table | admin
(2 rows)

테이블이 생성되었으니 데이터를 삽입한다.

~/workspace/occult-graph$ uv run scripts/database/02_data.py

2026-05-28 08:39:20,454 [INFO] ingestion_pipeline: 오컬트 지식 그래프 ETL 파이프라인 가동
2026-05-28 08:39:20,454 [INFO] ingestion_pipeline: 데이터베이스 연결 시도 중... (Host: localhost:5432, DB: occult_knowledge)
2026-05-28 08:39:20,466 [INFO] ingestion_pipeline: --- 1단계: Nodes 데이터 벌크 인서트 시작 ---
2026-05-28 08:39:20,535 [INFO] ingestion_pipeline: Nodes 벌크 인서트 완료. 총 380개 레코드 반영.
2026-05-28 08:39:20,535 [INFO] ingestion_pipeline: 메모리 내 노드 매핑 인덱스 캐싱 중...
2026-05-28 08:39:20,536 [INFO] ingestion_pipeline: --- 2단계: Edges 관계 데이터 벌크 인서트 시작 ---
2026-05-28 08:39:20,603 [INFO] ingestion_pipeline: Edges 벌크 인서트 완료. 총 402개 관계 반영.
2026-05-28 08:39:20,604 [INFO] ingestion_pipeline: 모든 데이터 가 정상적으로 데이터베이스에 커밋되었습니다. 파이프라인 종료.
2026-05-28 08:39:20,604 [INFO] ingestion_pipeline: 데이터베이스 커넥션 풀을 반환했습니다.
~/workspace/occult-graph$ docker exec -it occult_graph_db psql -U admin -d occult_knowledge -c "SELECT * FROM nodes LIMIT 10;"
                  id                  |  name   | entity_type |                        attributes                        |          created_at           |         updated_at           
--------------------------------------+---------+-------------+----------------------------------------------------------+-------------------------------+-------------------------------
 019e6bd2-74e5-7314-9d7e-9afed6e520c6 | Fire    | Element     | {"tattva": "Tejas", "category": "Classical"}             | 2026-05-27 23:43:35.138989+00 |2026-05-27 23:43:35.138989+00
 019e6bd2-74e7-71a1-a2b0-08f002118b55 | Water   | Element     | {"tattva": "Apas", "category": "Classical"}              | 2026-05-27 23:43:35.138989+00 |2026-05-27 23:43:35.138989+00
 019e6bd2-74e7-7707-a8d4-42e844a898cf | Air     | Element     | {"tattva": "Vayu", "category": "Classical"}              | 2026-05-27 23:43:35.138989+00 |2026-05-27 23:43:35.138989+00
 019e6bd2-74e7-7c1e-973c-5da477d61ae0 | Earth   | Element     | {"tattva": "Prithivi", "category": "Classical"}          | 2026-05-27 23:43:35.138989+00 |2026-05-27 23:43:35.138989+00                           
 019e6bd2-74e8-70f0-a4af-ec1bbf3c0d9b | Saturn  | Planet      | {"metal": "Lead", "category": "Classical Planet"}        | 2026-05-27 23:43:35.138989+00 |2026-05-27 23:43:35.138989+00
 019e6bd2-74e8-79c1-9443-1f2413a13d84 | Jupiter | Planet      | {"metal": "Tin", "category": "Classical Planet"}         | 2026-05-27 23:43:35.138989+00 |2026-05-27 23:43:35.138989+00
 019e6bd2-74e8-7e72-bc52-563afb5038f0 | Mars    | Planet      | {"metal": "Iron", "category": "Classical Planet"}        | 2026-05-27 23:43:35.138989+00 |2026-05-27 23:43:35.138989+00
 019e6bd2-74e9-7293-aa89-f503d2b53615 | Sun     | Planet      | {"metal": "Gold", "category": "Classical Planet"}        | 2026-05-27 23:43:35.138989+00 |2026-05-27 23:43:35.138989+00
 019e6bd2-74e9-7684-bdaf-bcb22e70857c | Venus   | Planet      | {"metal": "Copper", "category": "Classical Planet"}      | 2026-05-27 23:43:35.138989+00 |2026-05-27 23:43:35.138989+00
 019e6bd2-74e9-7a94-a10d-953a1142237f | Mercury | Planet      | {"metal": "Quicksilver", "category": "Classical Planet"} | 2026-05-27 23:43:35.138989+00 |2026-05-27 23:43:35.138989+00
(10 rows)

~/workspace/occult-graph$ docker exec -it occult_graph_db psql -U admin -d occult_knowledge -c "SELECT COUNT(*) FROM nodes;"
 count 
-------
   380
(1 row)
~/workspace/occult-graph$ docker exec -it occult_graph_db psql -U admin -d occult_knowledge -c "SELECT * FROM edges LIMIT 10;" 
            parent_node_id            |            child_node_id             | relation_type | weight |          created_at           |          updated_at           
--------------------------------------+--------------------------------------+---------------+--------+-------------------------------+-------------------------------
 019e6bd2-74ea-713d-9c6a-c6eb5a3fdf89 | 019e6bd2-750a-7e20-9ead-bd4aa481ee3e | CONTAINS      |      1 | 2026-05-27 23:43:35.138989+00 | 2026-05-27 23:43:35.138989+00
 019e6bd2-74ed-7135-ad21-5739bff750d5 | 019e6bd2-750a-7e20-9ead-bd4aa481ee3e | GOVERNS       |      2 | 2026-05-27 23:43:35.138989+00 | 2026-05-27 23:43:35.138989+00
 019e6bd2-74ea-713d-9c6a-c6eb5a3fdf89 | 019e6bd2-750b-704c-849a-5454665e5e57 | CONTAINS      |      1 | 2026-05-27 23:43:35.138989+00 | 2026-05-27 23:43:35.138989+00
 019e6bd2-74ed-758c-914a-d1cdb74e5a5c | 019e6bd2-750b-704c-849a-5454665e5e57 | GOVERNS       |      2 | 2026-05-27 23:43:35.138989+00 | 2026-05-27 23:43:35.138989+00
 019e6bd2-74ea-713d-9c6a-c6eb5a3fdf89 | 019e6bd2-750b-729f-aa69-f620b2a0247b | CONTAINS      |      1 | 2026-05-27 23:43:35.138989+00 | 2026-05-27 23:43:35.138989+00
 019e6bd2-74ed-787a-a8cd-0cde46055a70 | 019e6bd2-750b-729f-aa69-f620b2a0247b | GOVERNS       |      2 | 2026-05-27 23:43:35.138989+00 | 2026-05-27 23:43:35.138989+00
 019e6bd2-74ea-713d-9c6a-c6eb5a3fdf89 | 019e6bd2-750b-74f3-b554-4f7f8a57e9ea | CONTAINS      |      1 | 2026-05-27 23:43:35.138989+00 | 2026-05-27 23:43:35.138989+00
 019e6bd2-74ed-7b66-a33e-f787d7df9ad5 | 019e6bd2-750b-74f3-b554-4f7f8a57e9ea | GOVERNS       |      2 | 2026-05-27 23:43:35.138989+00 | 2026-05-27 23:43:35.138989+00
 019e6bd2-74ea-713d-9c6a-c6eb5a3fdf89 | 019e6bd2-750b-776d-bd2f-41ad9d364239 | CONTAINS      |      1 | 2026-05-27 23:43:35.138989+00 | 2026-05-27 23:43:35.138989+00
 019e6bd2-74ed-7e78-8ee2-18eb2a6a61f0 | 019e6bd2-750b-776d-bd2f-41ad9d364239 | GOVERNS       |      2 | 2026-05-27 23:43:35.138989+00 | 2026-05-27 23:43:35.138989+00
(10 rows)

~/workspace/occult-graph$ docker exec -it occult_graph_db psql -U admin -d occult_knowledge -c "SELECT COUNT(*) FROM edges;"
 count 
-------
   402
(1 row)

gRPC 인터페이스 정의

프로젝트 루트에 proto 디렉토리를 만들고 인터페이스를 정의한다.
여기서 정의한 인터페이스는 Rust와 Python에서 각각 적절하게 가공하여 사용한다.

proto/occult.proto

syntax = "proto3";

package occult;

// 노드(Node) 모델
message Node {
    string id = 1;               // UUIDv7 (문자열로 전달)
    string name = 2;             // 엔티티 이름 (예: Bael, Kether)
    string entity_type = 3;      // 분류 (예: Demon, Sephirah)
    string attributes_json = 4;  
}

// 경로(Path) 모델
message EdgePath {
    string parent_id = 1;
    string child_id = 2;
    string relation_type = 3;    // (예: BINDS_AND_CONTROLS)
    int32 depth = 4;             // 시작점으로부터의 탐색 깊이
    float weight = 5;            // 이 간선을 통과하는 데 드는 가중치
}


// 단일 노드 조회 요청
message GetNodeRequest {
    string identifier = 1;  // UUID 또는 정확한 이름
}

// 단일 노드 조회 응답
message GetNodeResponse {
    Node node = 1;
}

// 조건에 맞는 여러 노드 조회 요청
message SearchNodesRequest {
    string query = 1;               // 검색어 (LIKE '%query%')
    string entity_type_filter = 2;  // 특정 타입만 필터링 (선택적, 예: "Demon")
    int32 limit = 3;                // 최대 반환 개수 (기본값: 10)
}

// 조건에 맞는 여러 노드 조회 응답
message SearchNodesResponse {
    repeated Node nodes = 1;
}

// 그래프 재귀 탐색 요청
message TraversalRequest {
    string start_node_identifier = 1; // 시작 노드의 UUID 또는 이름
    int32 max_depth = 2;              // 최대 탐색 깊이 (무한 루프 방지용, 실무 필수)

    // 탐색 방향 제어 플래그
    // false(Top-Down): 시작 노드가 '부모'가 되어 하위 자식을 찾음 (예: 악마가 거느리는 군단 탐색)
    // true(Bottom-Up): 시작 노드가 '자식'이 되어 상위 부모를 찾음 (예: 악마를 억제하는 상위 천사 역추적)
    bool bottom_up = 3;               
}

// 그래프 재귀 탐색 응답
message TraversalResponse {
    repeated Node nodes = 1;          // 탐색 과정에서 발견된 모든 고유 노드 배열
    repeated EdgePath paths = 2;      // 노드들을 연결하는 간선들의 배열
}

// gRPC 서비스 정의
service OccultKnowledge {
    // 특정 노드의 상세 정보만 가볍게 가져옵니다.
    rpc GetNode (GetNodeRequest) returns (GetNodeResponse);

    // 탐색의 시작점을 찾기 위해 노드들을 검색합니다.
    rpc SearchNodes (SearchNodesRequest) returns (SearchNodesResponse);

    // 전체 서브 그래프(Sub-graph)를 재귀적으로 긁어옵니다. (Heavy Query)
    rpc TraverseGraph (TraversalRequest) returns (TraversalResponse);
}
profile
Peter J Online Space - since July 2020 | 아무데서나 채용해줬으면 좋겠다 (지금은 학생 때 하던 거 아무거나 공부하고 있고요, 취업시켜 주시면 그 분야로 공부할게요)

0개의 댓글