본문으로 건너뛰기

© 2026 Molayo

Zenn헤드라인2026. 06. 15. 07:48

개인으로서 AI API를 공개할 때 파산을 방지하기 위한 다층 방어

요약

개인 개발자가 Google Gemini API와 같은 종량제 외부 API를 서비스에 도입할 때 발생할 수 있는 비용 폭주 리스크를 방지하기 위한 다층 방어(Defense in Depth) 설계 전략을 다룹니다.

핵심 포인트

  • 비용 폭주 방지, 사용자 경험 유지, 남용 감지의 세 가지 목표 사이의 균형이 중요함
  • 인증, 레이트 리미트, 사용자별 상한, 전체 예산 가드 등 다층적 방어 체계 구축
  • 인증(Authentication)을 통해 익명 사용자의 무분별한 요청을 차단하고 사용자 식별 기반의 방어 가능
  • 시간축과 대상을 달리하는 방어 계층을 겹쳐 단일 대책의 취약점을 보완

서론

개인 개발로서, 이미지에서 시리얼 번호를 읽어내는 Web 도구인 「시리틀(シリトル)」을 실서비스로 공개하고 있습니다. 내부적으로는 Google Gemini의 API를 호출하여 OCR을 수행하고 있는데, 이러한 「종량제 외부 API를 불특정 다수에게 공개하는」 구성에는 개인 개발자만의 두려움이 있습니다.

누군가 남용한다면, 청구서는 나에게 온다.

기업이라면 어느 정도의 예상치 못한 비용은 흡수할 수 있지만, 개인 개발은 그럴 수 없습니다. 악의적인 대량 요청이나 부정 계정에 의한 공격적인 사용이 발생했을 때, 비용이 천정부지로 치솟을 위험이 있습니다. 이는 기능의 완성도와는 별개로, 서비스를 공개하는 이상 피할 수 없는 리스크입니다.

이 기사는 시리틀에서 그 리스크에 어떻게 대비했는지——다층 방어 (Defense in Depth) 설계에 대해 작성합니다. 코드보다는 「왜 그 방어를 그런 형태로 도입했는가」라는 판단을 중심으로 다룹니다. 지난 기사(AI OCR의 출력을 그대로 믿어서는 안 된다)가 「판독 품질」에 관한 이야기였다면, 이번에는 「공개하여 운영을 지속하기 위한 방어」에 관한 이야기입니다.

전제: 무엇을 지키고 싶은가

지키고자 하는 것을 먼저 정리하겠습니다. 우선순위가 높은 순서대로:

  • 비용 폭주 방지 (최우선). 청구가 개인의 허용 범위를 넘지 않도록 함.
  • 정상 사용자의 경험을 해치지 않음. 방어를 위해 일반 사용자가 이용할 수 없게 된다면 본말전도임.
  • 남용을 감지할 수 있음. 완전히 막지는 못하더라도, 이상을 감지하여 조치를 취할 수 있어야 함.

이 세 가지는 단순하게 접근하면 서로 충돌합니다. 「비용을 억제한다」를 무작정 밀어붙이면 「정상 사용자도 사용하기 어려워진다」가 됩니다. 반대로 「정상 사용자를 너무 우선시」하면 비용 제어가 되지 않습니다. 이 대립을 설계로 양립시키는 것——정상적인 이용에는 영향을 주지 않으면서, 남용과 비상사태에만 효과가 있는 방어를 어떻게 구성할 것인가. 그것이 이 기사의 핵심입니다.

다층 방어라는 사고방식

단일 대책에 의존하지 않고, 성질이 다른 방어를 여러 층으로 겹치는 것. 이것이 기본 방침입니다. 하나의 층이 뚫리더라도 다음 층이 막아줍니다. 하나의 층이 오작동하더라도 전체는 계속 기능합니다.

