계층 구조를 재귀적으로 탐색하는 데이터베이스 (2) Rust gRPC 엔진

Pt J·2026년 6월 1일
post-thumbnail

계층 구조를 재귀적으로 탐색하는 데이터베이스 (2) Rust gRPC 엔진

설정 파일

우선 Rust 프로젝트를 생성한다.

~/workspace/occult-graph$ cargo new rust-engine
~/workspace/occult-graph$ tree
.
├── compose.yaml
├── data
│   ├── edges.csv
│   └── nodes.csv
├── proto
│   └── occult.proto
├── rust-engine
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── scripts
    └── database
        ├── 01_init.sql
        └── 02_data.py

7 directories, 8 files

Cargo.toml

의존성 파일을 작성한다.

rust-engine/Cargo.toml

[package]
name = "rust-engine"
version = "0.1.0"
edition = "2024"
description = "오컬트 지식 그래프 재귀 탐색을 위한 고성능 gRPC 코어 엔진"

[profile.release]
opt-level = 3
lto = true
codegen-units = 1
panic = "abort"

[dependencies]
# 비동기 런타임
tokio = { version = "1.52", features = ["full"] }
tokio-stream = "0.1"

# gRPC
tonic = "0.14"
prost = "0.14"
tonic-prost = "0.14"

#  데이터베이스
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "json", "chrono", "macros"] }
uuid = { version = "1.23", features = ["serde", "v7"] }

# 직렬화 및 유틸리티
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenvy = "0.15"

# 구조화된 로깅
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }

[build-dependencies]
# gRPC proto 파일 컴파일러
tonic-prost-build = "0.14"

build.rs

proto/occult.proto 파일에 정의한 gRPC 서비스 명세를
Rust 코드로 자동 변환하는 코드를 작성한다.

rust-engine/build.rs

use std::env;
use std::path::PathBuf;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let proto_file = "../proto/occult.proto";
    let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());

    // proto 파일이 변경될 때만 재빌드를 트리거하도록 설정합니다.
    println!("cargo:rerun-if-changed={}", proto_file);

    tonic_prost_build::configure()
        .type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]")
        .file_descriptor_set_path(out_dir.join("occult_descriptor.bin"))
        .compile_protos(&[proto_file], &["../proto"])?;

    Ok(())
}

config.rs

안전한 서버 구동을 위해 애플리케이션 시작 시점에 환경 변수를 읽어오고
필수 값이 없으면 즉시 Panic을 발생시키는 Fail-fast 패턴을 적용한다.

rust-engine/src/config.rs

use std::env;

#[derive(Debug, Clone)]
pub struct AppConfig {
    pub database_url: String,
    pub grpc_port: u16,
}

impl AppConfig {
    pub fn from_env() -> Self {
    	dotenvy::dotenv().ok();

        let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| {
            panic!("DATABASE_URL 환경 변수가 설정되지 않았습니다. (.env 파일을 확인하세요)");
        });

        let grpc_port_str = env::var("GRPC_PORT").unwrap_or_else(|_| "50051".to_string());
        let grpc_port = grpc_port_str.parse::<u16>().unwrap_or_else(|_| {
            panic!("GRPC_PORT는 유효한 u16 숫자여야 합니다.");
        });

        Self {
            database_url,
            grpc_port,
        }
    }
}

코드 작성

occult.rs

자동 생성된 코드를 메인 로직에 직접 우겨넣기보다는
별도의 모듈로 깔끔하게 분리하는 것이 낫다.

rust-engine/src/occult.rs

pub mod occult {
    include!(concat!(env!("OUT_DIR"), "/occult.rs"));
}

db.rs

SQL문을 사용하여 DB와 실제적인 통신을 하는 코드를 작성한다.

rust-engine/src/db.rs

use crate::occult::{EdgePath, Node};
use sqlx::{PgPool, FromRow};
use tracing::instrument;
use uuid::Uuid;

/// 데이터베이스에서 조회한 노드의 내부 모델
#[derive(Debug, FromRow)]
pub struct NodeEntity {
    pub id: Uuid,
    pub name: String,
    pub entity_type: String,
    pub attributes: sqlx::types::JsonValue,
}

