GeminiOmni의 첫 번째 버전이 배포 17분 만에 API 키가 긁힌 이야기를 읽고 프록시 아키텍처의 전체 버전을 요청하신 분들이 계셨습니다. 이 글이 그 답변입니다.
유료 AI API(Gemini, OpenAI, Anthropic, Replicate 등 측정 단위로 과금되는 모든 키 사용)를 호출하는 무언가를 만들고 있다면, 아래 패턴이 화요일 아침 청구 알림과 여러분 사이를 지켜주는 유일한 방어선입니다. 복잡하지 않습니다. 하지만 필수적입니다.
아키텍처, 반드시 제대로 해야 하는 네 가지 구체적인 사항, 한 달간 프로덕션에서 운영하며 부딪힌 예외 케이스들을 차례로 살펴보겠습니다.
핵심 패턴 (그림으로 표현)
어떤 AI 제공업체를 사용하든 구조는 동일합니다.
┌─────────────────┐ ┌──────────────────────┐ ┌──────────────┐
│ │ │ │ │ │
│ 브라우저 │ │ 나의 API 라우트 │ │ 제공업체 │
│ (신뢰 불가) │ ───→ │ (서버, 키 보유) │ ───→ │ (Gemini / │
│ │ │ │ │ OpenAI...) │
│ - 프롬프트 │ │ - 인증 확인 │ │ │
│ - 업로드 │ │ - 속도 제한 │ └──────────────┘
│ │ │ - 할당량 확인 │
│ │ │ - API 키 첨부 │
│ │ │ - 제공업체 호출 │
│ │ │ - 결과 반환 │
│ │ ←─── │ │
└─────────────────┘ └──────────────────────┘핵심은 브라우저가 프롬프트를 보내고 결과를 받는 것만 볼 수 있다는 점입니다. 어떤 모델이 호출되었는지, API 키가 무엇인지, 상류 제공업체가 어떻게 생겼는지, 호출 비용이 얼마인지는 전혀 알 수 없습니다. 이것이 브라우저가 알아야 할 정확한 정보의 양입니다.
구체적으로 "올바른" 상태란
이 아키텍처가 실제로 보호 기능을 발휘하려면 네 가지 조건이 충족되어야 합니다. 하나라도 빠지면 보호 기능은 무너집니다.
1. API 키는 오직 서버에만 존재
Next.js 기준: env var의 이름이 NEXT_PUBLIC_으로 시작하면 안 됩니다. 런타임 기준: 라우트 핸들러(app/api/.../route.ts), 서버 컴포넌트, 또는 서버 액션 내에서만 키에 접근해야 합니다.
// app/api/ai/generate/route.ts — 서버 전용
const GEMINI_KEY = process.env.GEMINI_API_KEY; // ✅ 서버 전용
if (!GEMINI_KEY) throw new Error('GEMINI_API_KEY가 설정되지 않음');GEMINI_API_KEY가 없으면 앱이 충돌하도록 하는 시작 체크는 금값입니다. 최악의 실패 모드는 키 없이 앱이 조용히 실행되면서 어떤 기본값으로 대체되어 당신의 저축을 비워버리는 것입니다.
2. 브라우저는 절대 원시 제공업체 응답을 보지 못함
제공업체 호출이 반환되면, 보낼 데이터만 골라서 보냅니다. 브라우저는 다음을 알 필요가 없습니다:
- 정확히 어떤 모델이 호출되었는지 (Veo 3.1 Fast vs Veo 3.1 Standard)
- 제공업체의 요청 ID (지원에는 유용하지만 클라이언트에는 쓸모 없음)
- 제공업체의 속도 제한 헤더
- 호출의 토큰 수 또는 비용
이것이 중요한 이유: 정보 노출은 누적됩니다. 브라우저가 "model: gemini-2.5-flash, tokens: 4096"을 볼 수 있다면, 스크래퍼는 프론트엔드를 제공업체의 가격 페이지에 매핑하여 단위 경제성을 역공학할 수 있습니다. 치명적이지는 않지만 좋지 않습니다. 제거하세요.
// UI에 필요한 것만 반환
return Response.json({
imageUrl: result.generated_images[0].url,
watermarked: !user.isPro,
// 반환 금지: 모델 이름, 사용된 토큰, 원시 제공업체 응답
});3. 인증, 속도 제한, 할당량 확인은 제공업체 호출 전에 이루어져야 함
프록시는 단순한 통로가 아닙니다. 규칙을 적용하는 장소입니다:
// API 라우트 내부, 순서대로:
1. 요청 인증 (세션 쿠키, JWT 등).
익명 요청은 무료 계층 기능에 허용되지만 IP별로 추적됨.
2. 사용자 ID(또는 익명의 경우 IP)로 속도 제한.
GeminiOmni: 익명 10 req/min, Pro 60 req/min, Team 200 req/min.
3. 특정 작업에 대한 사용자의 할당량 확인.
무료 사용자는 월 5회 비디오 생성 가능 — 비용이 많이 드는 호출 전에
데이터베이스를 확인해야지, 이후가 아님.
4. 이제서야 — 제공업체 호출.
5. 성공 시 사용량 카운터 증가.순서가 중요합니다. 제공업체를 먼저 호출한 다음 할당량을 확인하면, 사용자에게 자격이 있든 없든 이미 추론 비용을 지불한 것입니다. 모든 할당량 확인은 사전에 이루어져야 합니다.
4. 프록시는 자체 관측 가능성을 가져야 함
모든 제공업체 호출을 프록시에서 기록하세요: 타임스탬프, 사용자 ID, 라우트, 모델, 입력 크기, 출력 크기, 지연 시간, 성공/실패, 대략적인 달러 비용. 이것이 청구 영수증이자 디버깅 표면입니다.
await logUsage({
userId,
route: '/api/ai/generate',
model: 'gemini-2.5-flash-image',
inputBytes: prompt.length + imageBytes,
outputBytes: result.length,
latencyMs: Date.now() - start,
costUsd: estimateCost(model, inputBytes, outputBytes),
status: 'success',
});화요일 아침, 지출이 하룻밤 사이에 두 배로 늘었다는 청구 알림이 울리면, 이 테이블이 어떤 사용자, 어떤 라우트, 어떤 모델인지 SQL로 찾아낼 수 있는 유일한 도구입니다. 이것이 없으면 추측만 할 수 있습니다.
프로덕션에서 운영하며 부딪힌 예외 케이스
한 달 동안 프록시를 프로덕션에서 운영하면서 몇 가지 명확하지 않은 것들을 발견했습니다.
스트리밍 응답에는 스트리밍 프록시가 필요합니다. Gemini의 streamGenerateContent 엔드포인트는 부분 생성의 스트림을 반환합니다. 프록시에서 response.json()을 무턱대고 호출하면 전체 스트림을 버퍼링하여 지연 시간 이점을 잃게 됩니다. 해결책은 업스트림 스트림을 ReadableStream으로 프록시를 통해 전달(piping)하는 것입니다. 즉, API 라우트가 Response.json(...) 대신 new Response(stream)을 반환하도록 만드는 것입니다. 해볼 가치가 있습니다 — 비디오 생성의 체감 지연 시간이 "90초 기다리기"에서 "즉시 무언가 진행 중임을 보기"로 줄어듭니다.
대용량 파일 업로드는 스토리지 직접 업로드가 필요합니다. 사용자가 이미지-투-비디오 생성을 위해 50MB 비디오를 업로드한다면, 그 50MB가 Next.js API 라우트를 통과하는 것을 원하지 않을 것입니다. 패턴은 다음과 같습니다: 클라이언트가 사전 서명된 URL을 통해 Cloud Storage / R2에 직접 업로드한 후, URL만 프록시로 보냅니다. 프록시는 URL을 Gemini의 "media URI" 매개변수로 전달하고, 바이트는 서버를 거치지 않습니다. 이렇게 하면 업로드가 많은 경로에서 송신 비용이 ~95% 감소합니다.
업스트림의 오류는 번역이 필요합니다. Gemini는 "PROMPT_BLOCKED" 또는 "QUOTA_EXCEEDED" 또는 "INVALID_ARGUMENT"와 같은 오류를 반환합니다. 브라우저는 이러한 문자열을 볼 필요가 없습니다 — 혼란스럽고 제공업체에 대한 세부 정보를 누출합니다. 각각을 사용자 대상 메시지로 번역하세요: "프롬프트가 안전 필터에 의해 차단되었습니다", "이번 달 무료 생성 횟수를 모두 사용했습니다", "해당 이미지가 손상된 것 같습니다." 세 줄짜리 조회 테이블이면 충분합니다.
비용 추정은 토큰당이 아니라 호출당입니다. 각 호출의 달러 비용을 토큰 수를 세어 추정하려고 시도했습니다. 그런 다음 Veo 요금이 출력 비디오의 초 단위이고, Imagen은 이미지 단위이며, Nano Banana 2는 해상도에 따라 4단계 가격이 있음을 알게 되었습니다. 프록시의 비용 함수는 곱셈이 아니라 switch 문입니다. 이를 우회할 깔끔한 방법은 없습니다 — 모든 제공업체가 다르게 청구하며 각각을 인코딩해야 합니다.
GeminiOmni 코드베이스에서의 실제 모습
/tools/text-to-video, /tools/image-to-video, /tools/nano-banana-edit, /tools/pdf-chat, 그리고 /chat의 라이브 채팅에서의 모든 모델 호출은 app/api/ai/generate/route.ts와 app/api/ai/query/route.ts를 거칩니다. 두 개의 엔드포인트입니다. 합쳐서 약 400줄의 코드입니다. 위의 네 가지 확인 사항은 모두 서버에서 제공업체 호출이 나가기 전에 적용됩니다.
사용자 인터페이스에는 모델 이름을 표시했지만 API 응답에는 포함시키지 않았습니다. 생성된 각 클립 아래의 배지는 "Veo 3.1 Fast"라고 표시됩니다 — 이는 렌더링 시점에 서버 측 조회에서 가져온 것이지 API 응답에서 온 것이 아닙니다. API가 반환하는 어떤 문자열이든 한 번의 스크린샷으로 표적이 될 수 있다는 사실을 깨달을 때까지는 바보 같아 보일 수 있는 종류의 세부 사항입니다.
솔직한 요약
프록시 패턴은 흥미롭지 않습니다. 그 안에 영리한 알고리즘은 없습니다. 유료 AI API를 공개 인터넷에서 비용을 지불하지 않은 사람들에게 돈을 빼앗기지 않고 운영하기 위해 필요한 절대 최소한의 조치일 뿐입니다.
하지만 그것은 절대 최소한입니다. 제가 아는 인디 빌더 중 프록시 없이 출시했다가 깨달음을 얻은 사람은 모두 같은 방식으로 배웠습니다: 이상한 시간에 울리는 청구 알림, 당황스러운 키 폐기, 그리고 긴 밤을 새운 데이터 흐름 재작성. 첫날에 하는 것이 더 저렴합니다.
다음 읽을거리
- GeminiOmni를 출시했을 때 처음으로 망가진 것
- 100만 토큰 컨텍스트는 공짜가 아니다 — Gemini PDF 채팅의 실제 페이지당 비용
- GeminiOmni 가격 정책 — 프록시가 계층을 어떻게 구성하는가
— Lena
