본문 바로가기

AI 개발

Firebase AI Logic + Gemini 실전 가이드 3편 — 웹(JavaScript/TypeScript) + Next.js 실전

반응형

2편에서 Android Kotlin 패턴을 다뤘습니다. 3편은 웹입니다. Firebase AI Logic은 Android와 동일한 SDK 구조를 JavaScript/TypeScript로 제공합니다. Next.js App Router 환경에서 클라이언트사이드와 서버사이드를 어떻게 구분해서 써야 하는지, React 커스텀 훅으로 추상화하는 법, 파일 입력 처리까지 다룹니다.

[3편 핵심 요약]
→ 웹 SDK: firebase/ai 패키지 — Android와 동일한 API 구조
→ 초기화: getAI() → getGenerativeModel() — Kotlin의 Firebase.ai()와 대응
→ 스트리밍: generateContentStream() → AsyncIterable → for await 루프
→ 채팅: startChat() → ChatSession → sendMessage() / sendMessageStream()
→ Next.js App Router: 'use client' 필수 — Firebase SDK는 브라우저 전용
→ 서버사이드: Firebase Admin SDK + Vertex AI (클라이언트 SDK 사용 불가)
→ 이미지 입력: File → ArrayBuffer → inlineData() 또는 fileData(url)
→ 구조화 출력: responseSchema + responseMimeType = "application/json"
→ 최신 firebase JS SDK: 12.12.1+ (TypeScript 빌드 오류 수정 버전)

실전 1 — 웹 프로젝트 세팅

설치

# npm
npm install firebase

# yarn
yarn add firebase

# pnpm
pnpm add firebase

Firebase 초기화 — 웹 공통 설정

// lib/firebase.ts — 싱글턴 초기화
import { initializeApp, getApps, getApp } from "firebase/app";
import { getAI, getGenerativeModel, GoogleAIBackend } from "firebase/ai";

const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};

// 중복 초기화 방지 (Next.js HMR 환경)
const app = getApps().length > 0 ? getApp() : initializeApp(firebaseConfig);

// Firebase AI Logic 초기화
// GoogleAIBackend = Gemini Developer API (무료 티어)
const ai = getAI(app, { backend: new GoogleAIBackend() });

// 기본 모델 — 앱 전체에서 재사용
export const geminiModel = getGenerativeModel(ai, {
  model: "gemini-3.1-flash-lite",
});

// 채팅 전용 모델 (시스템 프롬프트 포함)
export const chatModel = getGenerativeModel(ai, {
  model: "gemini-3.1-flash-lite",
  systemInstruction: {
    parts: [{ text: "당신은 친절한 AI 어시스턴트입니다. 한국어로 답변하세요." }]
  }
});

// 구조화 출력 전용 모델
export const structuredModel = getGenerativeModel(ai, {
  model: "gemini-3.1-flash-lite",
  generationConfig: {
    responseMimeType: "application/json",
  }
});

export { app, ai };
# .env.local — 환경변수 (절대 git에 커밋 금지)
NEXT_PUBLIC_FIREBASE_API_KEY=AIzaSy...
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=my-app.firebaseapp.com
NEXT_PUBLIC_FIREBASE_PROJECT_ID=my-app
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=my-app.appspot.com
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=123456789
NEXT_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abc123

실전 2 — 기본 호출과 스트리밍

// lib/gemini.ts — 기본 유틸 함수들

import { geminiModel } from "./firebase";

// 단일 텍스트 생성
export async function generateText(prompt: string): Promise<string> {
  const result = await geminiModel.generateContent(prompt);
  return result.response.text();
}

// 스트리밍 텍스트 생성
export async function* generateStream(prompt: string) {
  const stream = await geminiModel.generateContentStream(prompt);

  // for await로 청크 단위 수집
  for await (const chunk of stream.stream) {
    const text = chunk.text();
    if (text) yield text;
  }
}

// 토큰 수 확인
export async function countTokens(prompt: string): Promise<number> {
  const result = await geminiModel.countTokens(prompt);
  return result.totalTokens;
}
// components/StreamingText.tsx — 스트리밍 UI 컴포넌트
"use client"; // Firebase SDK는 브라우저에서만 동작

import { useState, useCallback } from "react";
import { generateStream } from "@/lib/gemini";

