Skip to main content
Summary

AI Studio의 실험이 코드가 되는 순간

앞 장까지 우리는 AI Studio에서 프롬프트를 다듬고, 모델을 고르고, 비용과 지연시간을 가늠하고, 구조화 출력이 왜 중요한지 살펴보았다. 이제부터는 그 실험을 실제 애플리케이션 코드로 옮긴다. 이때 중심에 놓이는 동사가 바로 generateContent다.

제미나이 API를 처음 보면 기능이 많아 보인다. 텍스트 생성, 이미지 이해, 파일 업로드, 스트리밍, 함수 호출, 검색 그라운딩, 코드 실행, RAG까지 모두 다른 세계처럼 느껴진다. 하지만 실무자의 눈으로 보면 대부분은 같은 뼈대를 공유한다. 모델에게 보낼 입력을 contents에 담고, 응답 방식을 generationConfig로 조정하며, 안전 정책을 safetySettings로 설정하고, 외부 기능이 필요하면 tools를 붙인다. 공식 문서에서도 콘텐츠 생성의 기본 축은 generateContent와 스트리밍 변형인 streamGenerateContent로 설명된다.1

이 장의 목표는 멋진 챗봇을 완성하는 것이 아니다. 그보다 중요한 것은 앞으로 나올 멀티모달, 도구 호출, RAG, 평가, 운영 코드를 흔들림 없이 확장할 수 있는 공통 문법을 익히는 것이다. 자동차 운전을 배우기 전에 핸들, 브레이크, 계기판의 위치를 알아야 하듯이, 제미나이 애플리케이션을 만들기 전에 요청과 응답의 기본 구조를 몸에 익혀야 한다.

Gemini API와 공식 SDK의 기본기

공식 SDK를 쓰되, REST 모양을 먼저 이해하라

제미나이 API는 REST API다. 따라서 원한다면 curl, Postman, 서버의 HTTP 클라이언트로 직접 호출할 수 있다. 다만 실제 개발에서는 공식 SDK를 쓰는 편이 훨씬 편하다. 2026년 5월 기준 Google GenAI SDK는 Python, JavaScript/TypeScript, Go, Java를 중심으로 제공되며, 이 책에서는 Python의 google-genai와 JavaScript/TypeScript의 @google/genai를 기본 축으로 삼는다.2

그렇다고 REST 구조를 몰라도 된다는 뜻은 아니다. SDK는 편의를 위해 문자열 하나를 받아도 내부적으로는 contents와 parts 구조로 바꿔 보낸다. 이 자동 변환은 입문 단계에서는 친절하지만, 멀티턴 대화나 도구 호출을 다룰 때는 오히려 헷갈릴 수 있다. 그러므로 먼저 원형을 보자.

REST에서 가장 단순한 텍스트 요청은 다음과 같은 모양이다.

curl -s -X POST 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent' -H 'Content-Type: application/json' -H x-goog-api-key:${GEMINI_API_KEY} -d '{
  "contents": [
    {
      "role": "user",
      "parts": [
        { "text": "고객 문의를 한 문장으로 요약해줘: 배송이 늦어져서 환불하고 싶습니다." }
      ]
    }
  ],
  "generationConfig": {
    "temperature": 0.2,
    "maxOutputTokens": 256
  }
}'

이 예제는 작지만 거의 모든 것을 품고 있다. models/gemini-2.5-flash:generateContent는 어떤 모델의 어떤 메서드를 호출할지 나타낸다. contents는 모델이 읽을 대화 내용이다. role은 이 발화가 사용자에게서 온 것인지, 이전 모델 응답인지 구분한다. parts는 한 메시지 안에 들어가는 조각이다. 지금은 텍스트 하나뿐이지만, 뒤 장에서 이미지, PDF, 오디오, 동영상, 함수 호출 결과가 이 자리에 들어온다.

REST 요청 본문은 더 많은 필드를 가질 수 있다. 공식 모델 참조에서는 contents, systemInstruction, tools, safetySettings, generationConfig, cachedContent, labels 같은 필드를 설명한다.3 처음부터 전부 외울 필요는 없다. 다만 다음 표의 역할만큼은 정확히 구분해야 한다.

