Spanner Omni를 사용한 온디바이스 데이터 에이전트 및 GraphRAG

1. 개요

이 Codelab에서는 Spanner Omni의 기본사항, Spanner Omni가 자율 AI 에이전트를 위한 궁극적인 데이터 기반 역할을 하는 이유, Spanner Omni (관계형 + 그래프 GQL), Python Flask, Gemini 3.8 Flash를 기반으로 에지에 배포된 에이전트형 피클볼 전술 코치 및 경기 시뮬레이터의 대략적인 엔드 투 엔드 구현을 다룹니다.

문제

최신 대화형 스포츠 플랫폼과 실시간 게임 환경에서는 다음과 같은 세 가지 워크로드를 동시에 처리해야 합니다.

동시 실행이 많은 운영 트랜잭션:

코트 예약, 선수 멤버십, 결제, 토너먼트 일정에는 중복 예약 충돌을 방지하기 위해 엄격한 ACID 직렬화 가능성이 필요합니다.

연결된 그래프 원격 분석:

대규모 ETL 파이프라인 없이 지연 시간이 짧은 순회가 필요한 연속 랠리 샷 시퀀스, 전환 마르코프 체인, 영역 커버리지 그래프

밀리초 미만의 에지 응답성:

광역 클라우드 네트워크 왕복 (100~200ms)으로 인해 허용되지 않는 지터가 발생하는 빠른 속도의 60FPS 물리 시뮬레이션

과거에는 엔지니어링팀에서 트랜잭션 데이터용 PostgreSQL, 샷 그래프용 Neo4j, 에지 캐싱용 Redis, 백그라운드 동기화 파이프라인과 같이 단편화된 아키텍처를 결합하여 이 문제를 해결했습니다. 이로 인해 아키텍처 드리프트, 높은 운영 복잡성, eventual consistency 지연이 발생합니다.

Google의 접근 방식

Google Cloud Spanner Omni를 에지에 배포하고 Google Gemini 3.8 Flash와 결합하여 Spanner Omni Pickleball Game을 빌드했습니다. 이 게임은 관계형 비즈니스 로직, 네이티브 ISO GQL 샷 텔레메트리, 실시간 전술 AI 코칭을 통합하는 단일 엔진 스포츠 게임 플랫폼입니다.

Spanner Omni

Spanner Omni는 수평적 확장성, ACID 트랜잭션, 멀티 모델 기능 (관계형, 그래프, 벡터, 분석)을 갖춘 Google의 전 세계 규모 분산 데이터베이스 엔진을 자체 관리형, 온프레미스, 멀티 클라우드, 에어 갭 환경에 제공합니다. Spanner Omni는 Google Cloud Spanner의 다운로드 가능한 자체 관리형 버전입니다. Spanner의 핵심 분산 데이터베이스 기능인 자동 샤딩, Paxos 기반 복제, 강력한 외부 일관성을 실행 위치에 관계없이 제공합니다.

acc871a470f63b29.png

학습할 내용

이 Codelab에서는 기존의 사일로화된 데이터베이스 아키텍처에서 현대적인 통합 에지 데이터베이스 솔루션으로 전환합니다. 학습할 내용

  1. Docker를 사용하여 Spanner Omni를 로컬에 배포하고 조정하여 고성능 단일 인스턴스 관계형 워크로드를 관리합니다.
  2. 외부 데이터베이스 동기화 없이 고차원 게임 원격 분석과 함께 트랜잭션 비즈니스 모델을 매핑하는 통합 관계형 및 ISO GQL 그래프 스키마를 설계합니다.
  3. Python 읽기-쓰기 트랜잭션 내에서 엄격한 ACID 직렬화 가능성을 구현하여 프로그래매틱 방식으로 경합 상태를 방지하고 중복 예약 충돌을 제거합니다.
  4. SQL 통합 ISO GQL 그래프 쿼리를 활용하는 네이티브 GraphRAG 파이프라인을 빌드하여 실시간으로 상황에 맞는 경기 원격 분석을 Google Gemini에 직접 제공하고 즉각적인 전술 코칭을 지원합니다.

