Skip to main content
Summary

노트북의 한 줄 호출을 업무 도구로 바꾸는 순간

앞 장에서 우리는 Gemini API와 공식 SDK의 기본 호출 방식을 살펴보았다. client.models.generate_content() 한 줄로 답변을 얻는 경험은 강력하다. 그러나 업무 현장에 들어가는 순간 질문은 달라진다. "잘 답하나요?"보다 먼저 "누가, 어떤 입력으로, 얼마만큼의 비용을 쓰며, 실패했을 때 어떻게 복구하나요?"를 물어야 한다.

Python은 이 전환에 가장 적합한 언어다. 실험 노트북에서 프롬프트를 다듬고, 같은 코드를 내부 자동화 스크립트로 옮기고, 다시 FastAPI 서버나 Streamlit 업무 도구로 확장하기 쉽다. 데이터 처리에는 pandas, 웹 API에는 FastAPI, 간단한 사내 UI에는 Streamlit, 배포에는 컨테이너와 Cloud Run 같은 선택지가 자연스럽게 이어진다.

이번 장의 목표는 거창한 챗봇을 만드는 것이 아니다. 문서 요약기, FAQ 도우미, 보고서 생성기처럼 실제 팀에서 바로 쓸 수 있는 작은 애플리케이션을 만든다. 단, 장난감 예제가 아니라 나중에 평가·튜닝·운영 장으로 확장할 수 있는 형태로 만든다. 핵심은 네 가지다.

  • Gemini 호출을 직접 흩뿌리지 않고 공통 클라이언트와 래퍼로 감싼다.

  • API 키는 서버에만 두고, 입력 검증과 출력 제한을 서버에서 처리한다.

  • Streamlit으로 비개발자도 파일을 올리고 결과를 확인할 수 있게 한다.

  • 프롬프트, 샘플 데이터, 실행 로그를 분리해 재현 가능한 실험 단위로 남긴다.

공식 google-genai SDK는 Gemini Developer API와 Vertex AI의 Gemini API를 모두 다루는 통합 인터페이스를 제공한다. Google은 기존 라이브러리를 쓰는 개발자에게 Google Gen AI SDK로 이전할 것을 권장하고 있으며, Python 패키지는 pip install google-genai로 설치할 수 있다.1 이 장에서는 책의 기준 모델인 gemini-2.5-flash를 중심으로 설명하되, 구조 자체는 Pro나 Flash-Lite로 바꾸어도 유지되도록 만든다.

Python으로 업무형 애플리케이션 만들기

프로젝트 골격: 호출 코드를 흩뜨리지 않는다

먼저 작은 프로젝트를 하나 만든다. 이름은 gemini-office-kit이라고 하자. 이 프로젝트는 세 가지 실행 경로를 갖는다. 명령행에서 바로 요약을 테스트하는 경로, FastAPI 서버로 호출하는 경로, Streamlit UI에서 파일을 올리는 경로다.

gemini-office-kit/
├── app/
│   ├── core/
│   │   ├── config.py
│   │   ├── gemini_client.py
│   │   ├── logging.py
│   │   └── errors.py
│   ├── prompts/
│   │   ├── summarize.py
│   │   ├── faq.py
│   │   └── report.py
│   ├── services/
│   │   ├── summarizer.py
│   │   ├── faq_assistant.py
│   │   └── report_writer.py
│   ├── api/
│   │   └── main.py
│   └── ui/
│       └── streamlit_app.py
├── data/
│   ├── samples/
│   └── runs/
├── tests/
│   ├── test_prompts.py
│   └── test_summarizer.py
├── .env.example
├── requirements.txt
└── README.md

처음부터 이렇게 나누는 이유는 단순하다. 프롬프트를 UI 코드 안에 직접 넣으면 나중에 평가할 수 없다. API 호출을 서비스마다 복사하면 오류 처리와 로깅 정책이 제각각이 된다. 샘플 데이터와 실행 결과를 남기지 않으면 "어제는 잘됐는데 오늘은 왜 다르지?"라는 질문에 답하기 어렵다.

requirements.txt는 다음처럼 시작할 수 있다.

google-genai>=1.0.0
python-dotenv>=1.0.1
pydantic>=2.8.0
pydantic-settings>=2.4.0
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
streamlit>=1.37.0
python-multipart>=0.0.9
pypdf>=4.3.0
pandas>=2.2.0
tabulate>=0.9.0
pytest>=8.0.0