요소하는 일설계할 때 던질 질문
contents현재 요청에 필요한 사용자 입력과 대화 기록을 담는다.모델이 이번 답변을 만들기 위해 반드시 읽어야 하는 내용은 무엇인가?
parts하나의 메시지를 텍스트, 파일, 이미지, 함수 호출 등 여러 조각으로 나눈다.이 입력은 텍스트 하나인가, 여러 모달리티가 섞인 입력인가?
role발화 주체를 user 또는 model로 표시한다.이 메시지는 사용자가 한 말인가, 이전 모델 답변인가?
systemInstruction전체 응답 태도와 역할을 지시한다.모델이 대화 내내 지켜야 할 규칙은 무엇인가?
generationConfig온도, 최대 출력 토큰, 응답 형식 같은 생성 방식을 조절한다.더 창의적인 답이 필요한가, 더 예측 가능한 답이 필요한가?
safetySettings유해 가능성이 있는 응답을 어느 정도 차단할지 정한다.서비스 정책상 어떤 유형의 출력을 더 엄격히 막아야 하는가?
tools함수 호출, 코드 실행, 검색 같은 외부 능력을 모델에 연결한다.모델 혼자 답하면 안 되고 외부 시스템을 확인해야 하는가?

여기서 중요한 감각이 하나 있다. contents는 대화의 본문이고, generationConfig는 모델의 말투를 직접 지시하는 곳이 아니다. 예를 들어 고객지원 상담원처럼 말하라는 지시는 systemInstruction이나 프롬프트에 들어가야 한다. 반면 temperature를 낮추거나 maxOutputTokens를 제한하는 설정은 generationConfig에 들어간다. 이 둘을 섞어 생각하면 나중에 디버깅이 어려워진다.

contents, parts, role을 해부하기

제미나이의 요청 구조에서 가장 자주 만지는 필드는 contents다. OpenAI의 Chat Completions에 익숙한 독자라면 messages와 비슷하다고 느낄 수 있다. 하지만 제미나이는 멀티모달을 기본 설계에 넣었기 때문에 각 메시지가 다시 parts 배열을 가진다.

단일 턴 질문은 이렇게 표현된다.

{
  "contents": [
    {
      "role": "user",
      "parts": [
        { "text": "AI Studio와 Gemini API의 차이를 쉽게 설명해줘." }
      ]
    }
  ]
}

멀티턴 대화는 같은 배열에 이전 발화를 순서대로 넣으면 된다.

{
  "contents": [
    {
      "role": "user",
      "parts": [
        { "text": "나는 강아지 두 마리를 키워." }
      ]
    },
    {
      "role": "model",
      "parts": [
        { "text": "좋아요. 강아지 두 마리가 있다는 점을 기억할게요." }
      ]
    },
    {
      "role": "user",
      "parts": [
        { "text": "그럼 우리 집 강아지 발은 모두 몇 개야?" }
      ]
    }
  ]
}

이 구조에서 role: model은 현재 모델에게 답을 시키는 것이 아니라, 과거에 모델이 했던 말을 대화 기록으로 넣는 것이다. 서버에서 대화 이력을 관리한다면 이전 응답을 저장했다가 다음 요청의 contents에 포함하면 된다. 반대로 사용자가 임의로 보낸 대화 기록을 그대로 신뢰하면 안 된다. 사용자가 role: model 메시지를 조작해 시스템이 승인한 것처럼 꾸밀 수 있기 때문이다. 실무에서는 대화 기록을 서버 세션이나 데이터베이스에 저장하고, 클라이언트는 새 사용자 메시지만 보내도록 설계하는 편이 안전하다.

parts는 이 책 후반부의 확장 포인트다. 텍스트만 다룰 때는 parts: [{ text: ... }]가 전부처럼 보인다. 그러나 이미지를 함께 보내면 텍스트 파트와 이미지 파트가 같은 메시지 안에 들어간다. 파일을 업로드하면 fileData나 SDK의 파일 객체가 들어간다. 함수 호출을 쓰면 모델이 제안한 functionCall과 개발자가 돌려주는 functionResponse도 parts의 한 종류가 된다. 그러므로 지금부터 prompt = 문자열이라는 감각을 조금씩 버리고, contents = 역할이 있는 파트들의 배열이라는 감각으로 넘어가야 한다.

Python SDK로 첫 호출 만들기

Python에서는 google-genai 패키지를 사용한다. 이전 세대 자료에서 google.generativeai나 @google/generative-ai 같은 패키지를 본 적이 있을 수 있다. 이 책의 본문 예제는 새로운 Google GenAI SDK를 기준으로 한다. 공식 Python SDK 문서는 client.models.generate_content가 문자열, Content 객체, Part 객체 등을 받아 내부적으로 표준 contents 구조로 변환한다고 설명한다.4

