개발··14분 읽기

20만엔 사고 이후: FinPal 아키텍처 전면 개편기

API 키 탈취 사고 이후, FinPal의 AI 호출 구조를 클라이언트 직접 호출에서 서버 사이드 프록시로 전면 개편한 과정을 기록했다.

사고 48시간 후

API 키를 폐기하고 숨을 돌린 건 3월 10일 밤이었다. 하지만 진짜 일은 거기서 시작됐다.

키를 새로 발급받아 다시 클라이언트에 넣는다? 같은 일이 또 벌어질 뿐이다. 근본적인 구조를 바꿔야 했다.

48시간 동안 잠을 거의 못 자면서, FinPal의 전체 AI 아키텍처를 서버 사이드 프록시 구조로 전면 개편했다. 보안 조치만 53단계. 관리자 대시보드 5페이지. 자동 킬스위치. 이 모든 걸 혼자서.

사고 대응 타임라인
3/10 22:40

API 키 폐기

분당 3,000건 → 0건. 공격 즉시 중단

3/10 23:00

포렌식 시작

Cloud Monitoring 로그 역추적, 증거 수집

3/11 00:00

아키텍처 재설계 시작

서버 사이드 프록시 구조 설계

3/11 16:30

AI API 12개 전부 비활성화

2차 공격 경로 차단

3/12 04:00

Cloud Function 구현 완료

scanReceipt, analyzeReceipt 서버 이전

3/12 12:00

관리자 대시보드 구축

실시간 모니터링 + 킬스위치

3/12 18:00

보안 오버홀 완료

53단계 보안 조치 적용 완료

기존 구조: 왜 뚫렸나

FinPal의 기존 AI 호출 구조는 단순했다. iOS 앱이 Firebase AI SDK를 통해 Gemini API를 직접 호출하는 구조.

변경 전 — 클라이언트 직접 호출취약

정상 요청

iOS 앱
API Key
Gemini API
과금
내 계정

해커 요청 — 동일 경로, 구분 불가

해커 봇
같은 Key
Gemini API
과금
내 계정
변경 후 — 서버 사이드 프록시안전

정상 요청 — 6단계 검증

iOS 앱
Auth Token
Cloud Function
AppCheck
Rate Limit
Validation
Cloud Function
서버 키
Gemini API

해커 요청 — 즉시 차단

해커 봇
키 없음
BLOCKED

이 구조의 근본적인 문제: API 키가 앱 바이너리에 포함된다.

GoogleService-Info.plist에 들어있는 API 키는 앱을 다운로드한 누구나 추출할 수 있다. IPA 파일을 열면 설정 파일이 평문으로 들어있다. 거창한 해킹도 필요 없다.

Firebase 공식 문서에는 "클라이언트 앱에 포함된 API 키는 안전합니다"라고 적혀있다. 맞는 말이다, Firestore나 Auth 같은 서비스에 대해서는. 이 서비스들은 보안 규칙이 별도로 걸려있어서 키만 가지고는 아무것도 못 한다.

근데 Gemini API는 달랐다. 키 하나면 인증 없이 바로 호출이 된다. 거기다 내 키는 Firebase가 자동 생성한 "iOS key"인데, 실제로는 아무런 제한이 없는 범용 키였다. iOS 번들 ID 제한도 없고, API 제한도 없고. 31개 API 서비스에 대한 만능 열쇠.

BEFORESwift — 클라이언트 직접 호출
// iOS 앱에서 Gemini API 직접 호출
let model = GenerativeModel(
name: "gemini-2.5-flash-lite",
apiKey: apiKey  // ← 앱에 포함된 키
)

// 누구나 이 키를 추출해서
// 같은 API를 무제한으로 호출할 수 있다
let response = try await model
.generateContent(prompt)
AFTER문제점
1. API 키가 앱 바이너리에 평문 포함
2. 키에 iOS 번들 ID 제한 없음
3. 키에 API 서비스 제한 없음
4. 서버 사이드 검증 없음
5. 사용량 모니터링 없음
6. 비용 알림 없음
7. 비상 차단 수단 없음

→ 누가 키를 꺼내면 끝.
→ 10일 동안 몰랐다.

새 구조: 서버 사이드 프록시

핵심 변경: 앱은 절대 Gemini API를 직접 호출하지 않는다.