/// 데이터베이스에서 조회한 경로의 내부 모델
#[derive(Debug, FromRow)]
pub struct PathEntity {
    pub parent_node_id: Uuid,
    pub child_node_id: Uuid,
    pub relation_type: String,
    pub depth: i32,
    pub weight: f32,
}

/// 데이터베이스 커넥션 풀 초기화
pub async fn init_pool(database_url: &str) -> Result<PgPool, sqlx::Error> {
    let pool = PgPool::connect(database_url).await?;

    tracing::info!("PostgreSQL 커넥션 풀 연결 성공");

    Ok(pool)
}

/// 이름으로 단일 노드의 UUID 조회
pub async fn find_node_id_by_name(pool: &PgPool, name: &str) -> Result<Uuid, sqlx::Error> {
    let record = sqlx::query!("SELECT id FROM nodes WHERE name = $1 LIMIT 1", name)
        .fetch_one(pool)
        .await?;

    Ok(record.id)
}

/// 식별자(UUID/이름)로 단일 노드의 정보 조회
pub async fn get_node(pool: &PgPool, identifier: &str) -> Result<Node, sqlx::Error> {
    let id = match Uuid::parse_str(identifier) {
        Ok(uuid) => uuid,
        Err(_) => find_node_id_by_name(pool, identifier).await?,
    };

    let record = sqlx::query_as::<_, NodeEntity>(
        "SELECT id, name, entity_type, attributes FROM nodes WHERE id = $1"
    )
    .bind(id)
    .fetch_one(pool)
    .await?;

    Ok(Node {
        id: record.id.to_string(),
        name: record.name,
        entity_type: record.entity_type,
        attributes_json: record.attributes.to_string(),
    })
}

/// 검색어로 노드 목록 조회
pub async fn search_nodes(
    pool: &PgPool,
    query: &str,
    entity_type_filter: &str,
    limit: i32,
) -> Result<Vec<Node>, sqlx::Error> {
    let search_pattern = format!("%{}%", query);

    let records = if entity_type_filter.is_empty() {
        sqlx::query_as::<_, NodeEntity>(
            // name 뿐만 아니라 attributes(JSONB)를 text로 변환하여 내부 값까지 검색
            "SELECT id, name, entity_type, attributes FROM nodes 
             WHERE name ILIKE $1 OR attributes::text ILIKE $1 
             LIMIT $2"
        )
        .bind(&search_pattern)
        .bind(limit)
        .fetch_all(pool)
        .await?
    } else {
        sqlx::query_as::<_, NodeEntity>(
            // AND 조건 연산자 우선순위를 위해 괄호() 처리 필수
            "SELECT id, name, entity_type, attributes FROM nodes 
             WHERE (name ILIKE $1 OR attributes::text ILIKE $1) AND entity_type = $2 
             LIMIT $3"
        )
        .bind(&search_pattern)
        .bind(entity_type_filter)
        .bind(limit)
        .fetch_all(pool)
        .await?
    };

    let nodes = records
        .into_iter()
        .map(|r| Node {
            id: r.id.to_string(),
            name: r.name,
            entity_type: r.entity_type,
            attributes_json: r.attributes.to_string(),
        })
        .collect();

    Ok(nodes)
}