시리틀에서 겹쳐둔 층을 바깥쪽에서 안쪽 순으로 나열하면 다음과 같습니다.

  • 층 0: 인증 (Authentication): 애초에 로그인하지 않으면 사용할 수 없음 (익명의 무한 이용을 차단)
  • 층 1: 레이트 리미트 (Rate Limit): 짧은 시간 동안의 집중적인 요청을 제한함
  • 층 2: 사용자 단위 일일 상한: 1인당 「남용 라인」을 설정함 (정상 이용 시에는 도달하지 않는 높이)
  • 층 3: 전체 일일 예산 가드 (Budget Guard): 공격급의 비상시, 서비스 존속을 위해 전체에 천장을 설정함 (최종 방파제)
  • 층 4: 남용 감지 (Abuse Detection): 이것들을 뚫고 지나가는 이상한 사용법을 사후에도 감지함

각각 「무엇을」 「어떤 시간축으로」 지키는지가 다릅니다. 레이트 리미트는 초 단위의 집중을, 일일 상한은 1인당 이상한 총량을, 예산 가드는 비상시의 서비스 존속을 보고 있습니다. 시간축과 대상을 바꾸며 겹침으로써 빈틈을 메워 나갑니다.

층 0: 인증——익명의 무한 이용을 차단한다

구체적인 제한에 앞서, 가장 바깥쪽의 층으로서 인증을 배치했습니다. 시리틀은 Clerk를 통한 로그인 필수화를 통해, 로그인하지 않으면 판독 기능을 사용할 수 없습니다.

이것은 사소해 보이지만 효과가 큽니다. 익명 상태로 무제한 요청을 보내는 가장 단순한 남용을 입구에서 차단할 수 있기 때문입니다. 그리고 무엇보다, 이후의 모든 층을 「로그인한 사용자」를 전제로 설계할 수 있게 됩니다. 누가 이용하는지 알 수 있으므로 사용자 단위의 제한도, 남용 라인도, 감지도 성립합니다. 인증은 방어라기보다, 다른 방어들이 기능하기 위한 토대입니다.

층 1: 레이트 리미트——짧은 시간의 집중을 막는다

인증의 안쪽에서 첫 번째 동적인 방어가 되는 것이 레이트 리미트입니다. 「일정 시간 내에 일정 횟수까지」라는 제한으로, 짧은 시간에 대량의 요청을 퍼붓는 공격을 방지합니다.

시리틀에서는 Upstash를 사용하여 사용자별로 슬라이딩 윈도우 (Sliding Window) 방식으로 제한하고 있습니다. 고정 윈도우 (Fixed Window, 매분 0초에 카운트 리셋) 방식은 윈도우 경계를 넘나들며 순간적으로 두 배의 요청을 통과시킬 수 있기 때문에, 보다 직관적으로 「최근 N초 동안 몇 번」인지를 확인하는 슬라이딩 윈도우를 선택했습니다.

레이트 리미트에서 중요한 것은 무엇을 식별자로 삼을 것인가입니다. 시리틀은 로그인이 필수이므로, OCR 본체는 사용자 ID로 간단히 제한할 수 있습니다. 반면, IP 주소로 제한할 수밖에 없는 경로도 있습니다. 예를 들어 로그인이 필요 없는 문의 양식 같은 경우입니다.

IP로 제한할 경우에는 주의가 필요합니다. x-forwarded-for

헤더의 맨 앞부분은 간단히 위조할 수 있기 때문입니다. 리버스 프록시 (Reverse Proxy) 환경에서 이 맨 앞부분을 신뢰하도록 구현하면, 헤더를 교체하는 것만으로 제한을 회피당할 수 있습니다. 여기서 "맨 앞부분을 신뢰하는 구현이라면 뚫릴 수 있다"는 점을 깨닫고, 인프라가 부여하는 신뢰할 수 있는 값(x-real-ip)을 우선하도록 수정했습니다.

층 2: 사용자 단위의 일일 상한——"남용 라인"을 긋기

레이트 리미트 (Rate Limit)는 "단시간의 집중"은 막을 수 있지만, "천천히 장시간에 걸쳐 대량으로 사용하는 것"은 막을 수 없습니다. 그래서 1사용자가 하루에 읽을 수 있는 횟수에 상한을 둡니다.

여기서 설계 의도를 명확히 해두겠습니다. 이 상한은 정상 사용자의 이용을 제한하기 위한 것이 아니라, "이것은 더 이상 정상적인 사용 방식이 아니다"라는 남용 라인을 긋기 위한 것입니다. 따라서 기준은 일반적인 이용으로는 거의 도달할 수 없는 높이로 설정해 두었습니다. 평범하게 CD를 읽어 사용하고 있는 한, 이 상한의 존재를 의식할 일은 없습니다. 어디까지나 "명백히 이상한 양"을 걸러내기 위한 선입니다.