먼저 로컬 프로젝트를 만든다.

mkdir gemini-basic
cd gemini-basic
python -m venv .venv
source .venv/bin/activate
pip install google-genai python-dotenv

윈도우 PowerShell이라면 가상환경 활성화 명령이 다르다.

.venvScriptsActivate.ps1

프로젝트 루트에 .env 파일을 만든다.

GEMINI_API_KEY=여기에_발급받은_API_키
GEMINI_MODEL=gemini-2.5-flash

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

.env
.venv/
__pycache__/

이제 python_app.py를 작성한다.

import os
from dotenv import load_dotenv
from google import genai
from google.genai import types

load_dotenv()

client = genai.Client(api_key=os.environ['GEMINI_API_KEY'])
model = os.getenv('GEMINI_MODEL', 'gemini-2.5-flash')

response = client.models.generate_content(
    model=model,
    contents='고객에게 보낼 짧은 배송 지연 사과 문구를 작성해줘.',
    config=types.GenerateContentConfig(
        temperature=0.2,
        max_output_tokens=200,
    ),
)

print(response.text)

이 코드는 가장 짧은 길이다. contents에 문자열 하나만 넣으면 SDK가 이를 사용자 텍스트 파트로 감싸 준다. 즉 내부적으로는 role이 user인 Content 하나와 text 파트 하나가 만들어진다고 이해하면 된다.

하지만 실무 코드에서는 명시적 구조를 아는 편이 좋다. 같은 요청을 표준 구조에 가깝게 쓰면 다음과 같다.

contents = [
    types.Content(
        role='user',
        parts=[
            types.Part.from_text(
                text='고객에게 보낼 짧은 배송 지연 사과 문구를 작성해줘.'
            )
        ],
    )
]

response = client.models.generate_content(
    model=model,
    contents=contents,
    config=types.GenerateContentConfig(
        temperature=0.2,
        max_output_tokens=200,
    ),
)

print(response.text)

초반에는 짧은 문자열 방식을 써도 괜찮다. 다만 팀 코드나 라이브러리 코드에서는 명시적인 Content 구조를 추천한다. 특히 대화 기록, 파일, 함수 호출 결과가 섞이기 시작하면 자동 변환 규칙을 머릿속으로 추측하는 것보다 처음부터 구조를 드러내는 편이 오류가 적다.

JavaScript와 TypeScript SDK로 첫 호출 만들기

JavaScript와 TypeScript에서는 @google/genai를 사용한다. 공식 JavaScript SDK는 Gemini Developer API와 Gemini Enterprise Agent Platform을 모두 지원하고, models, chats, files, caches, live 같은 하위 모듈을 제공한다. 또한 문서에서는 API 키를 클라이언트 코드에 노출하지 말고 프로덕션에서는 서버 측 구현을 사용하라고 명확히 경고한다.5

Node.js 프로젝트를 만든다. 2026년 기준 새 프로젝트라면 Node.js 20 이상을 권장한다.

mkdir gemini-basic-js
cd gemini-basic-js
npm init -y
npm pkg set type=module
npm install @google/genai dotenv

.env는 Python 예제와 비슷하다.

GEMINI_API_KEY=여기에_발급받은_API_키
GEMINI_MODEL=gemini-2.5-flash

node_app.mjs를 작성한다.

import 'dotenv/config';
import { GoogleGenAI } from '@google/genai';

const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
const model = process.env.GEMINI_MODEL ?? 'gemini-2.5-flash';

const response = await ai.models.generateContent({
  model,
  contents: '고객에게 보낼 짧은 배송 지연 사과 문구를 작성해줘.',
  config: {
    temperature: 0.2,
    maxOutputTokens: 200,
  },
});

console.log(response.text);

Python과 JavaScript 예제를 나란히 보면 공통점과 차이가 보인다. 공통점은 model, contents, config가 핵심이라는 점이다. 차이는 언어별 이름 규칙이다. Python은 max_output_tokens처럼 스네이크 케이스를 쓰고, JavaScript는 maxOutputTokens처럼 카멜 케이스를 쓴다. REST 원형에서는 generationConfig.maxOutputTokens처럼 JSON 필드명이 카멜 케이스다.