/// 주어진 시작 노드부터 그래프 재귀적 탐색 (Recursive CTE)
/// bottom_up이 true일 경우 자식에서 부모로 역추적
#[instrument(skip(pool), err)]
pub async fn traverse_graph(
    pool: &PgPool,
    start_id: Uuid,
    max_depth: i32,
    bottom_up: bool,
) -> Result<(Vec<Node>, Vec<EdgePath>), sqlx::Error> {
  
    // 방향에 따라 탐색에 사용할 조인(Join) 조건 설정
    let join_condition = if bottom_up {
        "e.child_node_id = pt.current_node"
    } else {
        "e.parent_node_id = pt.current_node"
    };

    let next_node = if bottom_up { "e.parent_node_id" } else { "e.child_node_id" };

    // 재귀 CTE 쿼리 구성
    let query = format!(
        r#"
        WITH RECURSIVE path_tree AS (
            -- Base Case: 깊이 0 (시작 노드)
            SELECT 
                $1::uuid AS current_node,
                $1::uuid AS parent_node_id,
                $1::uuid AS child_node_id,
                'START'::varchar AS relation_type,
                0 AS depth,
                0.0::real AS weight
         
            UNION ALL
          
            -- Recursive Step: 간선을 타고 탐색
            SELECT 
                {} AS current_node,
                e.parent_node_id,
                e.child_node_id,
                e.relation_type,
                pt.depth + 1,
                e.weight
            FROM edges e
            INNER JOIN path_tree pt ON {}
            WHERE pt.depth < $2
        )
        -- 결과 반환 시 시작점 더미 데이터(depth=0)는 제외
        SELECT parent_node_id, child_node_id, relation_type, depth, weight
        FROM path_tree
        WHERE depth > 0;
        "#,
        next_node, join_condition
    );

    // 간선(Edge) 경로 쿼리 실행
    let db_paths: Vec<PathEntity> = sqlx::query_as(&query)
        .bind(start_id)
        .bind(max_depth)
        .fetch_all(pool)
        .await?;

    // 관련된 모든 고유 노드 ID 추출
    let mut node_ids = vec![start_id];
    for p in &db_paths {
        node_ids.push(p.parent_node_id);
        node_ids.push(p.child_node_id);
    }
    node_ids.sort();
    node_ids.dedup();

    // 고유 ID 배열을 이용하여 노드 상세 정보 한 번에 조회 (= ANY)
    let db_nodes: Vec<NodeEntity> = sqlx::query_as(
        r#"
        SELECT id, name, entity_type, attributes
        FROM nodes
        WHERE id = ANY($1)
        "#
    )
    .bind(&node_ids)
    .fetch_all(pool)
    .await?;

    // Protobuf 모델로 변환
    let nodes = db_nodes.into_iter().map(|n| Node {
        id: n.id.to_string(),
        name: n.name,
        entity_type: n.entity_type,
        attributes_json: n.attributes.to_string(),
    }).collect();

    let paths = db_paths.into_iter().map(|p| EdgePath {
        parent_id: p.parent_node_id.to_string(),
        child_id: p.child_node_id.to_string(),
        relation_type: p.relation_type,
        depth: p.depth,
        weight: p.weight,
    }).collect();

    Ok((nodes, paths))
}

service.rs

db.rs 의 함수들을 활용하여 API를 처리하는 코드를 작성한다.

rust-engine/src/service.rs

use crate::db;
use crate::occult::occult_knowledge_server::OccultKnowledge;
use crate::occult::{
    GetNodeRequest, GetNodeResponse,
    SearchNodesRequest, SearchNodesResponse,
    TraversalRequest, TraversalResponse,
};
use sqlx::PgPool;
use tonic::{Request, Response, Status};
use uuid::Uuid;

pub struct OccultServiceImpl {
    pool: PgPool,
}

impl OccultServiceImpl {
    pub fn new(pool: PgPool) -> Self {
        Self { pool }
    }
}

#[tonic::async_trait]
impl OccultKnowledge for OccultServiceImpl {
    /// 단건 조회 API
    async fn get_node(
        &self,
        request: Request<GetNodeRequest>,
    ) -> Result<Response<GetNodeResponse>, Status> {
        let req = request.into_inner();
        let identifier = req.identifier.trim();

        if identifier.is_empty() {
            return Err(Status::invalid_argument("식별자가 비어 있습니다."));
        }

        match db::get_node(&self.pool, identifier).await {
            Ok(node) => Ok(Response::new(GetNodeResponse {
                node: Some(node)
            })),
            Err(e) => {
                tracing::error!("GetNode 에러 ({}): {:?}", identifier, e);
                Err(Status::not_found("노드를 찾을 수 없습니다."))
            }
        }
    }