(무료 플랜은 애초에 월 단위로 읽기 횟수가 정해져 있어 남용할 수 있는 양이 없습니다. 따라서 일일 남용 라인이 주로 의미를 갖는 것은 횟수가 많은 유료 플랜 쪽입니다.)

이 상한 판정에서 의도적으로 설계한 부분이 하나 있습니다. 성공·실패에 관계없이 모든 시도를 카운트하는 것입니다. "읽기에 성공한 횟수"만 세면, 일부러 실패하게 만들면서 계속해서 요청을 보내는 식의 우회로가 남게 됩니다. API 비용은 성공·실패와 관계없이 발생하므로, 남용 라인을 지키려면 모든 시도를 세는 것이 옳습니다.

이는 사용자에게 보여주는 이용 상황(잔여 횟수 등)과는 별도의 계통으로 관리합니다. 사용자에게 보여주는 숫자는 "정상적으로 사용한 횟수"를 기반으로 친절하게 제공하고, 내부의 남용 라인은 "전체 시도"를 기반으로 엄격하게 관리합니다. 같은 "횟수"라도 목적이 다르면 별도로 계산해야 한다는 판단입니다.

층 3: 전체 일일 예산 가드——비상시를 위한 긴급 차단

이 층은 설계 중에서 가장 신중하게 위치시켜야 하는 것입니다.

층 2까지는 개별 사용자의 남용을 막을 수 있습니다. 하지만 남용하는 계정이 대량으로 발생했을 경우——예를 들어 어떤 공격을 받아 다수의 부정 계정이 일제히 API를 호출하는 상황——를 고려하면, 개별 상한만으로는 총비용을 억제할 수 없습니다. 한 명 한 명은 상한 이내라도 그 수가 방대하다면 총액은 불어납니다. 여기서 서비스 전체의 비용에 천장이 없다면, 서비스 자체가 존속할 수 없게 됩니다.

그래서 모든 사용자 합계의 하루당 OCR 횟수에 서비스 전체의 천장을 설정해 두었습니다. 이는 평상시에는 작동하지 않습니다. 정상적인 사용자가 평범하게 사용하는 한, 거의 도달하지 않는 수준으로 설정되어 있습니다. 이것이 발동하는 것은 공격이나 대규모 남용과 같은 비상사태 때뿐입니다.

생각의 방식은, 웹 서비스가 공격을 받아 다운되어 그동안은 유료 회원까지 포함해 일시적으로 사용할 수 없게 되는 상황과 비슷합니다. 바람직하지는 않지만, 서비스를 통째로 잃는 것——즉 모든 사용자가 영구적으로 사용할 수 없게 되는 것——보다는 비상시에 일시적으로 멈춰서 존속을 지키는 편이 낫다는 결단입니다. 개인 개발에 있어서 서비스가 일시적으로 멈추는 것과, 청구 금액의 폭주로 인해 서비스 자체를 접어야만 하는 것은 리스크의 무게가 차원이 다릅니다.

이 층은 정상 이용을 제한하기 위한 메커니즘이 아닙니다. 정상적인 서비스 운영 그 자체를 공격으로부터 지켜내기 위한 최종 방파제입니다. 애플리케이션 측에서 취할 수 있는 조치로는 이것이 마지막 보루가 됩니다. 다른 어떤 층이 뚫리더라도, 여기서 "서비스를 잃는" 최악의 사태만은 막아냅니다. 그래서 최종 방파제라고 부릅니다.

층 4: 남용 탐지——우회 사례를 사후에 포착하기

지금까지의 층을 모두 빠져나가는 사용법도 이론상으로는 가능합니다. 상한선 끝자락을 매일 유지하는 식의 회색 지대 이용입니다. 이는 사전 차단으로는 포착하기 어렵습니다. 그래서 사후 탐지를 별도의 계통으로 운영하고 있습니다.

