AI 코딩 보안 2026.11.10 · 조회 11

LLM API 키 유출이 부르는 요금 폭탄: LLMjacking 확인·차단·폐기 순서 [2026]

LLM API 키가 새면 짧은 시간에 큰 청구서가 나올 수 있다. Google Cloud 위협 인텔리전스(Mandiant)의 2026-11-02 보고서는 하루 10만 달러를 넘는 부정 청구와, 일부 조직의 누적 100만 달러를 확인했다. 서버 기록과 콘솔 사용량을 맞대어 알아채는 법, 한도로 피해를 묶는 법, 털렸을 때의 순서를 정리했다.

S
보안 전문 기업 SENTRIX 레드팀
해킹대회 수상 · 보안 실무 10년 · 실전 진단 경험을 바탕으로 작성합니다.
웹사이트를 운영하고 있다면
보안 헤더·인증서·공개 노출부터 무료로 점검하세요. 결과 링크는 이메일로 받습니다. 로그인 이후 권한·업무 로직은 별도 검증이 필요합니다.

다음 화면에서 이메일과 점검 권한을 확인합니다. 회원가입·카드 없이 무료 요약을 받고, 상세 리포트는 선택 구매합니다.

LLMjacking은 유출되거나 탈취된 LLM API 키·클라우드 자격증명을 남이 몰래 써서, 그 호출 비용이 원래 주인의 청구서로 넘어오는 공격이다.
Google Cloud 위협 인텔리전스(Mandiant)가 2026년 11월 2일 공개한 보고서에 따르면, 2026년 여러 사고에서 공격자가 기업의 LLM 접근 권한을 가로챘다. 개별 사고에서 하루 10만 달러를 넘는 부정 청구가 확인됐고 일부 조직은 접근이 끊기기 전까지 누적 100만 달러에 이르렀다. 보고서가 꼽은 주된 최초 침투 경로는 노출되거나 탈취된 클라우드 신원 토큰(IAM 자격증명, 서비스 계정 키, GitHub 개인 액세스 토큰, 세션 토큰)이고, 공개 GitHub 저장소에 올라간 기업 LLM API 키를 주워 간 사례도 나온다. 브라우저로 내려가는 번들이나 열려 있는 설정 파일도 같은 키를 누구에게나 내놓을 수 있고, AI 기능을 붙인 웹서비스에서는 그 키가 남에게 쓰이는 동안 호출 비용이 계속 쌓인다. 경로 점검은 관련 글에 맡기고, 새고 난 뒤 청구서와 사용량에 남는 신호와 피해를 묶는 한도 쪽에 무게를 둔다.
이 글은 자주 발생하는 실수 패턴, 점검·조치 절차, 자가진단을 정리했다.

한눈에 보는 핵심

LLM 키가 샜는지 어디서 먼저 확인하나요?

발급처 콘솔의 사용량 화면입니다. 내 서버가 남긴 호출 기록의 합계와 같은 날짜·시간대의 콘솔 수치를 같은 프로젝트·키 단위로 맞대어 보고, 콘솔 쪽이 눈에 띄게 크거나 서비스가 쉬는 시간대에 호출이 찍히거나 쓰지 않는 모델이 보이면 조사할 신호입니다. 내 서버에서 오류·시간 초과로 끝난 호출이나 배치 작업 같은 다른 정상 호출원을 먼저 빼 보고, 그래도 남는 차이를 내 서버를 거치지 않은 호출로 봅니다. Mandiant 보고서에는 훔친 키로 8시간 안에 50만 건이 넘는 호출을 실행한 사례와, 응답을 토큰 1개로 제한한 요청으로 키가 살아 있는지 먼저 확인하는 패턴이 나옵니다. AWS 예산 정보는 하루 최대 세 번 갱신되고 Google Cloud 문서도 사용과 비용 보고 사이에 지연이 있다고 적으므로, 청구서나 알림을 기다리지 말고 이 대조를 매일 하는 편이 빠릅니다.

예산 알림만 걸어 두면 요금이 거기서 멈추나요?

멈추지 않습니다. Google Cloud 문서는 알림 전용 예산이 사용량이나 지출을 자동으로 막지 않는다고 적고, AWS 문서는 알림이 오기 전에 기준을 넘는 비용이 먼저 발생할 수 있다고 적습니다. 멈추는 장치는 따로 둬야 하며, OpenAI 문서는 월 상한을 강제하려면 하드 한도를 설정하라고 안내하고 프로젝트별로 속도와 지출 한도를 걸 수 있다고 적습니다. 알림은 사후 신호, 한도는 상한이라 둘 다 필요합니다.

