Skip to main content
Summary

웹 애플리케이션에 Gemini를 붙일 때 가장 먼저 버려야 할 습관이 하나 있다. 브라우저에서 바로 AI API를 호출하고 싶은 마음이다. 버튼을 누르면 곧장 모델이 답하고, 코드도 짧아 보이니 그럴듯하다. 하지만 실제 서비스에서는 이 방식이 거의 항상 나쁜 출발점이 된다. Gemini API 키는 사용량과 비용, 업로드 파일, 프로젝트 권한과 이어질 수 있는 민감한 인증 정보다. 프런트엔드 번들에 들어간 키는 사용자가 개발자 도구를 여는 순간 노출된다.

이 장의 핵심은 단순하다. 브라우저는 우리 서버를 호출하고, 우리 서버가 Gemini API를 호출한다. Express를 쓰든 Next.js Route Handler를 쓰든 원리는 같다. 서버는 API 키를 숨기고, 입력을 검증하고, 모델을 고르고, 프롬프트를 조립하고, 오류를 사용자 친화적인 응답으로 바꾼다. 프런트엔드는 그 결과를 웹 사용자 경험에 맞게 보여 준다.

JavaScript와 풀스택 통합

브라우저가 아니라 서버에서 호출한다

JavaScript 개발자는 두 종류의 JavaScript를 동시에 다룬다. 하나는 브라우저에서 실행되는 JavaScript이고, 다른 하나는 Node.js 런타임에서 실행되는 JavaScript다. Gemini API 키는 반드시 두 번째 세계, 즉 서버 런타임에만 있어야 한다.

2026년 기준 새 프로젝트에서는 구형 @google/generativeai가 아니라 공식 @google/genai SDK를 기준으로 시작하는 것이 좋다. Google Gen AI JavaScript SDK는 Gemini Developer API와 Gemini Enterprise Agent Platform 양쪽을 지원하는 통합 SDK이며, Gemini 2.x 이후 기능을 전제로 설계되어 있다.1 npm 패키지 역시 @google/genai가 현재 공식 패키지다.2

가장 작은 구조는 다음과 같다.

브라우저 React/Vue/HTML
  ↓ fetch('/api/generate')
Express 또는 Next.js 서버 라우트
  ↓ @google/genai
Gemini API

여기서 중요한 것은 Gemini API 키가 화살표의 두 번째 구간에만 존재한다는 점이다. 사용자의 브라우저는 API 키를 모른다. 사용자는 우리 서비스의 /api/generate 같은 내부 엔드포인트만 호출한다. 이 구조가 만들어지면 서버에서 사용자 인증, 요청 제한, 입력 길이 제한, 모델 선택, 로깅, 비용 추적, 안전 필터 처리까지 통제할 수 있다.

REST API도 사용할 수 있다. Gemini API reference는 표준 API, 스트리밍 API, 실시간 API를 HTTP 요청이 가능한 환경에서 사용할 수 있다고 설명한다.3 다만 이 책의 JavaScript 예제는 유지보수성과 타입 지원, Gemini Developer API와 Agent Platform 간 이동성을 고려해 @google/genai를 중심으로 통일한다.

프로젝트 준비: 환경 변수와 SDK 초기화

새 Node.js 프로젝트를 만든다고 하자. 공식 SDK 문서는 최신 Node.js 런타임을 전제로 하므로 신규 프로젝트는 Node.js 20 LTS 이상을 기준으로 잡는 편이 안전하다. 기존 팀 표준이 Node.js 18이라면 실제 배포 환경에서 SDK 버전과 호환성을 먼저 검증해야 한다.

mkdir gemini-web-server
cd gemini-web-server
npm init -y
npm install @google/genai dotenv express zod
npm install -D typescript tsx @types/node @types/express

package.json은 ESM 기반으로 두면 Next.js와 최신 Node.js 예제에 맞추기 쉽다.

{
  "type": "module",
  "scripts": {
    "dev": "tsx src/server.ts"
  }
}

프로젝트 루트에 .env를 만든다.

GEMINI_API_KEY=여기에_AI_Studio에서_발급한_키
GEMINI_MODEL=gemini-2.5-flash

그리고 .gitignore에 반드시 .env를 넣는다.

.env
.env.*
!.env.example
node_modules
.next
dist