export function StreamingText() {
  const [prompt, setPrompt] = useState("");
  const [output, setOutput] = useState("");
  const [isStreaming, setIsStreaming] = useState(false);

  const handleGenerate = useCallback(async () => {
    if (!prompt.trim() || isStreaming) return;

    setOutput("");
    setIsStreaming(true);

    try {
      // async generator로 청크 수신
      for await (const chunk of generateStream(prompt)) {
        setOutput(prev => prev + chunk);
      }
    } catch (error) {
      console.error("생성 오류:", error);
      setOutput("오류가 발생했습니다.");
    } finally {
      setIsStreaming(false);
    }
  }, [prompt, isStreaming]);

  return (
    <div className="p-4 space-y-4">
      <textarea
        value={prompt}
        onChange={e => setPrompt(e.target.value)}
        placeholder="프롬프트 입력"
        className="w-full border rounded p-2 h-24"
        disabled={isStreaming}
      />
      <button
        onClick={handleGenerate}
        disabled={isStreaming || !prompt.trim()}
        className="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
      >
        {isStreaming ? "생성 중..." : "생성"}
      </button>
      {output && (
        <div className="border rounded p-4 whitespace-pre-wrap">
          {output}
          {isStreaming && <span className="animate-pulse">▌</span>}
        </div>
      )}
    </div>
  );
}

실전 3 — 멀티턴 채팅 커스텀 훅

// hooks/useGeminiChat.ts
"use client";

import { useState, useRef, useCallback, useEffect } from "react";
import { chatModel } from "@/lib/firebase";
import type { ChatSession } from "firebase/ai";

export interface Message {
  id: string;
  role: "user" | "model";
  text: string;
  isStreaming?: boolean;
}

export function useGeminiChat(initialSystemPrompt?: string) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const chatSessionRef = useRef<ChatSession | null>(null);

  // ChatSession 초기화 (한 번만)
  useEffect(() => {
    chatSessionRef.current = chatModel.startChat({
      history: [],  // 빈 히스토리로 시작
    });
  }, []);

  const sendMessage = useCallback(async (userInput: string) => {
    if (!userInput.trim() || isLoading || !chatSessionRef.current) return;

    const userMessage: Message = {
      id: Date.now().toString(),
      role: "user",
      text: userInput,
    };

    // AI 응답 자리 확보
    const aiMessageId = (Date.now() + 1).toString();
    const aiMessage: Message = {
      id: aiMessageId,
      role: "model",
      text: "",
      isStreaming: true,
    };

    setMessages(prev => [...prev, userMessage, aiMessage]);
    setIsLoading(true);

    try {
      const stream = await chatSessionRef.current.sendMessageStream(userInput);
      let fullText = "";

      for await (const chunk of stream.stream) {
        const chunkText = chunk.text();
        if (chunkText) {
          fullText += chunkText;
          // 실시간으로 AI 메시지 업데이트
          setMessages(prev =>
            prev.map(msg =>
              msg.id === aiMessageId
                ? { ...msg, text: fullText }
                : msg
            )
          );
        }
      }

      // 스트리밍 완료
      setMessages(prev =>
        prev.map(msg =>
          msg.id === aiMessageId
            ? { ...msg, text: fullText, isStreaming: false }
            : msg
        )
      );

    } catch (error) {
      setMessages(prev =>
        prev.map(msg =>
          msg.id === aiMessageId
            ? { ...msg, text: "오류가 발생했습니다.", isStreaming: false }
            : msg
        )
      );
    } finally {
      setIsLoading(false);
    }
  }, [isLoading]);

  const clearChat = useCallback(() => {
    // 새 ChatSession 생성
    chatSessionRef.current = chatModel.startChat({ history: [] });
    setMessages([]);
  }, []);

  // 히스토리 복원 (DB에서 불러오기)
  const restoreHistory = useCallback((savedMessages: Message[]) => {
    const history = savedMessages.map(msg => ({
      role: msg.role as "user" | "model",
      parts: [{ text: msg.text }],
    }));
    chatSessionRef.current = chatModel.startChat({ history });
    setMessages(savedMessages);
  }, []);

  return { messages, isLoading, sendMessage, clearChat, restoreHistory };
}
// components/ChatUI.tsx
"use client";

import { useRef, useEffect, useState } from "react";
import { useGeminiChat } from "@/hooks/useGeminiChat";