키를 서버로 옮기기만 하면 안전해지나요?

브라우저로 키가 내려가는 길만 막힙니다. 서버 프록시에 로그인과 사용자별 호출 제한이 없으면 남이 내 서버를 통해 내 키로 계속 호출할 수 있으므로, 사용자별 호출 수와 하루 토큰 상한, 서버가 고정하는 모델, 응답 길이 제한을 함께 둬야 합니다. 아래 코드 블록이 그 최소 형태이고 발급처의 하드 한도가 마지막 방어선입니다.

자주 발생하는 실수 패턴

#패턴위험도
1키를 NEXT_PUBLIC_·VITE_ 변수에 넣거나 dangerouslyAllowBrowser 를 켜서 브라우저가 LLM 발급처를 직접 호출치명
2서버 프록시는 만들었지만 로그인과 사용자별 호출 제한이 없음, 키는 숨겼어도 누구나 내 키로 호출치명
3알림 전용 예산만 걸고 하드 한도·프로젝트별 한도는 없음, 알림을 상한으로 착각높음
4개발·운영·모든 모델에 키 하나를 공용으로 쓰고, 서버용 클라우드 자격증명에도 LLM 호출 권한이 그대로 붙어 있음높음
5내 서버의 호출 기록을 남기지 않고 콘솔 사용량도 열어 보지 않아 이상을 청구서로만 발견높음
6새는 걸 확인하고도 파일만 지우고 키는 그대로 두거나, 폐기만 하고 노출 구간의 사용 기록은 확인하지 않음치명

키를 서버로 옮긴 최소 프록시

# proxy.py  (pip install flask openai)  실행: flask --app proxy run
# 프론트엔드: fetch('/api/ask', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({q})})
import os, time, logging
from collections import defaultdict, deque
from threading import Lock
from flask import Flask, request, session, jsonify
from openai import OpenAI

logging.basicConfig(filename='llm_usage.log', level=logging.INFO, format='%(asctime)s %(message)s')
app = Flask(__name__)
app.secret_key = os.environ['FLASK_SECRET']   # 세션 서명용. 기존 로그인 코드가 session['uid']를 채운다고 가정
client = OpenAI(timeout=30)                   # 키는 OPENAI_API_KEY 환경변수에서만 읽는다 (코드·저장소·번들에 없음)
MODEL = os.environ['LLM_MODEL']               # 모델은 서버가 고정한다. 요청 값으로 못 바꾼다
MAX_IN, MAX_OUT = 2000, 600                   # 입력 글자 수, 응답 토큰 상한 (추론형 모델은 추론 토큰도 포함되니 더 크게)
PER_MIN, DAY_TOKENS = 5, 20000                # 사용자당 분당 호출 수, 하루 토큰
calls, used, lock = defaultdict(deque), defaultdict(int), Lock()

@app.post('/api/ask')
def ask():
    uid = session.get('uid')
    if uid is None:
        return jsonify(error='login'), 401    # 로그인 없는 호출은 발급처에 닿기 전에 끊는다
    data = request.get_json(silent=True)
    text = str(data.get('q', ''))[:MAX_IN].strip() if isinstance(data, dict) else ''
    if not text:
        return jsonify(error='empty'), 400
    now, day = time.time(), time.strftime('%Y%m%d')
    with lock:
        q = calls[uid]
        while q and now - q[0] > 60:
            q.popleft()
        if len(q) >= PER_MIN or used[(uid, day)] >= DAY_TOKENS:
            return jsonify(error='limit'), 429
        q.append(now)
    try:
        r = client.responses.create(model=MODEL, input=text, max_output_tokens=MAX_OUT)
    except Exception:
        logging.exception('upstream error')   # 발급처 오류 원문은 로그에만 남기고 응답에는 싣지 않는다
        logging.info('llm uid=%s error=1', uid)   # 시간 초과·오류 호출도 발급처에서는 처리될 수 있어 대조용으로 남긴다(사용량을 못 받아 토큰은 모름)
        return jsonify(error='upstream'), 502
    tok = r.usage.total_tokens if r.usage else MAX_OUT
    with lock:
        used[(uid, day)] += tok
    logging.info('llm uid=%s tokens=%s', uid, tok)   # 사용자별 기록: 콘솔 사용량과 대조할 근거
    return jsonify(answer=r.output_text)

