관계형 로직의 데이터베이스 - 로직

Pt J·2일 전
post-thumbnail

관계형 로직의 데이터베이스 - 로직

설정 파일

Python과 Rust가 공유할 엄격한 명세서를 작성한다.
이것을 통해 Python과 Rust에서 프로토콜 버퍼 모듈을 생성하여 통신하게 된다.

proto/saju.proto

syntax = "proto3";

package saju;

// 사주 서비스 정의
service SajuEngine {
  rpc Register (RegisterRequest) returns (RegisterResponse);
  rpc GetProfile (ProfileRequest) returns (ProfileResponse);
  rpc CheckDbAlive (DbStatusRequest) returns (DbStatusResponse);
}

message RegisterRequest {
  string name = 1;
  string gender = 2;
  string birth_date = 3; // ISO 8601 String
  bool is_solar = 4;
}

message RegisterResponse {
  bool success = 1;
  string msg = 2;
}

message ProfileRequest {
  string name = 1;
}

message GanjiMetadata {
  int32 code = 1;
  string name_ko = 2;
  string name_hani = 3;
  string type_name = 4;
  string element = 5;
  string yin_yang = 6;
}

message ProfileResponse {
  string name = 1;
  string birth_date = 2;
  repeated GanjiMetadata ganjis = 3; // repeated는 Vec/List를 의미
  repeated string ten_gods = 4;
  string analysis_json = 5;           // JSONB 데이터를 문자열로 직렬화하여 전송
}

message DbStatusRequest {}

message DbStatusResponse {
  bool is_alive = 1;
}

Rust 설정 파일에 필요한 크레이트들을 적절한 버전으로 추가해 준다.

saju-engine/Cargo.toml

[package]
name = "saju-engine"
version = "0.1.0"
edition = "2024"

[dependencies]
# gRPC 및 Protobuf 통신 핵심 의존성
tonic = "0.14"
prost = "0.14"
tonic-prost = "0.14"

# 비동기 런타임 및 가반 시스템
tokio = { version = "1.52", features = ["full"] }
chrono = { version = "0.4", features = ["serde"] }