export function ChatUI() {
  const { messages, isLoading, sendMessage, clearChat } = useGeminiChat();
  const [input, setInput] = useState("");
  const bottomRef = useRef<HTMLDivElement>(null);

  // 새 메시지마다 자동 스크롤
  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

  const handleSend = () => {
    if (!input.trim()) return;
    sendMessage(input);
    setInput("");
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      handleSend();
    }
  };

  return (
    <div className="flex flex-col h-screen max-w-2xl mx-auto">
      {/* 메시지 목록 */}
      <div className="flex-1 overflow-y-auto p-4 space-y-4">
        {messages.length === 0 && (
          <p className="text-center text-gray-400">대화를 시작하세요</p>
        )}
        {messages.map(message => (
          <div
            key={message.id}
            className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
          >
            <div
              className={`max-w-xs lg:max-w-md px-4 py-2 rounded-2xl whitespace-pre-wrap ${
                message.role === "user"
                  ? "bg-blue-500 text-white"
                  : "bg-gray-100 text-gray-900"
              }`}
            >
              {message.text}
              {message.isStreaming && (
                <span className="animate-pulse ml-1">▌</span>
              )}
            </div>
          </div>
        ))}
        <div ref={bottomRef} />
      </div>

      {/* 입력창 */}
      <div className="border-t p-4 flex gap-2">
        <textarea
          value={input}
          onChange={e => setInput(e.target.value)}
          onKeyDown={handleKeyDown}
          placeholder="메시지 입력 (Enter: 전송, Shift+Enter: 줄바꿈)"
          className="flex-1 border rounded-lg p-2 resize-none"
          rows={1}
          disabled={isLoading}
        />
        <button
          onClick={handleSend}
          disabled={isLoading || !input.trim()}
          className="px-4 py-2 bg-blue-500 text-white rounded-lg disabled:opacity-50"
        >
          전송
        </button>
        <button
          onClick={clearChat}
          className="px-3 py-2 border rounded-lg text-gray-500"
        >
          초기화
        </button>
      </div>
    </div>
  );
}

실전 4 — 파일·이미지 입력 (웹)

// lib/multimodal.ts
import { geminiModel } from "./firebase";
import { Part } from "firebase/ai";

// 이미지 파일 → Gemini 입력
export async function analyzeImage(
  file: File,
  prompt: string
): Promise<string> {
  // File을 ArrayBuffer로 변환
  const arrayBuffer = await file.arrayBuffer();
  const uint8Array = new Uint8Array(arrayBuffer);

  const imagePart: Part = {
    inlineData: {
      data: btoa(String.fromCharCode(...uint8Array)),  // Base64 인코딩
      mimeType: file.type as "image/jpeg" | "image/png" | "image/webp",
    }
  };

  const result = await geminiModel.generateContent([
    imagePart,
    { text: prompt }
  ]);

  return result.response.text();
}

// URL로 이미지 분석
export async function analyzeImageUrl(
  imageUrl: string,
  prompt: string
): Promise<string> {
  const result = await geminiModel.generateContent([
    {
      fileData: {
        fileUri: imageUrl,
        mimeType: "image/jpeg",
      }
    },
    { text: prompt }
  ]);

  return result.response.text();
}

// PDF 분석
export async function analyzePdf(
  file: File,
  prompt: string
): Promise<string> {
  const arrayBuffer = await file.arrayBuffer();
  const uint8Array = new Uint8Array(arrayBuffer);

  const pdfPart: Part = {
    inlineData: {
      data: btoa(String.fromCharCode(...uint8Array)),
      mimeType: "application/pdf",
    }
  };

  const result = await geminiModel.generateContent([
    pdfPart,
    { text: prompt }
  ]);

  return result.response.text();
}
// components/ImageAnalyzer.tsx
"use client";

import { useState, useCallback } from "react";
import { analyzeImage } from "@/lib/multimodal";

