본문으로 건너뛰기

© 2026 Molayo

Zenn헤드라인2026. 06. 16. 09:12

AI의 API 키가 클라이언트에 유출된다면 '빌드 에러'로 만들기 — server-only로 구축하는 안전한 Gemini 통합

요약

Web 앱 개발 시 Gemini API 키가 클라이언트에 노출되는 보안 사고를 방지하기 위한 설계 패턴을 소개합니다. 'server-only' 패키지와 Next.js의 Server Action을 활용하여 빌드 단계부터 보안 가드레일을 구축하는 방법을 다룹니다.

핵심 포인트

  • 'server-only'를 사용하여 클라이언트 번들에 AI 로직이 포함될 경우 빌드 에러를 발생시킴
  • NEXT_PUBLIC_ 접두사를 제거하여 환경 변수가 브라우저로 노출되는 것을 원천 차단
  • Server Action을 유일한 진입점으로 설정하여 API 엔드포인트 관리 부담을 줄이고 보안 강화
  • AI 출력값의 신뢰성을 확보하기 위해 Gemini의 responseSchema를 통한 스키마 강제 적용

AI 기능이 포함된 Web 앱에서 가장 흔히 발생하는 사고는 LLM의 API 키가 브라우저에 노출되는 것입니다. NEXT_PUBLIC_GEMINI_API_KEY라고 쓰는 순간, 키는 JS 번들(Bundle)에 포함되어 전 세계에 공개됩니다. 누구나 DevTools를 통해 이를 추출하여, 당신의 과금으로 원하는 만큼 Gemini를 호출할 수 있습니다.

개인 개발 SNS인 「ShippAI」(게시된 실패 사례를 AI가 정리해 주는 기능이 있음)에서는 이를 리뷰나 주의력이 아닌, 빌드 메커니즘을 통해 방지하고 있습니다. 구성은 3계층이며, 코드는 모두 실제 구현물입니다.

전체 구성: 3계층으로 경계를 고정하기

useAILesson(client hook)
↓ 함수 호출(Server Action)
generate-lesson.ts('use server'=서버 경계)
...

포인트는 역할을 1계층당 1개로 고정하는 것입니다. 클라이언트는 「호출만 수행」, Server Action은 「경계 및 입력 체크」, lib/ai/는 「AI 호출 구현」을 담당합니다.

제1의 방벽: import 'server-only'

AI 호출 구현 파일은 맨 앞의 한 줄이 전부입니다.