    /// 검색 API
    async fn search_nodes(
        &self,
        request: Request<SearchNodesRequest>,
    ) -> Result<Response<SearchNodesResponse>, Status> {
        let req = request.into_inner();
        let limit = if req.limit <= 0 || req.limit > 100 { 10 } else { req.limit };

        match db::search_nodes(&self.pool, &req.query, &req.entity_type_filter, limit).await {
            Ok(nodes) => Ok(Response::new(SearchNodesResponse {
                nodes
            })),
            Err(e) => {
                tracing::error!("SearchNodes 에러 ({}): {:?}", req.query, e);
                Err(Status::internal("검색 중 오류가 발생했습니다."))
            }
        }
    }

    /// 그래프 탐색 API
    async fn traverse_graph(
        &self,
        request: Request<TraversalRequest>,
    ) -> Result<Response<TraversalResponse>, Status> {
        let req = request.into_inner();
        let identifier = req.start_node_identifier.trim();
      
        tracing::info!(
            "탐색 요청: 시작 = {}, 깊이 = {}, Bottom-Up = {}",
            identifier, req.max_depth, req.bottom_up
        );

        // UUID 파싱 또는 이름 기반 조회
        let start_uuid = match Uuid::parse_str(identifier) {
            Ok(id) => id,
            Err(_) => match db::find_node_id_by_name(&self.pool, identifier).await {
                Ok(id) => id,
                Err(e) => {
                    tracing::error!("시작 노드를 찾을 수 없습니다 ({}): {:?}", identifier, e);
                    return Err(Status::not_found("시작 노드를 찾을 수 없습니다."));
                }
            },
        };

        // 깊이 제한
        let max_depth = if req.max_depth <= 0 || req.max_depth > 20 { 5 } else { req.max_depth };

        // DB 탐색 호출
        let (nodes, paths) = db::traverse_graph(&self.pool, start_uuid, max_depth, req.bottom_up)
            .await
            .map_err(|e| {
                tracing::error!("그래프 탐색 중 DB 에러: {:?}", e);
                Status::internal("데이터베이스 탐색 실패")
            })?;

        Ok(Response::new(TraversalResponse {
            nodes, paths
        }))
    }
}

main.rs

rust-engine/src/main.rs

mod config;
mod db;
mod pb;
mod service;

use config::AppConfig;
use crate::pb::occult;
use std::net::SocketAddr;
use tokio::signal;
use tracing::Level;
use tracing_subscriber::FmtSubscriber;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let subscriber = FmtSubscriber::builder()
        .with_max_level(Level::INFO)
        .finish();

    tracing::subscriber::set_global_default(subscriber)
        .expect("로깅 시스템 초기화 실패");

    tracing::info!("Occult Graph Core Engine 구동을 시작합니다 (Rust 2024)");

    // 환경 변수 로드 (.env 파일 또는 시스템 환경변수)
    let config = AppConfig::from_env();

    // PostgreSQL 커넥션 풀 초기화
    tracing::info!("데이터베이스 연결 시도: {}", config.database_url);
    let pool = db::init_pool(&config.database_url).await?;

    // gRPC 서비스 구현체 생성 및 서버 라우팅 바인딩
    let addr: SocketAddr = format!("0.0.0.0:{}", config.grpc_port).parse()?;

    // 비즈니스 로직이 담긴 서비스 생성
    let occult_service = service::OccultServiceImpl::new(pool.clone());

    // tonic이 생성한 gRPC 서버 래퍼에 서비스를 주입
    let svc = occult::occult_knowledge_server::OccultKnowledgeServer::new(occult_service);

    tracing::info!("gRPC 서버가 {} 포트에서 요청을 대기합니다.", config.grpc_port);

    // 서버 실행 및 Graceful Shutdown 대기
    tonic::transport::Server::builder()
        .add_service(svc)
        .serve_with_shutdown(addr, shutdown_signal())
        .await?;

    // 종료 시그널 수신 후 리소스 안전 정리 (Graceful Shutdown)
    tracing::info!("서버가 안전하게 종료되었습니다. 데이터베이스 커넥션 풀을 반환합니다.");
    pool.close().await;

    Ok(())
}

