반응형
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 모니터링 대시보드
→ 비용 최적화 전략
관련 글
반응형