# 데이터베이스 및 직렬화
dotenvy = "0.15"
uuid = { version = "1.23", features = ["v7", "serde"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "macros", "uuid", "chrono", "json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

# 로그 수집
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-appender = "0.2"

# 천문 연산
swiss-eph = "0.2"

[build-dependencies]
# 빌드 시점에 saju.proto를 Rust 코드로 컴파일하는 도구
tonic-build = "0.14"
tonic-prost-build = "0.14"

프로젝트 루트에 proto 파일의 경로를 담아 build.rs 파일을 생성해 주어야
cargo build 명령어를 사용하여 빌드했을 때
src/main.rs 에서 사용할 수 있는 gRPC 코드가 자동 생성된다.

saju-engine/build.rs

fn main() -> Result<(), Box<dyn std::error::Error>> {
    tonic_prost_build::configure()
        .compile_protos(
            &["../proto/saju.proto"],
            &["../proto"]
        )?;
    Ok(())
}

로직 구현

데이터 모델

사용되는 모든 데이터 모델을 모아 놓는다.
서로 연관되어 있는 데이터는 이곳저곳에 분산되어 있으면
관리하기 어려워지므로 한 곳에 모아놓는 게 좋다.

saju-engine/src/models.rs

use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use chrono::{DateTime, Utc};

/// 간지 메타데이터 for SajuProfile
/// table: ganji_metadata
#[derive(Debug, Serialize, Deserialize, FromRow, Clone)]
pub struct GanjiMetadata {
    pub code: i16, // 0~9, 10~21
    pub name_ko: String, // 간지 이름 (한글)
    pub name_hani: String, // 간지 이름 (한자)
    pub type_name: String, // '천간' / '지지'
    pub element: String, // '목' / '화' / '토' / '금' / '수'
    pub yin_yang: String, // '음' / '양'
}

/// 십성 구분 for SajuProfile
#[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Copy)]
pub enum TenGods {
    BiGyeon, // 비견
    GeopJae, // 겁재
    SikShin, // 식신
    SangGwan, // 상관
    PyeonJae, // 편재
    JeongJae, // 정재
    PyeonGwan, // 편관
    JeongGwan, // 정관
    PyeonIn, // 편인
    JeongIn, // 정인
}

/// 오행 점수 for SajuAnalysis
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ElementScore {
    pub wood: i32,
    pub fire: i32,
    pub earth: i32,
    pub metal: i32,
    pub water: i32,
}

/// 신강/신약 여부 for SajuAnalysis
#[derive(Debug, Serialize, Deserialize, Clone)]
pub enum Strength {
    Strong(i32), // 신강
    Weak(i32), // 신약
    Neutral(i32), // 보통
}

/// 관계 분석 데이터 for SajuAnalysis
/// table: ganji_interaction + interaction_groups + interaction_metadata
#[derive(Debug, Serialize, Deserialize)]
pub struct InteractionReport {
    pub title: String, // 관계 이름
    pub category: String, // 상호작용 유형
    pub keywords: Vec<String>, // 핵심 에너지 목록
    pub interpretation: String, // 인문학적 해석
}

/// 분석 결과 전체 데이터
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct SaJuProfile {
    pub name: String, // 사용자 이름
    pub gender: String, // 성별
    pub birth_date: DateTime<Utc>, // 생년월일시
    pub is_solar: bool, // 양력 여부

    pub ganji_data: Vec<GanjiMetadata>, // 사주의 여덟 글자에 대한 메타데이터
    pub ten_gods: Vec<TenGods>, // 사주의 여덟 글자의 십성
    pub analysis_result: serde_json::Value, // 사주 분석 결과 JSON
}

만세력 변환

생년월일시를 사주의 여덟 글자로 변환하는 코드를 작성한다.
생일이 음력인 경우 양력으로 변환하여 계산한다.

saju-engine/src/saju.rs

use swiss_eph::safe;
use chrono::{DateTime, Utc, Datelike, Timelike, TimeZone};

/// 율리우스일 계산
fn find_jd(year: i32, target_longitude: f64, start_month: i32) -> f64 {
    let mut jd = safe::julday(year, start_month, 1, 0.0);
    let flags = swiss_eph::SEFLG_SPEED;

    for _ in 0..10 {
        let res = safe::calc_ut(jd, swiss_eph::SE_SUN, flags).unwrap();
        let diff = (res.longitude - target_longitude + 180.0).rem_euclid(360.0) - 180.0;

        // 오차가 충분히 작아질 때까지만 반복
        if diff.abs() < 0.00001 {
            break;
        }
        jd -= diff / 0.9856;
    }
    jd
}

/// 천문학적 합삭 추적 (음력 초하루)
/// 태양과 달의 황경 차이가 0이 되는 정밀한 율리우스일 반환
fn find_new_moon_near(approx_jd: f64) -> f64 {
    let mut jd = approx_jd;
    let flags = 0;

    for _ in 0..15 {
        let sun = safe::calc_ut(jd, swiss_eph::SE_SUN, flags).unwrap();
        let moon = safe::calc_ut(jd, swiss_eph::SE_MOON, flags).unwrap();

        let mut diff = (moon.longitude - sun.longitude).rem_euclid(360.0);
        if diff > 180.0 {
            diff -= 360.0;
        }

        // 오차가 충분히 작아질 때까지만 반복
        if diff.abs() < 0.00001 {
            break;
        }
        // 달이 태양보다 하루에 약 12.19도 더 빠르게 이동함을 기준으로 보정
        jd -= diff / 12.19075;
    }
    jd
}

/// 음력 생년월일을 양력 생년월일로 변환
fn convert_lunar_to_solar(lunar_date: DateTime<Utc>) -> DateTime<Utc> {
    let year = lunar_date.year();
    let month = lunar_date.month() as i32;
    let day = lunar_date.day() as f64;

    // 해당 연도 1월 1일을 기준, 음력 1월 초하루는 약 한 달 뒤에 있다
    let base_jd = safe::julday(year, 1, 1, 0.0);
    let mut current_new_moon = find_new_moon_near(base_jd + 30.0);

    // 생월을 기준으로 음력 월의 초하루 율리우스일 계산
    for _ in 1..month {
        current_new_moon = find_new_moon_near(current_new_moon + 29.53); // 삭망월 주기 반영
    }

    // 초하루(1일) 시점에 생일을 기준으로 일수(day - 1)를 더한다
    let solar_jd = current_new_moon + (day - 1.0) 
        + (lunar_date.hour() as f64 / 24.0) 
        + (lunar_date.minute() as f64 / 1440.0);

    // 율리우스일을 양력 날짜로 변환
    let (y, m, d, hour_fraction) = safe::revjul(solar_jd);
    let total_minutes = (hour_fraction * 24.0 * 60.0).round() as u32;
    let h = total_minutes / 60;
    let min = total_minutes % 60;

    Utc.with_ymd_and_hms(y, m as u32, d as u32, h, min, 0).unwrap()
}

/// 생년월일시를 입력받아 여덟 개 글자로 변환
pub fn calculate_saju(birth_date: DateTime<Utc>, is_solar: bool) -> [i16; 8] {
    // 음력일 경우 양력 시간선으로 정밀 치환 후 프로세스 진행
    let solar_birth_date = if is_solar {
        birth_date
    } else {
        convert_lunar_to_solar(birth_date)
    };

    // 서울 표준시(KST)와 서울 지방시(LST)의 오차 -30분 보정 적용
    let target = solar_birth_date - chrono::Duration::minutes(30);

    let target_jd = safe::julday(
        target.year(),
        target.month() as i32,
        target.day() as i32,
        target.hour() as f64 + target.minute() as f64 / 60.0,
    );

    //                   0:인    1:묘   2:진   3:사  4:오   5:미   6:신    7:유    8:술   9:해    10:자  11:축
    //                   0:입춘  1:경칩  2:청명 3:입하 4:망종 5:소서  6:입추  7:백로   8:한로  9:입동  10:대설 11:소한
    let jeolgi_points = [315.0, 345.0, 15.0, 45.0, 75.0, 105.0, 135.0, 165.0, 195.0, 225.0, 255.0, 285.0];

    // 연주 계산

    let mut year = target.year();
    let ipchun = find_jd(year, jeolgi_points[0], 2);

    // 해당 연도 입춘 전에 태어난 사람은 전년도로 계산한다
    if target_jd < ipchun {
        year -= 1;
    }

    let year_gan = ((year - 4) % 10).rem_euclid(10) as i16;
    let year_ji = ((year - 4) % 12).rem_euclid(12) as i16 + 10;

    // 월주 계산

    let mut month_idx = 0;
    for i in 0..12 {
        let search_year = if i == 11 { year + 1 } else { year };
        let start_month = ((i as i32 + 1) % 12) + 1;

        let jeolgi_jd = find_jd(search_year, jeolgi_points[i], start_month);

        // 월 구분 기준이 되는 절기가 사용자 생일을 넘긴 순간, 사용자 생일은 이전 달에 있던 게 된다.
        if target_jd < jeolgi_jd {
            month_idx = if i == 0 { 11 } else { i - 1 };
            break;
        }
        // 루프 끝까지 돌 경우 11(축월)로 마무리
        month_idx = i;
    }

    let month_gan = ((year_gan * 2 + 2 + month_idx as i16) % 10) as i16;
    let month_ji = ((month_idx as i16 + 2) % 12) + 10;

    // 일주 계산: 글로벌 표준 천문학 일수 변환 매크로 적용

    let day_num = (target_jd + 0.5).floor() as i64;
    let ganji_idx = (day_num + 49) % 60;

    let day_gan = (ganji_idx % 10) as i16;
    let day_ji = ((ganji_idx % 12) + 10) as i16;

    // 시주 계산

    let hour_idx = (((target.hour() as i16 + 1) % 24) / 2) as i16;

    let hour_gan = ((day_gan * 2 + hour_idx) % 10) as i16;
    let hour_ji = hour_idx + 10;

    [year_gan, year_ji, month_gan, month_ji, day_gan, day_ji, hour_gan, hour_ji]
}

사주 분석

사주 분석 부분은 모듈화하여 작성한다.

saju-engine/analysis/mod.rs

pub mod interaction;
pub mod pattern;

DB에서 관계를 뽑아내지 않고 글자들 자체로 얻을 수 있는 분석을
한 파일에 모아 놓는다.

saju-engine/analysis/pattern.rs

use crate::models::{ElementScore, Strength, TenGods};

/// 각 글자의 오행 판단
fn get_element_from_code(code: i16) -> i32 {
    // 0: 목, 1: 화, 2: 토, 3: 금, 4: 수
    match code {
        0 | 1 | 12 | 13 => 0,
        2 | 3 | 15 | 16 => 1,
        4 | 5 | 11 | 14 | 17 | 20 => 2,
        6 | 7 | 18 | 19 => 3,
        8 | 9 | 21 | 10 => 4,
        _ => -1,
    }
}

/// 오행 점수 계산
pub fn calculate_element_scores(codes: &[i16; 8]) -> ElementScore {
    let mut score = ElementScore {
        wood: 0,
        fire: 0,
        earth: 0,
        metal: 0,
        water: 0,
    };

    let weights = [10, 10, 10, 25, 15, 10, 10, 10];

    for (code, weight) in codes.iter().zip(weights.iter()) {
        let element = get_element_from_code(*code);
        match element {
            0 => score.wood += weight,
            1 => score.fire += weight,
            2 => score.earth += weight,
            3 => score.metal += weight,
            4 => score.water += weight,
            _ => {},
        }
    }

    score
}

/// 신강/신약 여부 판단
pub fn determine_strength(day_gan: i16, scores: &ElementScore) -> Strength {
    let self_element = get_element_from_code(day_gan);

    // 일간과 같은 오행 + 그것을 생하는 오행
    let supporter_score = match self_element {
        0 => scores.wood + scores.water,
        1 => scores.fire + scores.wood,
        2 => scores.earth + scores.fire,
        3 => scores.metal + scores.earth,
        4 => scores.water + scores.metal,
        _ => 0,
    };

    if supporter_score >= 55 {
        Strength::Strong(supporter_score)
    } else if supporter_score <= 45 {
        Strength::Weak(supporter_score)
    } else {
        Strength::Neutral(supporter_score)
    }
}

/// 일간과 비교하여 각 글자의 십성 판단
pub fn map_ten_gods(day_gan: i16, target: i16) -> TenGods {
    let self_element = get_element_from_code(day_gan);
    let target_element = get_element_from_code(target);

    let is_same_yin_yang = (day_gan % 2) == (target % 2);
    let relation = (target_element - self_element).rem_euclid(5);

    match (relation, is_same_yin_yang) {
        (0, true) => TenGods::BiGyeon,
        (0, false) => TenGods::GeopJae,
        (1, true) => TenGods::SikShin,
        (1, false) => TenGods::SangGwan,
        (2, true) => TenGods::PyeonJae,
        (2, false) => TenGods::JeongJae,
        (3, true) => TenGods::PyeonGwan,
        (3, false) => TenGods::JeongGwan,
        (4, true) => TenGods::PyeonIn,
        (4, false) => TenGods::JeongIn,
        _ => unreachable!(),
    }
}

// 십성 한글로 변환
impl TenGods {
    pub fn as_ko_str(&self) -> &'static str {
        match self {
            TenGods::BiGyeon => "비견",
            TenGods::GeopJae => "겁재",
            TenGods::SikShin => "식신",
            TenGods::SangGwan => "상관",
            TenGods::PyeonJae => "편재",
            TenGods::JeongJae => "정재",
            TenGods::PyeonGwan => "편관",
            TenGods::JeongGwan => "정관",
            TenGods::PyeonIn => "편인",
            TenGods::JeongIn => "정인",
        }
    }
}

DB에서 관계를 뽑아내어 분석하는 코드도 별개의 파일에 작성한다.
관계는 둘 이상의 글자가 만나야 의미가 있으므로
중복 없이 입력한 글자들을 입력으로 넣어 둘 이상이 나온 관계만 취급한다.

사주 분석은 여기서 모두 다루기 복잡하므로
쟁합(여러 개의 양간이 하나의 음간을 차지하려고 다투는 형국)이나
투합(여러 개의 음간이 하나의 양간을 차지하려고 다투는 형국) 같은 건 생략하겠다.

saju-engine/analysis/interation.rs

use sqlx::PgPool;

use crate::models::InteractionReport;

/// 사주 코드에 포함된 관계를 뽑아내어 반환
#[tracing::instrument(skip(pool))]
pub async fn find_interactions(
    pool: &PgPool,
    codes: &[i16]
) -> Result<Vec<InteractionReport>, sqlx::Error> {
    let reports = sqlx::query_as!(
        InteractionReport,
        r#"
        SELECT
            metadata.title as "title!",
            groups.category as "category!",
            metadata.keyword as "keywords!",
            metadata.interpretation as "interpretation!"
        FROM ganji_interaction interaction
        JOIN interaction_groups groups ON interaction.group_id = groups.id
        JOIN interaction_metadata metadata ON interaction.group_id = metadata.group_id
        WHERE interaction.code = ANY($1)
        GROUP BY groups.id, metadata.id, metadata.title, groups.category, metadata.keyword, metadata.interpretation
        HAVING COUNT(interaction.id) >= 2
        ORDER BY groups.id ASC
        "#,
        codes
    )
    .fetch_all(pool)
    .await?;

    Ok(reports)
}

메인 로직

사용자 정보를 받아 분석하고 DB에 결과를 저장하는 함수와
기존에 분석한 바 있는 사용자 정보를 불러오는 함수를 작성한다.

각 글자는 숫자 코드의 형태로 전달 시 사용자가 알아보기 어려우므로
메타데이터로 변환하여 반환한다.

saju-engine/src/service.rs

use sqlx::PgPool;
use chrono::{DateTime, Utc};

use crate::saju;
use crate::analysis::{interaction, pattern};
use crate::models::{GanjiMetadata, SaJuProfile};

#[derive(Debug)]
pub enum SajuError {
    NotFound,
    DatabaseError(sqlx::Error),
}

// 글자 코드를 간지 메타데이터로 변환
#[tracing::instrument(skip(pool))]
pub async fn fetch_ganji_metadata(
    pool: &PgPool,
    codes: &[i16; 8]
) -> Result<Vec<GanjiMetadata>, sqlx::Error> {
    let raw_data = sqlx::query_as!(
        GanjiMetadata,
        r#"
        SELECT code, name_ko, name_hani, 
            type as "type_name!",
            element::text as "element!",
            yin_yang::text as "yin_yang!"
        FROM ganji_metadata
        WHERE code = ANY($1)
        "#,
        codes
    )
    .fetch_all(pool)
    .await?;

    let lookup: HashMap<i16, GanjiMetadata> = raw_data
        .into_iter()
        .map(|g| (g.code, g))
        .collect();

    let mut ordered_ganji = Vec::with_capacity(8);
    for &code in codes {
        if let Some(meta) = lookup.get(&code).cloned() {
            ordered_ganji.push(meta);
        }
    }

    Ok(ordered_ganji)
}

// [입력] 사용자 정보를 받아 분석하고 DB에 저장
#[tracing::instrument(skip(pool))]
pub async fn register_user_analysis(
    pool: &PgPool,
    name: &str,
    gender: &str,
    birth_date: DateTime<Utc>,
    is_solar: bool,
) -> Result<(), SajuError> {
    let mut tx = pool.begin().await
        .map_err(SajuError::DatabaseError)?;

    let codes = saju::calculate_saju(birth_date, is_solar);

    let element_scores = pattern::calculate_element_scores(&codes);
    let strength = pattern::determine_strength(codes[4], &element_scores);

    let interactions = interaction::find_interactions(pool, &codes).await
        .map_err(SajuError::DatabaseError)?;

    let analysis_result = serde_json::json!({
        "element_scores": element_scores,
        "strength": strength,
        "interactions": interactions
    });

    sqlx::query!(
        r#"
        INSERT  INTO saju_profiles (name, gender, birth_date, is_solar,
            year_gan, year_ji, month_gan, month_ji, day_gan, day_ji,
            hour_gan, hour_ji, analysis_result)
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
        "#,
        name, gender, birth_date, is_solar,
        codes[0], codes[1], codes[2], codes[3], codes[4], codes[5],
        codes[6], codes[7], analysis_result
    )
    .execute(&mut *tx)
    .await
    .map_err(SajuError::DatabaseError)?;

    tx.commit().await.map_err(SajuError::DatabaseError)?;
    Ok(())
}

// [출력] 사용자 정보를 받아 DB에 저장된 정보 출력
#[tracing::instrument(skip(pool))]
pub async fn get_analysis_by_user(
    pool: &PgPool,
    name: &str,
) -> Result<SaJuProfile, SajuError> {
    let profile = sqlx::query!(
        r#"
        SELECT name, gender, birth_date, is_solar, year_gan, year_ji,
            month_gan, month_ji, day_gan, day_ji, hour_gan, hour_ji, 
            analysis_result
        FROM saju_profiles
        WHERE name = $1
        "#,
        name
    )
    .fetch_optional(pool)
    .await
    .map_err(SajuError::DatabaseError)?
    .ok_or(SajuError::NotFound)?;

    let codes = [
        profile.year_gan.unwrap(), profile.year_ji.unwrap(),
        profile.month_gan.unwrap(), profile.month_ji.unwrap(),
        profile.day_gan.unwrap(), profile.day_ji.unwrap(),
        profile.hour_gan.unwrap(), profile.hour_ji.unwrap(),
    ];

    let ganji_data = fetch_ganji_metadata(pool, &codes).await
        .map_err(SajuError::DatabaseError)?;

    let ten_gods: Vec<_> = codes.iter()
        .map(|&c| pattern::map_ten_gods(codes[4], c))
        .collect();

    Ok(SaJuProfile {
        name: profile.name,
        gender: profile.gender.unwrap_or_else(|| "M".to_string()),
        birth_date: profile.birth_date,
        is_solar: profile.is_solar.unwrap_or(true),
        ganji_data: ganji_data,
        ten_gods: ten_gods,
        analysis_result: profile.analysis_result.unwrap_or_default(),
    })
}

gRPC 구동 서버

saju-engine/src/main.rs

mod db;
mod logger;
mod models;
mod saju;
mod analysis;
mod service;

use tonic::{transport::Server, Request, Response, Status};
use chrono::DateTime;
use std::net::SocketAddr;

// proto/saju.proto 와 build.rs로 생성된 프로토컬 버퍼 모듈 로드
pub mod saju_proto {
    tonic::include_proto!("saju");
}

use saju_proto::saju_engine_server::{SajuEngine, SajuEngineServer};
use saju_proto::{
    RegisterRequest, RegisterResponse,
    ProfileRequest, ProfileResponse, GanjiMetadata,
    DbStatusRequest, DbStatusResponse
};

pub struct MySajuEngine {
    pool: sqlx::PgPool,
}

#[tonic::async_trait]
impl SajuEngine for MySajuEngine {
    // 등록 RPC
    async fn register(
        &self,
        request: Request<RegisterRequest>,
    ) -> Result<Response<RegisterResponse>, Status> {
        let req = request.into_inner();
        let birth_date = DateTime::parse_from_rfc3339(&req.birth_date)
            .map_err(|_| Status::invalid_argument("유효하지 않은 날짜 형식"))?
            .with_timezone(&chrono::Utc);

        service::register_user_analysis(&self.pool, &req.name, &req.gender, birth_date, req.is_solar)
            .await
            .map_err(|e| Status::internal(format!("등록 실패: {:?}", e)))?;

        Ok(Response::new(RegisterResponse {
            success: true,
            msg: "gRPC를 통해 성공적으로 등록 완료".into(),
        }))
    }

    // 조회 RPC
    async fn get_profile(
        &self,
        request: Request<ProfileRequest>,
    ) -> Result<Response<ProfileResponse>, Status> {
        let req = request.into_inner();

        let profile = service::get_analysis_by_user(&self.pool, &req.name)
            .await
            .map_err(|e| match e {
                service::SajuError::NotFound => Status::not_found("존재하지 않는 사용자"),
                service::SajuError::DatabaseError(db_err) => Status::internal(db_err.to_string()),
            })?;

        let grpc_ganjis = profile.ganji_data.into_iter().map(|g| GanjiMetadata {
            code: g.code as i32,
            name_ko: g.name_ko,
            name_hani: g.name_hani,
            type_name: g.type_name,
            element: g.element,
            yin_yang: g.yin_yang,
        }).collect();

        let grpc_ten_gods: Vec<String> = profile.ten_gods.into_iter()
            .map(|s| s.as_ko_str().to_string())
            .collect();

        Ok(Response::new(ProfileResponse {
            name: profile.name,
            birth_date: profile.birth_date.to_rfc3339(),
            ganjis: grpc_ganjis,
            ten_gods: grpc_ten_gods,
            analysis_json: profile.analysis_result.to_string(),
        }))
    }

    // DB 헬스체크 RPC
    async fn check_db_alive(
        &self,
        _request: Request<DbStatusRequest>,
    ) -> Result<Response<DbStatusResponse>, Status> {
        let alive = db::is_alive();

        Ok(Response::new(DbStatusResponse {
            is_alive: alive
        }))
    }
}

sync fn shutdown_signal() {
    tokio::signal::ctrl_c()
        .await
        .expect("Ctrl+C 신호 핸들러 등록 실패");
  
    tracing::info!("Ctrl+C 감지");
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    dotenvy::dotenv().ok();

    let _log_guard = logger::init_tracing();
    tracing::info!("사주 분석 엔진 내부 시스템 초기화 시작");

    let db_url = std::env::var("DATABASE_URL")
        .expect("환경변수 DATABASE_URL 설정 필요");
    db::connect(&db_url).await?;

    let pool = db::DB_POOL.get()
        .expect("DB 커넥션 풀 로드 실패")
        .clone();

    // gRPC 네이티브 네트워킹 설정 및 서버 기동
    let addr: SocketAddr = "127.0.0.1:50051".parse()?;
    let engine = MySajuEngine { pool };

    tracing::info!("gRPC 통신용 바이너리 프로토콜 채널 활성화: {}", addr);

    Server::builder()
        .add_service(SajuEngineServer::new(engine))
        .serve_with_shutdown(addr, shutdown_signal())
        .await?;

    // 서버 연동 종료 절차
    db::close().await;
    Ok(())
}

빌드 및 실행

saju-engine/build.rs 가 정상 작동하려면
OS 레벨에 protoc가 설치되어 있어야 하므로
다음 명령어를 통해 설치한다. (Mac 기준)

~/workspace/saju-analysis$ brew install protobuf

도커 서비스를 실행하고 Rust 엔진을 컴파일 및 실행한다.

~/workspace/saju-analysis$ cd saju-engine
~/workspace/saju-analysis/saju-engine$ docker-compose up -d
~/workspace/saju-analysis/saju-engine$ cargo build --release
~/workspace/saju-analysis/saju-engine$ cargo run

터미널에서 gRPC 작동을 확인하기 위해서는 grpcurl이 필요하다.
새 터미널에서 grpcurl을 설치하고 명령어를 입력해 보자.

~/workspace/saju-analysis/saju-engine$ brew install grpcurl
~/workspace/saju-analysis/saju-engine$ grpcurl -plaintext \
  -proto ../proto/saju.proto \
  '127.0.0.1:50051' \  
  saju.SajuEngine/CheckDbAlive

{
  "isAlive": true
}
~/workspace/saju-analysis/saju-engine$ grpcurl -plaintext \
  -proto ../proto/saju.proto \
  -d '{"name": "Daniil", "gender": "M", "birth_date": "1999-01-08T06:00:00Z", "is_solar": true}' \
  '127.0.0.1:50051' \  
  saju.SajuEngine/Register

{
  "success": true,
  "msg": "gRPC를 통해 성공적으로 등록 완료"
}
~/workspace/saju-analysis/saju-engine$ grpcurl -plaintext \ 
  -proto ../proto/saju.proto \
  -d '{"name": "Daniil"}' \
  '127.0.0.1:50051' \
  saju.SajuEngine/GetProfile

{
  "name": "Daniil",
  "birthDate": "1999-01-08T06:00:00+00:00",
  "ganjis": [
    {
      "code": 4,
      "nameKo": "무",
      "nameHani": "戊",
      "typeName": "천간",
      "element": "토",
      "yinYang": "양"
    },
    {
      "code": 12,
      "nameKo": "인",
      "nameHani": "寅",
      "typeName": "지지",
      "element": "목",
      "yinYang": "양"
    },
    {
      "code": 1,
      "nameKo": "을",
      "nameHani": "乙",
      "typeName": "천간",
      "element": "목",
      "yinYang": "음"
    },
    {
      "code": 11,
      "nameKo": "축",
      "nameHani": "丑",
      "typeName": "지지",
      "element": "토",
      "yinYang": "음"
    },
    {
      "code": 6,
      "nameKo": "경",
      "nameHani": "庚",
      "typeName": "천간",
      "element": "금",
      "yinYang": "양"
    },
    {
      "code": 18,
      "nameKo": "신",
      "nameHani": "申",
      "typeName": "지지",
      "element": "금",
      "yinYang": "양"
    },
    {
      "code": 5,
      "nameKo": "기",
      "nameHani": "己",
      "typeName": "천간",
      "element": "토",
      "yinYang": "음"
    },
    {
      "code": 13,
      "nameKo": "묘",
      "nameHani": "卯",
      "typeName": "지지",
      "element": "목",
      "yinYang": "음"
    }
  ],
  "tenGods": [
    "편인",
    "편재",
    "정재",
    "정인",
    "비견",
    "비견",
    "정인",
    "정재"
  ],
  "analysisJson": "{\"element_scores\":{\"earth\":45,\"fire\":0,\"metal\":25,\"water\":0,\"wood\":30},\"interactions\":[{\"category\":\"천간합\",\"interpretation\":\"유연한 을목과 강직한 경금이 만나 서로를 보완하며 강한 결속력과 의리를 발휘합니다.\",\"keywords\":[\"정의\",\"결속\",\"의리\"],\"title\":\"을경합(乙庚合): 인의의 합\"},{\"category\":\"지지방합\",\"interpretation\":\"계절적 동질성을 가진 강력한 목기가 형성되어 거침없는 시작과 추진력을 보여줍니다.\",\"keywords\":[\"생동\",\"추진\",\"동료\"],\"title\":\"인묘진(寅卯辰) 방합: 봄의 세력\"},{\"category\":\"지지육충\",\"interpretation\":\"시작과 결실의 기운이 부딪혀 매우 활동적이며 거주지나 직업의 변동이 잦을 수 있습니다.\",\"keywords\":[\"활동\",\"변화\",\"속도\"],\"title\":\"인신충(寅申沖): 역마의 정면충돌\"},{\"category\":\"지지형\",\"interpretation\":\"강력한 세 기운이 얽혀 시시비비를 가려야 하니, 전문적인 제어 기술이나 리더십이 요구됩니다.\",\"keywords\":[\"조절\",\"수술\",\"기술\"],\"title\":\"인사신(寅巳申) 삼형: 권력의 제어\"}],\"strength\":{\"Strong\":70}}"
}

analysisJson 을 보기 좋게 정리하면 다음과 같다.

"analysisJson": "{
	\"element_scores\":{
        \"earth\":45,
        \"fire\":0,
        \"metal\":25,
        \"water\":0,
        \"wood\":30
    },
    \"interactions\":[
        {
            \"category\":\"천간합\",
            \"interpretation\":\"유연한 을목과 강직한 경금이 만나 서로를 보완하며 강한 결속력과 의리를 발휘합니다.\",
            \"keywords\":[\"정의\",\"결속\",\"의리\"],
            \"title\":\"을경합(乙庚合): 인의의 합\"
        },
        {
            \"category\":\"지지방합\",
            \"interpretation\":\"계절적 동질성을 가진 강력한 목기가 형성되어 거침없는 시작과 추진력을 보여줍니다.\",
            \"keywords\":[\"생동\",\"추진\",\"동료\"],
            \"title\":\"인묘진(寅卯辰) 방합: 봄의 세력\"
        },
        {
            \"category\":\"지지육충\",
            \"interpretation\":\"시작과 결실의 기운이 부딪혀 매우 활동적이며 거주지나 직업의 변동이 잦을 수 있습니다.\",
            \"keywords\":[\"활동\",\"변화\",\"속도\"],
            \"title\":\"인신충(寅申沖): 역마의 정면충돌\"
        },
        {
            \"category\":\"지지형\",
            \"interpretation\":\"강력한 세 기운이 얽혀 시시비비를 가려야 하니, 전문적인 제어 기술이나 리더십이 요구됩니다.\",
            \"keywords\":[\"조절\",\"수술\",\"기술\"],
            \"title\":\"인사신(寅巳申) 삼형: 권력의 제어\"
        }
    ],
    \"strength\":{
        \"Strong\":70
    }
}"

대충 살자 샘플 데이터에서 수정 안 해서 성별 잘못 들어간 다냐 데이터처럼

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

0개의 댓글