export function ImageAnalyzer() {
  const [preview, setPreview] = useState<string>("");
  const [result, setResult] = useState("");
  const [isLoading, setIsLoading] = useState(false);
  const [selectedFile, setSelectedFile] = useState<File | null>(null);

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    setSelectedFile(file);
    // 미리보기 생성
    const reader = new FileReader();
    reader.onload = (e) => setPreview(e.target?.result as string);
    reader.readAsDataURL(file);
  };

  const handleAnalyze = useCallback(async () => {
    if (!selectedFile) return;

    setIsLoading(true);
    setResult("");

    try {
      const text = await analyzeImage(selectedFile, "이 이미지를 자세히 설명해줘");
      setResult(text);
    } catch (error) {
      setResult("분석 중 오류가 발생했습니다.");
    } finally {
      setIsLoading(false);
    }
  }, [selectedFile]);

  // 드래그 앤 드롭 지원
  const handleDrop = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    const file = e.dataTransfer.files[0];
    if (file && file.type.startsWith("image/")) {
      setSelectedFile(file);
      const reader = new FileReader();
      reader.onload = (e) => setPreview(e.target?.result as string);
      reader.readAsDataURL(file);
    }
  }, []);

  return (
    <div className="p-4 space-y-4">
      <div
        onDrop={handleDrop}
        onDragOver={e => e.preventDefault()}
        className="border-2 border-dashed rounded-lg p-8 text-center cursor-pointer"
      >
        <input
          type="file"
          accept="image/*"
          onChange={handleFileChange}
          className="hidden"
          id="file-input"
        />
        <label htmlFor="file-input" className="cursor-pointer">
          이미지 드래그하거나 클릭해서 선택
        </label>
      </div>

      {preview && (
        <img src={preview} alt="선택한 이미지" className="max-h-64 rounded-lg" />
      )}

      <button
        onClick={handleAnalyze}
        disabled={!selectedFile || isLoading}
        className="w-full py-2 bg-blue-500 text-white rounded-lg disabled:opacity-50"
      >
        {isLoading ? "분석 중..." : "이미지 분석"}
      </button>

      {result && (
        <div className="border rounded-lg p-4 whitespace-pre-wrap">
          {result}
        </div>
      )}
    </div>
  );
}

실전 5 — Next.js App Router 구조 설계

[App Router에서 Firebase AI Logic 사용 규칙]

✅ 클라이언트 컴포넌트 ('use client'):
→ Firebase SDK 전체 사용 가능
→ useGeminiChat 같은 커스텀 훅
→ 스트리밍 UI 컴포넌트
→ 파일 업로드, 사용자 인터랙션

❌ 서버 컴포넌트 (기본):
→ Firebase AI Logic SDK 사용 불가
→ 브라우저 API에 의존하기 때문
→ 서버사이드에서 Gemini 쓰려면 Vertex AI SDK 직접 사용

[Next.js 프로젝트 구조 권장]
app/
├── layout.tsx          # 서버 컴포넌트 (Firebase 초기화 없음)
├── page.tsx            # 서버 컴포넌트 (정적 껍데기만)
├── chat/
│   └── page.tsx        # 서버 컴포넌트
└── components/
    ├── ChatUI.tsx      # 'use client' — Firebase AI Logic 사용
    ├── ImageAnalyzer.tsx # 'use client'
    └── StreamingText.tsx # 'use client'

lib/
├── firebase.ts         # Firebase 초기화 (클라이언트)
├── gemini.ts           # Gemini 유틸 함수
└── multimodal.ts       # 멀티모달 유틸
// app/chat/page.tsx — 서버 컴포넌트
// Firebase 코드 없음 — ChatUI 컴포넌트에 위임
import { ChatUI } from "@/components/ChatUI";

export default function ChatPage() {
  return (
    <main>
      <h1 className="text-2xl font-bold p-4">Gemini 채팅</h1>
      <ChatUI />  {/* 'use client' 컴포넌트 */}
    </main>
  );
}

서버사이드에서 Gemini 써야 할 때

// app/api/generate/route.ts — Next.js API Route (서버사이드)
// Firebase AI Logic SDK 사용 불가 → Vertex AI SDK 직접 사용

import { VertexAI } from "@google-cloud/vertexai";
import { NextRequest, NextResponse } from "next/server";

// 서버사이드: Vertex AI SDK 직접 사용 (GCP 서비스 계정 필요)
const vertexAI = new VertexAI({
  project: process.env.GOOGLE_CLOUD_PROJECT!,
  location: "us-central1"
});

export async function POST(req: NextRequest) {
  const { prompt } = await req.json();

  const model = vertexAI.getGenerativeModel({
    model: "gemini-3.1-flash-lite"
  });

  // Server-Sent Events로 스트리밍 응답
  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    async start(controller) {
      try {
        const result = await model.generateContentStream(prompt);
        for await (const chunk of result.stream) {
          const text = chunk.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
          if (text) {
            controller.enqueue(encoder.encode(`data: ${JSON.stringify({ text })}\n\n`));
          }
        }
        controller.enqueue(encoder.encode("data: [DONE]\n\n"));
        controller.close();
      } catch (error) {
        controller.error(error);
      }
    }
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      "Connection": "keep-alive",
    }
  });
}