Google의 API 키 문서는 AI Studio에서 Gemini API 키를 만들고 관리할 수 있으며, 코드에서는 환경 변수로 설정해 사용할 것을 안내한다.4 웹 서비스에서는 여기서 한 걸음 더 나아가야 한다. .env에 넣는 것만으로는 충분하지 않다. 배포 환경에서는 Vercel, Cloud Run, Render, Fly.io, AWS, 사내 Kubernetes 같은 플랫폼의 secret 또는 environment variable 기능을 사용해야 한다. 절대 Git 저장소, 프런트엔드 코드, 이슈 댓글, 공유 문서, 스크린샷에 키를 남기지 않는다.

SDK 클라이언트는 서버 전용 모듈로 분리한다.

// src/gemini.ts
import { GoogleGenAI } from "@google/genai";

const apiKey = process.env.GEMINI_API_KEY;

if (!apiKey) {
  throw new Error("GEMINI_API_KEY is not set");
}

export const ai = new GoogleGenAI({ apiKey });

export const DEFAULT_MODEL = process.env.GEMINI_MODEL || "gemini-2.5-flash";

이 파일은 브라우저 번들에 포함되면 안 된다. Next.js에서는 app/api/**/route.ts 같은 서버 라우트나 서버 전용 파일에서만 import한다. 특히 환경 변수 이름을 NEXT_PUBLIC_GEMINI_API_KEY처럼 만들면 안 된다. NEXT_PUBLIC_ 접두사가 붙은 값은 브라우저 번들로 노출된다.

Express로 텍스트 생성 API 만들기

먼저 Express 서버에서 가장 단순한 텍스트 생성 API를 만들어 보자. 사용자는 브라우저에서 프롬프트를 보내고, 서버는 Gemini를 호출한 뒤 정규화된 JSON으로 응답한다.

// src/server.ts
import "dotenv/config";
import express from "express";
import { z } from "zod";
import { ai, DEFAULT_MODEL } from "./gemini.js";

const app = express();
app.use(express.json({ limit: "1mb" }));

const GenerateBody = z.object({
  prompt: z.string().min(1).max(4000),
  model: z.string().optional()
});

app.post("/api/generate", async (req, res) => {
  const parsed = GenerateBody.safeParse(req.body);

  if (!parsed.success) {
    return res.status(400).json({
      error: {
        code: "INVALID_INPUT",
        message: "요청 형식이 올바르지 않습니다. 프롬프트 길이를 확인해 주세요."
      }
    });
  }

  const { prompt, model = DEFAULT_MODEL } = parsed.data;

  try {
    const response = await ai.models.generateContent({
      model,
      contents: prompt
    });

    return res.json({
      text: response.text ?? "",
      model
    });
  } catch (error) {
    return sendGeminiError(res, error);
  }
});

function sendGeminiError(res: express.Response, error: unknown) {
  const status = typeof error === "object" && error && "status" in error
    ? Number((error as { status?: number }).status)
    : 500;

  if (status === 429) {
    return res.status(429).json({
      error: {
        code: "RATE_LIMITED",
        message: "요청이 잠시 많습니다. 잠시 후 다시 시도해 주세요."
      }
    });
  }

  if (status >= 500) {
    return res.status(503).json({
      error: {
        code: "MODEL_UNAVAILABLE",
        message: "AI 응답을 생성하지 못했습니다. 잠시 후 다시 시도해 주세요."
      }
    });
  }

  return res.status(status || 500).json({
    error: {
      code: "GEMINI_ERROR",
      message: "요청을 처리하지 못했습니다. 입력을 확인한 뒤 다시 시도해 주세요."
    }
  });
}

app.listen(3000, () => {
  console.log("Server listening on http://localhost:3000");
});

이 코드에서 눈여겨볼 부분은 Gemini 호출보다 그 주변이다. zod로 입력을 검증하고, 요청 본문 크기를 제한하고, 오류 메시지를 서비스용 문장으로 바꾼다. 사용자의 브라우저에 SDK의 원문 오류, 내부 모델명 목록, 스택 트레이스, API 키 관련 정보가 그대로 나가면 안 된다.

브라우저에서는 이렇게 호출한다.

async function generate(prompt: string) {
  const response = await fetch("/api/generate", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ prompt })
  });

  const data = await response.json();

  if (!response.ok) {
    throw new Error(data.error?.message ?? "요청 실패");
  }

  return data.text as string;
}