이 차이는 사소해 보이지만 복사-붙여넣기 오류의 단골 원인이다. REST 문서의 maxOutputTokens를 Python 코드에 그대로 넣으면 기대대로 동작하지 않을 수 있다. 반대로 Python 예제의 max_output_tokens를 JavaScript 코드에 그대로 옮겨도 문제가 생긴다. 공식 SDK는 언어별 타입과 문서에 맞춰 쓰는 것이 가장 안전하다.

멀티턴 대화: 상태를 어디에 둘 것인가

멀티턴 대화는 두 가지 방식으로 만들 수 있다. 첫 번째는 contents 배열에 직접 대화 기록을 넣는 방식이다. 이 방식은 REST와 가장 가깝고, 서버가 상태를 명확히 관리할 수 있어 운영 환경에 적합하다.

Python에서 직접 대화 기록을 구성하면 다음과 같다.

history = [
    types.Content(
        role='user',
        parts=[types.Part.from_text(text='나는 강아지 두 마리를 키워.')],
    ),
    types.Content(
        role='model',
        parts=[types.Part.from_text(text='좋아요. 강아지 두 마리가 있다는 점을 기억할게요.')],
    ),
    types.Content(
        role='user',
        parts=[types.Part.from_text(text='그럼 우리 집 강아지 발은 모두 몇 개야?')],
    ),
]

response = client.models.generate_content(
    model=model,
    contents=history,
)

print(response.text)

JavaScript에서도 같은 구조를 쓴다.

const contents = [
  {
    role: 'user',
    parts: [{ text: '나는 강아지 두 마리를 키워.' }],
  },
  {
    role: 'model',
    parts: [{ text: '좋아요. 강아지 두 마리가 있다는 점을 기억할게요.' }],
  },
  {
    role: 'user',
    parts: [{ text: '그럼 우리 집 강아지 발은 모두 몇 개야?' }],
  },
];

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

console.log(response.text);

두 번째 방식은 SDK의 채팅 헬퍼를 쓰는 것이다. 채팅 헬퍼는 로컬 객체가 대화 기록을 관리해 주므로 빠른 프로토타입에 편하다.

chat = client.chats.create(model=model)

first = chat.send_message('나는 강아지 두 마리를 키워.')
print(first.text)

second = chat.send_message('그럼 우리 집 강아지 발은 모두 몇 개야?')
print(second.text)

JavaScript에서는 다음처럼 쓸 수 있다.

const chat = ai.chats.create({
  model,
  history: [
    {
      role: 'user',
      parts: [{ text: '나는 강아지 두 마리를 키워.' }],
    },
    {
      role: 'model',
      parts: [{ text: '좋아요. 강아지 두 마리가 있다는 점을 기억할게요.' }],
    },
  ],
});

const response = await chat.sendMessage({
  message: '그럼 우리 집 강아지 발은 모두 몇 개야?',
});

console.log(response.text);

채팅 헬퍼는 편리하지만, 서버 재시작이나 여러 인스턴스 환경에서는 상태가 사라질 수 있다. Cloud Run, Kubernetes, 서버리스 함수처럼 인스턴스가 언제든 바뀔 수 있는 환경에서는 대화 기록을 데이터베이스나 캐시에 저장하고, 요청마다 필요한 기록을 contents로 재구성하는 편이 안전하다.

또 하나 기억할 점은 긴 대화일수록 비용과 지연시간이 증가한다는 사실이다. 매 요청마다 이전 대화를 모두 보내면 입력 토큰이 계속 늘어난다. 운영 서비스에서는 최근 몇 턴만 유지하거나, 오래된 대화를 요약하거나, 중요한 사실만 별도 메모리로 저장하는 전략이 필요하다. 이 문제는 8장 긴 컨텍스트와 10장 RAG에서 더 깊게 다룬다.

스트리밍: 빠른 답처럼 느끼게 만드는 기술

일반 generateContent 호출은 응답이 완성된 뒤 한 번에 결과를 돌려준다. 짧은 분류나 JSON 추출 작업에는 이 방식이 단순하고 좋다. 하지만 사용자가 화면을 보고 기다리는 챗봇, 글쓰기 도구, 리서치 도우미에서는 첫 글자가 빨리 나오는 경험이 중요하다. 이때 스트리밍을 사용한다.

Python에서는 generate_content_stream을 사용한다.