요구사항

  • Linux (Ubuntu 22.04 이상), macOS 또는 Windows를 실행하는 표준 로컬 머신 또는 개발 VM
  • Docker Desktop 또는 Docker Engine이 설치되어 실행 중이어야 합니다.
  • Python 3.10 이상이 설치되어 있어야 합니다.
  • 텍스트 편집기 또는 IDE (예: VS Code 또는 PyCharm)
  • 활성 Google Gemini API 키 (참고: Spanner Omni는 무료 등급의 자체 관리형 개발자 평가 라이선스로 실행되므로 로컬에서 실행하는 데 Google Cloud 결제 계정이 필요하지 않습니다.)

2. Spanner Omni 배포 및 구성

이 단계에서는 로컬 에지 데이터베이스 인스턴스를 가동합니다. Spanner Omni는 고도로 최적화된 컨테이너 내에서 실행되므로 로컬 개발자 노트북에서 전 세계 규모의 관계형 기능을 사용할 수 있습니다.

  1. 터미널을 열고 데이터베이스 스키마와 게임 원격 분석을 저장할 영구 Docker 볼륨을 만듭니다.
docker volume create spanner_omni_data
  1. 단일 서버 개발자 모드로 구성된 공식 Spanner Omni 컨테이너 이미지를 가져와 시작합니다.
docker run -d --name spanner-omni \
  -p 9010:9010 -p 9020:9020 \
  -v spanner_omni_data:/spanner \
  us-docker.pkg.dev/spanner-omni/images/spanner-omni:2026.r2.1-beta \
  start-single-server
  1. 컨테이너가 성공적으로 실행되었는지 확인합니다.
docker ps

🛠️ 한 줄로 빠르게 문제 해결하기: 백그라운드 프로세스 또는 기존 에뮬레이터 실행에서 포트 9010 또는 9020를 이미 사용 중이어서 실행에 실패하는 경우 터미널에서 다음 정리 스크립트를 실행하세요.

pkill -f "spanner" || true
fuser -k 9010/tcp 9020/tcp 2>/dev/null || true
docker rm -f spanner-local spanner-omni spanner-omni-local 2>/dev/null || true

3. 관계형 및 ISO GQL 그래프 DDL 스키마 설계

Spanner Omni가 활성화되면 데이터베이스 스키마를 설정할 수 있습니다. 이 스키마는 단일 정보 소스 역할을 하며, 관계형 비즈니스 항목 (예: 코트, 선수, 예약)을 빠르고 연결된 그래프 원격 분석 스키마 (슛, 구역, 전환)와 통합합니다.

운영 및 원격 분석 테이블을 만들려면 다음 안내를 따르세요.

  1. Spanner CLI 또는 애플리케이션 클라이언트를 사용하여 데이터베이스에 연결합니다.
  2. 다음 DDL 문을 실행합니다.

애플리케이션 코드에서 이 문제를 해결하고 있으므로 현재 별도의 조치를 취하실 필요는 없습니다.

-- ============================================================================
-- 1. OPERATIONAL & FACILITY MANAGEMENT (RELATIONAL ACID)
-- ============================================================================

CREATE TABLE Courts (
    court_id INT64 NOT NULL,
    name STRING(100) NOT NULL,
    location STRING(100) NOT NULL,
    surface_type STRING(50) NOT NULL,
    is_indoor BOOL NOT NULL,
    hourly_rate FLOAT64 NOT NULL,
    has_lighting BOOL NOT NULL,
    created_at TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true)
) PRIMARY KEY (court_id);

CREATE TABLE Players (
    player_id INT64 NOT NULL,
    name STRING(100) NOT NULL,
    email STRING(100) NOT NULL,
    skill_level STRING(20) NOT NULL,
    phone STRING(50),
    created_at TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true)
) PRIMARY KEY (player_id);

CREATE TABLE Bookings (
    booking_id INT64 NOT NULL,
    court_id INT64 NOT NULL,
    player_id INT64 NOT NULL,
    booking_date DATE NOT NULL,
    start_hour INT64 NOT NULL,
    end_hour INT64 NOT NULL,
    player_name STRING(100) NOT NULL,
    court_name STRING(100) NOT NULL,
    status STRING(20) NOT NULL,
    notes STRING(MAX),
    created_at TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true)
) PRIMARY KEY (booking_id);