이 정도만 해도 첫 번째 웹 AI 기능은 완성된다. 하지만 실무 서비스는 곧바로 다음 질문을 던진다. "응답을 JSON으로 안정적으로 받을 수 있을까?", "이미지도 보낼 수 있을까?", "대화 맥락은 어떻게 유지하지?", "긴 응답을 기다리는 동안 사용자는 무엇을 보나?" 이제 이 질문들을 하나씩 풀어 보자.

구조화된 JSON 응답 만들기

4장에서 구조화 출력의 핵심은 "모델이 알아서 예쁜 JSON을 주겠지"라고 믿지 않는 것이라고 말했다. 웹 서비스에서는 이 원칙이 더 중요하다. 프런트엔드 컴포넌트는 문자열 수필이 아니라 예측 가능한 데이터 구조를 원한다.

예를 들어 고객 문의를 받아 카테고리, 긴급도, 답변 초안을 반환하는 API를 만들 수 있다.

import { Type } from "@google/genai";

app.post("/api/classify-ticket", async (req, res) => {
  const parsed = z.object({
    message: z.string().min(1).max(3000)
  }).safeParse(req.body);

  if (!parsed.success) {
    return res.status(400).json({
      error: { code: "INVALID_INPUT", message: "문의 내용을 확인해 주세요." }
    });
  }

  try {
    const response = await ai.models.generateContent({
      model: DEFAULT_MODEL,
      contents: `다음 고객 문의를 분류하고 답변 초안을 작성하세요.nn${parsed.data.message}`,
      config: {
        responseMimeType: "application/json",
        responseSchema: {
          type: Type.OBJECT,
          properties: {
            category: {
              type: Type.STRING,
              enum: ["배송", "환불", "결제", "상품", "기타"]
            },
            urgency: {
              type: Type.STRING,
              enum: ["low", "medium", "high"]
            },
            summary: { type: Type.STRING },
            draftReply: { type: Type.STRING }
          },
          required: ["category", "urgency", "summary", "draftReply"]
        }
      }
    });

    const text = response.text ?? "{}";
    const json = JSON.parse(text);

    return res.json(json);
  } catch (error) {
    return sendGeminiError(res, error);
  }
});

서버에서 JSON.parse까지 수행한 뒤 프런트엔드에 넘기는 이유가 있다. 파싱 실패는 서버에서 잡아야 한다. 그래야 실패율을 기록하고, 필요하면 재시도하거나 일반 텍스트 응답으로 폴백할 수 있다. 프런트엔드가 모델 출력을 직접 파싱하게 만들면 화면마다 실패 처리가 흩어지고, 나중에 출력 스키마를 바꿀 때 고통이 커진다.

실무에서는 여기에 한 단계를 더 붙인다. 모델이 반환한 JSON을 다시 zod 스키마로 검증한다. 모델 출력은 신뢰할 수 있는 프로그램 출력이 아니라, 확률적 시스템이 생성한 후보 데이터다. "JSON 형식"과 "우리 서비스가 받아도 되는 데이터"는 다르다.

이미지 입력: 업로드는 브라우저, 해석은 서버

Gemini의 장점은 텍스트만이 아니라 이미지, 문서, 오디오, 비디오까지 다룰 수 있다는 점이다. 이 장에서는 작은 이미지 업로드만 다루고, 파일 크기와 Files API, 긴 컨텍스트 전략은 다음 장에서 깊게 다룬다.

Express에서는 multer를 사용해 사용자가 업로드한 이미지를 메모리에서 받아 Gemini에 inline data로 전달할 수 있다.

npm install multer
npm install -D @types/multer
import multer from "multer";

const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 5 * 1024 * 1024 }
});

app.post("/api/describe-image", upload.single("image"), async (req, res) => {
  const prompt = typeof req.body.prompt === "string"
    ? req.body.prompt.slice(0, 1000)
    : "이 이미지를 한국어로 설명해 주세요.";

  if (!req.file) {
    return res.status(400).json({
      error: { code: "NO_FILE", message: "이미지 파일을 첨부해 주세요." }
    });
  }

  if (!req.file.mimetype.startsWith("image/")) {
    return res.status(400).json({
      error: { code: "INVALID_FILE", message: "이미지 파일만 업로드할 수 있습니다." }
    });
  }

  try {
    const base64 = req.file.buffer.toString("base64");

    const response = await ai.models.generateContent({
      model: DEFAULT_MODEL,
      contents: [
        {
          inlineData: {
            mimeType: req.file.mimetype,
            data: base64
          }
        },
        { text: prompt }
      ]
    });

    return res.json({ text: response.text ?? "" });
  } catch (error) {
    return sendGeminiError(res, error);
  }
});