여기서 pydantic-settings는 환경설정을 읽기 위해 필요하고, tabulate는 pandas.DataFrame.to_markdown()을 사용할 때 필요하다. 이런 작은 의존성 누락이 실습을 가장 자주 멈춘다. 기술서의 예제는 멋진 구조보다 먼저 재현 가능해야 한다.

PDF 텍스트 추출에는 pypdf를 사용한다. 다만 이것은 편의를 위한 선택이다. Gemini는 파일 입력과 문서 이해를 지원하므로, 문서의 시각 요소와 텍스트 요소를 함께 요약하거나 질문에 답하도록 설계할 수 있다.2 단, 이번 장에서는 업무 도구의 구조를 익히는 것이 목표이므로 작은 PDF와 텍스트 파일을 대상으로 시작한다.

.env.example은 다음처럼 만든다.

GEMINI_API_KEY=your_api_key_here
GEMINI_MODEL=gemini-2.5-flash
MAX_OUTPUT_TOKENS=1024
REQUEST_TIMEOUT_SECONDS=60
LOG_DIR=data/runs

실제 .env 파일은 저장소에 올리지 않는다. .gitignore에 반드시 .env, 서비스 계정 키, 업로드 임시 파일, 실행 로그 중 민감한 결과물을 추가한다. API 키를 노트북이나 Streamlit 코드에 직접 적는 순간, 그 코드는 공유하기 어려운 코드가 된다.

설정과 클라이언트 초기화

app/core/config.py부터 작성하자. 설정은 여기저기에서 os.getenv()로 직접 읽지 말고 한 곳에서 관리한다.

# app/core/config.py
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

    gemini_api_key: str = Field(alias="GEMINI_API_KEY")
    gemini_model: str = Field(default="gemini-2.5-flash", alias="GEMINI_MODEL")
    max_output_tokens: int = Field(default=1024, alias="MAX_OUTPUT_TOKENS")
    request_timeout_seconds: int = Field(default=60, alias="REQUEST_TIMEOUT_SECONDS")
    log_dir: str = Field(default="data/runs", alias="LOG_DIR")


@lru_cache
def get_settings() -> Settings:
    return Settings()

단순하게 가고 싶다면 python-dotenv와 os.getenv()만으로도 충분하다. 중요한 것은 설정값이 코드 곳곳에 흩어지지 않는 것이다.

이제 Gemini 클라이언트를 만든다. 공식 Python SDK에서는 from google import genai로 가져온 뒤 genai.Client(api_key=...) 형태로 Gemini Developer API 클라이언트를 만들 수 있다. 환경 변수 GEMINI_API_KEY 또는 GOOGLE_API_KEY를 설정해 두면 genai.Client()가 이를 자동으로 사용할 수도 있다.3

# app/core/gemini_client.py
from __future__ import annotations

import random
import time
from dataclasses import dataclass
from typing import Any

from google import genai
from google.genai import types

from app.core.config import get_settings
from app.core.errors import RateLimitError, UpstreamTemporaryError


@dataclass
class GeminiResult:
    text: str
    model: str
    latency_ms: int
    usage: dict[str, Any]
    raw_finish_reason: str | None = None


class GeminiService:
    def __init__(self) -> None:
        settings = get_settings()
        self.model = settings.gemini_model
        self.max_output_tokens = settings.max_output_tokens
        self.client = genai.Client(api_key=settings.gemini_api_key)

    def generate_text(
        self,
        prompt: str,
        *,
        system_instruction: str | None = None,
        temperature: float = 0.2,
        max_output_tokens: int | None = None,
    ) -> GeminiResult:
        started = time.perf_counter()

        response = self.client.models.generate_content(
            model=self.model,
            contents=prompt,
            config=types.GenerateContentConfig(
                system_instruction=system_instruction,
                temperature=temperature,
                max_output_tokens=max_output_tokens or self.max_output_tokens,
            ),
        )

        latency_ms = int((time.perf_counter() - started) * 1000)
        usage = {}
        if getattr(response, "usage_metadata", None):
            usage = response.usage_metadata.model_dump(exclude_none=True)

        finish_reason = None
        if getattr(response, "candidates", None):
            finish_reason = getattr(response.candidates[0], "finish_reason", None)
            if finish_reason is not None:
                finish_reason = str(finish_reason)

        return GeminiResult(
            text=response.text or "",
            model=self.model,
            latency_ms=latency_ms,
            usage=usage,
            raw_finish_reason=finish_reason,
        )

    def generate_text_with_retry(self, prompt: str, **kwargs: Any) -> GeminiResult:
        last_error: Exception | None = None

        for attempt in range(3):
            try:
                return self.generate_text(prompt, **kwargs)
            except Exception as exc:  # 운영 코드에서는 SDK 예외 타입별 분기를 추가한다.
                last_error = exc
                message = str(exc).lower()

                if "429" in message or "resource exhausted" in message:
                    _sleep_for_retry(attempt)
                    continue

                if any(code in message for code in ["500", "503", "504", "deadline"]):
                    _sleep_for_retry(attempt)
                    continue

                raise

        if last_error and "429" in str(last_error).lower():
            raise RateLimitError() from last_error
        raise UpstreamTemporaryError() from last_error


