👗 Flutter, ADK Go, Gemini로 가상 피팅룸 및 AI 스타일리스트 빌드

1. 소개

빌드할 항목

이 Codelab에서는 가상의 소매업체 브랜드를 위한 Flutter 쇼핑 앱인 패션 앱을 빌드하는 개발자의 입장이 되어 봅니다. 미션: 온라인 쇼핑 경험을 혁신하는 AI 기반 기능을 두 개 추가하세요.

  1. 가상 피팅룸: 사용자가 자신의 사진을 업로드하고 의류 상품을 선택하면 해당 상품을 착용한 자신의 AI 생성 이미지가 표시됩니다.
  2. AI 스타일리스트: AI 에이전트가 사용자의 위치, 상황, 스타일 선호도를 기반으로 전체 의상 추천을 선별하며, 사용자는 대화를 통해 이를 수정할 수 있습니다.

아이디어는 간단합니다. 사람들이 탈의실에서 옷을 입어 보면 구매할 가능성이 훨씬 높아집니다. 하지만 온라인은 어떤가요? 그냥 추측하는 거예요. 이 프로젝트는 AI를 통해 이러한 격차를 해소합니다.

한눈에 보는 아키텍처

Flutter App  ──── HTTP/REST ────▶  ADK Go Backend
                                       
                            ┌──────────┼──────────┐
                       Fitting Room  Stylist    Catalog
                         Agent       Agent      Agent
                                       
                            Gemini API + Cloud Storage

핵심 기술

구성요소

기술

목적

에이전트 프레임워크

Go용 ADK (에이전트 개발 키트)

멀티 에이전트 조정, 세션, 아티팩트

에이전트 추론 (Pro)

Gemini 3.1 Pro 프리뷰

피팅룸 및 스타일리스트 상담사 지원

에이전트 추론 (Flash)

Gemini 3 Flash 프리뷰

루트 및 카탈로그 에이전트 지원 (경량 라우팅/조회)

이미지 생성

Gemini 2.5 Flash Image

테스트 및 의상 이미지 생성

프런트엔드

Flutter (Dart)

크로스 플랫폼 앱 (웹, iOS, Android)

스토리지

Google Cloud Storage

제품 이미지와 생성된 아티팩트를 저장합니다.

호스팅

Cloud Run

서버리스 컨테이너 배포

2. 📦 사전 요구사항 및 Cloud Shell 설정

1. Cloud Shell 편집기 열기

👉 브라우저에서 Cloud Shell 편집기를 엽니다.

터미널이 화면 하단에 표시되지 않는 경우 다음 단계를 따르세요.

  • 보기터미널을 클릭합니다.

2. Flutter SDK 설정

Cloud Shell은 /google/flutter에 Flutter가 사전 설치된 상태로 제공됩니다. 해당 디렉터리는 다른 시스템 사용자가 소유하므로 flutter을 처음 실행할 때 fatal: detected dubious ownership 오류가 발생합니다. 다음과 같이 git의 safe-directory 목록에 한 번 추가합니다.

git config --global --add safe.directory /google/flutter

PATH에 Flutter가 설치되어 있고 작동하는지 확인합니다.

flutter --version

첫 번째 실행에서는 Dart SDK를 다운로드하고 Flutter 도구를 빌드합니다. 잠시 기다리세요. Flutter 3.x • channel stable와 같이 표시됩니다.

3. 저장소 클론

cd ~
git clone https://github.com/gca-americas/fashion-app-demo
cd fashion_app_demo

4. 프로젝트 구조 살펴보기

fashion_app_demo/
├── adk_backend/                 # Go backend with ADK agents
   ├── main.go                  # Entry point — wires all agents + REST server
   ├── catalog/                 # Catalog Agent — product lookup
      ├── agent.go
      ├── catalog.yaml         # Product database (YAML)
      └── instructions.md      # Agent persona prompt
   ├── fittingroom/             # Fitting Room Agent — virtual try-on
      ├── agent.go
      ├── instructions.md      # Agent persona prompt
      └── tool_instructions.md # Image generation prompt
   ├── stylist/                 # Stylist Agent — outfit curation
      ├── agent.go
      └── instructions.md      # Agent persona + output format
   ├── rootagent/               # Root Agent — routes to the right agent
      └── agent.go
   └── tools/                   # Shared tools
       ├── imagetool.go         # getProductImage — loads product images
       ├── outfit_gen_tool.go   # generate_outfit_image — creates outfit images
       └── cors_helper.go      # CORS middleware + request logging

├── flutter_frontend/            # Flutter cross-platform app
   ├── lib/
      ├── main.dart            # App entry point + Provider setup
      ├── app_config.dart      # Backend URL configuration
      ├── core_app/            # Pre-built shopping app (browse, cart, etc.)
      └── workshop_tasks/      # AI feature code
          ├── step_1_try_it_on/  # Virtual Try-On flow
             ├── providers/     # TryItOnProvider (state management)
             ├── services/      # AdkFittingRoomService (HTTP calls)
             └── ui/            # Screens (product detail → try on → fitting room)
          └── step_2_style_me/   # Style Me flow
              ├── models/        # Outfit, StyleRequest data classes
              ├── providers/     # StylingProvider (state management)
              ├── services/      # AdkStylingService (HTTP calls)
              └── ui/            # Screens (form sheet → outfit carousel)
   └── assets/images/           # Product catalog images

3. ☁️ Google Cloud 프로젝트 설정

1. 새 프로젝트 만들기

gcloud projects create fashion-app-demo --name="Fashion App Demo"
gcloud config set project fashion-app-demo

결제 계정을 나열합니다.

gcloud billing accounts list

OPEN

열을 기준으로 파티션을 나눕니다. True이라고 표시되어야 합니다. False라고 표시되면 (무료 체험이 만료된 경우에 흔함) 계정이 폐쇄되어 실제로 아무것도 결제하지 않습니다. 계속하기 전에 아래의 문제 해결 블록으로 건너뛰세요.

OPEN: True 계정의 ACCOUNT_ID을 복사하고 (0X0X0X-0X0X0X-0X0X0X와 유사) 프로젝트에 연결합니다.

gcloud billing projects link fashion-app-demo \
 --billing-account=YOUR_BILLING_ACCOUNT_ID

링크를 확인합니다.

gcloud billing projects describe fashion-app-demo

billingEnabled: true이 표시됩니다. 연결 후에도 billingEnabled: false가 표시되면 계정이 폐쇄된 것입니다 (OPEN: False). 아래의 문제 해결 블록을 참고하세요.

3. 필수 API 사용 설정

gcloud services enable \
 aiplatform.googleapis.com \
 storage.googleapis.com \
 run.googleapis.com \
 cloudbuild.googleapis.com \
 artifactregistry.googleapis.com

API

목적

aiplatform.googleapis.com

Vertex AI: fitting_tool가 Vertex AI를 통해 Gemini의 이미지 생성을 호출합니다.