프런트엔드에서는 FormData를 사용한다.

async function describeImage(file: File, prompt: string) {
  const form = new FormData();
  form.append("image", file);
  form.append("prompt", prompt);

  const response = await fetch("/api/describe-image", {
    method: "POST",
    body: form
  });

  const data = await response.json();

  if (!response.ok) {
    throw new Error(data.error?.message ?? "이미지 분석 실패");
  }

  return data.text as string;
}

작은 이미지는 inline data로 보내면 구현이 단순하다. 그러나 파일이 커지거나 같은 파일을 여러 번 질문해야 한다면 Files API가 더 적합하다. 공식 SDK는 ai.files 하위 모듈을 통해 파일 업로드와 참조를 제공하며, 큰 파일을 반복해서 사용할 때 대역폭을 줄이는 데 도움이 된다.5

대화 맥락은 어디에 저장할까

챗 UI를 만들 때 흔한 착각이 있다. "SDK의 chat 객체를 만들면 대화 기록 문제가 끝난다"는 생각이다. 로컬 스크립트나 단일 Node.js 프로세스에서는 어느 정도 맞다. 하지만 웹 서버와 서버리스 환경에서는 조심해야 한다.

Express 서버 하나가 계속 떠 있고 사용자도 한 명뿐이라면 메모리에 대화 기록을 둘 수 있다. 그러나 실제 서비스에서는 서버가 여러 대일 수 있고, 서버리스 함수는 요청마다 새로 뜰 수 있으며, 사용자가 새로고침하면 브라우저 상태도 사라질 수 있다. 따라서 대화 맥락은 의도적으로 설계해야 한다.

가장 단순한 방식은 브라우저가 최근 대화 몇 턴을 서버로 보내고, 서버가 이를 Gemini의 contents로 변환하는 것이다.

type ChatMessage = {
  role: "user" | "model";
  text: string;
};

const ChatBody = z.object({
  messages: z.array(z.object({
    role: z.enum(["user", "model"]),
    text: z.string().min(1).max(3000)
  })).min(1).max(20)
});

app.post("/api/chat", async (req, res) => {
  const parsed = ChatBody.safeParse(req.body);

  if (!parsed.success) {
    return res.status(400).json({
      error: { code: "INVALID_INPUT", message: "대화 형식이 올바르지 않습니다." }
    });
  }

  const recentMessages = parsed.data.messages.slice(-12);

  try {
    const response = await ai.models.generateContent({
      model: DEFAULT_MODEL,
      contents: recentMessages.map((message) => ({
        role: message.role,
        parts: [{ text: message.text }]
      }))
    });

    return res.json({
      role: "model",
      text: response.text ?? ""
    });
  } catch (error) {
    return sendGeminiError(res, error);
  }
});

이 방식은 투명하다. 서버는 매 요청마다 필요한 맥락을 명시적으로 받는다. 단점은 요청 크기와 비용이 늘어난다는 점이다. 그래서 보통은 최근 N턴만 보내고, 오래된 대화는 요약해서 하나의 시스템성 메모리로 압축하거나 데이터베이스에 저장한다. 로그인 기반 서비스라면 conversation_id를 두고 서버에서 대화 기록을 불러오는 편이 더 안정적이다.

프런트엔드는 다음처럼 메시지 배열을 관리한다.

const [messages, setMessages] = useState<ChatMessage[]>([]);

async function sendMessage(text: string) {
  const nextMessages = [...messages, { role: "user" as const, text }];
  setMessages(nextMessages);

  const response = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ messages: nextMessages })
  });

  const data = await response.json();

  if (!response.ok) {
    throw new Error(data.error?.message ?? "응답 생성 실패");
  }

  setMessages([...nextMessages, data]);
}

여기서도 원칙은 같다. 브라우저는 Gemini를 직접 호출하지 않는다. 브라우저는 대화 상태를 보여 주고, 서버는 모델 호출과 정책 적용을 담당한다.

스트리밍으로 체감 지연 줄이기

기본 generateContent 호출은 모델이 전체 응답을 끝낸 뒤 결과를 반환한다. 짧은 분류나 한두 문장 답변에는 충분하다. 하지만 보고서 초안, 긴 설명, 코드 생성, 챗봇 답변처럼 출력이 길어지면 사용자는 빈 화면을 보며 기다려야 한다.