stream = client.models.generate_content_stream(
    model=model,
    contents='제미나이 API를 처음 배우는 개발자에게 5문장으로 설명해줘.',
    config=types.GenerateContentConfig(
        temperature=0.4,
        max_output_tokens=500,
    ),
)

for chunk in stream:
    if chunk.text:
        print(chunk.text, end='', flush=True)

JavaScript에서는 generateContentStream이 비동기 반복자를 반환한다.

const stream = await ai.models.generateContentStream({
  model,
  contents: '제미나이 API를 처음 배우는 개발자에게 5문장으로 설명해줘.',
  config: {
    temperature: 0.4,
    maxOutputTokens: 500,
  },
});

for await (const chunk of stream) {
  process.stdout.write(chunk.text ?? '');
}

스트리밍은 비용을 줄이는 기능이 아니다. 전체 생성량이 같다면 대체로 처리해야 할 토큰도 같다. 스트리밍의 장점은 체감 지연시간을 낮추는 데 있다. 사용자는 전체 답변이 완성될 때까지 빈 화면을 보지 않아도 된다.

다만 스트리밍에는 운영상 주의점이 있다. 응답 중간에 네트워크가 끊길 수 있고, 서버가 일부 토큰을 보낸 뒤 오류가 날 수도 있다. UI는 응답 상태를 생성 중, 완료, 중단됨처럼 구분해야 한다. 또한 JSON 구조화 출력은 스트리밍 도중에는 아직 완성된 JSON이 아닐 수 있다. 따라서 구조화 출력이 필요한 업무 자동화에서는 전체 응답을 받은 뒤 파싱하거나, 스트리밍 전용 파서를 별도로 설계해야 한다.

generationConfig, safetySettings, tools는 확장 슬롯이다

초기 예제에서는 temperature와 maxOutputTokens만 사용했다. 하지만 실무에서는 설정이 조금씩 늘어난다. 고객지원 답변은 낮은 온도로 일관성을 높이고, 브레인스토밍 도구는 온도를 조금 높일 수 있다. JSON 응답이 필요하면 응답 MIME 타입과 스키마를 지정한다. 특정 문구에서 생성을 멈추고 싶으면 중지 시퀀스를 사용한다.

Python에서는 다음처럼 설정한다.

response = client.models.generate_content(
    model=model,
    contents='환불 문의를 JSON으로 분류해줘: 상품이 파손되어 도착했습니다.',
    config=types.GenerateContentConfig(
        system_instruction='너는 전자상거래 고객지원 분류기다. 과장하지 말고 근거가 있는 값만 출력한다.',
        temperature=0,
        max_output_tokens=300,
        response_mime_type='application/json',
    ),
)

print(response.text)

JavaScript에서는 같은 의도를 다음처럼 표현한다.

const response = await ai.models.generateContent({
  model,
  contents: '환불 문의를 JSON으로 분류해줘: 상품이 파손되어 도착했습니다.',
  config: {
    systemInstruction: '너는 전자상거래 고객지원 분류기다. 과장하지 말고 근거가 있는 값만 출력한다.',
    temperature: 0,
    maxOutputTokens: 300,
    responseMimeType: 'application/json',
  },
});

console.log(response.text);

safetySettings는 모델의 안전 필터 기준을 요청별로 조정하는 장치다. 이것은 애플리케이션의 모든 안전 정책을 대신하지 않는다. 예를 들어 미성년자 서비스, 금융 상담, 의료 정보 서비스라면 모델의 안전 설정 외에도 자체 금칙어, 관리자 검토, 로그 감시, 사용자 신고 흐름이 필요하다. 모델 안전 설정은 안전망의 한 겹이지, 전체 안전 체계가 아니다.

tools는 모델이 외부 세계와 연결되는 통로다. 날씨 API를 부르거나, 사내 주문 DB를 조회하거나, 검색 결과를 근거로 답하게 하거나, 코드 실행을 통해 계산을 검증하게 만들 수 있다. 지금은 tools를 빈 확장 슬롯으로만 기억해도 충분하다. 9장에서 함수 호출과 그라운딩을 다룰 때, 이 장에서 배운 contents와 parts 구조가 그대로 다시 등장한다.

응답을 text 하나로만 보지 말기

입문 예제에서는 response.text를 출력한다. 이 속성은 편리한 지름길이다. 텍스트 앱을 만들 때는 대부분 충분하다. 하지만 프로덕션 코드에서는 응답이 항상 단순 텍스트 하나라고 가정하지 않는 편이 좋다.