storage.googleapis.com

Cloud Storage: 제품 카탈로그 이미지와 생성된 가상 체험 결과를 저장합니다.

run.googleapis.com

Cloud Run: 백엔드를 서버리스 컨테이너로 호스팅합니다.

cloudbuild.googleapis.com

Cloud Build - 소스에서 Docker 이미지를 빌드합니다.

artifactregistry.googleapis.com

Artifact Registry - 빌드된 Docker 이미지를 저장합니다.

4. GCS 버킷 만들기

export PROJECT_ID=$(gcloud config get-value project)
gcloud storage buckets create gs://fashion-app-$PROJECT_ID \
 --location=us-central1 \
 --uniform-bucket-level-access

5. 제품 카탈로그 이미지 업로드

백엔드의 getProductImage 도구는 gs://$GCS_BUCKET/catalog-assets/images/에서 읽습니다. 카탈로그 이미지를 정확한 경로에 업로드합니다.

cd ~/fashion_app_demo
gcloud storage cp flutter_frontend/assets/images/*.png \
 gs://fashion-app-$PROJECT_ID/catalog-assets/images/

업로드를 확인합니다 (.png 파일 목록이 표시됨).

gcloud storage ls gs://fashion-app-$PROJECT_ID/catalog-assets/images/

6. .env 파일 구성

cd ~/fashion_app_demo/adk_backend
cat > .env << EOF
GOOGLE_CLOUD_PROJECT=$PROJECT_ID
GCS_BUCKET=fashion-app-$PROJECT_ID
EOF

7. 애플리케이션 기본 사용자 인증 정보로 인증

로컬에서 백엔드를 시작하기 전에 이 명령어를 실행해야 합니다. Go 백엔드는 ADC를 사용하여 Vertex AI (Gemini) 및 Cloud Storage에 대한 모든 호출을 인증합니다. ADC가 없으면 백엔드가 시작되지만 모든 가상 체험 요청이 401 CREDENTIALS_MISSING로 실패합니다.

하나의 사용자 인증 정보로 두 서비스를 모두 사용할 수 있습니다. 다음 두 명령어를 순서대로 실행합니다.

# 1. Log in (opens a browser; in Cloud Shell, paste the verification code back)
gcloud auth application-default login

# 2. Attach your project as the quota / billing project for ADC
gcloud auth application-default set-quota-project $(gcloud config get-value project)

ADC가 정상인지 확인합니다.

gcloud auth application-default print-access-token | head -c 20 && echo "..."

토큰의 약 20개 문자와 ...가 표시됩니다. 오류가 발생하면 로그인이 적용되지 않은 것이므로 1단계를 다시 실행합니다.

4. 🏗️ 아키텍처 개요

이제 환경이 준비되었으므로 코드를 살펴보기 전에 시스템 작동 방식을 알아보겠습니다.

4개 에이전트 시스템

백엔드는 Go용 ADK (에이전트 개발 키트)를 사용하여 멀티 에이전트 시스템으로 빌드됩니다. 4개의 에이전트가 각각 특정 책임을 지고 함께 작동합니다.

                   ┌──────────────┐
                    Root Agent      Routes requests to the right agent
                    (gemini-3-   
                     flash-      
                     preview)    
                   └──────┬───────┘
                          
           ┌──────────────┼──────────────┐
                                       
  ┌────────────────┐ ┌──────────┐ ┌────────────────┐
   Fitting Room     Catalog    Stylist        
   Agent            Agent      Agent          
   (gemini-3.1-     (gemini-   (gemini-3.1-   
    pro-preview)     3-flash-   pro-preview)  
                     preview)                 
   Tools:                      Tools:         
    fitting_tool   Tools:      fitting_tool 
    getProduct      list      getProduct   
     Image          Products     Image        
    catalog_agent   get       catalog_agent
     (delegation)   Product      (delegation) 
                     Image      generate_    
  └────────────────┘ └──────────┘    outfit_image 
                                  └────────────────┘

에이전트

모델

역할

Root Agent

gemini-3-flash-preview

교통 경찰관. 사용자의 메시지를 읽고 적합한 전문가 상담사에게 위임합니다. 라우팅 결정만 내리면 되므로 빠르고 가벼운 모델을 사용합니다.

카탈로그 에이전트

gemini-3-flash-preview

제품 전문가 YAML 파일에서 제품 카탈로그를 로드하고 제품 질문에 답변합니다. 또한 경량입니다. 데이터를 조회하기만 합니다.

Fitting Room Agent

gemini-3.1-pro-preview

가상 테스트 전문가 사용자 사진과 제품 이미지를 가져와 사람이 해당 상품을 착용한 합성 이미지를 생성합니다. 이미지에 대해 추론해야 하므로 더 강력한 모델을 사용합니다.

Stylist Agent

gemini-3.1-pro-preview

패션 어드바이저 위치, 상황, 선호도를 고려하여 카탈로그에서 3가지 의상 조합을 선별합니다. 각 의상에 대한 착용 이미지를 생성할 수 있습니다. 또한 광고 소재 추론을 위해 유능한 모델을 사용합니다.

진입점: main.go

모든 것은 에이전트를 연결하고 HTTP 서버를 시작하는 main.go에서 시작됩니다.

// main.go  simplified for clarity


func main() {
   godotenv.Load()  // Load .env file


   // 1. Create the artifact storage (GCS-backed)
   artifacts, _ := gcsartifact.NewService(ctx, bucket)


   // 2. Build agents bottom-up (dependencies first)
   catagent, _    := catalog.NewCatalogAgent(apikey, "catalog/catalog.yaml")
   fitagent, _    := fittingroom.NewFittingRoomAgent(apikey, catagent)
   stylistAgent, _ := stylist.NewStylistAgent(apikey, catagent)
   ragent, _      := rootagent.NewRootAgent(apikey, fitagent, catagent, stylistAgent)


   // 3. Register all agents with a multi-loader
   loader, _ := agent.NewMultiLoader(ragent, fitagent, catagent, stylistAgent)


   // 4. Create the ADK REST server
   restHandler, _ := adkrest.NewServer(adkrest.ServerConfig{
       SessionService:  session.InMemoryService(),
       MemoryService:   memory.InMemoryService(),
       AgentLoader:     loader,
       ArtifactService: artifacts,
   })


   // 5. Mount behind /api/ with CORS support
   r := mux.NewRouter()
   r.Use(tools.LocalhostCORS)
   r.PathPrefix("/api/").Handler(
       http.StripPrefix("/api", tools.LogHandler(restHandler)))


   http.Server{Addr: ":8080", Handler: r}.ListenAndServe()
}

다음은 몇 가지 중요한 사항입니다.

  • 에이전트는 하향식으로 빌드됨: 피팅룸 에이전트와 스타일리스트 에이전트가 모두 카탈로그 에이전트에 의존하므로 (제품 조회를 카탈로그 에이전트에 위임) 카탈로그 에이전트가 먼저 생성됩니다.
  • agent.NewMultiLoader는 네 가지 에이전트를 모두 등록하므로 REST API가 이름으로 에이전트 중 하나로 라우팅할 수 있습니다.
  • adkrest.NewServer는 REST API를 자동으로 제공하므로 엔드포인트 핸들러를 직접 작성하지 않아도 됩니다. ADK는 세션 관리, 아티팩트 스토리지, 에이전트 실행을 기본적으로 제공합니다.
  • session.InMemoryService()은 세션을 메모리에 저장합니다. 즉, 서버가 다시 시작되면 세션이 손실되지만 데모에서는 괜찮습니다. 프로덕션에서는 영구 저장소를 사용합니다.
  • gcsartifact.NewService는 Google Cloud Storage에 아티팩트 (생성된 이미지)를 저장하므로 요청 간에 유지되고 GCS URI를 통해 공유할 수 있습니다.

5. 🤖 ADK (에이전트 개발 키트) 심층 분석

ADK란 무엇인가요?

에이전트 개발 키트 (ADK)는 Go (및 Python/Java)에서 AI 에이전트를 빌드하기 위한 Google의 오픈소스 프레임워크입니다. 애플리케이션과 Gemini API 사이의 레이어입니다.

Gemini API를 직접 호출할 수 있습니다. 하지만 앱이 다음을 수행해야 하는 경우

  • 카탈로그에서 제품 검색
  • 사용자 사진을 기반으로 이미지 생성
  • 이전에 추천된 의상을 기억합니다.
  • 여러 AI 에이전트 조정

구조가 필요합니다. ADK는 이러한 구조를 제공합니다.

에이전트 루프

모든 ADK 에이전트는 다음 루프를 따릅니다.

1. Receive a message (from user or another agent)
2. Think  the LLM reasons about what to do
3. Act  call a tool, delegate to a sub-agent, or respond
4. Return  send the result back

이 루프는 단일 요청 내에서 여러 번 반복될 수 있습니다. 예를 들어 스타일리스트 에이전트는 다음 작업을 할 수 있습니다.

  1. '해변 휴가에 어울리는 스타일을 추천해 줘'
  2. catalog_agent 도구를 호출하여 제품 목록 가져오기
  3. 의상 조합 3개 선택
  4. 각 의상에 대해 fitting_tool를 호출하여 이미지를 생성합니다.
  5. 구조화된 JSON 응답 반환

핵심 개념 (이 저장소의 코드로)

LLM 에이전트

기본 구성요소입니다. llmagent.New()로 만든 이미지:

// From catalog/agent.go
agent, err := llmagent.New(llmagent.Config{
   Name:        "catalog_agent",                    // Unique identifier
   Model:       m,                                  // Which LLM to use
   Description: "An agent that can search and list products from the catalog",
   Instruction: instructions,                       // Persona prompt (embedded from .md file)
   Tools:       []tool.Tool{listTool, imageTool},   // What the agent can do
})

Instruction 필드는 에이전트의 페르소나입니다. LLM에게 에이전트가 누구인지, 어떻게 행동해야 하는지 알려줍니다. 이 저장소에서 안내는 마크다운 파일로 작성되고 컴파일 시간에 Go의 //go:embed 지시어를 사용하여 삽입됩니다.

//go:embed instructions.md
var instructions string

이렇게 하면 프롬프트가 인라인 문자열이 아닌 별도의 버전 관리 가능한 문서로 유지됩니다.

도구

도구는 LLM이 호출할 수 있는 Go 함수입니다. ADK는 LLM의 도구 호출 형식과 입력된 Go 함수 간의 변환을 처리합니다.

// From catalog/agent.go
type ListProductsArgs struct{}                    // Input (can be empty)
type ListProductsResult struct {
   Products []Product `json:"products"`          // Output
}


func ListProducts(ctx tool.Context, args ListProductsArgs) (ListProductsResult, error) {
   return ListProductsResult{Products: catalogProducts}, nil
}


// Register it:
listTool, _ := functiontool.New(functiontool.Config{
   Name:        "listProducts",
   Description: "list all products in the catalog",
}, ListProducts)

ADK는 Go 구조체에서 JSON 스키마를 자동으로 생성하여 LLM에 전송합니다. LLM이 listProducts를 호출하기로 결정하면 ADK가 인수를 역직렬화하고 함수를 호출하고 결과를 다시 보냅니다.

tool.Context 매개변수를 사용하면 도구가 ADK의 런타임 서비스(가장 중요한 아티팩트)에 액세스할 수 있습니다.

// Save an image as an artifact
ctx.Artifacts().Save(ctx, "my_image", imagePart)


// Load an artifact
resp, _ := ctx.Artifacts().Load(ctx, "my_image")

하위 에이전트 위임

에이전트는 agenttool.New()를 통해 다른 에이전트를 도구로 사용할 수 있습니다.

// From fittingroom/agent.go
Tools: []tool.Tool{
   loadartifactstool.New(),              // List available artifacts
   imgtool,                              // Get product images
   agenttool.New(catalogAgent, nil),     // Delegate to catalog agent
   fittingTool,                          // Generate try-on image
},

피팅룸 에이전트가 제품 정보가 필요한 경우 일반 도구인 것처럼 카탈로그 에이전트를 호출할 수 있습니다. LLM은 도구 목록에서 이를 확인하고 호출할 수 있습니다.

세션

세션은 대화 기록을 추적합니다. ADK의 REST API는 이를 자동으로 관리합니다.

POST /api/apps/{appName}/users/{userId}/sessions    Creates a new session
POST /api/run  (with sessionId)                     Runs agent within that session

이 앱의 중요한 설계 결정은 피팅룸은 요청당 새 세션을 생성 (각 가상 착용은 독립적임)하는 반면 스타일리스트는 동일한 세션을 재사용 (이전 추천을 기억하고 피드백을 기반으로 개선할 수 있음)한다는 것입니다.

상태는 세션에 연결된 키-값 저장소입니다. 에이전트는 상태를 읽고 써서 조정합니다.

// Write to state
ctx.State().Set("previously_used_products", "[\"id_bomber\",\"id_hat\"]")


// Read from state
val, err := ctx.State().Get("previously_used_products")

스타일리스트 에이전트는 상태를 사용하여 이미 추천한 제품을 기억하므로 다음에 다른 제품을 선택합니다.

아티팩트

아티팩트는 세션별로 저장되는 바이너리 객체 (일반적으로 이미지)입니다. 텍스트 응답과 달리 별도로 저장되고 이름으로 가져옵니다.

// Save a generated image as an artifact
artName := fmt.Sprintf("generated_fitting_%s_%s", ctx.InvocationID(), uuid.NewString()[:8])
ctx.Artifacts().Save(ctx, artName, imagePart)


// The frontend fetches it via:
// GET /api/apps/{app}/users/{user}/sessions/{session}/artifacts/{artName}

이렇게 하면 응답이 경량화됩니다. 에이전트는 아티팩트 이름만 반환하고 프런트엔드는 바이너리 이미지 데이터를 별도로 가져옵니다.

콜백

콜백은 에이전트 루프의 특정 시점에 실행되는 후크입니다. 실행을 검사하거나 수정하거나 단락시킬 수 있습니다.

llmagent.Config{
   // Runs before the agent starts  used to save uploaded images
   BeforeAgentCallbacks: []agent.BeforeAgentCallback{SaveIncomingBlobs},


   // Runs before each LLM call  used to inject context
   BeforeModelCallbacks: []llmagent.BeforeModelCallback{
       ExtractAndInjectUserImage,
       InjectPreviousProducts,
   },


   // Runs after each LLM response  used to extract data
   AfterModelCallbacks: []llmagent.AfterModelCallback{SaveSelectedProducts},
}

콜백이 nil이 아닌 응답을 반환하면 기본 동작이 건너뛰어집니다. 예를 들어 캐시된 응답을 반환하는 BeforeModelCallback는 실제 LLM 호출을 완전히 건너뜁니다.

JSON 스키마 적용

피팅룸과 스타일리스트 상담사 모두 LLM이 구조화된 JSON으로 응답하도록 강제합니다.

GenerateContentConfig: &genai.GenerateContentConfig{
   ResponseMIMEType:   "application/json",
   ResponseJsonSchema: fittingSchemaMap(),  // Defines the expected structure
}

이렇게 하면 Flutter 프런트엔드가 자유 형식 텍스트가 아닌 파싱 가능한 데이터를 항상 수신할 수 있습니다.

카탈로그 에이전트: 가장 간단한 예

카탈로그 에이전트 (catalog/agent.go)는 시스템에서 가장 간단한 에이전트이며 ADK 패턴을 이해하기에 좋은 시작점입니다.

여기에는 두 가지 도구가 있습니다.

  1. listProducts - YAML 파일에서 전체 제품 카탈로그를 반환합니다.
  2. getProductImage - GCS (또는 로컬 대체)에서 제품 이미지를 로드하고 아티팩트로 저장합니다.

getProductImage 도구는 중요한 패턴인 아티팩트 캐싱을 사용한 다중 소스 로드를 보여줍니다.

// From tools/imagetool.go  simplified
func GetProductImage(ctx tool.Context, args GetProductImageArgs) (GetProductImageResult, error) {
   // First: check if it's already an artifact
   _, err := ctx.Artifacts().Load(ctx, args.ImageName)
   if err == nil {
       return GetProductImageResult{Image: args.ImageName}, nil  // Cache hit!
   }


   // Second: try loading from GCS bucket
   rc, err := client.Bucket(bucket).Object("catalog-assets/images/" + args.ImageName).NewReader(ctx)
   if err != nil {
       // Third: fall back to local filesystem
       localData, _ := os.ReadFile("../flutter_frontend/assets/images/" + args.ImageName)
       ctx.Artifacts().Save(ctx, args.ImageName, &genai.Part{InlineData: ...})
       return GetProductImageResult{Image: args.ImageName}, nil
   }


   // Save to artifact cache and return
   ctx.Artifacts().Save(ctx, args.ImageName, &genai.Part{InlineData: ...})
   return GetProductImageResult{Image: args.ImageName}, nil
}

이 도구는 먼저 아티팩트를 시도한 다음 GCS, 로컬 파일을 시도합니다. 이미지가 로드되면 아티팩트로 캐시되므로 후속 호출은 즉시 이루어집니다.

6. 🧪 AI 파이프라인: 에이전트의 활용

이제 실제로 이미지를 생성하고 의상을 선별하는 가장 정교한 두 가지 에이전트를 살펴보겠습니다.

6.1 피팅룸 에이전트

파일:

adk_backend/fittingroom/agent.go

피팅룸 에이전트는 '가상 테스트'의 기반이 되는 엔진입니다. 사용자가 사진을 업로드하고 제품을 선택하면 이 에이전트가 사람이 해당 제품을 착용한 합성 이미지를 생성합니다.

fitting_tool - 단계별

핵심 로직은 doFitting 함수에 있습니다. 상담사가 이를 호출하면 다음과 같은 상황이 발생합니다.

1단계: 사용자 이미지 확인

func doFitting(ctx tool.Context, args FittingToolArgs) (FittingToolResult, error) {
   if len(args.Accessories) > 2 {
       args.Accessories = args.Accessories[:2]  // Safety limit: max 2 items
   }


   var userPart *genai.Part
   if strings.HasPrefix(args.UserImage, "gs://") {
       // If we have a GCS URI from a previous fitting, use it directly
       userPart = &genai.Part{FileData: &genai.FileData{
           FileURI:  args.UserImage,
           MIMEType: gcsURIMimeType(args.UserImage),
       }}
   } else {
       // Otherwise, load the image from artifact storage
       userImgResp, err := ctx.Artifacts().Load(ctx, args.UserImage)
       userPart = userImgResp.Part
   }

사용자 이미지는 다음 두 소스에서 가져올 수 있습니다.

  • 아티팩트 이름 (예: upload_abc123_1) - SaveIncomingBlobs 콜백으로 저장된 초기 업로드입니다.
  • gs:// URI: 이전에 생성된 피팅 결과로, 교차 세션 재사용을 위해 GCS에 저장됩니다.

이 이중 경로 설계는 의도적인 것입니다. 스타일리스트 에이전트가 나중에 의상 가상 체험을 생성할 때 초기 가상 피팅룸 결과의 GCS URL을 재사용하므로 사용자의 신원이 모든 의상에서 일관되게 유지됩니다.

2단계: 멀티모달 프롬프트 빌드

   parts := []*genai.Part{
       genai.NewPartFromText(toolInstructions),           // Identity preservation prompt
       genai.NewPartFromText("Reference Person Photo:"),
       userPart,                                          // The user's photo
   }


   for _, acc := range args.Accessories {
       accResp, _ := ctx.Artifacts().Load(ctx, acc)       // Load product image artifact
       parts = append(parts, genai.NewPartFromText("Product Image to Apply:"))
       parts = append(parts, accResp.Part)                // The product photo
   }

toolInstructions (tool_instructions.md에서 삽입됨)는 매우 중요합니다. Gemini에게 옷 항목만 적용하면서 사용자의 정체성 (얼굴, 체형, 피부색, 머리카락)을 유지하도록 지시합니다. 이 프롬프트 엔지니어링이 없으면 모델이 사람의 외모를 변경할 수 있습니다.

3단계: 이미지 생성을 위해 Gemini 호출

   client, _ := genai.NewClient(ctx, &genai.ClientConfig{
       Backend:  genai.BackendVertexAI,                 // Vertex AI endpoint
       Project:  os.Getenv("GOOGLE_CLOUD_PROJECT"),     // From your .env
       Location: "global",                              // Multi-region endpoint
   })


   resp, _ := client.Models.GenerateContent(ctx, "gemini-2.5-flash-image",
       []*genai.Content{genai.NewContentFromParts(parts, "user")},
       &genai.GenerateContentConfig{
           ResponseModalities: []string{"TEXT", "IMAGE"},  // Request both text and image output
           Temperature:        genai.Ptr(float32(0.2)),    // Low temperature for consistency
       })

네 명의 에이전트와 이미지 생성 도구는 모두 단일 인증 경로(Backend: genai.BackendVertexAI, 프로젝트 ID 사용, 애플리케이션 기본 사용자 인증 정보를 통해 인증)를 공유합니다. 오케스트레이션 모델 (gemini-3.1-pro-preview, gemini-3-flash-preview)과 이미지 모델 (gemini-2.5-flash-image)은 모두 동일한 Vertex AI 엔드포인트 뒤에 있으며 동일한 ADC가 Cloud Storage 액세스 권한을 부여합니다. 즉, 모든 호출에 하나의 사용자 인증 정보가 사용됩니다.

4단계: 결과 저장하기

   // Find the image in the response (may contain both text + image parts)
   var genPart *genai.Part
   for _, p := range resp.Candidates[0].Content.Parts {
       if p.InlineData != nil {
           genPart = p
           break
       }
   }


   // Save as an ADK artifact
   artName := fmt.Sprintf("generated_fitting_%s_%s", ctx.InvocationID(), uuid.NewString()[:8])
   ctx.Artifacts().Save(ctx, artName, genPart)


   // Also upload to GCS for cross-session reuse
   objectName := fmt.Sprintf("generated-fittings/%s.jpg", ctx.InvocationID())
   w := storageClient.Bucket(bucket).Object(objectName).NewWriter(ctx)
   w.Write(genPart.InlineData.Data)
   gcsURI := fmt.Sprintf("gs://%s/%s", bucket, objectName)


   return FittingToolResult{ArtifactName: artName, GCSUrl: gcsURI}, nil

이중 저장 (아티팩트 + GCS)은 피팅룸과 스타일리스트 간의 에이전트 핸드오프의 핵심입니다. 아티팩트는 현재 세션 내에서 즉시 액세스를 제공하는 반면 GCS URI를 사용하면 스타일리스트 (다른 세션에서 실행됨)가 나중에 동일한 이미지를 참조할 수 있습니다.

SaveIncomingBlobs 콜백

상담사가 추론을 시작하기 전에 이 BeforeAgentCallback가 실행되어 사용자가 업로드한 이미지를 저장합니다.

func SaveIncomingBlobs(ctx agent.CallbackContext) (*genai.Content, error) {
   for pindex, p := range ctx.UserContent().Parts {
       if p.InlineData != nil {
           aname := fmt.Sprintf("upload_%s_%d", ctx.InvocationID(), pindex)
           ctx.Artifacts().Save(ctx, aname, p)
       }
   }
   return nil, nil  // Return nil to proceed with normal agent execution
}

(nil, nil)를 반환하면 콜백은 '전처리 완료. 이제 에이전트를 정상적으로 실행하세요'라고 신호를 보냅니다. nil이 아닌 콘텐츠를 반환하면 에이전트가 완전히 단락됩니다.

6.2 스타일리스트 에이전트

파일:

adk_backend/stylist/agent.go

스타일리스트 에이전트는 시스템에서 가장 정교합니다. 맞춤 의상 추천을 선별하고 대화를 통해 반복적인 개선을 지원합니다.

세 가지 콜백 - 스타일리스트의 메모리

스타일리스트는 세 가지 콜백을 사용하여 멀티턴 대화에서 컨텍스트를 유지합니다.

콜백 1:

InjectPreviousProducts (BeforeModel)

문제: 사용자가 '다른 옵션을 보여 줘'라고 말하면 LLM은 이미 추천한 제품을 기본적으로 추적하지 않기 때문에 동일한 제품을 다시 추천할 수 있습니다.

해결 방법: 각 응답 후 제품 ID가 세션 상태에 저장됩니다. 다음 LLM 호출 전에 이 콜백이 이를 읽고 힌트를 삽입합니다.

func InjectPreviousProducts(ctx agent.CallbackContext, req *model.LLMRequest) (*model.LLMResponse, error) {
   prev, err := ctx.State().Get(stateKeyPreviousProducts)  // Read from session state
   if err != nil {
       return nil, nil  // No previous state  first run
   }


   // Append hint to the user's message
   for i := len(req.Contents) - 1; i >= 0; i-- {
       if req.Contents[i].Role == "user" {
           req.Contents[i].Parts = append(req.Contents[i].Parts,
               genai.NewPartFromText(fmt.Sprintf(
                   "IMPORTANT: You previously suggested these products: %s. "+
                   "You MUST pick DIFFERENT complementary products this time.", prev)))
           break
       }
   }
   return nil, nil  // Continue to LLM call
}

콜백 2:

ExtractAndInjectUserImage (BeforeModel)

문제: 사용자가 의견 ('더 캐주얼하게 만들어 줘')을 제공하면 후속 메시지에 사용자의 사진이 다시 포함되지 않습니다. 하지만 피팅 도구에는 필요합니다.

해결 방법: 첫 번째 요청에서 이 콜백은 사용자 이미지 참조를 추출하여 상태에 저장합니다. 후속 요청에서는 다시 삽입합니다.

func ExtractAndInjectUserImage(ctx agent.CallbackContext, req *model.LLMRequest) (*model.LLMResponse, error) {
   var foundImgStr string


   // Search for user image in the latest message
   for i := len(req.Contents) - 1; i >= 0; i-- {
       if req.Contents[i].Role == "user" {
           for _, part := range req.Contents[i].Parts {
               if strings.Contains(part.Text, "User try-on base image") {
                   foundImgStr = part.Text  // Found the GCS URI reference
               }
           }
           break
       }
   }


   if foundImgStr != "" {
       ctx.State().Set(stateKeyUserImageStr, foundImgStr)  // Save for later
   } else {
       // Not in current message  retrieve from state and inject
       val, _ := ctx.State().Get(stateKeyUserImageStr)
       if savedImgStr, ok := val.(string); ok {
           // Inject into the latest user message
           req.Contents[last].Parts = append(req.Contents[last].Parts,
               genai.NewPartFromText("REMINDER: Use this image: " + savedImgStr))
       }
   }
   return nil, nil
}

콜백 3:

SaveSelectedProducts (AfterModel)

LLM이 의상 추천으로 응답하면 이 콜백은 JSON을 파싱하여 제품 ID를 추출하고 다음번에 InjectPreviousProducts 콜백에서 사용할 수 있도록 저장합니다.

func SaveSelectedProducts(ctx agent.CallbackContext, resp *model.LLMResponse, respErr error) (*model.LLMResponse, error) {
   for _, part := range resp.Content.Parts {
       ids := extractProductIDs(part.Text)  // Parse JSON  extract product IDs
       if len(ids) > 0 {
           data, _ := json.Marshal(ids)
           ctx.State().Set(stateKeyPreviousProducts, string(data))  // Save to state
       }
   }
   return nil, nil  // Don't modify the response
}

이 세 가지 콜백은 함께 피드백 루프를 만듭니다.

Request 1:  User sends styling request + user image
            ExtractAndInjectUserImage SAVES image to state
            LLM generates 3 outfits
            SaveSelectedProducts SAVES product IDs to state


Request 2:  User says "make it more casual"
            ExtractAndInjectUserImage INJECTS saved image into prompt
            InjectPreviousProducts INJECTS "don't reuse these IDs"
            LLM generates 3 NEW outfits
            SaveSelectedProducts UPDATES product IDs in state

6.3 루트 에이전트

파일:

adk_backend/rootagent/agent.go

가장 간단한 에이전트(31줄)는 다음과 같습니다.

func NewRootAgent(project string, fittingAgent, catalogAgent, stylistAgent agent.Agent) (agent.Agent, error) {
   m, _ := gemini.NewModel(ctx, "gemini-3-flash-preview", &genai.ClientConfig{
       Backend:  genai.BackendVertexAI,
       Project:  project,
       Location: "global",
   })


   return llmagent.New(llmagent.Config{
       Name:        "root_agent",
       Model:       m,
       Description: "A root agent that delegates to other agents",
       Instruction: "You are a helpful shopping assistant. If the user asks about fitting " +
           "items or generating images, delegate to the fitting room agent. If the user " +
           "asks about products, delegate to the catalog agent. If the user asks for " +
           "styling advice, delegate to the stylist agent.",
       SubAgents: []agent.Agent{fittingAgent, catalogAgent, stylistAgent},
   })
}

라우팅 결정이 간단하기 때문에 가장 빠른 모델인 gemini-3-flash-preview을 사용합니다. LLM은 사용자의 인텐트를 읽고 올바른 하위 에이전트를 선택하기만 하면 됩니다. 도구가 필요하지 않습니다. SubAgents에서 위임을 자동으로 처리합니다.

7. 📱 Flutter 프런트엔드 아키텍처

Flutter 프런트엔드는 완전한 기능을 갖춘 소매 쇼핑 앱입니다. AI 기능은 core_app/의 사전 빌드된 쇼핑 환경과 별도로 flutter_frontend/lib/workshop_tasks/에 상주합니다.

MVVM 패턴

앱은 제공자 패키지를 사용하여 Model-View-ViewModel 아키텍처를 따릅니다.

┌──────────────────┐    ┌────────────────────┐    ┌──────────────────┐
  View (Widget)   │◀───│ ViewModel (Provider)│◀───│  Service (HTTP)  
                                                                 
  Renders UI           Holds state             Makes API calls
  User gestures  │───▶│  Business logic    │───▶│  Parses response
  Listens for          notifyListeners()       Returns data   
   state changes                                                 
└──────────────────┘    └────────────────────┘    └──────────────────┘

각 레이어에는 명확한 역할이 있습니다.

  • 모델: Product, Outfit, StyleRequest과 같은 데이터 클래스, TryOnState과 같은 enum
  • ViewModel (ChangeNotifier): 현재 상태를 보유하고 notifyListeners()를 통해 UI에 변경사항을 브로드캐스트합니다.
  • (위젯): context.watch()로 ViewModel을 구독하고 상태가 변경되면 다시 빌드합니다.
  • 서비스: ADK 백엔드에 HTTP 호출을 실행하고 유형이 지정된 데이터를 반환합니다.

서비스 레이어

서비스는 ADK별 구현이 있는 추상 인터페이스로 정의됩니다.

// Abstract interface  defines WHAT the service does
abstract class TryItOnService {
 Future<(Uint8List?, String?)> generateTryOnImage(
   Uint8List userImageBytes,
   Uint8List productImageBytes,
 );
}


// Concrete implementation  defines HOW (via ADK REST API)
class AdkFittingRoomService implements TryItOnService { ... }

이 분리를 통해 앱의 나머지 부분을 변경하지 않고 ADK 백엔드를 Firebase AI, 모의 서비스 또는 기타 구현으로 바꿀 수 있습니다.

3단계 API 패턴

AdkFittingRoomServiceAdkStylingService 모두 ADK 백엔드와 통신하기 위해 동일한 패턴을 따릅니다.

1단계: 세션 만들기

Future<String> _createSession() async {
 final url = Uri.parse(
   '$_baseUrl/apps/${Uri.encodeComponent(_appName)}/users/$_userId/sessions');
 final response = await _client.post(url,
   headers: {'Content-Type': 'application/json'},
   body: jsonEncode({}));
 final body = jsonDecode(response.body) as Map<String, dynamic>;
 return body['id'] as String;  // Returns the session ID
}

2단계: 에이전트 실행

Future<(String?, String?)> _runAgent({required String sessionId, ...}) async {
 final requestBody = jsonEncode({
   'appName': _appName,
   'userId': _userId,
   'sessionId': sessionId,
   'newMessage': {
     'role': 'user',
     'parts': [
       {'text': 'Generate a virtual try-on...'},
       {'inlineData': {'mimeType': 'image/jpeg', 'data': base64Encode(userImageBytes)}},
       {'inlineData': {'mimeType': 'image/png', 'data': base64Encode(productImageBytes)}},
     ],
   },
 });
 final response = await _client.post(Uri.parse('$_baseUrl/run'), body: requestBody);
 // Parse response events for artifact name and GCS URL...
}

3단계: 아티팩트 가져오기

Future<Uint8List?> _loadArtifact({required String sessionId, required String artifactName}) async {
 final url = Uri.parse(
   '$_baseUrl/apps/$_appName/users/$_userId/sessions/$sessionId/artifacts/$artifactName');
 final response = await _client.get(url);
 final part = jsonDecode(response.body) as Map<String, dynamic>;
 final data = part['inlineData']['data'] as String;
 return base64Decode(data);  // Returns raw image bytes
}

중요한 설계 차이점은 피팅룸 서비스는 요청마다 새 세션을 생성 (_createSession()이 매번 호출됨)하는 반면 스타일링 서비스는 동일한 세션을 재사용 (_sessionId ??= await _createSession())하여 다중 턴 대화를 지원한다는 점입니다.

상태 관리: TryItOnProvider

파일:

workshop_tasks/step_1_try_it_on/providers/try_it_on_provider.dart

TryItOnProvider는 전체 가상 체험 흐름을 관리합니다. TryOnState 열거형을 상태 시스템으로 사용합니다.

enum TryOnState { initial, imagePicked, generating, success, error }


class TryItOnProvider with ChangeNotifier {
 TryOnState _state = TryOnState.initial;
 Uint8List? _userImageBytes;
 Uint8List? _generatedImage;
 String? _errorMessage;

비공개 상태 전환은 일관성을 보장합니다. 오래된 데이터를 삭제하고 UI에 알리지 않고는 상태를 업데이트할 수 없습니다.

 void _setGenerating() {
   _state = TryOnState.generating;
   _errorMessage = null;            // Clear any previous error
   _wasLastGenerationCached = false;
   notifyListeners();               // Tell the UI to rebuild
 }


 void _setSuccess(Uint8List image, {bool isCached = false}) {
   _generatedImage = image;
   _errorMessage = null;
   _wasLastGenerationCached = isCached;
   _state = TryOnState.success;
   notifyListeners();
 }

주 생성 메서드는 이 모든 것을 연결합니다.

 Future<String?> generateTryOnImage(String productImagePath) async {
   final userImageBytes = _userImageBytes;
   if (userImageBytes == null) {
     _setError('No image selected.');
     return _errorMessage;
   }


   // Check local cache first
   final cachedImage = ImageUtils.getCachedImage(_sessionCache, userImageBytes, productUint8List);
   if (cachedImage != null) {
     _setSuccess(cachedImage, isCached: true);
     return null;
   }


   _setGenerating();  // Triggers loading state in UI


   try {
     final (generatedBytes, gcsUrl) = await _aiService.generateTryOnImage(
       userImageBytes, productUint8List);
     if (generatedBytes != null) {
       _fittingGcsUrl = gcsUrl;     // Save for the stylist agent later
       _setSuccess(generatedBytes);
     }
   } catch (e) {
     _setError(e.toString());
   }
   return _state == TryOnState.success ? null : _errorMessage;
 }

UI: 화면을 상태 라우터로 사용

파일:

workshop_tasks/step_1_try_it_on/ui/2_try_it_on_screen.dart

가상 체험 화면은 Dart 3의 패턴 매칭과 AnimatedSwitcher를 사용하여 제공업체의 상태에 따라 하위 화면 간에 라우팅합니다.

class TryItOnScreen extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   final tryOnProvider = context.watch<TryItOnProvider>();


   return ScaffoldWithBackgroundNoise(
     appBar: const TryOnAppBar(),
     body: AnimatedSwitcher(
       duration: AppDurations.medium,
       child: switch (tryOnProvider.state) {
         TryOnState.initial || TryOnState.error => const ChooseImageScreen(),
         TryOnState.imagePicked || TryOnState.generating => LoadingScreen(
           userImage: tryOnProvider.userImageBytes),
         TryOnState.success => const FittingRoomScreen(),
       },
     ),
   );
 }
}

context.watch()는 제공업체를 구독합니다. notifyListeners()가 호출될 때마다 이 위젯이 다시 빌드되고 AnimatedSwitcher가 화면 간에 부드럽게 전환됩니다. Navigator.push가 없습니다. 상태 enum에 따라 화면 콘텐츠가 제자리에서 변경됩니다.

에이전트 핸드오프: 피팅룸 → 스타일리스트

가장 흥미로운 UX 패턴은 앱이 피팅룸 에이전트에서 스타일리스트 에이전트로 컨텍스트를 전달하는 방식입니다.

5_fitting_room.dart에서 가상 체험 이미지가 생성된 후 '스타일 추천' 버튼을 누르면 양식이 열립니다. 사용자가 제출하는 경우:

// From 1_style_me_form_sheet.dart
Navigator.pop(context, StyleRequest(
 location: _locationController.text.trim(),
 occasion: _occasionController.text.trim(),
 notes: _notesController.text.trim(),
 gcsUserImageUrl: provider.fittingGcsUrl,          // GCS URI from fitting result
 userImageData: provider.fittingGcsUrl == null
     ? provider.userImageBytes : null,              // Fallback to raw bytes
 selectedProductId: provider.selectedProduct?.id,   // Product they already tried on
 selectedProductTitle: provider.selectedProduct?.title,
));

StyleRequest는 스타일리스트에게 필요한 모든 것을 번들로 제공합니다.

  • 위치 및 상황 - 스타일 지정을 위한 텍스트 컨텍스트
  • GCS 사용자 이미지 URL: 스타일리스트가 정확히 동일한 사용자 표현을 재사용할 수 있습니다.
  • 선택한 제품: 스타일리스트가 모든 의상에 포함

이것이 에이전트형 핸드오프입니다. 사용자는 간단한 양식만 보면서 한 AI 에이전트에서 다른 AI 에이전트로 멀티모달 컨텍스트를 원활하게 전송할 수 있습니다.

스타일 지정 흐름: StylingProvider

파일:

workshop_tasks/step_2_style_me/providers/styling_provider.dart

StylingProvider는 대부분의 복잡성을 백엔드에 위임하므로 TryItOnProvider보다 간단합니다.

class StylingProvider with ChangeNotifier {
 StylingState _state = StylingState.initial;
 List<Outfit> _outfits = [];


 Future<bool> getStyleSuggestions(StyleRequest request) async {
   if (_state == StylingState.loading) return false;  // Prevent spam
   _currentRequest = request;
   _setLoading();
   try {
     final suggestions = await _stylingService.getStyleSuggestions(request);
     _setSuccess(suggestions);
     return true;
   } catch (e) {
     _setError('Something went wrong.');
   }
   return false;
 }


 // Multi-turn refinement  uses the same session
 Future<bool> refineWithFeedback(String feedback) async {
   if (_state == StylingState.loading) return false;
   _setLoading();
   try {
     final suggestions = await _stylingService.refineWithFeedback(feedback);
     _setSuccess(suggestions);
     return true;
   } catch (e) {
     _setError('Something went wrong.');
   }
   return false;
 }
}

refineWithFeedback 메서드는 동일한 세션에 일반 텍스트 메시지를 전송합니다. 백엔드의 InjectPreviousProductsExtractAndInjectUserImage 콜백은 모든 컨텍스트 관리를 자동으로 처리합니다.

8. 🚀 로컬에서 앱 실행

원활한 Cloud Shell 환경을 위해 Go 백엔드는 동일한 포트 (8080)에서 컴파일된 Flutter 웹 앱을 제공합니다. 하나의 프로세스, 하나의 미리보기 URL, 교차 출처 문제 없음, 구성 파일 수정 없음

시작하기 전에 ADC 상태 검사

백엔드에서 Vertex AI를 호출하려면 애플리케이션 기본 사용자 인증 정보가 필요합니다. Cloud Shell 세션과 Google 계정에서 프로젝트 설정의 7단계를 완료했다면 괜찮습니다. 휴식 후 돌아왔거나, 계정을 전환했거나, 확실하지 않은 경우 5초 동안 다음을 확인하세요.

gcloud auth application-default print-access-token | head -c 20 && echo "..."

토큰이 20자 정도 출력되면 설정이 완료된 것입니다. 오류가 발생하면 프로젝트 설정의 7단계를 다시 실행합니다.

gcloud auth application-default login
gcloud auth application-default set-quota-project $(gcloud config get-value project)

두 개의 Cloud Shell 터미널을 사용합니다.

  • 터미널 A - 백엔드를 지속적으로 실행합니다 (./run.sh). 열어 둡니다.
  • 터미널 B - Flutter 웹 빌드를 한 번 실행합니다 (flutter build web). 완료되면 종료됩니다.

순서는 중요하지 않으며 어느 쪽부터 시작해도 됩니다. 하지만 가장 깔끔한 첫 실행 환경을 위해서는 백엔드가 시작되는 순간부터 제공할 UI가 있도록 먼저 Flutter를 빌드하세요.

1. 터미널 B - Flutter 웹 번들 빌드 (일회성)

새 Cloud Shell 탭 (터미널 패널 상단의 +)을 열고 다음을 실행합니다.

cd ~/fashion_app_demo/flutter_frontend
flutter pub get
flutter build web

그러면 정적 파일 (HTML, JS, 애셋) 디렉터리인 flutter_frontend/build/web/가 생성되고 완료되면 종료됩니다. 백엔드는 디렉터리가 존재하는 것을 확인하는 즉시 이를 제공합니다.

2. 터미널 A - 백엔드 시작 (장기 실행)

원래 Cloud Shell 터미널에서 다음을 실행합니다.

cd ~/fashion_app_demo/adk_backend
./run.sh

다음과 같이 표시됩니다.

Serving Flutter web build from ../flutter_frontend/build/web

이 터미널을 실행된 상태로 둡니다. run.sh이 활성 상태인 동안 백엔드가 유지됩니다. 중지하려면 Ctrl+C를 누릅니다.

서버는 포트 8080에서 모든 것을 노출합니다.

  • / - Flutter 웹 앱 (쇼핑 UI)
  • /api/ - ADK REST 엔드포인트 (Flutter 앱에서 호출)
  • ADK 개발 UI - Flutter 빌드가 없는 경우 /에서도 제공되며, 직접 에이전트 디버깅에 유용합니다.

3. 웹 미리보기 열기

  1. Cloud Shell에서 웹 미리보기 아이콘 (오른쪽 상단) → 포트 8080에서 미리보기를 클릭합니다.
  2. Flutter 쇼핑 앱이 새 탭에 로드됩니다.
  3. 제품 카탈로그를 둘러보고 상품을 선택합니다.
  4. 사람 아이콘 (👤)을 탭하여 가상 체험 흐름을 시작합니다.
  5. 사진을 업로드하면 AI가 착용 이미지를 생성합니다.
  6. '스타일 추천'을 탭하여 의상 추천 받기
  7. '더 편안한 분위기로 바꿔 줘'와 같은 후속 의견을 입력합니다(동일한 세션에서 수정).

9. ☁️ Cloud Run에 배포

Flutter 빌드를 백엔드에 번들로 묶기

Cloud Run 컨테이너는 하나의 이미지에서 API와 UI를 모두 제공합니다. Flutter 웹 빌드를 adk_backend/flutter_web/에 복사합니다. 이는 Go 서버가 제공할 UI를 선택할 때 확인하는 첫 번째 경로입니다.

cd ~/fashion_app_demo/flutter_frontend
flutter build web
rm -rf ../adk_backend/flutter_web
cp -r build/web ../adk_backend/flutter_web

(로컬에서 반복한 경우 '로컬에서 실행' 단계에서 이미 build/web가 있을 수 있습니다. flutter build web를 다시 실행해도 됩니다.)

백엔드 배포 (API + UI 제공)

cd ~/fashion_app_demo/adk_backend

gcloud run deploy fashion-app-backend \
 --source . \
 --region us-central1 \
 --allow-unauthenticated \
 --set-env-vars "GOOGLE_CLOUD_PROJECT=$PROJECT_ID,GCS_BUCKET=fashion-app-$PROJECT_ID" \
 --memory 1Gi \
 --cpu 2 \
 --timeout 300s \
 --min-instances 0 \
 --max-instances 3

배포가 완료되면 https://fashion-app-backend-xyz-uc.a.run.app와 같은 서비스 URL이 표시됩니다. 브라우저에서 열면 Flutter 쇼핑 앱이 /에서 로드되고 API 호출이 동일한 호스트의 /api/로 이동합니다. 프런트엔드 구성 수정이 필요하지 않고 API 키가 전달되지 않습니다.

배포 확인

브라우저에서 Cloud Run URL을 열고 전체 흐름을 실행합니다.

  1. 둘러보기 → 제품 선택
  2. 착용해 보기 → 사진 업로드 → AI 생성 이미지 확인
  3. 스타일 추천 → 위치/상황 입력 → 맞춤 의상 확인
  4. 의견 → '더 캐주얼하게 만들어 줘'라고 입력 → 업데이트된 의상 확인
  5. 장바구니에 추가 → 쇼핑 흐름 완료

10. 🎉 결론

빌드한 항목

다음과 같은 기능을 통해 AI 기반의 완벽한 소매 환경을 살펴봤습니다.

  • ✅ 4개의 전문 에이전트가 함께 작동하는 멀티 에이전트 백엔드
  • ✅ 맞춤 테스트 이미지를 생성하는 가상 피팅룸
  • ✅ 대화를 통해 의상을 추천하고 다듬는 AI 스타일리스트
  • ✅ 에이전트 백엔드에 연결되는 크로스 플랫폼 Flutter 앱
  • ✅ 확장 가능한 서버리스 호스팅을 위한 Cloud Run 배포

주요 개념

개념

본 위치

ADK 멀티 에이전트 조정

피팅룸, 카탈로그, 스타일리스트 에이전트로 라우팅하는 루트 에이전트

Gemini 멀티모달 이미지 생성

fitting_tool 사용자 사진과 제품 이미지를 결합

대화형 AI의 세션 상태

반복적인 피드백을 위해 세션을 재사용하는 스타일리스트

바이너리 데이터용 아티팩트 스토리지

이미지 스토리지를 텍스트 응답과 분리

미들웨어 로직 콜백

SaveIncomingBlobs, InjectPreviousProducts, SaveSelectedProducts

Flutter의 MVVM + Provider

ChangeNotifier이 있는 TryItOnProviderStylingProvider

에이전트형 인계

StyleRequest 에이전트 간 멀티모달 컨텍스트 전달

다음 단계

  • 🎨 에이전트 프롬프트 맞춤설정: instructions.md을 수정하여 스타일리스트의 성격 변경
  • 🛍️ 제품 추가: catalog.yaml를 새 항목으로 업데이트
  • 📱 휴대기기에 적합한 광고 제작 - flutter build ios 또는 flutter build apk 실행
  • 🔄 영구 세션 추가: InMemoryService을 데이터베이스 지원 구현으로 대체
  • 🔒 인증 추가 - IAM으로 Cloud Run 엔드포인트 보안

리소스