def _sleep_for_retry(attempt: int) -> None:
    base = min(2 ** attempt, 8)
    time.sleep(base + random.random())

이 래퍼는 일부러 얇게 만들었다. 얇지만 중요한 일을 한다. 모델명, 온도, 출력 토큰 제한, 지연시간, 사용량 메타데이터를 한곳에서 다룬다. 나중에 gemini-2.5-pro로 바꾸거나, 요약 업무에는 Flash-Lite를 쓰고 보고서 작성에는 Pro를 쓰는 라우팅을 넣어도 이 파일만 확장하면 된다.

공식 SDK의 generate_content는 문자열, types.Content, types.Part, 파일 객체 등 다양한 입력을 contents로 받을 수 있다. 문자열을 넣으면 SDK가 사용자 텍스트 파트로 변환하고, 여러 파트를 넣으면 하나의 사용자 입력으로 묶어 준다.4 처음에는 문자열로 시작해도 되지만, 멀티모달과 도구 호출로 넘어갈수록 types를 명시적으로 쓰는 편이 디버깅에 유리하다.

공통 오류 처리: 실패를 제품 경험의 일부로 설계한다

업무 애플리케이션에서 API 호출 실패는 예외 상황이 아니다. 네트워크가 끊기고, 입력이 너무 길고, 요청량이 몰리고, 안전 필터가 응답을 막을 수 있다. 앞 장의 오류 처리 흐름을 이어받아, 이번에는 애플리케이션 코드에 넣을 수 있는 형태로 정리하자.

app/core/errors.py를 만든다.

# app/core/errors.py
from dataclasses import dataclass


@dataclass
class AppError(Exception):
    code: str
    message: str
    status_code: int = 500
    retryable: bool = False


class InvalidInputError(AppError):
    def __init__(self, message: str = "입력값을 확인해 주세요.") -> None:
        super().__init__("INVALID_INPUT", message, 400, False)


class RateLimitError(AppError):
    def __init__(self, message: str = "요청이 많습니다. 잠시 후 다시 시도해 주세요.") -> None:
        super().__init__("RATE_LIMIT", message, 429, True)


class UpstreamTemporaryError(AppError):
    def __init__(self, message: str = "AI 응답 생성이 일시적으로 실패했습니다.") -> None:
        super().__init__("UPSTREAM_TEMPORARY", message, 503, True)

SDK 내부 예외 타입은 버전에 따라 세부 구조가 달라질 수 있으므로, 제품 코드에서는 원본 예외를 그대로 사용자에게 보여 주지 않는다. 대신 로그에는 남기고, 사용자에게는 재시도 가능 여부가 포함된 단순한 메시지를 준다. 개발자의 언어와 사용자의 언어를 분리하는 것이 프록시 API의 첫 번째 역할이다.

사용량 로깅: 나중에 평가할 수 있게 남긴다

생성형 AI 애플리케이션의 로그는 양날의 검이다. 남기지 않으면 비용과 품질을 분석할 수 없고, 너무 많이 남기면 개인정보와 기밀정보 위험이 커진다. 따라서 이번 장에서는 최소 로그를 JSONL 형식으로 남긴다. JSONL은 한 줄에 JSON 하나를 저장하는 형식이다. 나중에 pandas.read_json(..., lines=True)로 읽기 쉽다.

# app/core/logging.py
from __future__ import annotations

import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from app.core.config import get_settings


def _hash_text(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]