모든 AI 호출은 Cloud Functions를 경유한다. 앱 안에는 Gemini API를 호출할 수 있는 키가 아예 존재하지 않는다. 누가 앱을 역공학해도 쓸 수 있는 키가 없다.

새로운 AI 호출 경로 — 6단계 검증
1
iOS 앱 → Cloud Function 호출

Firebase Auth 토큰 + AppCheck 토큰을 함께 전송

2
AppCheck 검증

App Attest 기반. 진짜 앱에서 온 요청인지 확인

3
Firebase Auth 인증 확인

로그인된 사용자인지 검증. 미인증 요청 즉시 거부

4
사용자별 Rate Limit 확인

Free: 15회/일, Pro: 100회/일, Max: 200회/일 서버 한도

5
Gemini API 호출 (서버 키)

Secret Manager에 저장된 키로 서버에서만 호출. 키는 앱에 없음

6
결과 검증 + 비용 추적

receiptValidator로 수학적 검증, AICostTracker로 비용 기록

BEFOREBefore: Swift 직접 호출
// 앱에 API 키가 포함됨
let model = GenerativeModel(
name: "gemini-2.5-flash-lite",
apiKey: apiKey
)
let response = try await model
.generateContent(prompt)

// 문제: 키를 꺼내면 누구나 호출 가능
AFTERAfter: Cloud Function 경유
// 앱에 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 에이전트가 병렬로 동작하는 구조다.

스캔 모드별 AI 에이전트 구성
일반 스캔 (Normal)
2-Agent 병렬
→ Summary Agent
가게명, 날짜, 총액, 세금
→ Items Agent
개별 품목 추출
정밀 스캔 (Precision)
4-Agent 단계적
→ Items Agent
품목 이름, 수량
→ Amounts Agent
단가, 할인, 세율
→ Info Agent
가게 정보, 결제수단
→ Verification Agent
3개 결과 병합 + 교차 검증

geminiService.ts — 764줄. 20+ 국가 통화/세금 체계 자동 감지 포함

이 전체 로직이 이전에는 클라이언트에서 돌았다. API 키가 앱에 있었으니까. 지금은 전부 Cloud Function 안에서 실행된다.

53단계 보안 오버홀

사고 이후 48시간 동안 적용한 보안 조치를 정리하면 이렇다.

보안 레이어 구조
클라이언트
AppCheck (App Attest) 강제

진짜 앱에서 온 요청만 허용. 변조된 앱, 스크립트 차단

앱 내 AI API 키 완전 제거

GoogleService-Info.plist에서 Gemini 관련 키 삭제

클라이언트 사용량 추적 (UsageLimiter)

Free 5+3보너스/일, Pro 무제한, Max 무제한

서버 (Cloud Functions)
Cloud Function 전용 AI 호출

scanReceipt, analyzeReceipt — 서버에서만 Gemini 호출

Firebase Auth 인증 강제

미로그인 요청 즉시 거부

사용자별 서버 Rate Limit

Free 15/일, Pro 100/일, Max 200/일 (Safety Net)

글로벌 API Rate Limit

전체 일일 10,000콜 한도, 월 $50 예산

영수증 데이터 수학적 검증

receiptValidator — 품목 합계, 세금, 할인 교차 검증

멱등성 보장

Firestore 트랜잭션으로 중복 실행 방지

재시도 비활성화

retry: false — 실패 시 무한 재시도 방지

모니터링
시간별 자동 집계

adminHourlyAggregator — 매시 API 사용량 집계

24시간 베이스라인 이상 탐지

adminAnomalyDetector — 평소 대비 이상 트래픽 감지

자동 킬스위치

비용/에러율 임계값 초과 시 AI 서비스 자동 중단

관리자 FCM 즉시 알림

이상 감지 시 관리자 디바이스에 푸시 알림

결제 알림

$10, $30, $50 단위로 GCP 빌링 알림 설정

자동 킬스위치

이번 사고에서 가장 뼈아팠던 건, 10일 동안 몰랐다는 거다. 알림이 없었으니까. 이제는 비정상 상황이 감지되면 자동으로 모든 AI 서비스를 멈추는 시스템을 만들었다.

자동 킬스위치 발동 조건
시간당 비용
> $2
📅
일일 비용
> $10
에러율
> 50%
킬스위치 자동 발동
모든 AI API 호출 차단
관리자 FCM 즉시 알림
Firebase 콘솔에서 수동 해제 가능