제미나이 응답에는 후보 응답, 각 후보의 콘텐츠 파트, 종료 이유, 안전 관련 정보, 토큰 사용량 메타데이터가 포함될 수 있다. 도구 호출을 사용하면 텍스트 대신 함수 호출 파트가 올 수 있고, 이미지 생성 모델을 쓰면 이미지 데이터가 응답 파트에 포함될 수 있다. 따라서 애플리케이션의 핵심 로직에서는 다음 질문을 확인해야 한다.

확인할 항목이유
response.text가 비어 있지 않은가?안전 차단, 도구 호출, 비텍스트 응답일 수 있다.
종료 이유가 정상인가?최대 토큰 도달, 안전 차단, 기타 중단을 구분해야 한다.
사용량 메타데이터가 있는가?비용 추정과 지연시간 분석에 필요하다.
함수 호출 또는 파일 파트가 있는가?단순 출력이 아니라 다음 처리 단계가 필요할 수 있다.

앞 장에서 구조화 출력 실패율, 파싱 성공 여부, 토큰 사용량, 지연시간, 폴백 여부를 운영 대시보드의 핵심 지표로 언급했다. 이 지표들은 결국 API 응답과 예외 처리에서 나온다. 그러므로 처음부터 응답 객체를 로깅하고 관찰하는 습관을 들이면, 나중에 장애를 만났을 때 훨씬 덜 당황한다. 단, 로그에 개인정보나 민감한 업무 데이터가 그대로 남지 않도록 마스킹 정책을 먼저 세워야 한다.

API 키와 비밀 관리: 브라우저에 키를 두지 않는다

API 키는 모델 사용 권한이 담긴 열쇠다. 열쇠를 현관 앞 매트 밑에 두지 않듯이, API 키를 브라우저 JavaScript에 넣어 배포하면 안 된다. 사용자가 개발자 도구를 열면 키를 볼 수 있고, 누군가 그 키로 비용을 발생시킬 수 있다.

로컬 개발에서는 .env 파일을 사용해도 된다. 하지만 .env는 편의 장치일 뿐 보안 시스템이 아니다. 운영 환경에서는 플랫폼의 비밀 관리 기능을 사용해야 한다. Google Cloud라면 Secret Manager와 Cloud Run 환경변수 또는 볼륨 마운트를 조합할 수 있고, 다른 클라우드나 사내 환경에서도 그에 준하는 secret store를 써야 한다.

실무에서 지켜야 할 원칙은 단순하다.

  • API 키는 서버에서만 읽고, 브라우저·모바일 앱·공개 저장소에 넣지 않는다.

  • 개발, 스테이징, 운영 키를 분리하고 필요할 때 빠르게 폐기할 수 있게 한다.

  • .env, 서비스 계정 파일, 디버그 로그가 Git에 올라가지 않도록 사전 훅과 스캔 도구를 사용한다.

  • 사용자 입력과 모델 출력을 로그로 남길 때 개인정보와 영업기밀을 마스킹한다.

  • 키 사용량을 모니터링하고 비정상적인 호출량 증가를 알림으로 받는다.

웹 앱에서는 프런트엔드가 직접 Gemini API를 호출하지 않게 한다. 대신 브라우저는 여러분의 백엔드에 요청하고, 백엔드가 API 키를 사용해 Gemini API를 호출한 뒤 결과만 돌려준다. Next.js라면 Route Handler나 Server Action, Express라면 /api/generate 같은 서버 라우트를 두는 식이다. 이 구조는 단순히 키를 숨기는 것뿐 아니라 rate limit, 사용자 인증, 감사 로그, 캐싱, 비용 제한을 구현하는 출발점이 된다.

처음부터 오류 처리를 넣어라

데모 코드는 보통 행복한 경로만 보여 준다. 그러나 실제 서비스의 첫 번째 적은 모델 성능이 아니라 예외 처리 부재인 경우가 많다. API 키가 잘못되었거나, 모델명이 바뀌었거나, 요청이 너무 크거나, 호출량 제한에 걸리거나, 일시적인 서버 오류가 발생할 수 있다. 공식 문제 해결 가이드는 400, 403, 404, 429, 500 계열 오류를 구분해 확인하도록 안내한다.6