def write_run_log(
    *,
    task: str,
    input_text: str,
    output_text: str,
    model: str,
    latency_ms: int,
    usage: dict[str, Any],
    extra: dict[str, Any] | None = None,
) -> None:
    settings = get_settings()
    log_dir = Path(settings.log_dir)
    log_dir.mkdir(parents=True, exist_ok=True)

    record = {
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "task": task,
        "input_hash": _hash_text(input_text),
        "input_chars": len(input_text),
        "output_chars": len(output_text),
        "model": model,
        "latency_ms": latency_ms,
        "usage": usage,
        "extra": extra or {},
    }

    with (log_dir / "runs.jsonl").open("a", encoding="utf-8") as f:
        f.write(json.dumps(record, ensure_ascii=False) + "n")

여기서 주목할 점은 원문 입력과 출력 전문을 그대로 저장하지 않는다는 것이다. 실습 단계에서는 편의를 위해 저장하고 싶겠지만, 사내 문서가 들어오는 순간 로그는 곧 데이터베이스가 된다. 일단 해시와 길이, 모델명, 지연시간, 토큰 사용량, 태스크명만 남겨도 많은 것을 볼 수 있다. 예를 들어 어떤 문서 유형에서 지연시간이 긴지, 어떤 프롬프트 버전에서 출력이 지나치게 길어지는지 확인할 수 있다.

나중에 평가를 다룰 때는 이 로그에 prompt_version, schema_valid, human_score, fallback_used 같은 필드를 추가할 것이다. 지금 그 길을 열어 두는 것이 중요하다.

첫 번째 업무 기능: 문서 요약기

가장 흔한 내부 도구는 문서 요약기다. 회의록, 제안서, 고객 인터뷰, 정책 문서를 넣으면 핵심만 뽑아 준다. 그러나 "요약해 줘"라는 프롬프트만으로는 업무에 쓰기 어렵다. 좋은 요약기는 독자가 누구인지, 어떤 형식을 원하는지, 원문에 없는 추론을 어디까지 허용할지 정해야 한다.

프롬프트를 함수로 분리하자.

# app/prompts/summarize.py
SUMMARY_SYSTEM_INSTRUCTION = """
당신은 한국어 업무 문서를 요약하는 전문 분석가입니다.
원문에 없는 사실을 만들어내지 말고, 불확실한 내용은 '원문에서 확인되지 않음'이라고 표시하세요.
문장은 간결하게 쓰되, 의사결정에 필요한 맥락은 생략하지 마세요.
""".strip()


def build_summary_prompt(document_text: str, audience: str = "팀장") -> str:
    return f"""
다음 문서를 {audience}가 빠르게 검토할 수 있도록 요약하세요.

[요약 형식]
1. 한 줄 결론
2. 핵심 내용 5개 이내
3. 결정이 필요한 사항
4. 후속 질문
5. 원문에서 불확실하거나 확인이 필요한 부분

[문서]
{document_text}
""".strip()

이제 서비스 함수를 만든다.

# app/services/summarizer.py
from app.core.gemini_client import GeminiService
from app.core.logging import write_run_log
from app.prompts.summarize import SUMMARY_SYSTEM_INSTRUCTION, build_summary_prompt


def summarize_document(document_text: str, audience: str = "팀장") -> str:
    if len(document_text.strip()) < 20:
        raise ValueError("요약할 문서가 너무 짧습니다.")

    prompt = build_summary_prompt(document_text, audience)
    gemini = GeminiService()
    result = gemini.generate_text_with_retry(
        prompt,
        system_instruction=SUMMARY_SYSTEM_INSTRUCTION,
        temperature=0.2,
        max_output_tokens=1200,
    )

    write_run_log(
        task="document_summary",
        input_text=document_text,
        output_text=result.text,
        model=result.model,
        latency_ms=result.latency_ms,
        usage=result.usage,
        extra={"audience": audience},
    )
    return result.text

이 함수는 테스트하기 쉽다. 입력 문자열과 대상 독자를 넣으면 문자열이 나온다. UI도 없고 FastAPI도 없다. 이것이 좋다. 생성형 AI 기능도 결국 소프트웨어이므로, 핵심 로직은 프레임워크에서 분리되어야 한다.

간단한 명령행 실행 파일을 만들어 확인해 보자.

# run_summary.py
from pathlib import Path
from app.services.summarizer import summarize_document

text = Path("data/samples/meeting_note.txt").read_text(encoding="utf-8")
print(summarize_document(text, audience="프로덕트 매니저"))

여기까지 되면 "노트북에서 한 번 되는 코드"가 "반복 실행 가능한 기능"이 된다. 차이는 작아 보이지만, 팀에서 공유할 수 있는 코드와 개인 실험의 경계가 바로 여기서 갈린다.

두 번째 업무 기능: FAQ 도우미