포인트는 탐지를 과금과는 독립된 통계 테이블에서 수행하는 것입니다. 과금 데이터에 탐지 로직을 섞으면, 과금 관련 사정(해지·환불 등)으로 인해 통계가 왜곡되어 탐지가 오작동할 수 있습니다. 따라서 "읽기 사실"만을 담담하게 기록하는 별도의 테이블을 두고, 그곳을 일 단위로 집계하여 이상 패턴이 나타나면 자신에게 이메일 알림이 오도록 설정했습니다.

탐지는 "차단하기" 위한 것이 아니라 "알아차리기" 위한 메커니즘입니다. 완전한 사전 방어는 불가능하다는 전제하에, 우회당하더라도 사람이 알아차리고 조치를 취할 수 있는 상태를 유지합니다. 이 또한 다층 방어의 일부입니다.

또한, 구체적인 임계값(하루에 몇 회, 한 달에 몇 회로 탐지할 것인지)은 공개하지 않습니다. 공개하면 그 직전 단계에서 멈추도록 조정되어 버려, 탐지의 의미가 없어지기 때문입니다.

이 통계 기록 자체도, 실패했을 때 읽기 결과에 영향을 주지 않도록 설계했습니다(기록에 실패하더라도 사용자에게는 정상적으로 결과가 반환됩니다). 탐지라는 "방어 기제" 또한, 무너질 때는 자신만 조용히 무너져서 정규 사용자를 휘말리게 하지 않습니다. 이는 후술할 fail-open의 사고방식을 탐지에도 관철한 것입니다.

설계의 핵심: fail-open과 fail-closed의 구분 사용

지금까지의 방어층에는 피할 수 없는 문제가 있습니다. 방어 기제 자체가 고장 난다면, 어떻게 동작해야 하는가입니다.

예를 들어, 레이트 리미트 (Rate Limit)에 사용 중인 Upstash에 장애가 발생하여 응답이 돌아오지 않는다고 가정해 봅시다. 이때 두 가지 선택지가 있습니다.

fail-open: 판정할 수 없다면 일단 통과시킨다 (문을 열어둔 채로 쓰러진다)
fail-closed: 판정할 수 없다면 중단한다 (문을 닫은 채로 쓰러진다)

어느 쪽이 정답인지는 그 방어가 무엇을 지키고 있는가에 따라 결정됩니다. 시리틀(Siritol)에서는 층마다 이를 구분하여 사용했습니다.

방어층은 fail-open

레이트 리미트 (Rate Limit), 예산 가드 (Budget Guard), 남용 탐지 (Abuse Detection)와 같은 "방어 기제"는 fail-open으로 설정했습니다. 이러한 기반 (Upstash 등)이 일시적으로 다운되었을 때, 판정할 수 없다는 이유로 모든 사용자를 차단해 버리면, 기반의 장애가 정규 사용자의 전면적인 서비스 중단으로 직결되기 때문입니다.

여기서 fail-open을 선택할 수 있는 이유는 이것이 다층 방어이기 때문입니다. 어떤 층이 잠시 열려 남용이 통과하더라도, 그것이 유일한 방어는 아닙니다. 후단에도 다른 층이 있으며, 최종적으로는 플랫폼 측의 지출 상한(후술)이 물리적인 천장으로서 버티고 있습니다. 그렇기에 하나의 층은 "판정할 수 없다면 통과시킨다"로 무너질 수 있습니다. 반대로, 방어의 오작동으로 정규 사용자를 휘말리게 하여 발생하는 손실은 그 즉시 매우 큽니다. 방어의 층이 무너질 때는 서비스를 휘말리게 하지 말고, 자신만 조용히 무너져 주길 바랍니다.

코드로 구현하면 다음과 같습니다. 판정에 실패하더라도 예외를 삼키고 "통과(ok)"를 반환합니다.

