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

Pt J·2일 전
post-thumbnail

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

설정 파일

uv가 자동 생성한 명세서에 필요한 의존성을 작성한다.

saju-gateway/pyproject.toml

[project]
name = "saju-gateway"
version = "0.1.0"
description = "FastAPI 비동기 gRPC 게이트웨이 서비스"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "fastapi>=0.115.0",
    "orjson>=3.10.15",
    "uvicorn[standard]>=0.30.0",
    "pydantic>=2.9.0",
    "grpcio>=1.66.0",
    "grpcio-tools>=1.66.0",
]

[dependency-groups]
dev = [
    "black>=24.8.0",
]

uv 가상환경 런타임 내에서 상위 디렉토리의 명세를 읽어와
파이썬 모듈 파일로 자동 컴파일해 주는 쉘 스크립트를 작성한다.

saju-gateway/generate_proto.sh

#!/bin/bash

uv run python -m grpc_tools.protoc \
    -I../proto \
    --python_out=. \
    --grpc_python_out=. \
    ../proto/saju.proto

echo "✨ 파이썬용 gRPC 프로토버퍼 스텁(saju_pb2.py, saju_pb2_grpc.py) 생성 완료!"

작성한 쉘 스크립트는 다음과 같이 실행한다.

~/workspace/saju-analysis/saju-gateway$ chmod +x generate_proto.sh
~/workspace/saju-analysis/saju-gateway$ ./generate_proto.sh

쉘 스크립트를 실행하고 나면 몇 가지 파일이 생성된다.

~/workspace/saju-analysis/saju-gateway$ tree -a -I .venv -I .git
.
├── .gitignore
├── .python-version
├── README.md
├── generate_proto.sh
├── main.py
├── pyproject.toml
├── saju_pb2.py # 생성됨!
├── saju_pb2_grpc.py # 생성됨!
└── uv.lock # 생성됨!

생성된 Python 코드는 다음과 같다.

saju-gateway/saju_pb2.py

# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler.  DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: saju.proto
# Protobuf Python Version: 6.31.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
    _runtime_version.Domain.PUBLIC,
    6,
    31,
    1,
    '',
    'saju.proto'
)
# @@protoc_insertion_point(imports)

_sym_db = _symbol_database.Default()




DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\nsaju.proto\x12\x04saju\"U\n\x0fRegisterRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0e\n\x06gender\x18\x02 \x01(\t\x12\x12\n\nbirth_date\x18\x03 \x01(\t\x12\x10\n\x08is_solar\x18\x04 \x01(\x08\"0\n\x10RegisterResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0b\n\x03msg\x18\x02 \x01(\t\"\x1e\n\x0eProfileRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\"w\n\rGanjiMetadata\x12\x0c\n\x04\x63ode\x18\x01 \x01(\x05\x12\x0f\n\x07name_ko\x18\x02 \x01(\t\x12\x11\n\tname_hani\x18\x03 \x01(\t\x12\x11\n\ttype_name\x18\x04 \x01(\t\x12\x0f\n\x07\x65lement\x18\x05 \x01(\t\x12\x10\n\x08yin_yang\x18\x06 \x01(\t\"\x81\x01\n\x0fProfileResponse\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\nbirth_date\x18\x02 \x01(\t\x12#\n\x06ganjis\x18\x03 \x03(\x0b\x32\x13.saju.GanjiMetadata\x12\x10\n\x08ten_gods\x18\x04 \x03(\t\x12\x15\n\ranalysis_json\x18\x05 \x01(\t\"\x11\n\x0f\x44\x62StatusRequest\"$\n\x10\x44\x62StatusResponse\x12\x10\n\x08is_alive\x18\x01 \x01(\x08\x32\xc1\x01\n\nSajuEngine\x12\x39\n\x08Register\x12\x15.saju.RegisterRequest\x1a\x16.saju.RegisterResponse\x12\x39\n\nGetProfile\x12\x14.saju.ProfileRequest\x1a\x15.saju.ProfileResponse\x12=\n\x0c\x43heckDbAlive\x12\x15.saju.DbStatusRequest\x1a\x16.saju.DbStatusResponseb\x06proto3')