FAQ 도우미는 고객지원팀이나 사내 헬프데스크에 유용하다. 이번 장에서는 아직 벡터 검색이나 RAG를 붙이지 않는다. 대신 작은 FAQ 목록을 프롬프트에 넣고, 그 범위 안에서만 답하게 만든다. RAG를 만들 때 이 구조를 검색 결과 삽입 방식으로 확장할 수 있다.

# app/prompts/faq.py
FAQ_SYSTEM_INSTRUCTION = """
당신은 회사 내부 FAQ 도우미입니다.
제공된 FAQ 근거 안에서만 답하세요.
근거가 부족하면 추측하지 말고 담당 부서에 확인하라고 안내하세요.
답변 끝에는 사용한 FAQ 항목 번호를 표시하세요.
""".strip()


def build_faq_prompt(question: str, faq_items: list[dict]) -> str:
    faq_text = "n".join(
        f"[{i+1}] Q: {item['question']}nA: {item['answer']}"
        for i, item in enumerate(faq_items)
    )

    return f"""
[FAQ]
{faq_text}

[사용자 질문]
{question}

[답변 조건]
- 한국어로 답변하세요.
- 질문과 관련된 FAQ가 없으면 '제공된 FAQ에서 확인할 수 없습니다'라고 말하세요.
- 필요한 경우 사용자가 다음에 확인해야 할 정보를 물어보세요.
""".strip()
# app/services/faq_assistant.py
from app.core.gemini_client import GeminiService
from app.core.logging import write_run_log
from app.prompts.faq import FAQ_SYSTEM_INSTRUCTION, build_faq_prompt


def answer_faq(question: str, faq_items: list[dict]) -> str:
    prompt = build_faq_prompt(question, faq_items)
    gemini = GeminiService()
    result = gemini.generate_text_with_retry(
        prompt,
        system_instruction=FAQ_SYSTEM_INSTRUCTION,
        temperature=0.1,
        max_output_tokens=700,
    )

    write_run_log(
        task="faq_answer",
        input_text=question,
        output_text=result.text,
        model=result.model,
        latency_ms=result.latency_ms,
        usage=result.usage,
        extra={"faq_count": len(faq_items)},
    )
    return result.text

여기서 온도를 0.1로 낮춘 이유는 FAQ 답변이 창의적일 필요가 없기 때문이다. 원문 근거에 충실해야 하는 작업은 낮은 온도, 짧은 출력, 명확한 거절 규칙이 어울린다. 반대로 마케팅 문구 초안처럼 다양한 아이디어가 필요한 작업은 온도를 조금 높일 수 있다.

세 번째 업무 기능: 보고서 생성기

보고서 생성기는 요약기보다 까다롭다. 요약은 원문을 줄이는 작업이지만, 보고서는 목적에 맞게 재구성하는 작업이다. 특히 숫자 데이터가 들어갈 때 모델이 계산을 지어내지 않도록 조심해야 한다. 이번 장에서는 pandas로 먼저 기초 통계를 계산하고, Gemini에는 "해석과 문장화"를 맡기는 방식을 쓴다.

# app/prompts/report.py
REPORT_SYSTEM_INSTRUCTION = """
당신은 데이터 기반 업무 보고서를 작성하는 분석가입니다.
제공된 표와 통계 밖의 숫자를 만들지 마세요.
추론은 가능하지만, 추론과 확인된 사실을 구분해서 쓰세요.
""".strip()


def build_report_prompt(summary_table_markdown: str, stats_text: str, goal: str) -> str:
    return f"""
다음 데이터 요약을 바탕으로 업무 보고서 초안을 작성하세요.

[보고 목적]
{goal}

[기초 통계]
{stats_text}

[표 샘플]
{summary_table_markdown}

[작성 형식]
## 핵심 결론
## 주요 관찰
## 가능한 원인 가설
## 권장 액션
## 추가로 필요한 데이터
""".strip()
# app/services/report_writer.py
import pandas as pd

from app.core.gemini_client import GeminiService
from app.core.logging import write_run_log
from app.prompts.report import REPORT_SYSTEM_INSTRUCTION, build_report_prompt