이때 필요한 것이 스트리밍이다. JS SDK는 generateContentStream을 제공하며, 응답이 생성되는 동안 청크를 순차적으로 받을 수 있다.6 스트리밍은 모델의 총 계산 시간을 마법처럼 없애지는 않는다. 대신 사용자가 첫 글자를 보는 시간을 앞당긴다. 웹 UX에서는 이 차이가 크다. 사용자는 "멈춘 서비스"보다 "생각하며 쓰는 서비스"를 훨씬 더 신뢰한다.

Express에서 SSE(Server-Sent Events) 형태로 스트리밍해 보자.

app.post("/api/stream", async (req, res) => {
  const parsed = GenerateBody.safeParse(req.body);

  if (!parsed.success) {
    return res.status(400).json({
      error: { code: "INVALID_INPUT", message: "프롬프트를 확인해 주세요." }
    });
  }

  res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
  res.setHeader("Cache-Control", "no-cache, no-transform");
  res.setHeader("Connection", "keep-alive");

  let closed = false;
  req.on("close", () => {
    closed = true;
  });

  try {
    const stream = await ai.models.generateContentStream({
      model: parsed.data.model || DEFAULT_MODEL,
      contents: parsed.data.prompt
    });

    for await (const chunk of stream) {
      if (closed) break;

      const text = chunk.text ?? "";
      if (!text) continue;

      res.write(`data: ${JSON.stringify({ text })}nn`);
    }

    res.write(`event: donendata: {}nn`);
    res.end();
  } catch (error) {
    res.write(`event: errorndata: ${JSON.stringify({
      message: "응답을 생성하지 못했습니다. 잠시 후 다시 시도해 주세요."
    })}nn`);
    res.end();
  }
});

브라우저에서 EventSource는 기본적으로 GET 요청에 적합하다. 프롬프트를 POST 본문으로 보내고 싶다면 fetch와 ReadableStream을 이용하는 편이 단순하다. 서버가 SSE 형식으로 보내더라도 클라이언트에서는 텍스트 청크를 직접 읽어 누적할 수 있다.

async function streamAnswer(prompt: string, onToken: (text: string) => void) {
  const response = await fetch("/api/stream", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ prompt })
  });

  if (!response.ok || !response.body) {
    throw new Error("스트리밍 요청에 실패했습니다.");
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

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

    buffer += decoder.decode(value, { stream: true });
    const events = buffer.split("nn");
    buffer = events.pop() ?? "";

    for (const event of events) {
      const line = event.split("n").find((row) => row.startsWith("data: "));
      if (!line) continue;

      const payload = JSON.parse(line.slice(6));
      if (payload.text) onToken(payload.text);
    }
  }
}

React 컴포넌트에서는 부분 응답을 계속 이어 붙인다.

function StreamingBox() {
  const [prompt, setPrompt] = useState("");
  const [answer, setAnswer] = useState("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit() {
    setAnswer("");
    setError(null);
    setLoading(true);

    try {
      await streamAnswer(prompt, (token) => {
        setAnswer((prev) => prev + token);
      });
    } catch {
      setError("응답을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.");
    } finally {
      setLoading(false);
    }
  }

  return (
    <section>
      <textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} />
      <button onClick={handleSubmit} disabled={loading || !prompt.trim()}>
        {loading ? "생성 중..." : "생성"}
      </button>
      {error && <p role="alert">{error}</p>}
      <article>{answer || "아직 생성된 내용이 없습니다."}</article>
    </section>
  );
}

이 예제에는 아직 취소 버튼이 없다. 실제 챗 UI라면 AbortController를 붙여야 한다. 사용자가 "중지"를 눌렀는데도 서버가 계속 생성하고 있으면 비용과 UX가 모두 나빠진다. 또한 스트리밍 중에는 전송 버튼을 비활성화하거나, 같은 대화에 두 개의 응답이 동시에 붙지 않도록 요청 ID를 관리해야 한다.

Next.js Route Handler로 서버 라우트 만들기

Next.js에서는 별도 Express 서버 없이 app/api/**/route.ts에서 서버 라우트를 만들 수 있다. 이 방식은 프런트엔드와 API 프록시를 한 프로젝트에 두기 좋다.

먼저 서버 전용 Gemini 클라이언트를 만든다.

// src/lib/gemini.ts
import { GoogleGenAI } from "@google/genai";

if (!process.env.GEMINI_API_KEY) {
  throw new Error("GEMINI_API_KEY is not set");
}