오류는 크게 둘로 나눠야 한다. 즉시 고쳐야 하는 오류와 재시도할 수 있는 오류다.

상태 코드흔한 원인처리 전략
400요청 형식 오류, 잘못된 파라미터, 스키마 불일치, 입력 한도 초과재시도하지 말고 요청 생성 코드를 수정한다.
403API 키 권한 문제, 사용 불가 프로젝트, 정책상 접근 불가인증·권한·결제·프로젝트 설정을 확인한다.
404모델명 오류, 엔드포인트 경로 오류, 지원되지 않는 리소스모델 이름과 API 버전을 확인한다.
429분당 요청 수나 토큰 한도 초과지수 백오프 후 재시도하고, 큐·캐시·속도 제한을 도입한다.
500·503·504일시적 서버 오류, 과부하, 타임아웃짧은 재시도 후 폴백 모델이나 사용자 안내로 전환한다.

Python에서는 다음처럼 얇은 재시도 래퍼를 만들 수 있다.

import random
import time

RETRYABLE_STATUS = {429, 500, 502, 503, 504}


def status_of(error: Exception):
    return (
        getattr(error, 'status', None)
        or getattr(error, 'status_code', None)
        or getattr(error, 'code', None)
    )


def generate_with_retry(prompt: str, attempts: int = 4):
    last_error = None

    for attempt in range(attempts):
        try:
            return client.models.generate_content(
                model=model,
                contents=prompt,
                config=types.GenerateContentConfig(
                    temperature=0.2,
                    max_output_tokens=300,
                ),
            )
        except Exception as error:
            last_error = error
            status = status_of(error)

            if status not in RETRYABLE_STATUS or attempt == attempts - 1:
                raise

            delay = min(8, 2 ** attempt) + random.random()
            time.sleep(delay)

    raise last_error


response = generate_with_retry('배송 지연 안내 문구를 작성해줘.')
print(response.text)

JavaScript에서도 같은 생각을 적용한다.

const retryableStatus = new Set([429, 500, 502, 503, 504]);

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function generateWithRetry(prompt, attempts = 4) {
  let lastError;

  for (let attempt = 0; attempt < attempts; attempt += 1) {
    try {
      return await ai.models.generateContent({
        model,
        contents: prompt,
        config: {
          temperature: 0.2,
          maxOutputTokens: 300,
        },
      });
    } catch (error) {
      lastError = error;
      const status = error.status ?? error.code;

      if (!retryableStatus.has(status) || attempt === attempts - 1) {
        throw error;
      }

      const delay = Math.min(8000, 1000 * 2 ** attempt) + Math.random() * 300;
      await sleep(delay);
    }
  }

  throw lastError;
}

const response = await generateWithRetry('배송 지연 안내 문구를 작성해줘.');
console.log(response.text);

재시도에는 한 가지 함정이 있다. 생성형 AI 응답은 같은 요청을 다시 보내도 완전히 같은 답이 나오지 않을 수 있다. 고객에게 메시지를 실제로 발송하거나, 주문 상태를 변경하거나, 결제를 처리하는 작업과 연결되어 있다면 단순 재시도가 중복 실행으로 이어질 수 있다. 이때는 요청 ID를 붙이고, 외부 시스템 변경 작업은 멱등성 있게 설계해야 한다. 멱등성이란 같은 요청을 여러 번 보내도 결과가 한 번 처리된 것과 같게 만드는 성질이다.

또한 안전 차단은 항상 HTTP 오류로 오지 않는다. 요청은 성공했지만 후보 응답이 비어 있거나 종료 이유가 안전 관련 값일 수 있다. 이런 경우에는 예외 처리보다 응답 검사 로직이 중요하다. 사용자에게는 단순히 실패라고 말하기보다, 정책상 답변할 수 없는 요청임을 안내하고 안전한 대체 행동을 제시하는 편이 좋다.

REST, Python, JavaScript의 차이를 한눈에 보기

세 가지 호출 방식은 같은 API를 다른 문법으로 쓰는 것이다. 핵심 개념은 변하지 않는다.

개념RESTPython SDKJavaScript/TypeScript SDK
메서드:generateContentclient.models.generate_contentai.models.generateContent
스트리밍:streamGenerateContentgenerate_content_streamgenerateContentStream
입력 본문contentscontents 인자contents 속성
생성 설정generationConfigconfig=GenerateContentConfig(...)config: { ... }
최대 출력 토큰maxOutputTokensmax_output_tokensmaxOutputTokens
시스템 지시systemInstructionsystem_instructionsystemInstruction
안전 설정safetySettingssafety_settingssafetySettings