def write_report_from_dataframe(df: pd.DataFrame, goal: str) -> str:
    if df.empty:
        raise ValueError("보고서를 만들 데이터가 없습니다.")

    sample = df.head(30).to_markdown(index=False)
    stats = []
    stats.append(f"행 수: {len(df)}")
    stats.append(f"열 수: {len(df.columns)}")
    stats.append(f"열 이름: {', '.join(map(str, df.columns))}")

    numeric = df.select_dtypes(include="number")
    if not numeric.empty:
        stats.append("숫자 열 요약:")
        stats.append(numeric.describe().round(2).to_markdown())

    prompt = build_report_prompt(sample, "n".join(stats), goal)
    gemini = GeminiService()
    result = gemini.generate_text_with_retry(
        prompt,
        system_instruction=REPORT_SYSTEM_INSTRUCTION,
        temperature=0.2,
        max_output_tokens=1500,
    )

    write_run_log(
        task="data_report",
        input_text=sample + goal,
        output_text=result.text,
        model=result.model,
        latency_ms=result.latency_ms,
        usage=result.usage,
        extra={"rows": len(df), "cols": len(df.columns)},
    )
    return result.text

데이터프레임 전체를 무조건 프롬프트에 넣지 않는 점을 눈여겨보자. 작은 데이터라면 괜찮지만, 업무 데이터는 금방 커진다. 모델에 보낼 정보는 "원본 전체"가 아니라 "작업에 필요한 표현"이어야 한다. 숫자 계산은 Python이 하고, 언어적 해석은 Gemini가 한다. 이 분업이 비용과 품질을 동시에 잡는다.

FastAPI로 Gemini 프록시 API 만들기

이제 이 기능들을 API 서버로 감싼다. 왜 굳이 프록시 API가 필요할까? 브라우저나 사내 도구에서 Gemini API를 직접 호출하면 API 키가 노출될 수 있다. 또한 사용자별 권한, 입력 길이 제한, 출력 토큰 제한, 로깅, 금칙어 필터링, 속도 제한 같은 정책을 적용하기 어렵다. 서버는 단순한 중계기가 아니라 안전장치다.

app/api/main.py를 만든다.

# app/api/main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field

from app.core.errors import AppError
from app.services.faq_assistant import answer_faq
from app.services.summarizer import summarize_document

app = FastAPI(title="Gemini Office Kit API")


class SummaryRequest(BaseModel):
    document_text: str = Field(min_length=20, max_length=80_000)
    audience: str = Field(default="팀장", max_length=40)


class SummaryResponse(BaseModel):
    summary: str


class FAQItem(BaseModel):
    question: str = Field(min_length=1, max_length=500)
    answer: str = Field(min_length=1, max_length=2000)


class FAQRequest(BaseModel):
    question: str = Field(min_length=2, max_length=1000)
    faq_items: list[FAQItem] = Field(min_length=1, max_length=30)


class FAQResponse(BaseModel):
    answer: str


@app.get("/health")
def health() -> dict:
    return {"status": "ok"}


@app.post("/summaries", response_model=SummaryResponse)
def create_summary(req: SummaryRequest) -> SummaryResponse:
    try:
        summary = summarize_document(req.document_text, req.audience)
        return SummaryResponse(summary=summary)
    except AppError as exc:
        raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
    except Exception as exc:
        raise HTTPException(status_code=500, detail="요약 생성에 실패했습니다.") from exc


@app.post("/faq/answers", response_model=FAQResponse)
def create_faq_answer(req: FAQRequest) -> FAQResponse:
    try:
        faq_items = [item.model_dump() for item in req.faq_items]
        answer = answer_faq(req.question, faq_items)
        return FAQResponse(answer=answer)
    except AppError as exc:
        raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
    except Exception as exc:
        raise HTTPException(status_code=500, detail="FAQ 답변 생성에 실패했습니다.") from exc

서버를 실행한다.

uvicorn app.api.main:app --reload --port 8000

그리고 curl로 테스트한다.

curl -X POST http://localhost:8000/summaries \
  -H "Content-Type: application/json" \
  -d '{
    "audience": "고객지원팀 리더",
    "document_text": "오늘 회의에서는 환불 정책 개편과 상담 매크로 개선안을 논의했다. 환불 요청 중 배송 지연 관련 문의가 증가했고, 다음 분기에는 자동 분류 규칙을 개선하기로 했다. 상담팀은 반복 문의를 줄이기 위해 FAQ를 개편하고, 결제 장애 문의는 별도 긴급 큐로 분리하기로 했다."
  }'

이 API에는 세 가지 안전장치가 이미 들어 있다. 첫째, 요청 스키마가 있다. 너무 짧거나 지나치게 긴 입력은 애초에 거절된다. 둘째, API 키는 서버의 환경 변수에만 있다. 셋째, 출력 토큰 제한은 클라이언트가 마음대로 늘릴 수 없고 서버의 설정과 서비스 함수에서 통제한다.

