Cloud Run 서비스에서 개인 에이전트 실행 (커피숍 관리자 어시스턴트)

1. 소개

개요

이 Codelab에서는 채팅 UI를 통해 비즈니스 데이터를 분석하고 기타 작업을 수행하는 데 도움이 되는 개인 AI 어시스턴트를 빌드합니다. Cloud Run 서비스를 사용하여 개인 에이전트를 호스팅합니다.

에이전트는 Cloud Run 샌드박스를 사용합니다. Cloud Run 샌드박스는 신뢰할 수 없는 코드 및 에이전트 작업 부하를 실행하기 위해 특별히 빌드된 기본적이고 안전하며 매우 빠른 런타임 환경으로, 밀리초 단위로 시작됩니다. 샌드박스를 사용하면 AI 에이전트가 복잡한 분석 문제를 해결하기 위해 즉석에서 코드를 동적으로 작성, 실행, 테스트할 수 있습니다.

참고: 로컬에서 실행할 때와 프로덕션에서 실행할 때 원활한 개발 환경을 보장하려면 다음을 수행하세요.

  • 프로덕션 (Cloud Run 샌드박스): 에이전트는 전용 샌드박스 바이너리 (/usr/local/gcp/bin/sandbox)를 통해 격리된 컨테이너화된 플레이그라운드 내에서 코드를 안전하게 실행합니다.
  • 로컬 (머신): 로컬에서 실행할 때 앱은 프로덕션 샌드박스 환경이 없음을 감지합니다 (IS_LOCAL_MODE = True). 에이전트는 로컬 호스트 머신의 시스템 터미널에서 Python 스크립트와 셸 명령어를 직접 실행합니다.

빌드할 항목:

이 시나리오에서는 대학 도시에서 대규모 졸업 주말을 준비하는 커피숍을 관리합니다. 에이전트가 원시 POS (Point-of-Sale) 데이터를 대학의 행사 일정과 교차 참조하여 숨겨진 운영 병목 현상을 찾아내야 합니다.

에이전트는 보안 샌드박스를 사용하여 Python 스크립트를 작성하고 실행하여 음료 복잡성과 계산대 직원 수를 분석하여 직원 수 및 재고 조정을 추천합니다.

에이전트는 타겟 재고 및 직원 수 추천과 함께 모의 채팅 UI를 통해 소유자에게 핑을 보냅니다. 커피숍 관리자의 운영 할 일 목록으로 스프레드시트를 업데이트하기 전에 명시적 권한을 기다립니다.

학습할 내용

  • Cloud Run 서비스를 만드는 방법
  • Cloud Run 서비스에 ADK 에이전트를 배포하는 방법
  • Cloud Run 서비스 내의 샌드박스에서 에이전트가 코드를 실행하도록 하는 방법
  • WebSocket을 사용하여 백그라운드 에이전트와 상호작용하는 채팅 UI를 만드는 방법

2. 설정 및 요구사항

프로젝트 ID와 리전을 구성합니다.

GOOGLE_CLOUD_PROJECT=<YOUR_PROJECT_ID>

REGION=us-west2
gcloud config set project $GOOGLE_CLOUD_PROJECT
gcloud config set run/region $REGION

이 Codelab 전체에서 사용되는 환경 변수는 다음과 같습니다. 환경 파일에 저장하고 '소스'할 수 있습니다. 프로젝트 ID의 값과 리전(선택사항)을 올바르게 설정해야 합니다.

SA_NAME=coffee-shop-agent-sa
SERVICE_ACCOUNT_ADDRESS=$SA_NAME@$GOOGLE_CLOUD_PROJECT.iam.gserviceaccount.com

이 Codelab에 필요한 API 사용 설정

gcloud services enable --project $GOOGLE_CLOUD_PROJECT \
    run.googleapis.com \
    cloudbuild.googleapis.com \
    artifactregistry.googleapis.com \
    sheets.googleapis.com \
    aiplatform.googleapis.com

3. 서비스 계정 및 스프레드시트 만들기

Cloud Run 서비스 전용 서비스 계정을 만들어 필요한 역할만 부여하는 것이 좋습니다.

Cloud Run 서비스의 서비스 계정 만들기

gcloud iam service-accounts create $SA_NAME \
  --description="Service account for the Coffee Shop Agent Codelab" \
  --display-name="Coffee Shop Agent SA"