export const ai = new GoogleGenAI({
  apiKey: process.env.GEMINI_API_KEY
});

export const model = process.env.GEMINI_MODEL || "gemini-2.5-flash";

Next.js Route Handler에서는 Node.js 런타임을 명시하는 편이 안전하다. 특히 파일 처리나 일부 Node API를 사용할 때 Edge Runtime과 충돌할 수 있다.

// app/api/generate/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { ai, model } from "@/src/lib/gemini";

export const runtime = "nodejs";

const Body = z.object({
  prompt: z.string().min(1).max(4000)
});

export async function POST(request: NextRequest) {
  const json = await request.json().catch(() => null);
  const parsed = Body.safeParse(json);

  if (!parsed.success) {
    return NextResponse.json(
      { error: { code: "INVALID_INPUT", message: "프롬프트를 확인해 주세요." } },
      { status: 400 }
    );
  }

  try {
    const response = await ai.models.generateContent({
      model,
      contents: parsed.data.prompt
    });

    return NextResponse.json({ text: response.text ?? "" });
  } catch {
    return NextResponse.json(
      { error: { code: "GEMINI_ERROR", message: "응답 생성에 실패했습니다." } },
      { status: 503 }
    );
  }
}

Next.js에서 스트리밍을 하려면 ReadableStream을 반환한다.

// app/api/stream/route.ts
import { NextRequest } from "next/server";
import { ai, model } from "@/src/lib/gemini";

export const runtime = "nodejs";

export async function POST(request: NextRequest) {
  const { prompt } = await request.json();
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      try {
        const result = await ai.models.generateContentStream({
          model,
          contents: String(prompt ?? "").slice(0, 4000)
        });

        for await (const chunk of result) {
          const text = chunk.text ?? "";
          if (text) {
            controller.enqueue(encoder.encode(text));
          }
        }
      } catch {
        controller.enqueue(encoder.encode("n[응답 생성 중 오류가 발생했습니다.]"));
      } finally {
        controller.close();
      }
    }
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/plain; charset=utf-8",
      "Cache-Control": "no-cache, no-transform"
    }
  });
}

이 예제는 SSE 형식이 아니라 순수 텍스트 청크를 보낸다. 프런트엔드는 더 단순해진다. 청크를 읽는 즉시 화면에 붙이면 된다. 복잡한 이벤트 타입이 필요하면 SSE를 쓰고, 그냥 "생성되는 글자 보여 주기"라면 HTTP 스트리밍만으로 충분하다.

SSE, WebSocket, HTTP 스트리밍 구분하기

웹 AI 기능을 만들다 보면 스트리밍 기술 이름이 뒤섞인다. SSE, WebSocket, HTTP streaming, Live API가 모두 "실시간"처럼 들리기 때문이다. 하지만 쓰임새는 다르다.

SSE는 서버가 브라우저로 이벤트를 계속 밀어 보내는 단방향 스트리밍이다. 토큰 스트리밍, 작업 진행률, 로그 출력처럼 서버에서 클라이언트로 흐르는 정보가 중심일 때 잘 맞는다. 브라우저의 EventSource는 편하지만 기본적으로 GET 중심이므로, POST 본문이 필요한 챗 요청에서는 fetch 스트리밍이나 별도 세션 ID 설계를 고려해야 한다.

HTTP 스트리밍은 가장 단순한 선택지다. fetch로 POST 요청을 보내고 response.body.getReader()로 청크를 읽는다. 텍스트 생성 UI, 보고서 생성 UI, 코드 생성 UI에는 보통 이것만으로 충분하다. 구현이 단순하고 프록시와 배포 플랫폼에서도 비교적 다루기 쉽다.

WebSocket은 양방향 연결이 필요할 때 쓴다. 사용자의 음성 입력이 계속 들어가고, 서버가 중간 결과를 보내고, 다시 사용자가 끼어드는 인터페이스라면 WebSocket이 자연스럽다. 하지만 일반적인 챗 답변 스트리밍에 WebSocket을 먼저 선택할 필요는 없다. 연결 관리, 재접속, 인증 갱신, 서버 확장성이 모두 더 어려워진다.

실시간 음성·영상 상호작용은 이 책의 12장에서 Live API와 함께 다시 다룬다. 이 장에서는 원칙만 기억하면 된다. 단방향 토큰 스트리밍은 SSE나 HTTP 스트리밍, 양방향 실시간 상호작용은 WebSocket 또는 실시간 API 계열이다.