CREATE TABLE MatchRequests (
    match_id INT64 NOT NULL,
    player_id INT64 NOT NULL,
    player_name STRING(100) NOT NULL,
    court_id INT64,
    court_name STRING(100),
    play_date DATE NOT NULL,
    start_hour INT64 NOT NULL,
    skill_required STRING(20) NOT NULL,
    match_type STRING(20) NOT NULL,
    spots_open INT64 NOT NULL,
    status STRING(20) NOT NULL,
    notes STRING(MAX),
    created_at TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true)
) PRIMARY KEY (match_id);

-- ============================================================================
-- 2. TACTICAL GAME TELEMETRY & PROPERTY GRAPH (ISO GQL)
-- ============================================================================

CREATE TABLE CourtZones (
    zone_id STRING(50) NOT NULL,
    name STRING(100) NOT NULL,
    is_kitchen BOOL NOT NULL,
    side STRING(50) NOT NULL
) PRIMARY KEY (zone_id);

CREATE TABLE Shots (
    shot_id STRING(50) NOT NULL,
    player_id STRING(50) NOT NULL,
    shot_type STRING(100) NOT NULL,
    speed_mph FLOAT64,
    spin_type STRING(50),
    outcome STRING(50),
    from_zone_id STRING(50),
    to_zone_id STRING(50)
) PRIMARY KEY (shot_id);

CREATE TABLE ShotTransitions (
    transition_id STRING(50) NOT NULL,
    from_shot_id STRING(50) NOT NULL,
    to_shot_id STRING(50) NOT NULL,
    rally_depth INT64,
    pressure_level STRING(20)
) PRIMARY KEY (transition_id);

-- ============================================================================
-- 3. MATCH SESSIONS & AI COACHING HISTORY
-- ============================================================================

CREATE TABLE GameSessions (
    session_id STRING(50) NOT NULL,
    player_id INT64 NOT NULL,
    player_score INT64 NOT NULL,
    bot_score INT64 NOT NULL,
    bot_difficulty STRING(20) NOT NULL,
    winner STRING(20) NOT NULL,
    rally_length INT64 NOT NULL,
    fault_type STRING(100),
    coaching_summary STRING(MAX),
    created_at TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true)
) PRIMARY KEY (session_id);
cat << 'EOF' > Dockerfile
FROM ollama/ollama:latest
ENV OLLAMA_HOST=0.0.0.0:8080
ENV OLLAMA_MODELS=/root/.ollama
ENV MODEL=gemma4:e2b
RUN ollama serve & sleep 5 && ollama pull $MODEL
ENTRYPOINT ["ollama", "serve"]
EOF

💡 커밋 타임스탬프가 중요한 이유: OPTIONS (allow_commit_timestamp=true)를 지정하면 모든 로컬 노드 복제본에서 마이크로초 단위의 정확한 단조 순서가 가능해집니다. 이는 고속 게임 이벤트 (예: 라인 콜 동영상 영상)를 해당 데이터베이스 샷 원격 분석 로그와 동기화하는 데 중요합니다.

4. ACID 동시 실행 제어 및 중복 예약 방지

활발하게 사용되는 스포츠 예약 포털에서는 여러 사용자가 동일한 코트 시간대를 예약하기 위해 경쟁하는 경우가 많습니다. 경합 상태를 방지하기 위해 Spanner Omni에서 Python 읽기-쓰기 트랜잭션을 작성하여 절대적인 이중 예약 방지 직렬화 가능성을 보장합니다.

지금은 별도로 조치하실 필요는 없으며 코드를 살펴보세요.

from google.cloud import spanner