async function checkGlobalOcrBudget(): Promise<{ ok: boolean }> {
const limiter = getBudgetLimiter();
if (!limiter) return { ok: true }; // 미설정 상태라도 멈추지 않음
...

과금 정합성은 fail-closed

반대로 fail-closed로 설정한 부분이 있습니다. 사용량 카운트(과금 기록)에 실패하면, 읽기 결과를 반환하지 않는다는 처리입니다.

생각해 보면, "OCR은 성공했는데 사용량 기록에 실패한" 상태는 위험합니다. 여기서 결과를 반환해 버리면 "카운트되지 않고 사용할 수 있는" 상태를 만들게 됩니다. 이것이 알려지면 기록을 실패하게 만드는 조건을 공략하여 상한을 회피하는 우회로가 될 수 있습니다. 따라서 여기서는 "기록할 수 없다면 결과도 전달하지 않는다"며 문을 닫습니다.

사용자 입장에서는 "가끔 에러로 읽을 수 없다"는 상황이 되지만, 과금의 정합성이 깨지는 것보다는 훨씬 낫습니다. 돈과 관련된 불일치만큼은 사용자 경험을 다소 희생하더라도 방지한다는 원칙입니다.

const { error } = await supabase.rpc("increment_usage", { p_count: 1 });
if (error) {
// OCR은 성공했지만 사용량을 기록할 수 없었던 케이스.
...

둘 다 에러를 탐지했을 때의 분기이지만, 전자는 "실패하면 통과시킨다", 후자는 "실패하면 중단한다"입니다. 정반대의 쓰러지는 방식을 지키고자 하는 대상에 따라 선택하고 있는 것입니다.

참고로 실제 코드에는 이와 별개로 "DB 측에서 상한 초과를 탐지했다"는 시그널을 429로 변환하는 분기도 있습니다. 다만 그것은 "한도를 넘기지 않는다"는 층 2, 층 3에서 언급한 내용이므로, fail-closed의 테제를 흐리지 않도록 여기서는 생략하겠습니다. fail-closed가 주력으로 삼고 있는 것은 어디까지나 "OCR은 성공했는데 기록에 실패했다"는 정합성의 케이스입니다.

구분 사용의 원칙

정리하면 다음과 같습니다.

방어층 (레이트 리미트, 예산 가드, 탐지) $\rightarrow$ fail-open. 장애 시 정규 사용자를 휘말리게 하지 않음.
돈의 정합성 (과금 카운트) $\rightarrow$ fail-closed. 불일치를 만들 바에는 중단함.

「장애 발생 시 어느 쪽으로 기울 것인가」를 계층마다 의식적으로 선택해야 합니다. 저는 이것이 다층 방어 (Defense in Depth)를 설계할 때, 계층을 늘리는 것 자체만큼이나 중요하다고 생각합니다. 무심코 에러를 묵인해 버리면 모든 것이 암묵적인 fail-open 상태가 되어, 과금 구멍을 알아차릴 수 없게 됩니다.

방어는 「무거운 처리 직전」에 배치한다

지금까지 살펴본 계층들의 공통점은, 수수하지만 효과적인 배치 노하우가 있다는 것입니다. 이러한 판정을 비용이 발생하는 무거운 처리 (이미지 재인코딩이나 OCR 호출 등)의 직전에 수행하는 것입니다.

// 저렴한 체크 (예산 가드 등)를 무거운 처리 직전에 먼저 통과시킴
const budget = await checkGlobalOcrBudget();
if (!budget.ok) {
...

차단해야 할 요청은 비용이 발생하는 처리에 들어가기 전에 차단합니다. 그렇게 하면 공격적인 요청이 대량으로 들어오더라도, 방어 판정(저렴한 처리)만으로 튕겨낼 수 있으며, 무거운 처리(비싼 처리)까지 도달하지 않습니다. 방어 그 자체의 비용을 작게 유지하는 것 또한 비용 설계의 일부입니다. 순서를 반대로 하여 「OCR을 한 뒤 상한선을 체크」하게 되면, 차단하려던 요청에 대해서도 API 비용을 지불하게 되어 본말전도가 됩니다.

더 바깥쪽: 플랫폼 계층의 최후의 보루

지금까지의 계층은 모두 자신의 애플리케이션 코드에서 구현하는 방어입니다. 하지만 「자신의 코드로 지킨다」는 것은, 코드에 버그가 있다면 그 방어 자체가 제대로 작동하지 않을 수도 있음을 의미합니다. 그래서 한 단계 더 바깥쪽에, 플랫폼 측의 지출 상한도 설정해 두었습니다.

Gemini API에는 프로젝트 단위 등으로 월간 지출 상한(Spend Cap)을 설정할 수 있는 메커니즘이 있습니다. 이를 설정해 두면 상한에 도달한 시점에 Google 측에서 API 요청을 차단합니다. 내가 작성한 예산 가드가 버그로 인해 그냥 통과되더라도, 최종적으로는 플랫폼의 벽에 의해 물리적으로 멈추게 되는 구조입니다.

다만, 이것은 「1차 방어」가 아니라 어디까지나 최후의 보루로 위치를 정하고 있습니다. 이유는 이러한 종류의 플랫폼 상한에는 적용까지 타임래그(Time lag)가 있어 (상한 도달부터 실제로 차단될 때까지 짧은 유예가 있음), 그 사이의 요청은 과금될 수 있기 때문입니다. 즉, 「여기에 맡겨두면 1원도 넘지 않는다」는 성격의 것이 아니라, 「자체 방어가 모두 뚫리더라도 최악의 무제한 지출(Blue sky)만은 피한다」는 것을 위한 보험입니다. 따라서 상한값도 정말로 허용할 수 있는 천장보다는 조금 낮게 설정해 두는 것이 안전합니다.

정리하자면, 방어는 2층 구조로 되어 있습니다. 평상시의 제어는 자신의 앱 측 계층에서 수행하고, 그것들이 모두 뚫렸을 때를 위해 플랫폼 측에도 벽을 세워두는 것입니다. 자신의 코드를 완전히 신뢰하지 않는다는 사고방식입니다. 구현한 방어에도 버그는 있을 수 있다—이 전제에 서면, 자신의 관리 외에 또 하나의 벽이 있다는 사실은 큰 안도감을 줍니다.

주변의 작은 방어들

주역은 아니지만, 같은 사상으로 넣어둔 작은 방어들도 언급해 두겠습니다.

결제 관련 (Stripe의 checkout/portal)에도 레이트 리미트 (Rate Limit)를 연결해 두었습니다. 이를 소홀히 하면 결제 세션이 대량으로 생성되어 불필요한 Customer 레코드가 늘어나거나 API를 고갈시킬 수 있습니다. 「돈의 입구」도 보호 대상입니다.

이러한 작은 구멍들은 하나하나가 사소할지라도, 방치하면 남용의 발판이 됩니다. 「여기는 괜찮겠지」라는 생각을 줄여나가는 꾸준한 작업이 결국 다층 방어의 실체입니다.

요약: 파산을 방지하기 위한 설계 원칙

개인으로서 AI API를 공개할 때 제가 의식한 원칙을 정리합니다.

단일 방어에 의존하지 않고, 성격이 다른 계층을 겹친다. 초 단위, 일 단위, 전체 단위와 같이 시간 축과 대상을 바꾼다.

정상 이용을 제한하는 상한과, 공격으로부터 지키는 방어선을 분리한다. 사용자별 상한은 「남용 라인」, 전체 천장은 「비상시에 서비스를 존속시키기 위한 최종 방파제」다. 평상시에는 정상 사용자에게 영향을 주지 않는다.

장애 발생 시 어느 쪽으로 기울 것인지를 계층마다 선택한다. 방어 계층은 fail-open, 돈의 정합성은 fail-closed.

남용은 「완전히 막는 것」이 아니라 「알아차릴 수 있는」 상태를 만든다. 탐지는 과금과 독립시킨다.

비용 카운트는 모든 시도로, 사용자에게 보여주는 것은 성공 기반으로 한다. 목적이 다른 숫자는 별도로 센다.

자신의 코드를 완전히 신뢰하지 않는다. 앱 측의 방어에 더해, 플랫폼 측의 지출 상한도 세워둔다. 구현에도 버그는 있을 수 있다.

기능을 만드는 것과 그것을 안전하게 계속 공개하는 것은 별개의 기술입니다. 특히 AI API와 같이 사용량에 따라 과금되는 방식을 개인이 공개할 경우, "파산을 방지하는 설계"는 기능 구현만큼이나—어쩌면 어떤 이들에게는 그 이상으로—중요하다고 생각합니다. 화려하지는 않지만, 이것이 없으면 안심하고 서비스를 계속 운영할 수 없습니다.

이 기사에서 다룬 도구 "시리토루 (Shiritoru)"는 여기 있습니다 → 시리토루

Discussion

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0