에이전트는 Gemini API (GOOGLE_GENAI_USE_VERTEXAI=1)를 사용하므로 이 서비스 계정에 프로젝트의 Agent Platform 사용자 역할을 부여합니다.

gcloud projects add-iam-policy-binding $GOOGLE_CLOUD_PROJECT \
--member="serviceAccount:$SERVICE_ACCOUNT_ADDRESS" \
--role="roles/aiplatform.user"

이 서비스 계정을 사용하여 에이전트를 로컬에서 실행하고 테스트할 수 있도록 개인 Google Cloud ID에 이 서비스 계정을 가장할 수 있는 권한을 부여합니다.

gcloud iam service-accounts add-iam-policy-binding \
coffee-shop-agent-sa@$GOOGLE_CLOUD_PROJECT.iam.gserviceaccount.com \
--member="user:$(gcloud config get-value account)" \
--role="roles/iam.serviceAccountTokenCreator"

스프레드시트 만들기

이 스프레드시트는 졸업 주말에 발생한 작년 매출을 나타냅니다.

  1. Google Drive에서 새 Google 시트를 만듭니다.
  2. 다음 쉼표로 구분된 값 (CSV)을 새 Google 시트에 복사합니다 (예: A1 셀을 선택하고 붙여넣기).
Day,Time,Drip_Coffee,Cold_Brew,Extra_Espresso,Alt_Milk_Oz,Pastries,Cashiers_Working,Wait_Time_Minutes
Saturday,07:00:00,95,15,5,30,80,2,9
Saturday,08:00:00,80,25,10,35,60,2,7
Saturday,09:00:00,30,30,20,40,20,1,4
Saturday,10:00:00,40,130,95,50,25,2,4
Saturday,11:00:00,25,45,25,40,15,1,5
Saturday,12:00:00,30,35,15,45,20,1,3
Saturday,13:00:00,15,20,10,30,10,1,2
Saturday,14:00:00,45,60,30,65,30,2,8
Saturday,15:00:00,20,30,15,35,15,1,3
Saturday,16:00:00,25,35,10,50,20,1,4
Saturday,17:00:00,10,20,5,35,10,1,2
Saturday,18:00:00,20,40,10,190,65,2,12
Saturday,19:00:00,10,15,5,40,15,1,2
Sunday,07:00:00,90,10,5,35,75,2,8
Sunday,08:00:00,75,20,10,40,65,2,6
Sunday,09:00:00,25,25,15,35,15,1,3
Sunday,10:00:00,60,35,15,60,50,2,7
Sunday,11:00:00,20,30,10,35,10,1,3
Sunday,12:00:00,25,40,10,55,20,1,3
Sunday,13:00:00,15,25,5,40,10,1,2
Sunday,14:00:00,30,50,15,180,60,2,11
Sunday,15:00:00,15,25,10,45,15,1,3
Sunday,16:00:00,20,45,20,40,15,1,4
Sunday,17:00:00,10,25,10,30,10,1,2
Sunday,18:00:00,25,145,110,45,20,2,16
Sunday,19:00:00,15,30,15,35,10,1,4
  1. 데이터가 계속 선택된 상태에서 Google Sheets 상단 메뉴에서 데이터 > 텍스트를 열로 분할 을 클릭합니다.

서비스 계정에 스프레드시트에 대한 편집자 액세스 권한이 있는지 확인합니다. 팀원에게 액세스 권한을 부여하는 방법과 비슷합니다.

  1. 새 서비스 계정의 전체 이메일을 복사합니다.