프론트엔드는 키 없이 이 주소만 부르고, Next.js라면 같은 로직을 Route Handler 에 두고 NEXT_PUBLIC_ 이 붙지 않은 환경변수로 키를 읽으면 된다.
카운터가 프로세스 메모리에 있어 워커가 여럿이거나 재시작하면 갈라지고 초기화되니 운영에서는 Redis 같은 공유 저장소로 옮기고, 하루 상한은 호출이 끝난 뒤 합산되므로 동시에 몰리면 조금 넘을 수 있다.
이 코드는 서버 쪽 방어선일 뿐이라 계정을 여러 개 만들면 사용자별 상한이 합쳐지므로, 발급처의 하드 한도와 프로젝트별 한도가 마지막 상한이다.

자가진단

# 1) 배포 사이트가 내려 주는 스크립트에서 키 모양 찾기 (앞 8자만 출력)
#    판정: 한 줄이라도 나오면 그 키는 공개된 것으로 보고 폐기한다. AIza 로 시작하는 구글 키는 LLM 용인지 발급처에서 확인한다
SITE=https://내사이트.com
curl -s "$SITE/" | grep -oE 'src="[^"]+\.js[^"]*"' | cut -d'"' -f2 | while read -r p; do
  case "$p" in http*) u="$p";; //*) u="https:$p";; /*) u="$SITE$p";; *) u="$SITE/$p";; esac
  curl -s "$u" | grep -oE 'sk-proj-[A-Za-z0-9_-]{40,}|sk-ant-[A-Za-z0-9_-]{40,}|sk-[A-Za-z0-9]{32,}|AIza[0-9A-Za-z_-]{35}|AKIA[0-9A-Z]{16}' | cut -c1-8 | awk -v u="$u" '{print u"  "$0}'
done

# 2) 빌드 결과물에 키가 박혔는지 (프로젝트 폴더에서, 나중에 불러오는 파일까지)
#    판정: 나오면 번들에 들어간 것이다. 1번에서 안 보여도 같은 판정
grep -rEo 'sk-proj-[A-Za-z0-9_-]{40,}|sk-ant-[A-Za-z0-9_-]{40,}|sk-[A-Za-z0-9]{32,}' .next/static dist build 2>/dev/null | sed -E 's/(:.{8}).*/\1.../'

# 3) 소스에서 브라우저가 발급처를 직접 부르게 만든 설정 (AI 코딩 도구가 만든 호출 코드도 여기서 걸러진다)
#    판정: 키 이름이 든 NEXT_PUBLIC_·VITE_ 변수나 dangerouslyAllowBrowser 가 LLM 호출에 쓰이면 서버 호출로 바꾼다
grep -rnoE 'dangerouslyAllowBrowser|(NEXT_PUBLIC|VITE)_[A-Z_]*(KEY|SECRET|TOKEN)' src app pages lib .env* 2>/dev/null

# 4) 저장소 이력에 키가 올라간 적이 있는지 (지금은 지웠어도 이력에 남는다. 서로 다른 키마다 한 줄씩 나온다)
#    판정: 한 줄이라도 나오면 이력에 키가 남은 것이다. 저장소가 공개였던 적이 있으면 노출된 것으로 보고 폐기한다
git log --all -p | grep -oE 'sk-proj-[A-Za-z0-9_-]{40,}|sk-ant-[A-Za-z0-9_-]{40,}|sk-[A-Za-z0-9]{32,}' | sort -u | cut -c1-8

# 5) 내 서버 기록과 발급처 콘솔 사용량을 같은 날짜로 대조 (날짜는 확인할 날로 바꾼다)
grep '^2026-11-09' llm_usage.log | grep 'llm uid=' | awk '{for(i=1;i<=NF;i++){if($i ~ /^tokens=[0-9]+$/) s+=substr($i,8); else if($i=="error=1") e++}} END {print NR" calls, "e+0" errors (tokens unknown), "s+0" tokens"}'
#    오류 호출은 토큰을 모르는 채로 따로 센다. 콘솔 합계가 서버 합계보다 크면 이 오류 호출 몫부터 감안한다
#    콘솔: 발급처 사용량 화면에서 같은 날짜의 프로젝트·키별 요청 수와 토큰을 본다 (OpenAI는 Usage 페이지, 키별 보기는 추적을 켠 뒤부터 나온다)
#    판정: 콘솔이 내 서버 합계보다 눈에 띄게 크면 조사한다. 시간대·프로젝트·키를 같게 맞췄는지, 서버 기록의 오류·시간 초과 호출과
#    다른 정상 호출원(배치 작업 등)을 뺐는지 확인하고, 그래도 남는 차이가 내 서버를 거치지 않은 호출이다
#    서비스가 쉬는 시간대의 호출, 쓰지 않는 모델, 응답 토큰이 1~2개뿐인 요청이 몰리는 구간도 같은 판정이다