실무에서는 여기에 사용자 인증, 조직별 rate limit, 파일 크기 제한, 요청 ID, 감사 로그, CORS 정책을 추가해야 한다. 이 장의 코드는 출발점이지 운영 완성본이 아니다. 그래도 이 구조로 시작하면 다음 단계로 확장하기 쉽다.

Streamlit으로 내부 업무 UI 만들기

API만 있으면 개발자는 테스트할 수 있지만, 비개발자는 쓰기 어렵다. 사내 PoC나 워크숍에서는 Streamlit처럼 빠르게 화면을 만들 수 있는 도구가 유용하다. 단, Streamlit 앱도 서버 코드다. API 키를 화면에 노출하거나 브라우저로 보내지 않는다는 원칙은 그대로다.

app/ui/streamlit_app.py를 만든다.

# app/ui/streamlit_app.py
from pathlib import Path

import streamlit as st
from pypdf import PdfReader

from app.services.summarizer import summarize_document


st.set_page_config(page_title="Gemini Office Kit", layout="wide")
st.title("Gemini Office Kit")
st.caption("작은 문서 요약 도구부터 시작하는 업무형 Gemini 애플리케이션")


def read_pdf(uploaded_file) -> str:
    reader = PdfReader(uploaded_file)
    pages = []
    for page in reader.pages[:20]:
        pages.append(page.extract_text() or "")
    return "nn".join(pages)


def read_text(uploaded_file) -> str:
    return uploaded_file.read().decode("utf-8", errors="ignore")


uploaded = st.file_uploader("요약할 TXT 또는 PDF 파일", type=["txt", "pdf"])
audience = st.text_input("독자", value="팀장")

if uploaded:
    suffix = Path(uploaded.name).suffix.lower()
    if suffix == ".pdf":
        document_text = read_pdf(uploaded)
    else:
        document_text = read_text(uploaded)

    st.write(f"문서 길이: {len(document_text):,}자")

    if len(document_text.strip()) < 20:
        st.warning("추출된 텍스트가 너무 짧습니다. 스캔 PDF라면 OCR이 필요할 수 있습니다.")
    elif st.button("요약 생성"):
        with st.spinner("Gemini가 문서를 요약하는 중입니다..."):
            try:
                summary = summarize_document(document_text, audience=audience)
                st.subheader("요약 결과")
                st.markdown(summary)
            except Exception:
                st.error("요약 생성에 실패했습니다. 입력 파일과 API 설정을 확인해 주세요.")

실행은 다음과 같다.

streamlit run app/ui/streamlit_app.py

이 예제는 PDF 앞 20쪽만 읽는다. 이것은 기능 한계가 아니라 의도적 제한이다. 처음부터 500쪽 PDF를 넣게 하면 비용과 지연시간을 통제하기 어렵다. 실제 제품에서는 파일 크기, 페이지 수, 텍스트 길이, 민감정보 탐지, 업로드 보관 정책을 모두 정해야 한다. 문서 전체를 그대로 넣을지, 페이지별로 나눌지, Files API를 쓸지, RAG로 전환할지는 이후 멀티모달과 RAG 장에서 다시 판단한다.

입력 검증과 출력 제한은 친절함이다

초기 데모에서는 사용자가 무엇이든 넣어도 되게 만들고 싶어진다. 하지만 업무 도구에서는 제한이 친절함이다. 너무 큰 파일을 넣으면 사용자는 오래 기다리고, 비용은 늘며, 실패했을 때 원인을 알기 어렵다. 처음부터 허용 범위를 알려 주는 편이 낫다.

최소한 다음 제한은 서버에서 강제해야 한다.

항목권장 출발점이유
텍스트 입력 길이80,000자 이하토큰과 지연시간을 예측 가능하게 한다.
PDF 페이지 수20쪽 이하실습·PoC 단계의 비용 폭증을 막는다.
출력 토큰700~1,500장문 폭주를 막고 UX를 안정시킨다.
FAQ 항목 수30개 이하프롬프트 크기와 근거 혼선을 줄인다.
파일 형식txt, pdf부터 시작예외 처리 범위를 좁힌다.

제한을 걸었다면 오류 메시지도 친절해야 한다. "400 Bad Request"가 아니라 "현재 버전에서는 PDF 앞 20쪽까지만 요약합니다. 긴 문서는 장별로 나누어 업로드해 주세요"라고 말해야 한다. 제한은 사용자를 막는 장벽이 아니라, 제품이 안정적으로 작동하는 사용법을 알려 주는 안내문이다.