echo $SERVICE_ACCOUNT_ADDRESS
  1. Google 시트에서 오른쪽 상단의 공유를 클릭합니다.
  2. 서비스 계정 이메일을 붙여넣고 권한을 편집자로 설정한 후 공유를 클릭합니다. (알림 선택을 해제할 수 있습니다.)
  3. 에이전트에 전달할 스프레드시트 ID를 기록합니다(예: https://docs.google.com/spreadsheets/d/<YOUR_SPREADSHEET_ID>/edit?gid=0#gid=0).
SPREADSHEET_ID=<THE SPREADHSEET ID FROM ITS URL>

4. ADK 에이전트 만들기

먼저 코드의 디렉터리를 만듭니다.

mkdir coffee-mgr-agent && cd coffee-mgr-agent

requirements.txt 파일을 만듭니다.

fastapi>=0.100.0
uvicorn>=0.22.0
google-adk>=1.27.1
google-auth
google-api-python-client

Dockerfile을 만듭니다.

FROM python:3.11-slim

ENV PYTHONUNBUFFERED=1

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY main.py .

EXPOSE 8080

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

main.py 파일을 만듭니다.

import os
import asyncio
import subprocess
from pathlib import Path
from typing import List
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse
from pydantic import BaseModel

from google.adk.agents import LlmAgent as Agent
from google.adk.tools import FunctionTool
from google.adk.apps import App
from google.adk import Runner
from google.adk.sessions import InMemorySessionService
from google.genai import types

SANDBOX_CLI = '/usr/local/gcp/bin/sandbox'
IS_LOCAL_MODE = not Path(SANDBOX_CLI).exists()

active_connections: list[WebSocket] = []

SPREADSHEET_ID = os.environ.get("SPREADSHEET_ID")

# And
def run_sandbox_process(args: list[str]):
    cmd = args[2:] if IS_LOCAL_MODE and args[:2] == ['do', '--'] else ([SANDBOX_CLI] + args if not IS_LOCAL_MODE else args)
    return subprocess.run(cmd, capture_output=True, text=True, timeout=10)

def execute_sandbox_command(command: str) -> str:
    """Executes arbitrary POSIX shell/bash commands inside sandbox."""
    mode = "LOCAL" if IS_LOCAL_MODE else "CLOUD RUN SANDBOX"
    print(f"[ADK Sandbox Tool] Starting {mode} shell run...")
    try:
        res = run_sandbox_process(['do', '--', '/bin/sh', '-c', command])
        if res.returncode != 0:
            return f"Execution Failed!\nExit Code: {res.returncode}\nStdout:\n{res.stdout}\nStderr:\n{res.stderr}"
        return res.stdout
    except Exception as err:
        return f"Internal Sandbox Tool Error: {str(err)}"

def get_sheets_service():
    """Initializes and returns the Google Sheets client service."""
    from google.auth import default
    from googleapiclient.discovery import build
    credentials, _ = default(scopes=[
        'https://www.googleapis.com/auth/spreadsheets',
        'https://www.googleapis.com/auth/cloud-platform'
    ])
    return build('sheets', 'v4', credentials=credentials)

def read_spreadsheet_values(spreadsheet_id: str, range_name: str) -> str:
    """Reads a range of cells from a Google Spreadsheet."""
    try:
        service = get_sheets_service()
        result = service.spreadsheets().values().get(spreadsheetId=spreadsheet_id, range=range_name).execute()
        rows = result.get('values', [])
        return str(rows) if rows else "No data found in the specified range."
    except Exception as e:
        return f"Read Error: {str(e)}"

def update_spreadsheet_values(spreadsheet_id: str, range_name: str, values: List[List[str]]) -> str:
    """Updates a range of cells in a Google Spreadsheet with the provided values."""
    try:
        service = get_sheets_service()
        result = service.spreadsheets().values().update(
            spreadsheetId=spreadsheet_id, range=range_name,
            valueInputOption="USER_ENTERED", body={'values': values}).execute()
        return f"Successfully updated {result.get('updatedCells')} cells in {range_name}."
    except Exception as e:
        return f"Write Error: {str(e)}"

def create_spreadsheet_tab(spreadsheet_id: str, tab_name: str) -> str:
    """Creates a new sheet tab in a Google Spreadsheet if it doesn't already exist."""
    try:
        service = get_sheets_service()
        # Check if tab exists
        spreadsheet = service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute()
        for sheet in spreadsheet.get('sheets', []):
            if sheet.get('properties', {}).get('title') == tab_name:
                return f"Sheet tab '{tab_name}' already exists."
        # Create tab
        body = {'requests': [{'addSheet': {'properties': {'title': tab_name}}}]}
        service.spreadsheets().batchUpdate(spreadsheetId=spreadsheet_id, body=body).execute()
        return f"Successfully created new sheet tab '{tab_name}'."
    except Exception as e:
        return f"Error creating sheet tab: {str(e)}"

# ==========================================
# 3. ADK AGENT & RUNNER SETUP
# ==========================================

root_agent = Agent(
    name='secure_coding_assistant',
    description='ADK agent capable of executing shell commands and managing Google Spreadsheets.',
    model=os.environ.get('GEMINI_MODEL', 'gemini-3.1-flash-lite'),
    instruction=(
        f'You are an expert AI Business Analyst for a coffee shop during university graduation weekend.\n'
        f'The Google Spreadsheet ID you are managing is: "{SPREADSHEET_ID}". Use this ID for all sheet operations.\n'
        '1. Comparative Analysis Policy:\n'
        f'   - Ingest historical POS data from the "POS-2025" sheet tab using read_spreadsheet_values with spreadsheet_id="{SPREADSHEET_ID}".\n'
        '   - Receive the current graduation schedule directly from the manager\'s prompt (the manager will paste it and indicate it is the same schedule sequence as last year).\n'
        '   - Write a python3 script via the sandbox tool to:\n'
        '     a. Correlate the 2025 product spikes (Cold Brew, Alt Milk, Extra Espresso) with the specific ceremonies ending at those times.\n'
        '     b. Map those beverage profiles to the pasted schedule (which is the same sequence) to predict exactly when and where the 2026 spikes will occur.\n'
        '     c. Identify expected wait-time bottlenecks in 2026 based on the 2025 wait times for those same profiles.\n'
        '2. Bottleneck Diagnostics (Playbook):\n'
        '   - If a predicted high-volume slot in 2026 is expected to have Wait_Time_Minutes > 10:\n'
        '     - If Cashiers_Working < 2: Recommend scheduling another cashier.\n'
        '     - If Cashiers_Working == 2 and complex items (Cold Brew, Extra Espresso, Alt Milk) spike: Deduce that the bottleneck is barista output, not cashiers. Recommend adding a "Support Barista" role to handle fulfillment.\n'
        '3. Human-in-the-Loop Policy:\n'
        '   - Present your detailed data discoveries, wait-time bottlenecks, and actionable recommendations (stocking and staffing changes) to the manager.\n'
        '   - Highlight only two or three findings for specific ceremonies.\n'
        '   - Frame your recommendations as a clean list of suggested tasks for the manager\'s TODO list.\n'
        '   - Explicitly ask: "Would you like me to add these tasks to your \'TODO-2026\' TODO list?"\n'
        '   - Do NOT modify any spreadsheet data until explicit approval is given.\n'
        '4. Post-Approval Policy:\n'
        f'   - Upon receiving explicit user approval, first verify if the "TODO-2026" sheet tab exists in spreadsheet "{SPREADSHEET_ID}".\n'
        f'   - If the "TODO-2026" sheet tab does not exist, use the tool create_spreadsheet_tab to create it in spreadsheet "{SPREADSHEET_ID}".\n'
        f'   - Once the tab exists, use update_spreadsheet_values to append the approved adjustments as tasks to the "TODO-2026" sheet tab.\n'
        '   - Write the rows under the headers: Task (the actionable job, e.g., "Schedule a Support Barista role for Saturday morning"), Category ("Staffing" or "Inventory"), Ceremony, and Date_Added (today\'s date).\n'
        '   - Always confirm to the user exactly what tasks you have written to their "TODO-2026" TODO list.'
    ),
    tools=[
        FunctionTool(func=execute_sandbox_command),
        FunctionTool(func=read_spreadsheet_values),
        FunctionTool(func=update_spreadsheet_values),
        FunctionTool(func=create_spreadsheet_tab)
    ]
)

adk_app = App(name="secure_sandbox_app", root_agent=root_agent)
runner = Runner(app=adk_app, session_service=InMemorySessionService(), auto_create_session=True)

app = FastAPI(title="Secure ADK Sandbox Assistant")

# ==========================================
# 4. ENDPOINTS & WEBSOCKET ROUTING
# ==========================================

@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    active_connections.append(websocket)
    await websocket.send_text("🔌 System: Connected. Agent is ready...")

    try:
        while True:
            owner_reply = await websocket.receive_text()
            print(f"Owner replied via WS: {owner_reply}")
            await websocket.send_text("_Agent is running tools and thinking..._")
            
            new_message = types.Content(parts=[types.Part(text=owner_reply)])
            events = await asyncio.to_thread(
                runner.run,
                user_id="local_user",
                session_id="local_session",
                new_message=new_message
            )
            
            final_response = "".join(
                part.text
                for event in events
                if event.content and event.content.parts
                for part in event.content.parts
                if part.text
            ) or "Agent completed execution updates without text output."
            
            await websocket.send_text(final_response.strip())
            
    except WebSocketDisconnect:
        active_connections.remove(websocket)

class UserPrompt(BaseModel):
    prompt: str

@app.post("/chat")
def chat_with_agent(payload: UserPrompt):
    """Fallback HTTP POST endpoint if UI is not used."""
    try:
        events = runner.run(
            user_id="local_user",
            session_id="local_session",
            new_message=types.Content(parts=[types.Part(text=payload.prompt)])
        )
        final_response = "".join(
            part.text
            for event in events
            if event.content and event.content.parts
            for part in event.content.parts
            if part.text
        )
        return {"status": "success", "response": final_response.strip()}
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Agent loop failed: {str(e)}")

@app.get("/", response_class=HTMLResponse)
async def get_chat_ui():
    """Serves the warm coffee-themed chat interface."""
    return """
    <!DOCTYPE html>
    <html>
    <head>
        <title>Coffee Shop Agent</title>
        <!-- Load marked.js for client-side markdown rendering -->
        <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
        <style>
            body { display: flex; height: 100vh; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; }
            #sidebar { width: 250px; background: #3E2723; color: #EFEBE9; padding: 20px; }
            #sidebar h2 { font-size: 1.3em; margin-top: 10px; color: #FFF; border-bottom: 1px solid #5D4037; padding-bottom: 10px; }
            #main { flex-grow: 1; display: flex; flex-direction: column; background: #FAF8F6; }
            #chat-history { flex-grow: 1; padding: 20px; overflow-y: auto; background: #F5EFEB; }
            #input-area { padding: 20px; border-top: 1px solid #D7CCC8; background: #FAF8F6; display: flex;}
            input { 
                flex-grow: 1; 
                padding: 12px; 
                border-radius: 6px; 
                border: 1px solid #D7CCC8; 
                margin-right: 10px;
                font-size: 1em;
                background: #FFF;
                transition: border-color 0.2s, box-shadow 0.2s;
            }
            input:focus {
                outline: none;
                border-color: #8D6E63;
                box-shadow: 0 0 0 2px rgba(141, 110, 99, 0.25);
            }
            button { 
                padding: 10px 24px; 
                background: #6D4C41; 
                color: white; 
                border: none; 
                border-radius: 6px; 
                cursor: pointer; 
                font-weight: bold;
                font-size: 1em;
                transition: background-color 0.2s, transform 0.1s;
            }
            button:hover {
                background: #5D4037;
            }
            button:active {
                background: #4E342E;
                transform: scale(0.98);
            }
            .message { margin-bottom: 15px; padding: 12px 16px; border-radius: 8px; max-width: 85%; line-height: 1.5; }
            .user-msg { background: #EFEBE9; color: #3E2723; align-self: flex-end; margin-left: auto; border: 1px solid #D7CCC8;}
            .agent-msg { background: #fff; color: #3E2723; border: 1px solid #E0DCD8; box-shadow: 0 1px 3px rgba(62,39,35,0.06); }
            
            .msg-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
            .agent-name { font-weight: bold; color: #5D4037; margin: 0; font-size: 0.95em;}
            .user-name { font-weight: bold; color: #8D6E63; margin: 0; font-size: 0.95em;}
            .msg-timestamp { font-size: 0.8em; color: #8D6E63; font-weight: normal; }
            
            .day-divider {
                display: flex;
                align-items: center;
                text-align: center;
                color: #8D6E63;
                margin: 20px 0;
                font-size: 0.85em;
                font-weight: bold;
            }
            .day-divider::before, .day-divider::after {
                content: '';
                flex: 1;
                border-bottom: 1px solid #D7CCC8;
            }
            .day-divider:not(:empty)::before {
                margin-right: .5em;
            }
            .day-divider:not(:empty)::after {
                margin-left: .5em;
            }

            /* Markdown Styling inside Messages */
            .message p { margin: 4px 0 8px 0; }
            .message p:last-child { margin-bottom: 0; }
            .message ul, .message ol { margin: 4px 0 8px 0; padding-left: 20px; }
            .message li { margin-bottom: 3px; }
            .message h1, .message h2, .message h3, .message h4 { margin: 12px 0 6px 0; font-size: 1.15em; color: #3E2723; font-weight: bold; }
            .message h1:first-child, .message h2:first-child, .message h3:first-child { margin-top: 0; }
            
            /* Table Styling */
            .message table { border-collapse: collapse; width: 100%; margin: 10px 0; font-size: 0.95em; }
            .message th, .message td { border: 1px solid #D7CCC8; padding: 8px 10px; text-align: left; }
            .message th { background-color: #F5EFEB; font-weight: bold; color: #3E2723; }
            .message tr:nth-child(even) { background-color: #FAF8F6; }
            
            /* Code / Blockquote styling */
            .message code { background: #EFEBE9; padding: 2px 4px; border-radius: 3px; font-family: monospace; font-size: 0.9em; color: #5D4037; }
            .message pre { background: #F5EFEB; padding: 10px; border-radius: 5px; overflow-x: auto; margin: 8px 0; border: 1px solid #D7CCC8; }
            .message pre code { background: none; padding: 0; }
            .message blockquote { margin: 8px 0; padding-left: 12px; border-left: 4px solid #6D4C41; color: #5D4037; }
        </style>
    </head>
    <body>
        <div id="sidebar">
            <h2>☕ Coffee Shop Monitor</h2>
            <p>Monitoring sheet...</p>
        </div>
        <div id="main">
            <div id="chat-history"></div>
            <div id="input-area">
                <input type="text" id="msg" placeholder="Message Coffee Shop Monitor..." onkeypress="if(event.key === 'Enter') sendMessage()">
                <button onclick="sendMessage()">Send</button>
            </div>
        </div>

        <script>
            const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
            const ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
            const history = document.getElementById('chat-history');

            // Scroll to the bottom on load to show latest messages
            history.scrollTop = history.scrollHeight;

            ws.onmessage = function(event) {
                // Parse markdown content from the agent
                const parsedHtml = marked.parse(event.data);
                const timeStr = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
                history.innerHTML += `
                    <div class="message agent-msg">
                        <div class="msg-header">
                            <span class="agent-name">Inventory Agent APP</span>
                            <span class="msg-timestamp">${timeStr}</span>
                        </div>
                        <div>${parsedHtml}</div>
                    </div>`;
                history.scrollTop = history.scrollHeight;
            };

            function sendMessage() {
                const input = document.getElementById('msg');
                const text = input.value;
                if (!text) return;
                
                // Parse user markdown if any (optional, but keeps styling consistent)
                const parsedHtml = marked.parse(text);
                const timeStr = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
                history.innerHTML += `
                    <div class="message user-msg">
                        <div class="msg-header">
                            <span class="user-name">You</span>
                            <span class="msg-timestamp">${timeStr}</span>
                        </div>
                        <div>${parsedHtml}</div>
                    </div>`;
                
                ws.send(text);
                input.value = '';
                history.scrollTop = history.scrollHeight;
            }
        </script>
    </body>
    </html>
    """

if __name__ == "__main__":
    import uvicorn
    port_val = int(os.environ.get('PORT', 8080))
    uvicorn.run(app, host='0.0.0.0', port=port_val)

5. Cloud Run 서비스 배포

이미지를 빌드하는 대신 Google Cloud의 빌드팩을 사용하여 애플리케이션 소스 코드를 Cloud Run의 프로덕션에 즉시 사용 가능한 컨테이너 이미지로 변환합니다.

gcloud beta run deploy coffee-mgr-agent \
    --source=. \
    --region=$REGION \
    --sandbox-launcher \
    --max-instances=1 \
    --session-affinity \
    --allow-unauthenticated \
    --no-cpu-throttling \
    --set-env-vars GOOGLE_GENAI_USE_VERTEXAI=1,GOOGLE_CLOUD_PROJECT=$GOOGLE_CLOUD_PROJECT,GOOGLE_CLOUD_LOCATION=global,SPREADSHEET_ID=$SPREADSHEET_ID \
    --service-account $SERVICE_ACCOUNT_ADDRESS

6. 에이전트와 채팅

배포 후 URL: https://coffee-mgr-agent-YOUR_PROJECT_ID.YOUR_REGION.run.app이 표시됩니다.

브라우저에서 이 URL을 엽니다.

다음 프롬프트를 에이전트에 보냅니다.

The 2026 graduation schedule was just posted. It's the same schedule as last year. Can you review last year's POS data to help me prepare for this year?
Saturday, June 13:
College of Business (8:30 a.m.)
College of Science and Mathematics (12:30 p.m.)
College of Liberal Arts (4:30 p.m.)
Sunday, June 14:
College of Agriculture (8:30 a.m.)
College of Architecture (12:30 p.m.)
College of Engineering (4:30 p.m.)

에이전트는 다음과 같은 추천과 비슷한 응답을 해야 합니다.

Based on the 2025 POS data, I have correlated graduation ceremony patterns with beverage demand spikes. Last year, the post-ceremony windows consistently experienced high demand for complex items (Cold Brew, Extra Espresso, and Alt Milk).

Key Data Discoveries for 2026

- College of Business (Saturday 8:30 a.m. ceremony):
  - Significant demand spike observed at 10:00 a.m. Saturday.
  - Data: 130 Cold Brews and 95 Extra Espresso shots. This is a primary bottleneck for barista output.
- College of Engineering (Sunday 4:30 p.m. ceremony):
  - Significant demand spike observed at 6:00 p.m. Sunday.
  - Data: 145 Cold Brews and 110 Extra Espresso shots. This is the highest intensity period of the weekend.
- Alt-Milk Surge (Saturday/Sunday 2:00 p.m. post-ceremonies):
  - Consistent, high-volume Alt Milk consumption (65-180 oz) after the 12:30 p.m. ceremonies.

Diagnostic & Staffing Recommendations

Given that these complex beverage items (Cold Brew, Extra Espresso) correlate directly with your high-traffic post-ceremony windows, the risk of hitting wait times > 10 minutes is high. Assuming you typically have 2 cashiers, my diagnostic is that these are barista output bottlenecks, not cashier bottlenecks.

Suggested Tasks for your TODO-2026 List:

1. Staffing: Schedule a dedicated "Support Barista" for Saturday 9:30 a.m. – 11:00 a.m. to handle the Cold Brew/Espresso rush.
2. Staffing: Schedule a dedicated "Support Barista" for Sunday 5:30 p.m. – 7:00 p.m. to manage the high volume after the Engineering ceremony.
3. Inventory: Pre-stage extra Alt Milk cartons at the mobile station for Saturday 1:30 p.m. and Sunday 1:30 p.m. shifts.

Would you like me to add these tasks to your 'TODO-2026' TODO list?

그러면 에이전트가 TODO-2026 탭 시트를 만들거나 업데이트할 수 있는 권한을 요청합니다 (Human-in-the-Loop).

Yes로 에이전트에 응답합니다.

이제 TODO-2026 탭 시트를 보면 다음과 비슷한 내용이 표시됩니다.

Task	Category	Ceremony	Date_Added
Schedule a Support Barista role for Saturday morning rush	Staffing	College of Business	2025-05-22
Schedule a Support Barista role for Sunday evening rush	Staffing	College of Engineering	2025-05-22
Pre-stage extra Alt Milk cartons at mobile station	Inventory	College of Science/Math & Architecture	2025-05-22

7. 수고하셨습니다

축하합니다. Codelab을 완료했습니다.

Cloud Run 문서, Cloud Run 샌드박스 문서, ADK 문서를 검토하는 것이 좋습니다.

학습한 내용

  • Cloud Run 서비스를 만드는 방법
  • Cloud Run 서비스에 ADK 에이전트를 배포하는 방법
  • Cloud Run 서비스 내의 샌드박스에서 에이전트가 코드를 실행하도록 하는 방법

8. 정리

이 튜토리얼에서 사용된 리소스 비용이 Google Cloud 계정에 청구되지 않도록 하려면 프로젝트를 삭제하거나 개별 리소스를 삭제하면 됩니다.

옵션 1: 리소스 삭제

프로젝트를 정리하고 불필요한 요금이 발생하지 않도록 하는 단계에 대한 간략한 개요는 다음과 같습니다.

Cloud Run 서비스 삭제

gcloud run services delete coffee-mgr-agent --region $REGION

서비스 계정 삭제: 전용 스프레드시트 서비스 계정 삭제

gcloud iam service-accounts delete $SERVICE_ACCOUNT_ADDRESS

옵션 2: 프로젝트 삭제

전체 프로젝트를 삭제하려면 리소스 관리로 이동하여 2단계에서 만든 프로젝트를 선택하고 삭제를 선택합니다. 프로젝트를 삭제하면 Cloud SDK에서 프로젝트를 변경해야 합니다. gcloud projects list를 실행하여 사용 가능한 모든 프로젝트의 목록을 볼 수 있습니다. 명령줄을 사용하려면 다음 명령어를 사용해도 됩니다.

gcloud projects delete ${GOOGLE_CLOUD_PROJECT}