프런트엔드 UX: AI가 실패할 때도 화면은 친절해야 한다

AI 기능의 품질은 모델 답변만으로 결정되지 않는다. 사용자가 보는 것은 전체 경험이다. 빈 상태에서 무엇을 입력해야 할지 알려 주는가. 생성 중임을 알 수 있는가. 너무 오래 걸릴 때 취소할 수 있는가. 오류가 났을 때 다시 시도할 수 있는가. 안전 필터에 막혔을 때 사용자가 이유를 이해할 수 있는가.

프런트엔드에서는 최소한 다음 상태를 분리해야 한다.

  • 아직 아무 요청도 하지 않은 빈 상태

  • 요청을 보내는 중인 로딩 상태

  • 스트리밍 청크가 누적되는 생성 상태

  • 사용자가 취소한 상태

  • 429 또는 사용량 제한 상태

  • 5xx 또는 일시적 장애 상태

  • 안전 정책 또는 입력 문제로 차단된 상태

  • 정상 완료 상태

이 상태를 하나의 error: true로 뭉개면 사용자도, 운영자도 문제를 이해하지 못한다. 예를 들어 429는 "잠시 후 다시 시도"와 "요금제 또는 할당량 확인"이 필요한 문제다. 400은 대개 입력 형식이나 길이 문제다. 5xx는 재시도 버튼과 백오프가 필요하다. 안전 필터 차단은 사용자가 표현을 바꾸거나 민감한 내용을 제거하도록 안내해야 한다.

사용자에게 원문 오류를 그대로 보여 주지 않는 것도 중요하다. 내부 오류에는 모델명, 요청 ID, 라이브러리 경로, 정책 세부 문구가 섞일 수 있다. 화면에는 짧고 안전한 문장을 보여 주고, 자세한 내용은 서버 로그에 남긴다.

function messageForStatus(status: number) {
  if (status === 400) return "입력 내용을 확인해 주세요.";
  if (status === 401 || status === 403) return "서비스 설정을 확인하는 중입니다.";
  if (status === 429) return "요청이 많습니다. 잠시 후 다시 시도해 주세요.";
  if (status >= 500) return "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.";
  return "요청을 처리하지 못했습니다.";
}

프로덕션에서는 여기에 요청 ID를 붙인다. 사용자가 고객센터에 문의할 때 "오류가 났어요"가 아니라 "요청 ID req_abc123에서 오류가 났어요"라고 말할 수 있어야 한다. 그래야 로그와 연결된다.

API 키 보안은 배포 후가 아니라 첫날부터

API 키 보안은 마지막 점검 항목이 아니다. 프로젝트 첫 커밋부터 설계해야 한다. 특히 Gemini API 키는 단순한 공개 식별자가 아니라 비용과 데이터 접근에 영향을 줄 수 있다. 보안 연구에서는 공개 웹에 노출된 Google API 키가 Gemini 엔드포인트 접근에 악용될 수 있는 위험을 지적한 바 있다.7 Google AI Studio와 Gemini API 키 관리 화면에서 키 제한과 사용량 모니터링을 점검하는 습관이 필요하다.

실무 체크리스트는 어렵지 않지만, 빼먹으면 피해가 크다. 테스트용 키와 운영용 키를 분리한다. 운영 키는 서버 환경 변수나 secret manager에만 둔다. 브라우저 코드에는 절대 넣지 않는다. GitHub에 실수로 올라간 키는 "지웠으니 괜찮다"가 아니라 즉시 폐기하고 새로 발급한다. 가능하면 API 제한, 애플리케이션 제한, 결제 알림, 할당량 알림을 설정한다.

또한 서버 라우트 자체도 보호해야 한다. API 키를 숨겼다고 끝이 아니다. 누구나 /api/generate를 무한 호출할 수 있다면 결국 비용은 우리 계정으로 나간다. 로그인 사용자만 호출하게 하거나, IP·사용자·조직 단위 rate limit을 두고, 입력 크기와 출력 길이를 제한해야 한다. 사내 서비스라면 VPN, SSO, 조직 계정 인증을 붙인다.

프록시 서버를 별도로 두어 키 회전, 로드밸런싱, 사용량 추적을 구현할 수도 있다. 다만 초반부터 복잡한 프록시를 도입하기보다는, 먼저 서버 라우트에서 인증·검증·로깅·요청 제한을 제대로 잡는 것이 순서다.