import 'server-only';
import { GoogleGenAI } from "@google/genai";
export async function generateAILesson(title: string, detail: string): Promise<AIData> {
...

server-only는 내용이 거의 비어 있는 npm 패키지로, 클라이언트 번들(Bundle)에 포함되려고 하면 빌드가 실패합니다. 즉, 미래의 자신(또는 AI 어시스턴트)이 실수로 'use client'인 컴포넌트에서 이 파일을 import 하면, 런타임(Runtime) 시의 정보 유출이 아니라 빌드 시의 에러로 나타납니다.

「주의하자」는 확장성이 없지만, 「애초에 빌드가 통과되지 않는다」는 확실하게 확장됩니다. AI에게 코드를 작성하게 하는 개발 환경이라면 더욱이 가드레일(Guardrail)은 물리적으로 설치하는 것이 정답입니다.

환경 변수는 GEMINI_API_KEY입니다. NEXT_PUBLIC_을 붙이지 않는 것이 사양입니다. Next.js는 NEXT_PUBLIC_ 접두사가 없는 env를 클라이언트에 전달하지 않으므로, 명명 규칙 자체가 방벽이 됩니다.

제2의 방벽: Server Action이 유일한 입구

클라이언트에서 AI를 사용하는 유일한 경로는 이 Server Action입니다.

'use server';
import { generateAILesson } from '@/lib/ai/gemini';
export type GenerateLessonResult =
...
  • AI 기능용 API Route는 만들지 않습니다. Server Action을 사용하면 타입(Type)이 그대로 클라이언트로 흐르며, 엔드포인트(Endpoint) 관리도 불필요합니다.
  • 에러는 throw 하지 않고 반환합니다. 클라이언트 측은 분기 처리만 하면 되므로 에러 UI를 통일할 수 있습니다: { ok, data | error } 형태의 값.
  • 예외 메시지는 가공되지 않은 상태로 반환하지 않고, 사용자용 문구로 변환하여 반환합니다 (SDK의 원시 에러에는 내부 정보가 섞여 있을 수 있기 때문입니다).

AI의 출력은 신뢰하지 않는다: 스키마 강제 + 정규화

보안 문제와 더불어, AI의 출력을 앱에 넣는 측면에서의 방어도 2단계로 구성했습니다.

① API 레벨에서 스키마를 강제하기

Gemini에는 responseSchema를 전달할 수 있습니다. 프롬프트로 "JSON으로 응답해 줘"라고 부탁하는 것이 아니라, API 기능으로서 구조를 강제합니다.

const response = await ai.models.generateContent({
model: "gemini-2.5-flash",
contents: [{ role: "user", parts: [{ text: prompt }] }],
...

enum까지 지정할 수 있으므로, "원인 타입은 고정된 7가지 분류의 키만 허용한다"는 제약을 API가 준수하도록 만들 수 있습니다.

② 그럼에도 앱 측에서 정규화하기

스키마를 강제하더라도, 마지막에 한 번 더 필터링합니다.

const candidates = (parsed.failureTypeCandidates ?? []).filter(isFailureType);
return {
...parsed,
...

타입 가드 (isFailureType)를 통해 7가지 분류 이외의 것을 버리고, 비어 있게 되면 중립적인 기본값(default value)을 하나 보충합니다. UI가 반드시 선택지를 가질 수 있음을 앱 측의 불변 조건(invariant)으로 보장합니다. LLM의 출력은 "거의 항상" 정확하지만, UI의 전제 조건은 "항상" 정확해야 합니다. 그 차이를 메우는 것이 정규화 레이어 (normalization layer)입니다.

소소한 팁: 모크(Mock)도 세계관에 맞추기

개발 중에 API 키가 없을 때는 모크(Mock)를 반환하는데, ShippAI는 "실패를 웃음으로 바꾸는 SNS"이므로, 모크 자체도 "API 키 미설정이라는 실패"를 자학적인 소재로 삼고 있습니다.

return {
title: "API 키 미설정으로 AI에게 교훈을 구한 건에 대하여",
detail: "열쇠를 꽂지 않고 문을 열려고 시도하는 그림. 원인은 대개 '환경 구축 확인 누락'...",
...

모크는 프로덕트의 일부입니다. 어차피 작성할 거라면 대충 하지 않습니다.

요약

  • LLM의 API 키는 NEXT_PUBLIC_ 접두사를 붙이지 않는 것과 import 'server-only'를 통한 이중 잠금으로, 혼입을 "빌드 에러"로 바꿉니다.
  • 클라이언트로부터의 유일한 입구는 Server Action입니다. API Route가 필요 없으며, 타입은 자동으로 흐릅니다.
  • AI의 출력은 responseSchema (enum 포함)를 통해 API 레벨에서 강제하고, 앱 측에서 타입 가드 정규화를 수행하는 2단계 방어를 구축합니다.
  • 에러는 값으로 반환하고, 사용자용 문구로 변환한 뒤 경계를 넘도록 합니다.

이 구성으로 제작 중인 "ShippAI" (실패를 AI가 웃음과 교훈으로 바꾸는 SNS)는 곧 출시될 예정입니다. 시스템 프롬프트 설계 (AI가 "격려하지 않게" 만드는 법)는 다음 글에서 다루겠습니다. 진행 상황은 X @ShippAI_dev 에서 확인하세요.

Discussion

AI 자동 생성 콘텐츠

본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0