async fn shutdown_signal() {
    let ctrl_c = async {
        signal::ctrl_c().await.expect("Ctrl+C 리스너 설치 실패");
    };

    #[cfg(unix)]
    let terminate = async {
        signal::unix::signal(signal::unix::SignalKind::terminate())
            .expect("SIGTERM 리스너 설치 실패")
            .recv()
            .await;
    };

    #[cfg(not(unix))]
    let terminate = std::future::pending::<()>();

    tokio::select! {
        _ = ctrl_c => {
            tracing::warn!("SIGINT (Ctrl+C) 수신: 안전한 종료를 시작합니다.");
        },
        _ = terminate => {
            tracing::warn!("SIGTERM 수신: 안전한 종료를 시작합니다.");
        },
    }
}

빌드 및 실행

PostgreSQL가 돌아가고 있는 docker를 실행 중인 환경에서 진행한다.

 ~/workspace/occult-graph/rust-engine$ cargo run --release

새 터미널을 열고 작동 테스트를 수행한다.

~/workspace/occult-graph/rust-engine$ rpcurl -plaintext -import-path ../proto -proto occult.proto \
  -d '{"identifier": "Bael"}' \
  localhost:50051 occult.OccultKnowledge/GetNode

{
  "node": {
    "id": "019e6bd2-7511-73e5-ae3e-f7029acaeb43",
    "name": "Bael",
    "entityType": "Demon",
    "attributesJson": "{\"goetia_number\":1,\"rank\":\"King\"}"
  }
}
~/workspace/occult-graph/rust-engine$ grpcurl -plaintext -import-path ../proto -proto occult.proto \
  -d '{"query": "Fire", "limit": 5}' \
  localhost:50051 occult.OccultKnowledge/SearchNodes

{
  "nodes": [
    {
      "id": "019e6bd2-74e5-7314-9d7e-9afed6e520c6",
      "name": "Fire",
      "entityType": "Element",
      "attributesJson": "{\"category\":\"Classical\",\"tattva\":\"Tejas\"}"
    },
    {
      "id": "019e6bd2-7505-7ac8-93ff-df4a6a0851cf",
      "name": "Aries",
      "entityType": "Zodiac",
      "attributesJson": "{\"element\":\"Fire\",\"modality\":\"Cardinal\"}"
    },
    {
      "id": "019e6bd2-7506-74a5-8139-c938734beb4d",
      "name": "Leo",
      "entityType": "Zodiac",
      "attributesJson": "{\"element\":\"Fire\",\"modality\":\"Fixed\"}"
    },
    {
      "id": "019e6bd2-7507-7015-ba3c-b8933955eff0",
      "name": "Sagittarius",
      "entityType": "Zodiac",
      "attributesJson": "{\"element\":\"Fire\",\"modality\":\"Mutable\"}"
    }
  ]
}
~/workspace/occult-graph/rust-engine$ grpcurl -plaintext -import-path ../proto -proto occult.proto \
  -d '{"start_node_identifier": "5 of Wands", "max_depth": 5, "bottom_up": false}' \
  localhost:50051 occult.OccultKnowledge/TraverseGraph