// 클라이언트에서 SSE 수신
async function fetchServerStream(prompt: string) {
  const response = await fetch("/api/generate", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ prompt }),
  });

  const reader = response.body!.getReader();
  const decoder = new TextDecoder();

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    const chunk = decoder.decode(value);
    const lines = chunk.split("\n\n").filter(Boolean);

    for (const line of lines) {
      if (line.startsWith("data: ")) {
        const data = line.slice(6);
        if (data === "[DONE]") return;
        const { text } = JSON.parse(data);
        console.log(text);  // 실시간 텍스트 출력
      }
    }
  }
}
[클라이언트 vs 서버사이드 선택 기준]

Firebase AI Logic (클라이언트사이드):
→ 백엔드 구축 불필요
→ 무료 티어 시작 가능
→ App Check로 보안
→ 일반적인 AI 기능 추가 케이스

Vertex AI SDK (서버사이드):
→ 비밀 데이터 처리 (API Route에서)
→ 프롬프트를 공개하면 안 되는 경우
→ 여러 사용자 요청 배치 처리
→ 자체 인증/인가 로직 필요한 경우
→ GCP 서비스와 깊은 통합

실전 6 — 구조화 출력 (TypeScript)

// lib/structured.ts
import { structuredModel } from "./firebase";
import { Schema } from "firebase/ai";

// 제품 분석 스키마
interface ProductAnalysis {
  sentiment: "positive" | "negative" | "neutral";
  score: number;           // 0~10
  pros: string[];
  cons: string[];
  summary: string;
}

// responseSchema 설정
const reviewModel = /* getGenerativeModel */ (/* ai, */ {
  model: "gemini-3.1-flash-lite",
  generationConfig: {
    responseMimeType: "application/json",
    responseSchema: {
      type: "object",
      properties: {
        sentiment: {
          type: "string",
          enum: ["positive", "negative", "neutral"],
          description: "리뷰 감정"
        },
        score: {
          type: "number",
          description: "0~10점 평점"
        },
        pros: {
          type: "array",
          items: { type: "string" },
          description: "긍정적 포인트 목록"
        },
        cons: {
          type: "array",
          items: { type: "string" },
          description: "부정적 포인트 목록"
        },
        summary: {
          type: "string",
          description: "한 줄 요약"
        }
      },
      required: ["sentiment", "score", "pros", "cons", "summary"]
    }
  }
});

export async function analyzeReview(reviewText: string): Promise<ProductAnalysis> {
  const result = await structuredModel.generateContent(
    `다음 리뷰를 분석해줘:\n\n${reviewText}`
  );

  // 스키마 강제로 항상 올바른 JSON — 안전하게 파싱
  return JSON.parse(result.response.text()) as ProductAnalysis;
}

// 사용
const analysis = await analyzeReview("배송이 빠르고 제품 품질이 좋아요. 포장도 꼼꼼했습니다!");
console.log(analysis.sentiment);  // "positive"
console.log(analysis.score);      // 9.2
console.log(analysis.pros);       // ["빠른 배송", "좋은 품질", "꼼꼼한 포장"]

마무리

✅ 3편에서 한 것
→ 웹 SDK 설치 + Firebase 초기화 (싱글턴 패턴)
→ 스트리밍: generateContentStream() + for await 루프
→ 멀티턴 채팅: useGeminiChat 커스텀 훅
→ 이미지·PDF 입력: File → ArrayBuffer → Base64 변환
→ Next.js App Router: 'use client' 규칙, 구조 설계
→ 서버사이드: Vertex AI SDK + SSE 스트리밍 API Route
→ 구조화 출력: responseSchema + TypeScript 타입 연동

❌ 4편에서 다룰 것 (프로덕션 전환)
→ Firebase App Check 설정 (무단 사용 차단)
→ 유료 티어 전환 시점과 방법
→ Vertex AI 백엔드로 전환 (코드 한 줄)
→ Remote Config로 모델명 동적 관리
→ AI 모니터링 대시보드
→ 비용 최적화 전략

 


관련 글

반응형