배포 환경에서 달라지는 것들

로컬 개발에서는 .env와 npm run dev로 충분하다. 배포 환경에서는 몇 가지가 달라진다.

첫째, 환경 변수 주입 방식이 플랫폼마다 다르다. Vercel은 Project Settings의 Environment Variables에 넣고, Cloud Run은 Secret Manager나 서비스 환경 변수로 주입한다. Docker 이미지를 만들 때 ARG나 이미지 레이어에 키를 박아 넣으면 안 된다. 이미지는 여러 환경에 배포될 수 있고, 레지스트리에 남는다.

둘째, 서버리스 환경에서는 메모리를 믿으면 안 된다. 대화 기록, 사용자별 사용량, 프롬프트 버전, 평가 로그는 데이터베이스나 외부 저장소에 둔다. Route Handler 안의 전역 배열에 대화 기록을 저장하는 예제는 로컬 데모일 뿐이다.

셋째, 스트리밍 지원 여부를 확인해야 한다. 일부 프록시, CDN, 서버리스 플랫폼은 응답을 버퍼링할 수 있다. 버퍼링이 켜져 있으면 서버가 청크를 보내도 사용자는 마지막에 한 번에 받는다. Cache-Control: no-transform 같은 헤더를 설정하고, 실제 배포 URL에서 첫 토큰 지연시간을 측정해야 한다.

넷째, 나중에 Agent Platform이나 Vertex AI 계열로 옮길 가능성을 열어 둔다. Google 생성형 AI SDK는 Gemini Developer API로 프로토타입을 만든 뒤 Vertex AI 환경으로 이전할 때 코드 재작성 부담을 줄이는 통합 인터페이스를 제공한다.8 그러므로 애플리케이션 코드 전체에 SDK 초기화 코드를 흩뿌리지 말고, gemini.ts 같은 단일 모듈로 감싸 두는 편이 좋다.

이번 장의 완성본: 웹 AI 기능의 역할 분리

이 장에서 만든 코드는 화려한 에이전트도, 대규모 RAG도 아니다. 하지만 실무 Gemini 웹 서비스의 뼈대는 이미 들어 있다. 브라우저는 입력과 출력 경험을 담당한다. 서버 라우트는 보안과 정책, 모델 호출을 담당한다. Gemini API는 생성과 이해를 담당한다. 이 경계가 흐려지면 처음에는 빨라 보이지만, 배포 후에는 보안 사고와 비용 폭탄, 디버깅 지옥으로 돌아온다.

웹 서비스에 Gemini를 붙일 때 기억해야 할 기준은 다음과 같다.

  • 새 프로젝트는 @google/genai를 기준으로 시작하고, API 키는 서버 런타임에만 둔다.

  • 브라우저는 Express 라우트나 Next.js Route Handler를 호출하고, Gemini API를 직접 호출하지 않는다.

  • 서버는 입력 검증, 모델 선택, 프롬프트 조립, 오류 변환, 응답 포맷 정규화를 맡는다.

  • 긴 답변과 챗 UI에는 스트리밍을 적용해 첫 응답 체감 지연을 줄인다.

  • 단방향 텍스트 생성은 HTTP 스트리밍이나 SSE로 충분하고, 양방향 실시간 인터페이스에만 WebSocket 계열을 검토한다.

  • 프런트엔드는 로딩, 취소, 재시도, 네트워크 오류, 429, 5xx, 안전 차단 상태를 분리해 사용자에게 안전한 문장으로 안내한다.

다음 장에서는 입력의 세계를 넓힌다. 지금까지는 작은 텍스트와 이미지 중심이었다. 실제 업무에서는 PDF 계약서, 회의 녹음, 제품 이미지 묶음, 교육 영상, 코드베이스 전체가 들어온다. 그때부터는 파일 크기, 토큰, 비용, 컨텍스트 창, 재사용 전략이 중요해진다. 웹에서 Gemini를 안전하게 부르는 길을 만들었으니, 이제 더 큰 자료를 어떻게 모델 앞에 올려놓을지 살펴볼 차례다.

참고

1Google Gen AI SDK for TypeScript and JavaScript

2@google/genai - npm

3Gemini API reference

4Using Gemini API keys

5Google Gen AI SDK documentation

6Gemini API Streaming Quickstart

7Google API Keys Weren't Secrets. But then Gemini Changed the Rules.

8Google Gen AI SDK overview for Vertex AI

책 목록

제미나이 공략집

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기