{
  "nodes": [
    {
      "id": "019e6bd2-74e5-7314-9d7e-9afed6e520c6",
      "name": "Fire",
      "entityType": "Element",
      "attributesJson": "{\"category\":\"Classical\",\"tattva\":\"Tejas\"}"
    },
    {
      "id": "019e6bd2-74eb-7e1c-a747-8aef7f5c7fde",
      "name": "Geburah",
      "entityType": "Sephirah",
      "attributesJson": "{\"meaning\":\"Severity\",\"number\":5}"
    },
    {
      "id": "019e6bd2-74ec-716a-b0ec-34730e4efa8d",
      "name": "Tiferet",
      "entityType": "Sephirah",
      "attributesJson": "{\"meaning\":\"Beauty\",\"number\":6}"
    },
    {
      "id": "019e6bd2-74ec-77ee-b5ed-fef9379b8822",
      "name": "Hod",
      "entityType": "Sephirah",
      "attributesJson": "{\"meaning\":\"Glory\",\"number\":8}"
    },
    {
      "id": "019e6bd2-74f1-72ce-95b3-bc93a447385a",
      "name": "Justice",
      "entityType": "Tarot",
      "attributesJson": "{\"arcana\":\"Major\",\"hebrew_letter\":\"Lamed\",\"number\":11}"
    },
    {
      "id": "019e6bd2-74f1-75b5-9c67-28d11e7b6f11",
      "name": "The Hanged Man",
      "entityType": "Tarot",
      "attributesJson": "{\"arcana\":\"Major\",\"hebrew_letter\":\"Mem\",\"number\":12}"
    },
    {
      "id": "019e6bd2-74f6-7909-aa09-1301ad9df3a1",
      "name": "5 of Wands",
      "entityType": "Tarot",
      "attributesJson": "{\"arcana\":\"Minor\",\"value\":5}"
    },
    {
      "id": "019e6bd2-7501-7eea-aa02-12b355c7f5ee",
      "name": "Leo Decan 1",
      "entityType": "ZodiacDecan",
      "attributesJson": "{\"decan\":1,\"sign\":\"Leo\"}"
    },
    {
      "id": "019e6bd2-7506-74a5-8139-c938734beb4d",
      "name": "Leo",
      "entityType": "Zodiac",
      "attributesJson": "{\"element\":\"Fire\",\"modality\":\"Fixed\"}"
    },
    {
      "id": "019e6bd2-7509-73ed-abf7-2ea3ca1b0457",
      "name": "Path 22 (Lamed)",
      "entityType": "TreePath",
      "attributesJson": "{\"hebrew_letter\":\"Lamed\",\"path_number\":22}"
    },
    {
      "id": "019e6bd2-7509-7632-be67-7cb86414eb9d",
      "name": "Path 23 (Mem)",
      "entityType": "TreePath",
      "attributesJson": "{\"hebrew_letter\":\"Mem\",\"path_number\":23}"
    },
    {
      "id": "019e6bd2-7509-7871-a8e1-c655623ebad1",
      "name": "Path 24 (Nun)",
      "entityType": "TreePath",
      "attributesJson": "{\"hebrew_letter\":\"Nun\",\"path_number\":24}"
    },
    {
      "id": "019e6bd2-7509-7ac1-b08c-b62fdfe013b2",
      "name": "Path 25 (Samekh)",
      "entityType": "TreePath",
      "attributesJson": "{\"hebrew_letter\":\"Samekh\",\"path_number\":25}"
    },
    {
      "id": "019e6bd2-7509-7d3a-90ac-616ad90e1825",
      "name": "Path 26 (Ayin)",
      "entityType": "TreePath",
      "attributesJson": "{\"hebrew_letter\":\"Ayin\",\"path_number\":26}"
    },
    {
      "id": "019e6bd2-750a-7662-9faf-e7a313916482",
      "name": "Path 30 (Resh)",
      "entityType": "TreePath",
      "attributesJson": "{\"hebrew_letter\":\"Resh\",\"path_number\":30}"
    },
    {
      "id": "019e6bd2-750a-7915-858d-7b6346367960",
      "name": "Path 31 (Shin)",
      "entityType": "TreePath",
      "attributesJson": "{\"hebrew_letter\":\"Shin\",\"path_number\":31}"
    },
    {
      "id": "019e6bd2-750b-776d-bd2f-41ad9d364239",
      "name": "Geburah of Atziluth",
      "entityType": "Sephirah",
      "attributesJson": "{\"number\":5,\"world\":\"Atziluth\"}"
    }
  ],
  "paths": [
    {
      "parentId": "019e6bd2-74f6-7909-aa09-1301ad9df3a1",
      "childId": "019e6bd2-74e5-7314-9d7e-9afed6e520c6",
      "relationType": "BELONGS_TO_ELEMENT",
      "depth": 1,
      "weight": 1
    },
    {
      "parentId": "019e6bd2-74f6-7909-aa09-1301ad9df3a1",
      "childId": "019e6bd2-750b-776d-bd2f-41ad9d364239",
      "relationType": "MANIFESTS_IN",
      "depth": 1,
      "weight": 1.5
    },
    {
      "parentId": "019e6bd2-74f6-7909-aa09-1301ad9df3a1",
      "childId": "019e6bd2-7501-7eea-aa02-12b355c7f5ee",
      "relationType": "RULES_DECAN",
      "depth": 1,
      "weight": 2
    },
    {
      "parentId": "019e6bd2-7501-7eea-aa02-12b355c7f5ee",
      "childId": "019e6bd2-7506-74a5-8139-c938734beb4d",
      "relationType": "PART_OF_SIGN",
      "depth": 2,
      "weight": 1
    },
    {
      "parentId": "019e6bd2-750b-776d-bd2f-41ad9d364239",
      "childId": "019e6bd2-74eb-7e1c-a747-8aef7f5c7fde",
      "relationType": "MANIFESTATION_OF",
      "depth": 2,
      "weight": 1.5
    },
    {
      "parentId": "019e6bd2-74eb-7e1c-a747-8aef7f5c7fde",
      "childId": "019e6bd2-7509-73ed-abf7-2ea3ca1b0457",
      "relationType": "FLOWS_INTO_PATH",
      "depth": 3,
      "weight": 1
    },
    {
      "parentId": "019e6bd2-74eb-7e1c-a747-8aef7f5c7fde",
      "childId": "019e6bd2-7509-7632-be67-7cb86414eb9d",
      "relationType": "FLOWS_INTO_PATH",
      "depth": 3,
      "weight": 1
    },
    {
      "parentId": "019e6bd2-7509-73ed-abf7-2ea3ca1b0457",
      "childId": "019e6bd2-74ec-716a-b0ec-34730e4efa8d",
      "relationType": "LEADS_TO_SEPHIRAH",
      "depth": 4,
      "weight": 1
    },
    {
      "parentId": "019e6bd2-7509-73ed-abf7-2ea3ca1b0457",
      "childId": "019e6bd2-74f1-72ce-95b3-bc93a447385a",
      "relationType": "EMBODIES_TAROT",
      "depth": 4,
      "weight": 1
    },
    {
      "parentId": "019e6bd2-7509-7632-be67-7cb86414eb9d",
      "childId": "019e6bd2-74ec-77ee-b5ed-fef9379b8822",
      "relationType": "LEADS_TO_SEPHIRAH",
      "depth": 4,
      "weight": 1
    },
    {
      "parentId": "019e6bd2-7509-7632-be67-7cb86414eb9d",
      "childId": "019e6bd2-74f1-75b5-9c67-28d11e7b6f11",
      "relationType": "EMBODIES_TAROT",
      "depth": 4,
      "weight": 1
    },
    {
      "parentId": "019e6bd2-74ec-716a-b0ec-34730e4efa8d",
      "childId": "019e6bd2-7509-7871-a8e1-c655623ebad1",
      "relationType": "FLOWS_INTO_PATH",
      "depth": 5,
      "weight": 1
    },
    {
      "parentId": "019e6bd2-74ec-716a-b0ec-34730e4efa8d",
      "childId": "019e6bd2-7509-7ac1-b08c-b62fdfe013b2",
      "relationType": "FLOWS_INTO_PATH",
      "depth": 5,
      "weight": 1
    },
    {
      "parentId": "019e6bd2-74ec-716a-b0ec-34730e4efa8d",
      "childId": "019e6bd2-7509-7d3a-90ac-616ad90e1825",
      "relationType": "FLOWS_INTO_PATH",
      "depth": 5,
      "weight": 1
    },
    {
      "parentId": "019e6bd2-74ec-77ee-b5ed-fef9379b8822",
      "childId": "019e6bd2-750a-7662-9faf-e7a313916482",
      "relationType": "FLOWS_INTO_PATH",
      "depth": 5,
      "weight": 1
    },
    {
      "parentId": "019e6bd2-74ec-77ee-b5ed-fef9379b8822",
      "childId": "019e6bd2-750a-7915-858d-7b6346367960",
      "relationType": "FLOWS_INTO_PATH",
      "depth": 5,
      "weight": 1
    }
  ]
}
profile
Peter J Online Space - since July 2020 | 아무데서나 채용해줬으면 좋겠다

0개의 댓글