_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'saju_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
  DESCRIPTOR._loaded_options = None
  _globals['_REGISTERREQUEST']._serialized_start=20
  _globals['_REGISTERREQUEST']._serialized_end=105
  _globals['_REGISTERRESPONSE']._serialized_start=107
  _globals['_REGISTERRESPONSE']._serialized_end=155
  _globals['_PROFILEREQUEST']._serialized_start=157
  _globals['_PROFILEREQUEST']._serialized_end=187
  _globals['_GANJIMETADATA']._serialized_start=189
  _globals['_GANJIMETADATA']._serialized_end=308
  _globals['_PROFILERESPONSE']._serialized_start=311
  _globals['_PROFILERESPONSE']._serialized_end=440
  _globals['_DBSTATUSREQUEST']._serialized_start=442
  _globals['_DBSTATUSREQUEST']._serialized_end=459
  _globals['_DBSTATUSRESPONSE']._serialized_start=461
  _globals['_DBSTATUSRESPONSE']._serialized_end=497
  _globals['_SAJUENGINE']._serialized_start=500
  _globals['_SAJUENGINE']._serialized_end=693
# @@protoc_insertion_point(module_scope)

saju-gateway/saju_pb2_grpc.py

# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
import warnings

import saju_pb2 as saju__pb2

GRPC_GENERATED_VERSION = '1.80.0'
GRPC_VERSION = grpc.__version__
_version_not_supported = False

try:
    from grpc._utilities import first_version_is_lower
    _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
except ImportError:
    _version_not_supported = True

if _version_not_supported:
    raise RuntimeError(
        f'The grpc package installed is at version {GRPC_VERSION},'
        + ' but the generated code in saju_pb2_grpc.py depends on'
        + f' grpcio>={GRPC_GENERATED_VERSION}.'
        + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
        + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
    )


class SajuEngineStub(object):
    """사주 서비스 정의
    """

    def __init__(self, channel):
        """Constructor.

        Args:
            channel: A grpc.Channel.
        """
        self.Register = channel.unary_unary(
                '/saju.SajuEngine/Register',
                request_serializer=saju__pb2.RegisterRequest.SerializeToString,
                response_deserializer=saju__pb2.RegisterResponse.FromString,
                _registered_method=True)
        self.GetProfile = channel.unary_unary(
                '/saju.SajuEngine/GetProfile',
                request_serializer=saju__pb2.ProfileRequest.SerializeToString,
                response_deserializer=saju__pb2.ProfileResponse.FromString,
                _registered_method=True)
        self.CheckDbAlive = channel.unary_unary(
                '/saju.SajuEngine/CheckDbAlive',
                request_serializer=saju__pb2.DbStatusRequest.SerializeToString,
                response_deserializer=saju__pb2.DbStatusResponse.FromString,
                _registered_method=True)


class SajuEngineServicer(object):
    """사주 서비스 정의
    """

    def Register(self, request, context):
        """Missing associated documentation comment in .proto file."""
        context.set_code(grpc.StatusCode.UNIMPLEMENTED)
        context.set_details('Method not implemented!')
        raise NotImplementedError('Method not implemented!')

    def GetProfile(self, request, context):
        """Missing associated documentation comment in .proto file."""
        context.set_code(grpc.StatusCode.UNIMPLEMENTED)
        context.set_details('Method not implemented!')
        raise NotImplementedError('Method not implemented!')

    def CheckDbAlive(self, request, context):
        """Missing associated documentation comment in .proto file."""
        context.set_code(grpc.StatusCode.UNIMPLEMENTED)
        context.set_details('Method not implemented!')
        raise NotImplementedError('Method not implemented!')