이 표는 책상 앞에 붙여 두어도 좋다. 특히 Python과 JavaScript를 함께 쓰는 팀에서는 필드명 차이가 생각보다 자주 문제를 만든다. 백엔드가 Python이고 프런트엔드 개발자가 TypeScript 예제를 보는 상황, 또는 AI Studio에서 받은 REST 예제를 Python으로 옮기는 상황에서 이름 규칙을 헷갈리기 쉽다.

작은 코드 구조가 나중의 복잡도를 줄인다

처음에는 한 파일에 모든 코드를 넣어도 된다. 그러나 두 번째 기능을 추가하는 순간부터 Gemini 호출 코드는 분리하는 편이 좋다. 예를 들어 Python 프로젝트라면 gemini_client.py에 클라이언트 생성, 기본 모델명, 재시도 정책을 넣고, 업무 로직은 별도 파일에서 이 함수를 호출하게 만든다.

# gemini_client.py
import os
from google import genai
from google.genai import types

client = genai.Client(api_key=os.environ['GEMINI_API_KEY'])
DEFAULT_MODEL = os.getenv('GEMINI_MODEL', 'gemini-2.5-flash')


def generate_text(prompt: str, *, temperature: float = 0.2, max_tokens: int = 500):
    return client.models.generate_content(
        model=DEFAULT_MODEL,
        contents=prompt,
        config=types.GenerateContentConfig(
            temperature=temperature,
            max_output_tokens=max_tokens,
        ),
    )

JavaScript에서도 비슷하게 감싼다.

// geminiClient.mjs
import { GoogleGenAI } from '@google/genai';

export const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
export const defaultModel = process.env.GEMINI_MODEL ?? 'gemini-2.5-flash';

export async function generateText(prompt, options = {}) {
  const response = await ai.models.generateContent({
    model: options.model ?? defaultModel,
    contents: prompt,
    config: {
      temperature: options.temperature ?? 0.2,
      maxOutputTokens: options.maxOutputTokens ?? 500,
    },
  });

  return response;
}

이 정도의 얇은 래퍼만 있어도 이후 변경이 쉬워진다. 모델명을 바꾸거나, 공통 systemInstruction을 넣거나, 재시도 정책을 추가하거나, 사용량 로깅을 붙일 때 호출 지점을 모두 고치지 않아도 된다. 좋은 추상화는 거창한 프레임워크가 아니라 반복되는 위험을 한곳에 모으는 데서 시작한다.

이번 장의 기준선

이제 우리는 제미나이 API의 가장 중요한 기본기를 갖췄다. generateContent는 단순한 텍스트 생성 함수가 아니라 제미나이 기능 대부분이 지나가는 관문이다. contents, parts, role을 이해하면 멀티턴 대화와 멀티모달 입력이 자연스럽게 보인다. generationConfig, safetySettings, tools를 구분하면 프롬프트, 출력 제어, 안전 정책, 외부 기능 연결을 뒤섞지 않을 수 있다.

Python과 JavaScript SDK는 문법은 다르지만 생각의 구조는 같다. API 키는 서버에서만 다루고, .env는 로컬 개발에만 사용하며, 운영에서는 비밀 관리 시스템을 써야 한다. 또한 400·403·404처럼 즉시 수정해야 하는 오류와 429·500 계열처럼 제한적으로 재시도할 수 있는 오류를 처음부터 나눠 처리해야 한다.

다음 장부터는 이 기본기를 바탕으로 실제 업무형 애플리케이션을 만든다. 고객 문의를 요약하고, 문서를 읽고, UI로 결과를 확인하는 과정이 이어질 것이다. 그때도 핵심은 변하지 않는다. 모델에게 무엇을 보낼지 contents로 정리하고, 어떤 방식으로 답하게 할지 config로 제어하며, 실패했을 때 어떻게 복구할지 코드로 명시하는 것이다.

참고

1Generating content | Gemini API

2Gemini API libraries

3Vertex AI Gemini API model reference

4Google Gen AI SDK documentation

5Google Gen AI JavaScript SDK

6Gemini API troubleshooting guide

책 목록

제미나이 공략집

전체 목차 보기

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

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

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

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

참여하기