테스트 가능한 프롬프트 만들기

생성형 AI 결과를 완전히 고정하기는 어렵다. 그래도 프롬프트 함수와 입력 검증 로직은 테스트할 수 있다. 예를 들어 요약 프롬프트가 필수 섹션을 포함하는지 확인할 수 있다.

# tests/test_prompts.py
from app.prompts.summarize import build_summary_prompt


def test_summary_prompt_contains_required_sections():
    prompt = build_summary_prompt("회의록 본문입니다. 결정 사항이 있습니다.", "팀장")

    assert "한 줄 결론" in prompt
    assert "결정이 필요한 사항" in prompt
    assert "원문에서 불확실" in prompt
    assert "회의록 본문입니다" in prompt

서비스 함수 전체를 테스트할 때는 실제 Gemini API를 매번 호출하지 않는 편이 좋다. 비용과 속도, 재현성 때문이다. 단위 테스트에서는 GeminiService를 목(mock)으로 대체하고, 실제 API 호출은 별도의 통합 테스트나 수동 검증 스크립트로 분리한다.

샘플 데이터 정책

실습용 샘플 데이터는 공개 문서, 합성 문서, 익명화된 예시를 사용한다. "회사 내부 문서 하나쯤 괜찮겠지"라는 생각은 위험하다. AI 실습은 공유와 복제가 잦기 때문에, 한 번 샘플로 들어간 데이터가 저장소, 스크린샷, 로그, 교육 자료로 퍼질 수 있다.

샘플 데이터에는 다음 원칙을 적용한다.

  • 실제 고객 이름, 이메일, 전화번호, 주소를 넣지 않는다.

  • 계약서·소스 코드·가격표처럼 민감한 문서는 합성 예시로 대체한다.

  • 실험 로그에는 원문 대신 해시와 길이, 태스크명, 모델명, 사용량만 남긴다.

  • 팀원이 재현해야 하는 입력은 data/samples에 두되, 민감정보 제거 여부를 README에 적는다.

이 원칙은 보수적으로 보일 수 있지만, 나중에 법률·보안 장에서 다룰 데이터 거버넌스의 출발점이다. 좋은 AI 애플리케이션은 좋은 프롬프트보다 안전한 데이터 습관에서 시작한다.

이번 장의 결론

이번 장에서는 노트북의 한 줄 Gemini 호출을 업무 도구의 구조로 바꾸었다. 공통 설정, 얇은 GeminiService 래퍼, 오류 처리, JSONL 사용량 로그, 문서 요약기, FAQ 도우미, 보고서 생성기, FastAPI 프록시 API, Streamlit 내부 UI까지 이어 보았다.

핵심은 코드 양이 아니라 경계다. 프롬프트는 app/prompts에, 업무 로직은 app/services에, Gemini 호출은 app/core/gemini_client.py에, 외부 요청은 app/api에, 내부 UI는 app/ui에 둔다. 이렇게 나누면 작은 도구도 평가·보안·운영으로 확장할 수 있다.

이 장의 실무 규칙은 다음과 같다.

  • API 키는 코드가 아니라 환경과 비밀 관리 도구에 둔다.

  • Gemini 호출은 공통 래퍼로 감싸 모델명, 토큰 제한, 지연시간, 사용량을 중앙에서 관리한다.

  • 원문 로그를 기본값으로 남기지 말고, 해시·길이·모델·지연시간·사용량부터 기록한다.

  • 숫자 계산은 Python이 하고, 해석과 문장화는 Gemini가 하게 한다.

  • FastAPI 프록시는 키 보호, 입력 검증, 오류 변환, 출력 제한의 안전장치다.

  • Streamlit은 빠른 내부 UI에 좋지만, 파일 크기와 민감정보 정책을 반드시 함께 둔다.

다음 장에서는 같은 원칙을 JavaScript와 풀스택 웹 애플리케이션으로 옮긴다. 브라우저, 서버 라우트, 스트리밍 UI, Next.js 통합을 다루면서 "웹에서 Gemini를 안전하게 붙이는 방법"을 살펴볼 것이다.

참고

1Google Gen AI SDK - Python

2Gemini API file prompting strategies

3Gemini API quickstart - Google AI for Developers

4google-genai Python SDK README

책 목록

제미나이 공략집

전체 목차 보기

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

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

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

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

참여하기