adminHourlyAggregator가 매시간 API 사용량을 집계하고, adminAnomalyDetector가 24시간 베이스라인과 비교한다. 임계값을 넘으면 킬스위치가 자동으로 발동되고, 관리자에게 FCM 알림이 간다.

수동으로도 Firebase 콘솔에서 원클릭으로 활성화할 수 있다.

// adminAnomalyDetector — 킬스위치 자동 발동 조건
const THRESHOLDS = {
  hourlyCostUSD: 2,    // 시간당 $2 초과
  dailyCostUSD: 10,    // 일일 $10 초과
  errorRatePercent: 50, // 에러율 50% 초과
};

이 사고 때 이 시스템이 있었으면, 공격 시작 1시간 만에 잡았을 거다. 10일이 아니라.

사용자별 Rate Limit: 이중 안전장치

클라이언트와 서버 양쪽에서 사용량을 제한한다. 서버 한도는 정상 사용에서는 절대 도달하지 않는 Safety Net이다.

플랜별 사용량 제한 (이중 구조)
Free — 클라이언트
5 + 3 보너스 = 8회/일
Free — 서버
15회/일
Pro — 클라이언트
무제한
Pro — 서버
100회/일
Max — 클라이언트
무제한
Max — 서버
200회/일

서버 한도는 API 남용 방지 Safety Net. 정상 사용 시 도달하지 않는 수준으로 설정

관리자 대시보드

사고 이후 관리자 대시보드를 5페이지로 구축했다. 혼자 개발하니까 모니터링을 자동화하는 게 필수였다.

FinPal Admin Dashboard
오늘 API 호출
47
정상
오늘 비용
$0.02
예산 내
에러율
0.0%
정상
킬스위치
OFF
대기 중
시간별 API 호출 (최근 12시간)
00:0006:0012:00
실시간 모니터링 중 — 마지막 업데이트 1분 전
관리자 대시보드 구성 (5페이지)
실시간 대시보드
API 호출, 비용, 에러율, 킬스위치
비용 분석
시간별/일별 트렌드, 에이전트별 비용
보안
이상 탐지 로그, 알림 이력
사용자 관리
사용자별 사용량, 차단 기능
알림 설정
임계값 조정, FCM 채널 관리

인프라 전체 감사

아키텍처를 바꾸면서 GCP 프로젝트 전체를 감사했다. AI 관련 API 12개를 전부 비활성화하고, 불필요한 Cloud Function 37개를 삭제하고, 고아 리소스를 정리했다.

인프라 감사 결과
비활성화한 AI API
12개
삭제한 Cloud Function
37개 (59 → 22)
폐기한 Secret Manager 키
GEMINI_API_KEY (2 versions)
정리한 고아 리소스
빈 Storage 버킷 2개, Scheduler 잡
현재 월간 운영 비용
$0 (전부 Free Tier 내)

결과

구조 변경 후 일주일이 지났다.

현재 보안 상태
외부 호출 가능 AI API 키
0개
서버 사이드 보안 레이어
9단계
자동 이상 탐지 간격
1시간
킬스위치 발동 시간
< 5분
3/11 이후 비정상 트래픽
0건
3/11 이후 AI 비용
$0.02/일 (정상)

같은 공격이 다시 오면, 1시간 안에 자동 차단된다


20만 엔을 주고 배운 것들이다. 이 구조를 처음부터 했으면 사고는 없었을 거다. 하지만 사고가 없었으면 이 구조를 만들 생각도 못 했을 거다.

보안은 사후약방문이 되기 쉽다. 근데 적어도 한 번 약을 먹었으면, 그 약방문은 확실히 걸어놔야 한다.

과금이 발생하는 API는 반드시 서버를 거쳐서 호출해야 한다. 클라이언트에 키를 넣는 순간, 그 비용은 당신이 통제할 수 없는 것이 된다.


이전 글: 내 API 키가 털렸다 — 사고의 발견부터 Google Cloud와의 환불 협상까지.

관련 글

개발2026년 3월 16일

내 API 키가 털렸다

앱에 심어둔 API 키가 해커에게 털려서 20만엔 넘는 청구서가 날아왔다. 1인 개발자가 겪은 보안 사고의 기록.