def create_booking_transaction(transaction, booking_data):
    # 1. Query for existing overlapping confirmed bookings on the same court
    query = """
        SELECT booking_id, player_name, start_hour, end_hour
        FROM Bookings
        WHERE court_id = @court_id
          AND booking_date = @booking_date
          AND status = 'CONFIRMED'
          AND NOT (end_hour <= @start_hour OR start_hour >= @end_hour)
    """
    params = {
        "court_id": booking_data["court_id"],
        "booking_date": booking_data["booking_date"],
        "start_hour": booking_data["start_hour"],
        "end_hour": booking_data["end_hour"],
    }
    param_types = {
        "court_id": spanner.param_types.INT64,
        "booking_date": spanner.param_types.DATE,
        "start_hour": spanner.param_types.INT64,
        "end_hour": spanner.param_types.INT64,
    }
    
    results = list(transaction.execute_sql(query, params=params, param_types=param_types))
    
    if results:
        conflict = results[0]
        raise ValueError(
            f"Court conflict: Already booked by {conflict[1]} ({conflict[2]}:00-{conflict[3]}:00)"
        )
    
    # 2. Insert new confirmed booking with Spanner commit timestamp
    insert_stmt = """
        INSERT INTO Bookings (
            booking_id, court_id, player_id, booking_date, start_hour, end_hour,
            player_name, court_name, status, notes, created_at
        ) VALUES (
            @booking_id, @court_id, @player_id, @booking_date, @start_hour, @end_hour,
            @player_name, @court_name, 'CONFIRMED', @notes, PENDING_COMMIT_TIMESTAMP()
        )
    """
    # execute_update statement writing to Spanner Omni...
    transaction.execute_update(insert_stmt, params=booking_data, param_types=booking_types)

5. 실시간 60FPS 물리 엔진 및 조정 가능한 DUPR 봇 엔진

애플리케이션의 프런트엔드는 초당 60프레임 (FPS)으로 렌더링되는 2.5D HTML5 Canvas 시뮬레이션을 활용합니다. 이 렌더링은 데이터베이스 레이어와 완전히 분리되어 실행되므로 매우 원활한 성능을 유지하며, 랠리가 끝날 때만 원격 분석을 플러시합니다.

이 시뮬레이터는 실제 DUPR (Dynamic Universal Pickleball Rating) 수준을 기반으로 한 난이도 매트릭스를 활용하는 조정 가능한 OmniBot AI 엔진을 갖추고 있습니다.

1640056a824b47e6.png

지금은 별도로 취하실 조치는 없으며 세부정보를 확인하시면 됩니다.

6. ISO GQL Shot Telemetry Graph Processing

각 랠리가 끝나면 프런트엔드는 연결된 궤적 그래프를 백엔드 엔드포인트 /api/rally-end에 게시합니다. 모든 샷은 관계형 노드로 기록되며 시퀀스는 전환 에지를 통해 동적으로 연결됩니다.

지금은 별도로 조치할 필요가 없습니다. 코드를 살펴보세요.

@app.route("/api/execute-shot", methods=["POST"])
def execute_shot():
    data = request.json or {}
    shot_id = f"shot_{int(time.time()*1000)}"
    
    # 1. Insert the Shot Node
    insert_shot_sql = """
        INSERT INTO Shots (shot_id, player_id, shot_type, speed_mph, spin_type, outcome, from_zone_id, to_zone_id)
        VALUES (@shot_id, @player_id, @shot_type, @speed, @spin, @outcome, @from_zone, @to_zone)
    """
    
    # 2. If part of a continuous rally, link with a ShotTransition Edge
    if data.get("prev_shot_id"):
        transition_id = f"trans_{int(time.time()*1000)}"
        insert_trans_sql = """
            INSERT INTO ShotTransitions (transition_id, from_shot_id, to_shot_id, rally_depth, pressure_level)
            VALUES (@trans_id, @from_shot, @to_shot, @depth, @pressure)
        """
        # Executed within a single, unified Spanner Omni transaction...

Spanner의 네이티브 ISO GQL 그래프 쿼리 엔진을 활용하면 코치나 분석 프로그램이 대규모 데이터베이스 조인을 실행하지 않고도 복잡하고 깊은 홉의 구조적 패턴을 즉시 쿼리할 수 있습니다.