# 6) 상한이 실제로 걸려 있는지 발급처 설정 화면에서 확인
#    OpenAI: limits 페이지의 지출 알림과 하드 한도, 프로젝트별 속도·지출 한도
#    Google Cloud·AWS: 예산이 알림 전용인지 확인 (알림 전용 예산은 사용을 막지 않는다)
#    판정: 하드 한도나 프로젝트별 한도가 비어 있으면 내가 정한 상한이 없는 상태다. 알림만 있으면 반쪽이다

위 시그널이 발견되면 결함이다. Google Cloud 위협 인텔리전스 블로그의 2026-11-02 Mandiant LLMjacking 보고서를 함께 참조하라.

조치 절차

순위조치
1새 키를 발급해 서버 환경변수에 넣고 배포한 뒤 옛 키를 폐기한다. 발급처 문서가 안내하는 순서다. 요금이 이미 빠르게 나가는 중이면 서비스가 몇 분 멈추더라도 폐기를 먼저 한다 (하루 10만 달러는 평균 시간당 4천 달러가 넘는 속도다)
2새 키에는 처음부터 상한을 건다. 월 하드 한도와 프로젝트별 속도·지출 한도를 평소 사용량보다 조금 높은 선으로 정하고, 지출 알림은 그보다 낮은 금액으로 걸어 서비스가 멈추기 전에 알아챈다. 알림 전용 예산은 사용을 막지 않는다
3노출 구간을 확정한다. 콘솔 사용량에서 이상이 시작된 시각·키·모델·호출 수를 기록해 서버 기록과 대조하고, 클라우드 자격증명이면 감사 로그에서 모델 호출과 신규 키 생성·서비스 활성화 흔적까지 본다. 이 기록은 발급처에 사고를 알릴 때도 쓴다
4새는 경로를 닫는다. 브라우저 직접 호출을 서버 프록시로 바꾸고 NEXT_PUBLIC_·VITE_ 변수에서 키를 뺀다. 저장소 이력과 빌드 산출물에 남은 키도 노출된 것으로 보고 폐기 대상에 넣는다
5키 범위를 쪼갠다. 개발·운영과 기능별로 키와 프로젝트를 나눠 각각 한도를 걸고, 발급처가 지원하면 허용 IP로 키를 제한하고, 서버 계정에서 필요 없는 LLM 호출 권한은 뺀다. 만료일을 정해 주기적으로 교체한다
6감시를 습관으로 만든다. 키별 사용량 추적을 켜고, 하루 한 번 서버 기록과 콘솔 사용량을 대조하고, 저장소에 시크릿 스캐닝을 붙인다. 예산 알림은 늦게 올 수 있다는 전제로 설계한다

지금 해야 할 것

실무자: 키 폐기와 한도부터 걸고, 자가진단 2~6단계는 운영자가 직접 한다. CodeScan 무료 스캔은 입력한 주소의 화면에 든 스크립트와 거기 붙은 스크립트 파일 일부에서 OpenAI·Anthropic·구글·AWS 키 형식을 찾을 뿐, 저장소 이력·서버 환경변수·나중에 불러오는 파일·사용량과 청구는 보지 못한다.
CISO·임원: AI 기능을 붙인 서비스의 키 관리와 외부 노출을 함께 진단하려면 SENTRIX 보안 상담으로.

관련 글

내 사이트도 점검해보세요

URL 입력, 결과 링크는 메일로 받는 무료 점검. 회원가입·카드 불필요.

AI 코딩 보안 체크리스트 받기

AI 코딩 보안 체크리스트(PDF)를 무료로 받아보세요.

코드 자체가 걱정이라면 : 소스코드 보안 분석(SAST)이 ZIP 업로드만으로 취약점 위치와 수정 코드를 찾아드립니다.
이런 글을 계속 받아보려면 뉴스레터 구독 · RSS 피드