def add_SajuEngineServicer_to_server(servicer, server):
    rpc_method_handlers = {
            'Register': grpc.unary_unary_rpc_method_handler(
                    servicer.Register,
                    request_deserializer=saju__pb2.RegisterRequest.FromString,
                    response_serializer=saju__pb2.RegisterResponse.SerializeToString,
            ),
            'GetProfile': grpc.unary_unary_rpc_method_handler(
                    servicer.GetProfile,
                    request_deserializer=saju__pb2.ProfileRequest.FromString,
                    response_serializer=saju__pb2.ProfileResponse.SerializeToString,
            ),
            'CheckDbAlive': grpc.unary_unary_rpc_method_handler(
                    servicer.CheckDbAlive,
                    request_deserializer=saju__pb2.DbStatusRequest.FromString,
                    response_serializer=saju__pb2.DbStatusResponse.SerializeToString,
            ),
    }
    generic_handler = grpc.method_handlers_generic_handler(
            'saju.SajuEngine', rpc_method_handlers)
    server.add_generic_rpc_handlers((generic_handler,))
    server.add_registered_method_handlers('saju.SajuEngine', rpc_method_handlers)


 # This class is part of an EXPERIMENTAL API.
class SajuEngine(object):
    """사주 서비스 정의
    """

    @staticmethod
    def Register(request,
            target,
            options=(),
            channel_credentials=None,
            call_credentials=None,
            insecure=False,
            compression=None,
            wait_for_ready=None,
            timeout=None,
            metadata=None):
        return grpc.experimental.unary_unary(
            request,
            target,
            '/saju.SajuEngine/Register',
            saju__pb2.RegisterRequest.SerializeToString,
            saju__pb2.RegisterResponse.FromString,
            options,
            channel_credentials,
            insecure,
            call_credentials,
            compression,
            wait_for_ready,
            timeout,
            metadata,
            _registered_method=True)

    @staticmethod
    def GetProfile(request,
            target,
            options=(),
            channel_credentials=None,
            call_credentials=None,
            insecure=False,
            compression=None,
            wait_for_ready=None,
            timeout=None,
            metadata=None):
        return grpc.experimental.unary_unary(
            request,
            target,
            '/saju.SajuEngine/GetProfile',
            saju__pb2.ProfileRequest.SerializeToString,
            saju__pb2.ProfileResponse.FromString,
            options,
            channel_credentials,
            insecure,
            call_credentials,
            compression,
            wait_for_ready,
            timeout,
            metadata,
            _registered_method=True)

    @staticmethod
    def CheckDbAlive(request,
            target,
            options=(),
            channel_credentials=None,
            call_credentials=None,
            insecure=False,
            compression=None,
            wait_for_ready=None,
            timeout=None,
            metadata=None):
        return grpc.experimental.unary_unary(
            request,
            target,
            '/saju.SajuEngine/CheckDbAlive',
            saju__pb2.DbStatusRequest.SerializeToString,
            saju__pb2.DbStatusResponse.FromString,
            options,
            channel_credentials,
            insecure,
            call_credentials,
            compression,
            wait_for_ready,
            timeout,
            metadata,
            _registered_method=True)

게이트웨이 구현

saju-gateway/main.py

from fastapi import FastAPI, HTTPException, status
from fastapi.responses import ORJSONResponse
from pydantic import BaseModel, Field
from datetime import datetime
import grpc
import json

import saju_pb2
import saju_pb2_grpc

class UTF8ORJSONResponse(ORJSONResponse):
    media_type = "application/json; charset=utf-8"

app = FastAPI(
    title="사주 마이크로서비스 아키텍처 게이트웨이",
    description="Python FastAPI(Entry) <-> Rust Tonic(Engine) 분산 시스템",
    version="1.0.0",
    default_response_class=UTF8ORJSONResponse
)

RUST_GRPC_TARGET = "127.0.0.1:50051"

class SajuRegisterRequest(BaseModel):
    name: str = Field(..., min_length=1, max_length=50, example="Daniil")
    gender: str = Field(..., pattern="^[MF]$", example="M")
    birth_date: datetime = Field(..., example="1999-01-08T06:00:00Z")
    is_solar: bool = True

@app.post("/api/analysis", status_code=status.HTTP_201_CREATED)
async def register_saju_profile(payload: SajuRegisterRequest):
    """
    사용자의 생년월일시 원국 정보를 수신하여 
    Rust 고성능 연산 엔진에 바이너리 RPC 스트림 전송 후 데이터베이스 적재를 수행합니다.
    """
    async with grpc.aio.insecure_channel(RUST_GRPC_TARGET) as channel:
        stub = saju_pb2_grpc.SajuEngineStub(channel)
        try:
            response = await stub.Register(
                saju_pb2.RegisterRequest(
                    name=payload.name,
                    gender=payload.gender,
                    birth_date=payload.birth_date.isoformat(),
                    is_solar=payload.is_solar
                )
            )
            return {"success": response.success, "message": response.msg}

        except grpc.aio.AioRpcError as e:
            raise HTTPException(
                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                detail=f"연산 엔진 코어 통신 에러: {e.details()}"
            )


