20만엔 사고 이후: FinPal 아키텍처 전면 개편기
API 키 탈취 사고 이후, FinPal의 AI 호출 구조를 클라이언트 직접 호출에서 서버 사이드 프록시로 전면 개편한 과정을 기록했다.
사고 48시간 후
API 키를 폐기하고 숨을 돌린 건 3월 10일 밤이었다. 하지만 진짜 일은 거기서 시작됐다.
키를 새로 발급받아 다시 클라이언트에 넣는다? 같은 일이 또 벌어질 뿐이다. 근본적인 구조를 바꿔야 했다.
48시간 동안 잠을 거의 못 자면서, FinPal의 전체 AI 아키텍처를 서버 사이드 프록시 구조로 전면 개편했다. 보안 조치만 53단계. 관리자 대시보드 5페이지. 자동 킬스위치. 이 모든 걸 혼자서.
API 키 폐기
분당 3,000건 → 0건. 공격 즉시 중단
포렌식 시작
Cloud Monitoring 로그 역추적, 증거 수집
아키텍처 재설계 시작
서버 사이드 프록시 구조 설계
AI API 12개 전부 비활성화
2차 공격 경로 차단
Cloud Function 구현 완료
scanReceipt, analyzeReceipt 서버 이전
관리자 대시보드 구축
실시간 모니터링 + 킬스위치
보안 오버홀 완료
53단계 보안 조치 적용 완료
기존 구조: 왜 뚫렸나
FinPal의 기존 AI 호출 구조는 단순했다. iOS 앱이 Firebase AI SDK를 통해 Gemini API를 직접 호출하는 구조.
정상 요청
해커 요청 — 동일 경로, 구분 불가
정상 요청 — 6단계 검증
해커 요청 — 즉시 차단
이 구조의 근본적인 문제: API 키가 앱 바이너리에 포함된다.
GoogleService-Info.plist에 들어있는 API 키는 앱을 다운로드한 누구나 추출할 수 있다. IPA 파일을 열면 설정 파일이 평문으로 들어있다. 거창한 해킹도 필요 없다.
Firebase 공식 문서에는 "클라이언트 앱에 포함된 API 키는 안전합니다"라고 적혀있다. 맞는 말이다, Firestore나 Auth 같은 서비스에 대해서는. 이 서비스들은 보안 규칙이 별도로 걸려있어서 키만 가지고는 아무것도 못 한다.
근데 Gemini API는 달랐다. 키 하나면 인증 없이 바로 호출이 된다. 거기다 내 키는 Firebase가 자동 생성한 "iOS key"인데, 실제로는 아무런 제한이 없는 범용 키였다. iOS 번들 ID 제한도 없고, API 제한도 없고. 31개 API 서비스에 대한 만능 열쇠.
// iOS 앱에서 Gemini API 직접 호출
let model = GenerativeModel(
name: "gemini-2.5-flash-lite",
apiKey: apiKey // ← 앱에 포함된 키
)
// 누구나 이 키를 추출해서
// 같은 API를 무제한으로 호출할 수 있다
let response = try await model
.generateContent(prompt)1. API 키가 앱 바이너리에 평문 포함
2. 키에 iOS 번들 ID 제한 없음
3. 키에 API 서비스 제한 없음
4. 서버 사이드 검증 없음
5. 사용량 모니터링 없음
6. 비용 알림 없음
7. 비상 차단 수단 없음
→ 누가 키를 꺼내면 끝.
→ 10일 동안 몰랐다.새 구조: 서버 사이드 프록시
핵심 변경: 앱은 절대 Gemini API를 직접 호출하지 않는다.
모든 AI 호출은 Cloud Functions를 경유한다. 앱 안에는 Gemini API를 호출할 수 있는 키가 아예 존재하지 않는다. 누가 앱을 역공학해도 쓸 수 있는 키가 없다.
Firebase Auth 토큰 + AppCheck 토큰을 함께 전송
App Attest 기반. 진짜 앱에서 온 요청인지 확인
로그인된 사용자인지 검증. 미인증 요청 즉시 거부
Free: 15회/일, Pro: 100회/일, Max: 200회/일 서버 한도
Secret Manager에 저장된 키로 서버에서만 호출. 키는 앱에 없음
receiptValidator로 수학적 검증, AICostTracker로 비용 기록
// 앱에 API 키가 포함됨
let model = GenerativeModel(
name: "gemini-2.5-flash-lite",
apiKey: apiKey
)
let response = try await model
.generateContent(prompt)
// 문제: 키를 꺼내면 누구나 호출 가능// 앱에 API 키 없음
let function = Functions.functions()
let result = try await function
.httpsCallable("scanReceipt")
.call([
"imageBase64": imageData,
"scanMode": "normal"
])
// Cloud Function이 6단계 검증 후 호출서버 측 Cloud Function 코드는 이렇다:
// functions/src/scanReceipt.ts
export const scanReceipt = onCall({
enforceAppCheck: true, // AppCheck 강제
region: "asia-northeast1",
}, async (request) => {
// 1. 인증 확인
if (!request.auth)
throw new HttpsError("unauthenticated", "Login required");
// 2. Rate Limit 확인
const allowed = await checkUserRateLimit(request.auth.uid);
if (!allowed)
throw new HttpsError("resource-exhausted", "Daily limit reached");
// 3. 글로벌 API 한도 확인
const apiOk = await apiRateLimiter.checkLimit();
if (!apiOk)
throw new HttpsError("resource-exhausted", "API limit reached");
// 4. Gemini API 호출 (서버 키로)
const result = await geminiService.analyzeReceipt(imageBase64);
// 5. 결과 수학적 검증
const validated = receiptValidator.validate(result);
// 6. 비용 추적
await costTracker.track(request.auth.uid, result.tokenUsage);
return validated;
});
Gemini 엔진: 2-Agent와 4-Agent
FinPal의 영수증 분석은 단순한 API 호출이 아니다. 복수의 AI 에이전트가 병렬로 동작하는 구조다.
geminiService.ts — 764줄. 20+ 국가 통화/세금 체계 자동 감지 포함
이 전체 로직이 이전에는 클라이언트에서 돌았다. API 키가 앱에 있었으니까. 지금은 전부 Cloud Function 안에서 실행된다.
53단계 보안 오버홀
사고 이후 48시간 동안 적용한 보안 조치를 정리하면 이렇다.
진짜 앱에서 온 요청만 허용. 변조된 앱, 스크립트 차단
GoogleService-Info.plist에서 Gemini 관련 키 삭제
Free 5+3보너스/일, Pro 무제한, Max 무제한
scanReceipt, analyzeReceipt — 서버에서만 Gemini 호출
미로그인 요청 즉시 거부
Free 15/일, Pro 100/일, Max 200/일 (Safety Net)
전체 일일 10,000콜 한도, 월 $50 예산
receiptValidator — 품목 합계, 세금, 할인 교차 검증
Firestore 트랜잭션으로 중복 실행 방지
retry: false — 실패 시 무한 재시도 방지
adminHourlyAggregator — 매시 API 사용량 집계
adminAnomalyDetector — 평소 대비 이상 트래픽 감지
비용/에러율 임계값 초과 시 AI 서비스 자동 중단
이상 감지 시 관리자 디바이스에 푸시 알림
$10, $30, $50 단위로 GCP 빌링 알림 설정
자동 킬스위치
이번 사고에서 가장 뼈아팠던 건, 10일 동안 몰랐다는 거다. 알림이 없었으니까. 이제는 비정상 상황이 감지되면 자동으로 모든 AI 서비스를 멈추는 시스템을 만들었다.
adminHourlyAggregator가 매시간 API 사용량을 집계하고, adminAnomalyDetector가 24시간 베이스라인과 비교한다. 임계값을 넘으면 킬스위치가 자동으로 발동되고, 관리자에게 FCM 알림이 간다.
수동으로도 Firebase 콘솔에서 원클릭으로 활성화할 수 있다.
// adminAnomalyDetector — 킬스위치 자동 발동 조건
const THRESHOLDS = {
hourlyCostUSD: 2, // 시간당 $2 초과
dailyCostUSD: 10, // 일일 $10 초과
errorRatePercent: 50, // 에러율 50% 초과
};
이 사고 때 이 시스템이 있었으면, 공격 시작 1시간 만에 잡았을 거다. 10일이 아니라.
사용자별 Rate Limit: 이중 안전장치
클라이언트와 서버 양쪽에서 사용량을 제한한다. 서버 한도는 정상 사용에서는 절대 도달하지 않는 Safety Net이다.
서버 한도는 API 남용 방지 Safety Net. 정상 사용 시 도달하지 않는 수준으로 설정
관리자 대시보드
사고 이후 관리자 대시보드를 5페이지로 구축했다. 혼자 개발하니까 모니터링을 자동화하는 게 필수였다.
인프라 전체 감사
아키텍처를 바꾸면서 GCP 프로젝트 전체를 감사했다. AI 관련 API 12개를 전부 비활성화하고, 불필요한 Cloud Function 37개를 삭제하고, 고아 리소스를 정리했다.
결과
구조 변경 후 일주일이 지났다.
같은 공격이 다시 오면, 1시간 안에 자동 차단된다
20만 엔을 주고 배운 것들이다. 이 구조를 처음부터 했으면 사고는 없었을 거다. 하지만 사고가 없었으면 이 구조를 만들 생각도 못 했을 거다.
보안은 사후약방문이 되기 쉽다. 근데 적어도 한 번 약을 먹었으면, 그 약방문은 확실히 걸어놔야 한다.
과금이 발생하는 API는 반드시 서버를 거쳐서 호출해야 한다. 클라이언트에 키를 넣는 순간, 그 비용은 당신이 통제할 수 없는 것이 된다.
이전 글: 내 API 키가 털렸다 — 사고의 발견부터 Google Cloud와의 환불 협상까지.
관련 글
Next.js + MDX로 3개 국어 블로그 만들기
Next.js 14 App Router와 MDX, next-intl을 조합해서 한/영/일 3개 국어 개인 블로그를 만든 과정을 정리했다.