-- Find highest-frequency transition sequences leading to player unforced errors
GRAPH PickleballTelemetry
MATCH (s1:Shots)-[t:ShotTransitions]->(s2:Shots)
WHERE s1.shot_type = 'Speedup' AND s2.outcome = 'FAULT_NET'
RETURN s1.speed_mph, s2.from_zone_id, COUNT(*) AS occurrences
ORDER BY occurrences DESC
LIMIT 5;

7. Gemini AI 전술 코칭 및 그라운딩된 RAG 채팅

이제 애플리케이션의 두뇌인 Google Gemini 3.8 Flash를 활성화합니다. Python 백엔드는 이 모델을 사용하여 Spanner Omni에서 직접 그라운딩된 세 가지 실시간 AI 기능을 제공합니다.

이 시스템은 Google Gemini 3.8 Flash를 사용하여 다음과 같은 몇 가지 고유한 실시간 AI 워크플로를 수행합니다.

실시간 랠리 분석 및 전술 심판:

점수를 획득할 때마다 Gemini가 샷 체인, 봇 난이도, 코트 위치를 분석합니다.

Spanner 멀티 테이블 그라운딩 AI 컨시어지 및 경기 분석가:

사용자는 자연어로 코트 일정, 시설 요금을 쿼리하고 "OmniBot을 상대로 어떻게 했어?"라고 물어볼 수 있습니다. 백엔드는 4개 도메인 (Courts, Bookings, GameSessions, Shots)에서 Spanner Omni를 동적으로 쿼리하여 할루시네이션 없이 Gemini를 그라운딩합니다.

실시간 실적 분석 API (/api/performance):

플레이어 대시보드를 위해 Spanner Omni의 GameSessions 테이블에서 실시간 승패 비율, 평균 랠리 깊이, 최근 게임 세션을 직접 집계합니다.

지금은 별도로 취하실 조치는 없으며 세부정보를 확인하시면 됩니다.

8. 구성, 실행, 테스트

로컬 머신에서 전체 애플리케이션을 실행하여 시스템을 엔드 투 엔드로 확인해 보겠습니다.

  1. 호스트 터미널을 열고 프로젝트 저장소를 클론합니다.
git clone https://github.com/AbiramiSukumaran/pickleball

cd pickleball
  1. 다음 문서의 단계에 따라 Gemini API 키를 가져옵니다.

https://medium.com/google-cloud/get-api-key-from-google-ai-studio-b2d2059a171d

  1. 편집기에서 .env 파일을 열고 환경 세부정보를 구성합니다.
SPANNER_EMULATOR_HOST="localhost:9010"
SPANNER_PROJECT="PROJECT_ID"
SPANNER_INSTANCE="omni-instance"
SPANNER_DATABASE="pickleball-db"
GEMINI_API_KEY="REPLACE_WITH_YOUR_ACTUAL_GEMINI_KEY"
  1. Python 종속 항목을 설치하고 애플리케이션을 실행합니다.
pip install -r requirements.txt

python app.py
  1. 웹브라우저를 열고 http://localhost:5000으로 이동하여 플레이를 시작하세요. 예약 탭에서 코트 시간을 예약하고, 조정 가능한 DUPR OmniBot과 경기를 펼치고, 코트 사이드에서 실시간 Gemini 경기 분석가와 상담할 수 있습니다.

웹브라우저를 열거나 localhost에서 실행 중인 앱의 링크를 클릭합니다.

a2d18f8288607c66.png

9. 수고하셨습니다

이 Codelab을 완료하면서 데이터베이스 엔지니어링의 패러다임 전환을 경험했습니다. 수년 동안 에지 (공장 현장, 군사 구역, 코트사이드 스포츠 네트워크)에서 반응형 실시간 에이전트를 빌드하려면 고립된 트랜잭션 시스템, 별도의 그래프 노드, 벡터 검색 색인 간에 취약한 ETL 파이프라인을 연결하는 등 설계상의 타협이 필요했습니다.

Spanner Omni는 이러한 상충 관계를 해소합니다. 전 세계 규모의 분산 일관성, ISO GQL 그래프 쿼리, 관계형 성능을 다운로드 가능한 단일 로컬 컨테이너에 결합합니다. 도메인에 관계없이 Spanner Omni는 에지 에이전트가 흔들리지 않는 정답을 갖도록 보장합니다.