@app.get("/api/profile", status_code=status.HTTP_200_OK)
async def get_saju_profile(name: str):
    """
    지정한 대상의 이름을 기반으로 사주 프로필 데이터를 복구합니다.
    Rust 메모리 레이어에서 정렬 순서가 완전 보장된 간지 및 십성 메타데이터 객체를 결합하여 반환합니다.
    """
    async with grpc.aio.insecure_channel(RUST_GRPC_TARGET) as channel:
        stub = saju_pb2_grpc.SajuEngineStub(channel)
        try:
            response = await stub.GetProfile(
                saju_pb2.ProfileRequest(name=name)
            )

            return {
                "name": response.name,
                "birth_date": response.birth_date,
                "ganjis": [
                    {
                        "code": g.code,
                        "name_ko": g.name_ko,
                        "name_hani": g.name_hani,
                        "type": g.type_name,
                        "element": g.element,
                        "yin_yang": g.yin_yang
                    } for g in response.ganjis
                ],
                "ten_gods": list(response.ten_gods),
                "analysis": json.loads(response.analysis_json)
            }

        except grpc.aio.AioRpcError as e:
            if e.code() == grpc.StatusCode.NOT_FOUND:
                raise HTTPException(
                    status_code=status.HTTP_404_NOT_FOUND,
                    detail=f"요청하신 '{name}' 유저의 사주 원국 프로필이 인프라 내에 존재하지 않습니다."
                )
            raise HTTPException(
                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                detail=f"게이트웨이 RPC 제어 레이어 장애: {e.details()}"
            )


@app.get("/api/health", status_code=status.HTTP_200_OK)
async def infrastructure_health_check():
    """
    인프라 레이어의 하트비트를 점검하여 
    Gateway -> Saju Engine -> PostgreSQL 18 의 분산 시스템 전체 가용성을 모니터링합니다.
    """
    async with grpc.aio.insecure_channel(RUST_GRPC_TARGET) as channel:
        stub = saju_pb2_grpc.SajuEngineStub(channel)
        try:
            response = await stub.CheckDbAlive(saju_pb2.DbStatusRequest())
            if response.is_alive:
                return {
                    "status": "healthy",
                    "infrastructure": {
                        "api_gateway": "online",
                        "rust_engine_core": "online",
                        "postgresql_18_pool": "connected"
                    }
                }
            else:
                raise HTTPException(
                    status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
                    detail="인프라 경고: 연산 엔진 서버는 작동 중이나 DB 커넥션 풀이 유효하지 않습니다."
                )
        except grpc.aio.AioRpcError:
            raise HTTPException(
                status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
                detail="인프라 치명: Rust 연산 엔진 Core 서버가 응답하지 않습니다. (Down)"
            )

빌드 및 실행

~$ curl -iX 'GET' \
  'http://127.0.0.1:8000/api/health' \
  -H 'accept: application/json; charset=utf-8'
HTTP/1.1 200 OK
date: Sun, 17 May 2026 09:44:24 GMT
server: uvicorn
content-length: 123
content-type: application/json; charset=utf-8

{"status":"healthy","infrastructure":{"api_gateway":"online","rust_engine_core":"online","postgresql_18_pool":"connected"}}%
~$ curl -iX 'POST' \
  'http://127.0.0.1:8000/api/analysis' \
  -H 'accept: application/json; charset=utf-8' \
  -H 'Content-Type: application/json' \
  -d '{
  "name": "Danilushka",
  "gender": "F",
  "birth_date": "1999-01-08T06:00:00Z",
  "is_solar": true
}'
HTTP/1.1 201 Created
date: Sun, 17 May 2026 09:52:17 GMT
server: uvicorn
content-length: 73
content-type: application/json; charset=utf-8

{"success":true,"message":"gRPC를 통해 성공적으로 등록 완료"}%

임의의 이름에 대한 정보를 요구할 경우

~$ curl -iX 'GET' \
  'http://127.0.0.1:8000/api/profile?name=noname' \
  -H 'accept: application/json; charset=utf-8'
HTTP/1.1 404 Not Found
date: Sun, 17 May 2026 09:46:27 GMT
server: uvicorn
content-length: 115
content-type: application/json

{"detail":"요청하신 'noname' 유저의 사주 원국 프로필이 인프라 내에 존재하지 않습니다."}%

있는 이름에 대한 정보를 요구할 경우

~$ curl -iX 'GET' \ 
  'http://127.0.0.1:8000/api/profile?name=Danilushka' \   
  -H 'accept: application/json; charset=utf-8'  
HTTP/1.1 200 OK
date: Sun, 17 May 2026 09:52:58 GMT
server: uvicorn
content-length: 2074
content-type: application/json; charset=utf-8

{"name":"Danilushka","birth_date":"1999-01-08T06:00:00+00:00","ganjis":[{"code":4,"name_ko":"무","name_hani":"戊","type":"천간","element":"토","yin_yang":"양"},{"code":12,"name_ko":"인","name_hani":"寅","type":"지지","element":"목","yin_yang":"양"},{"code":1,"name_ko":"을","name_hani":"乙","type":"천간","element":"목","yin_yang":"음"},{"code":11,"name_ko":"축","name_hani":"丑","type":"지지","element":"토","yin_yang":"음"},{"code":6,"name_ko":"경","name_hani":"庚","type":"천간","element":"금","yin_yang":"양"},{"code":18,"name_ko":"신","name_hani":"申","type":"지지","element":"금","yin_yang":"양"},{"code":5,"name_ko":"기","name_hani":"己","type":"천간","element":"토","yin_yang":"음"},{"code":13,"name_ko":"묘","name_hani":"卯","type":"지지","element":"목","yin_yang":"음"}],"ten_gods":["편인","편재","정재","정인","비견","비견","정인","정재"],"analysis":{"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}}}%

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

{
  "name":"Danilushka",
  "birth_date":"1999-01-08T06:00:00+00:00",
  "ganjis":[
    {
      "code":4,
      "name_ko":"무",
      "name_hani":"戊",
      "type":"천간",
      "element":"토",
      "yin_yang":"양"
    },
    {
      "code":12,
      "name_ko":"인",
      "name_hani":"寅",
      "type":"지지",
      "element":"목",
      "yin_yang":"양"
    },
    {
      "code":1,
      "name_ko":"을",
      "name_hani":"乙",
      "type":"천간",
      "element":"목",
      "yin_yang":"음"
    },
    {
      "code":11,
      "name_ko":"축",
      "name_hani":"丑",
      "type":"지지",
      "element":"토",
      "yin_yang":"음"
    },
    {
      "code":6,
      "name_ko":"경",
      "name_hani":"庚",
      "type":"천간",
      "element":"금",
      "yin_yang":"양"
    },
    {
      "code":18,
      "name_ko":"신",
      "name_hani":"申",
      "type":"지지",
      "element":"금",
      "yin_yang":"양"
    },
    {
      "code":5,
      "name_ko":"기",
      "name_hani":"己",
      "type":"천간",
      "element":"토",
      "yin_yang":"음"
    },
    {
      "code":13,
      "name_ko":"묘",
      "name_hani":"卯",
      "type":"지지",
      "element":"목",
      "yin_yang":"음"
    }
  ],
  "ten_gods":["편인","편재","정재","정인","비견","비견","정인","정재"]
  ,"analysis":{
    "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
    }
  }
}

이걸 보기 좋게 꾸며서 보여주는 건... 프론트엔드의 몫이고(?) ㅋㅋ
여기서는 JOIN을 통해 여러 개의 테이블의 데이터를 조합하여 사용하는 방법을 알아보고
사용자 데이터를 저장하고 불러오는 것을 살펴 보았을 뿐이다.

실제로는 중복 불가의 id 값을 기준으로
권한이 있는 사용자만 데이터를 불러올 수 있도록 하는 경우가 많겠지만.

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

0개의 댓글