봇과 청구서가 공격이 될 때: 자동화 위협과 비용 고갈 막기

아프로·2026년 11월 1일

봇과 청구서 표지

2022년 11월 15일, 미국 Ticketmaster에서 대형 콘서트 투어의 사전 판매가 열렸습니다. 회사 발표에 따르면 그날 시스템에 들어온 요청은 약 35억 건으로, 이전 최고치의 네 배였습니다. Ticketmaster는 봇 공격과 초대 코드 없이 몰린 이용자를 원인으로 들었고, 예정된 일반 판매를 취소했습니다. 그날 들어온 요청 가운데 "취약점을 노린" 요청은 하나도 보고되지 않았습니다. 모두 표를 사려는 정상 요청이었고, 그중 상당수를 사람이 아닌 프로그램이 보냈다는 것이 회사의 설명입니다.

이 시리즈의 공격을 실제 쇼핑몰 코드에서 직접 재연하고 막아 보는 인프런 강의가 열렸습니다. 실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩 · 이 글과 이어지는 수업: 4-3, 10-6, 10-7

이 글의 범위. 자동화 위협과 비용 고갈의 원리와 방어를 다룹니다. 봇 스크립트, 카드 테스팅 절차, SMS 펌핑 경로 같은 공격 쪽 내용은 싣지 않습니다. 코드는 모두 방어 쪽 코드이고 값은 가짜입니다. 사실관계는 2026년 11월 1일 기준 공개 자료를 따르며, 회사 주장으로 독립 검증되지 않은 수치는 그렇게 밝힙니다.

한눈에 보기

기법한 줄 정의대표 사례막는 핵심가장 가까운 OWASP
카드 테스팅유출된 카드 번호가 살아 있는지 소액·0원 결제로 대량 확인Stripe, 정점에 하루 2천만 건 넘게 차단(2022)카드 지문·계정 축 속도 제한, 0원 인증 사전 조건A06, API6:2023, OAT-001·010
사재기·재고 선점한정 상품·표를 봇이 먼저 사거나 장바구니에 묶어 둠Ticketmaster(2022-11), FTC BOTS Act 집행흐름 단위 구매 한도, 가상 대기실, 짧은 점유 TTLA06, API6:2023, OAT-005·021·013
프로모 남용신규 가입 혜택을 다계정으로 반복 수령국내 선착순 쿠폰·이벤트 전반본인 확인 수단 1개당 1회A06, OAT-019
로직 불일치결제 거절·환불 경로 규칙이 시스템마다 달라 "거절됐는데 환불된" 상태Revolut 약 2천만 달러 순손실(2022 발생, 2023-07 보도)원장 기준 환불, 준실시간 대사A06
지갑 고갈서버리스 호출·트래픽을 대량으로 소모시켜 청구서를 늘림Netlify 무료 플랜 약 10만 4천 달러 청구(2024-02)동시성 상한, 대역폭 상한, 예산 경보 자동 조치API4:2023
LLM 비용 고갈상한 없는 LLM 기능이나 유출된 자격 증명으로 고가 모델 호출Sysdig LLMjacking(2024-05)사용자별 토큰 예산, 제공사 지출 한도, 단기 자격 증명API4:2023, A02·A07(자격 증명)
SMS 펌핑인증 문자 발송 API를 반복 호출시켜 발송 수익을 빼감Twitter 연 6천만 달러 주장(2023-02)허용 국가, 번호·기기별 한도, 쿨다운API4:2023

두 묶음으로 나뉩니다. 앞의 넷은 정상 기능을 비정상 규모로 쓰는 공격(강의 섹션 6)이고, 뒤의 셋은 데이터 대신 청구서를 노리는 공격(강의 섹션 7)입니다. 공통점은 요청 하나만 놓고 보면 막을 이유가 없다는 것입니다.

봇과 비용 고갈 관련 공개 사례 아홉 건을 시점순으로 정리한 표

1부. 정상 기능의 남용: 자동화 위협

원리: 요청 하나하나는 정상이다

사람 사용자와 봇의 요청 비교, 그리고 IP·카드 지문·계정·기기 중 어느 축으로 세어야 봇이 보이는지 정리한 표

웹 취약점 대부분은 "이상한 요청"에서 시작합니다. 따옴표가 들어간 입력, 남의 ID를 넣은 URL, 형식이 깨진 헤더가 그렇습니다. 자동화 위협은 다릅니다. 결제 승인, 장바구니 담기, 신규 가입 쿠폰 받기처럼 설계대로 동작하는 기능을 그대로 씁니다. 다른 점은 규모와 속도입니다. 사람 한 명이 쿠폰 하나를 받는 기능을, 봇은 계정 수백 개로 수천 번 실행합니다.

OWASP는 이 영역을 Top 10과 별도로 OAT(Automated Threats to Web Applications) 목록으로 정리했습니다. 이 글이 다루는 유형은 다음과 같습니다.

① 카드 테스팅(OAT-001 Carding, OAT-010 Card Cracking). 범죄자가 다른 곳에서 유출된 카드 번호 묶음을 손에 넣으면, 먼저 어느 카드가 아직 쓸 수 있는지 알아야 비싸게 팔거나 큰 결제에 쓸 수 있습니다. 그 확인을 아무 가맹점의 소액 결제나 0원 인증(카드 등록 확인용 승인)으로 대량으로 합니다. 가맹점은 결제가 성공하든 실패하든 손해를 봅니다. 승인 요청마다 수수료가 붙고, 나중에 카드 주인이 이의를 제기하면 차지백(결제 취소와 수수료)이 돌아오고, 거절률이 높아지면 카드사가 그 가맹점을 위험하게 봅니다.

② 사재기·재고 선점(OAT-005 Scalping, OAT-021 Denial of Inventory, OAT-013 Sniping). 한정 수량 상품이나 공연 표를 봇이 사람보다 먼저 확보해 되팔거나, 결제하지 않은 채 장바구니에 묶어 두어 다른 사람이 살 수 없게 만듭니다. 마감 직전에 들어와 낚아채는 것도 같은 묶음입니다.

③ 프로모 남용(OAT-019 Account Creation 등). 신규 가입 쿠폰, 첫 결제 할인, 추천인 포인트를 계정을 여러 개 만들어 반복해서 받습니다. 쿠폰 하나는 작아도, 계정 생성이 자동화되면 "1인 1회"라는 전제가 사라집니다.

④ 로직 불일치. 봇과 직접 관계는 없지만 같은 묶음에 넣는 이유가 있습니다. 결제 거절과 환불을 처리하는 경로가 시스템마다 다른 규칙을 쓰면, 거절된 결제에 대해 환불이 나가는 식의 "돈이 새는 상태"가 생깁니다. 이 틈을 발견한 쪽이 같은 요청을 대량으로 반복하면 그때부터는 자동화 위협과 같은 모양이 됩니다.

네 유형 모두 기획 단계의 질문 하나로 미리 걸러 낼 수 있습니다. "봇이 이 버튼을 1만 번 누르면 어떻게 되는가?" 선착순 쿠폰, 한정판 판매, 공연 예매처럼 국내 서비스에 흔한 "오픈런" 기능은 모두 이 질문의 대상입니다.

왜 OWASP Top 10에 이름이 없나

Top 10:2025에서는 이 영역이 A06 안전하지 않은 설계(Insecure Design)에 흡수됩니다. 그런데 A06은 범위가 넓어서, 점검 목록으로 내려오면 "자동화 남용"이라는 항목이 따로 생기지 않습니다. 더 근본적인 이유는 요청이 모두 정상이라는 점입니다. Top 10식 취약점 점검은 "이 요청이 허용되면 안 되는가"를 묻는데, 카드 테스팅의 결제 요청 하나는 허용되는 것이 맞습니다.

그래서 OWASP는 이 영역을 OAT라는 별도 목록으로 정리했고, API Security Top 10 2023에서는 API6:2023 Unrestricted Access to Sensitive Business Flows(민감한 비즈니스 흐름에 대한 무제한 접근)라는 이름으로 다룹니다. 이름 그대로 문제는 개별 엔드포인트보다 "흐름"에 있습니다.

실제 사례

Stripe 카드 테스팅 급증(2022). 결제대행사 Stripe는 자사 사기 탐지 서비스 Radar가 정점에 하루 2천만 건이 넘는 카드 테스팅 시도를 차단했다고 밝혔습니다. 이후 3단계 머신러닝 모델과 API 속도 제한을 도입해, 2년 동안 성공한 카드 테스팅 공격을 80% 줄였다고 보고했습니다. 회사 자체 발표 수치입니다.

Ticketmaster와 BOTS Act(2021~2025). 글머리의 2022년 11월 사전 판매 사례에서 "약 35억 요청, 이전 최고의 4배"는 회사 주장이며 독립 검증은 없습니다. 미국에는 티켓 봇을 겨냥한 BOTS Act라는 법이 있습니다. 미국 연방거래위원회(FTC)는 2021년 1월 이 법을 처음 집행해 티켓 재판매업자 3곳에 총 3,100만 달러가 넘는 민사 제재금을 부과했습니다(지급 능력 문제로 실제 납부는 약 370만 달러). 이들은 자동 구매 소프트웨어, IP 숨기기, 수백 개의 가짜 계정으로 구매 한도를 우회해 15만 장 이상을 샀습니다. 구매 한도가 "계정당"으로만 걸려 있으면 계정을 늘리는 것으로 우회된다는 점을 보여 줍니다. 2025년 9월 18일에는 FTC와 7개 주가 Live Nation·Ticketmaster를 BOTS Act 위반 등으로 제소했고, 소송은 진행 중입니다.

국내 공연법 개정(2024-03-22 시행). 매크로 프로그램으로 구매한 공연 입장권을 부정 판매하면 1년 이하 징역 또는 1천만 원 이하 벌금에 처하도록 바뀌었습니다. 같은 시기 국민체육진흥법도 개정됐습니다. 다만 수사기관이 매크로 사용을 입증해야 한다는 한계가 지적됐고, 이후 매크로 사용 여부와 상관없이 부정 판매를 금지하는 추가 개정이 추진·시행됐습니다. 추가 개정의 세부 시행일은 확인하지 못했습니다 [추정]. 법이 생겼어도 입증은 서비스가 남긴 기록에 기댑니다. 서버가 "누가 어떤 속도로 샀는지"를 남기지 않으면 법도 쓰기 어렵습니다.

Revolut 환불 로직 불일치(2022년 발생, 2023-07 보도). 영국 핀테크 Revolut에서 미국과 유럽 결제 시스템의 처리 차이 때문에, 거절된 결제 일부가 회사 자금으로 잘못 환불됐습니다. 범죄자들은 거절될 고액 결제를 일부러 일으킨 뒤 환불금을 ATM에서 인출했고, 약 2,300만 달러가 빠져나가 일부 회수 후 순손실은 약 2천만 달러였습니다. 파이낸셜타임스의 익명 소스 기반 보도이며 회사는 공식 논평을 하지 않았습니다. 이 사례의 "버그"는 두 시스템이 같은 거래 상태를 다르게 본 것이었고, 한 줄의 코드로 찾을 수 있는 결함은 아니었습니다.

서버에서 막기

① 흐름 단위 한도: 계정·결제수단·기기·배송지마다 센다

구매 수량 제한은 화면에서 막아도 의미가 없습니다. 서버가 흐름 단위로 셉니다. 그리고 한 축만 세면 그 축을 늘리는 것으로 우회되므로(FTC 사례의 가짜 계정 수백 개), 여러 축을 함께 셉니다.

# 한정 상품 구매 한도: 계정·결제수단·기기·배송지 네 축을 함께 센다 (방어 예시, 값은 가짜)
import hashlib
import redis

r = redis.Redis(host="127.0.0.1", port=6379)
LIMITS = {"acct": 2, "card": 2, "device": 2, "addr": 2}   # 상품당 축마다 최대 2개
TTL = 7 * 24 * 3600                                        # 판매 기간

def h(v: str) -> str:                                      # 원문 대신 해시로 저장
    return hashlib.sha256(("fake-pepper:" + v).encode()).hexdigest()[:24]

# 네 축을 한 번에 검사하고 한 번에 올린다(Lua로 원자적으로)
CHECK_AND_INCR = r.register_script("""
for i, k in ipairs(KEYS) do
  if tonumber(redis.call('GET', k) or '0') + tonumber(ARGV[1]) > tonumber(ARGV[i+2]) then
    return k
  end
end
for _, k in ipairs(KEYS) do
  redis.call('INCRBY', k, ARGV[1]); redis.call('EXPIRE', k, ARGV[2])
end
return 'ok'
""")

def reserve_purchase(item_id, acct_id, card_fp, device_id, ship_addr, qty):
    keys = [f"buy:{item_id}:acct:{acct_id}", f"buy:{item_id}:card:{card_fp}",
            f"buy:{item_id}:device:{h(device_id)}", f"buy:{item_id}:addr:{h(ship_addr.lower())}"]
    limits = [LIMITS["acct"], LIMITS["card"], LIMITS["device"], LIMITS["addr"]]
    res = CHECK_AND_INCR(keys=keys, args=[qty, TTL, *limits])
    if res != b"ok":
        raise PurchaseLimitExceeded(axis=res.decode())     # 어느 축에서 걸렸는지 로그에 남긴다
  • 네 키를 따로따로 검사하고 올리면, 동시에 들어온 요청 두 개가 둘 다 통과할 수 있습니다. 검사와 증가를 하나의 원자적 연산으로 묶는 이유입니다. 동시 요청 문제는 이전 글 경쟁 조건과 웹훅 멱등성에서 자세히 다뤘습니다.
  • 배송지는 공백·대소문자를 정규화한 뒤 해시합니다. 정규화 수준은 서비스마다 다르지만, 원문 주소를 카운터 키로 쓰지 않는 것은 공통입니다.

② 결제 시도 속도 제한: IP 대신 카드 지문과 계정 축으로

카드 테스팅은 IP를 계속 바꿉니다. IP 축 제한은 쉽게 흩어집니다. 대신 카드 지문(BIN, 곧 카드 번호 앞 6~8자리 발급사 번호와 끝 4자리를 묶어 해시한 값), 계정, 세션 축에서 셉니다. 카드 번호 원문은 서버 로그와 카운터 어디에도 남기지 않습니다.

# 결제 실패를 카드 지문·계정·BIN 대역 축으로 세고, 넘으면 결제 시도 자체를 막는다 (방어 예시)
import time

WINDOW = 600                                   # 10분
RULES = {                                      # 예시 값
    "acct_fail":   5,                          # 한 계정의 결제 실패
    "acct_cards":  3,                          # 한 계정이 시도한 서로 다른 카드 수
    "bin_decline": 200,                        # 같은 BIN 대역의 거절 수(가맹점 전체)
}

def card_fingerprint(bin6: str, last4: str) -> str:
    return h(f"{bin6}:{last4}")                # 위의 h()와 같은 페퍼 해시

def before_payment(acct_id, bin6, last4):
    fp, now = card_fingerprint(bin6, last4), time.time()
    pipe = r.pipeline()
    pipe.zadd(f"pay:cards:{acct_id}", {fp: now})
    pipe.zremrangebyscore(f"pay:cards:{acct_id}", 0, now - WINDOW)
    pipe.zcard(f"pay:cards:{acct_id}")
    pipe.get(f"pay:fail:{acct_id}")
    pipe.get(f"pay:bin_decline:{bin6}")
    _, _, card_count, fails, bin_declines = pipe.execute()
    if card_count > RULES["acct_cards"] or int(fails or 0) >= RULES["acct_fail"]:
        raise StepUpRequired("3ds")            # 3D Secure 추가 인증으로 넘긴다
    if int(bin_declines or 0) >= RULES["bin_decline"]:
        alert("bin_decline_spike", bin=bin6)   # 같은 대역 거절 급증은 운영자에게

def after_payment(acct_id, bin6, approved: bool):
    if not approved:
        for k in (f"pay:fail:{acct_id}", f"pay:bin_decline:{bin6}"):
            r.incr(k); r.expire(k, WINDOW)

③ 0원·소액 인증 API에는 사전 조건을

카드 등록 확인용 0원 인증은 카드 테스팅이 가장 좋아하는 통로입니다. 로그인하지 않은 상태, 가입 직후, 결제 이력이 없는 계정에서는 이 API를 호출할 수 없게 합니다. 또 계정당 등록 가능한 카드 수와 하루 등록 시도 수에 상한을 둡니다.

④ 장바구니 점유는 짧게, 계정별로 적게

결제하지 않은 장바구니가 재고를 오래 붙잡지 않게 합니다. 점유에는 짧은 만료 시간(TTL)을 두고, 결제 전 점유 수를 계정별로 제한합니다.

-- 재고 점유: 만료 시각을 두고, 계정당 동시 점유 수를 제한한다 (방어 예시, PostgreSQL)
CREATE TABLE stock_hold (
  id          bigserial PRIMARY KEY,
  item_id     bigint      NOT NULL,
  account_id  bigint      NOT NULL,
  qty         int         NOT NULL CHECK (qty BETWEEN 1 AND 2),
  expires_at  timestamptz NOT NULL DEFAULT now() + interval '10 minutes'
);
CREATE INDEX ON stock_hold (item_id, expires_at);

-- 점유 생성: 남은 재고와 이 계정의 활성 점유 수를 같은 문장에서 확인
INSERT INTO stock_hold (item_id, account_id, qty)
SELECT :item_id, :account_id, :qty
WHERE (SELECT count(*) FROM stock_hold
        WHERE account_id = :account_id AND expires_at > now()) < 3
  AND (SELECT i.stock - coalesce(sum(h.qty), 0)
         FROM item i LEFT JOIN stock_hold h
           ON h.item_id = i.id AND h.expires_at > now()
        WHERE i.id = :item_id GROUP BY i.stock) >= :qty;

동시에 들어온 점유 요청이 같은 남은 재고를 보고 둘 다 성공하지 않도록, 실제 서비스에서는 상품 행을 잠그거나 조건부 UPDATE로 재고 칸을 직접 줄이는 방식과 함께 씁니다(05편의 방법).

⑤ 환불은 원장을 보고 나서

Revolut 유형을 막는 규칙은 하나입니다. 환불은 원 거래가 실제로 승인·정산된 상태인지 우리 원장에서 확인한 뒤에만 실행합니다. 결제 시스템이 보낸 "환불 요청" 이벤트만 보고 돈을 내보내지 않습니다.

// 환불 전 원장 확인 (방어 예시, Spring)
@Transactional
public void refund(long paymentId, long amount) {
    Payment p = payments.findByIdForUpdate(paymentId)            // 행 잠금
        .orElseThrow(() -> new RefundRejected("no_payment"));
    if (p.getStatus() != PaymentStatus.CAPTURED)                  // 승인·정산된 거래만
        throw new RefundRejected("not_captured:" + p.getStatus());
    long refunded = refunds.sumByPaymentId(paymentId);
    if (refunded + amount > p.getCapturedAmount())                // 정산액을 넘는 환불 금지
        throw new RefundRejected("over_refund");
    refunds.save(new Refund(paymentId, amount));
    p.markPartiallyRefunded(refunded + amount);
}

⑥ 설정 수준

  • 결제대행사 위험 점수와 3D Secure: 고위험 구간(새 계정, 새 카드, 높은 실패율)에서는 3D Secure(카드사 추가 인증)를 강제합니다. 대부분의 결제대행사가 위험 점수를 함께 돌려주므로, 점수에 따라 서버가 다음 단계를 정합니다.
  • 신규 가입 쿠폰은 본인 확인 수단 1개당 1회: 계정당 1회로 걸면 계정을 늘리는 것으로 우회됩니다. 휴대폰 본인인증 결과값이나 결제수단 지문처럼 쉽게 늘릴 수 없는 값에 묶습니다.

⑦ 아키텍처 수준

  • 가상 대기실: 인기 판매는 대기열로 입장 속도를 정합니다. 대기열을 통과한 사용자에게 서버가 서명한 입장 토큰을 발급하고, 구매 API는 이 토큰이 없거나 만료됐거나 다른 계정 것이면 거부합니다. 대기열만 두고 구매 API를 열어 두면 봇은 대기열을 건너뜁니다.
# 대기열 입장 토큰: 서버가 서명하고, 구매 API가 검증한다 (방어 예시, 키는 가짜)
import hmac, hashlib, time, base64, json

QUEUE_KEY = b"fake-queue-signing-key-rotate-me"

def issue_entry_token(account_id: int, sale_id: str, ttl=300) -> str:
    body = json.dumps({"a": account_id, "s": sale_id, "exp": int(time.time()) + ttl}).encode()
    sig = hmac.new(QUEUE_KEY, body, hashlib.sha256).digest()
    return base64.urlsafe_b64encode(body).decode() + "." + base64.urlsafe_b64encode(sig).decode()

def verify_entry_token(token: str, account_id: int, sale_id: str) -> bool:
    b64body, b64sig = token.split(".", 1)
    body = base64.urlsafe_b64decode(b64body)
    expected = hmac.new(QUEUE_KEY, body, hashlib.sha256).digest()
    if not hmac.compare_digest(expected, base64.urlsafe_b64decode(b64sig)):
        return False
    claims = json.loads(body)
    return claims["a"] == account_id and claims["s"] == sale_id and claims["exp"] > time.time()
  • 봇 탐지와 규칙 엔진을 나눈다: 행동 신호·기기 신호로 점수를 내는 봇 탐지와, "점수 몇 이상이면 3DS·보류·차단" 같은 비즈니스 규칙을 분리합니다. 규칙을 배포 없이 바꿀 수 있어야 공격 패턴이 바뀌는 속도를 따라갈 수 있습니다.
  • 준실시간 대사(reconciliation): 정산·대사를 하루 한 번 대신 몇 분 단위로 돌립니다. "환불 총액이 원 거래 승인 총액보다 크다" 같은 불일치를 다음 날 아침까지 기다리지 않고 그 시간 안에 봅니다.

탐지 신호

  • 승인 거절률 급증, 특히 같은 BIN 대역에 거절이 몰리는 경우
  • 0원·소액 승인 비율 증가
  • 한 계정이 짧은 시간에 여러 카드로 결제를 시도함
  • 가입 직후 쿠폰만 쓰고 이탈하는 계정 군집(같은 기기·배송지·번호 대역)
  • 판매 오픈 직후 비정상적인 장바구니 점유
  • 환불 총액과 원 거래 승인 총액의 불일치

로그인·조회 쪽에서 봇이 남기는 흔적은 실시간 탐지 시리즈의 공격은 어디서 보이나와 신원별 슬라이딩 윈도우에서 다뤘습니다. 같은 방식(신원 축으로 짧은 창을 세는 것)을 결제·쿠폰 흐름에 그대로 옮기면 됩니다.

2부. 돈으로 공격당하기: 비용 고갈

원리: 서비스는 버티는데 청구서가 불어난다

공격자 요청 1건이 인증 없는 엔드포인트를 거쳐 운영자 과금으로 이어지는 흐름과, 지갑 고갈·LLM 비용·SMS 펌핑 세 갈래

전통적인 서비스 거부 공격(DoS)은 서버를 멈추는 것이 목표였습니다. 클라우드의 오토스케일링과 종량제는 이 문제를 상당 부분 풀었습니다. 요청이 몰리면 서버가 늘어나고 서비스는 버팁니다. 그 대가는 청구서입니다. 비용 고갈 공격은 서비스를 멈추는 대신 사용량 과금 자원을 대량으로 소모시켜 운영자에게 비용을 떠넘깁니다. 2021년 아일랜드 NUI Galway 연구진은 서버리스 환경의 이 위협을 "Denial of Wallet(지갑 고갈)"이라는 이름으로 정의했습니다.

구조는 단순합니다. 인증이 없거나 약한 엔드포인트가 비싼 자원을 호출하면, 공격자가 요청 1건을 보내는 비용과 우리가 그 1건을 처리하는 비용 사이에 큰 차이가 생깁니다. 이 차이가 클수록 공격은 쉬워집니다. 세 갈래로 나눠 봅니다.

① 지갑 고갈: 서버리스 호출과 트래픽. 함수 호출 수, 실행 시간, 내보낸 트래픽(대역폭)만큼 과금되는 자원은 요청이 늘면 비용도 같이 늘어납니다. 상한을 두지 않으면 공격 트래픽이 그대로 청구서로 바뀝니다.

② LLM 비용 고갈과 LLMjacking. LLM 호출은 입력·출력 토큰 수만큼 과금됩니다. 사용자별 상한 없이 LLM 기능을 열어 두면, 긴 입력과 긴 출력을 요구하는 요청이 반복되기만 해도 비용이 빠르게 쌓입니다. 더 큰 위험은 클라우드 자격 증명 유출입니다. 공격자가 피해자의 클라우드 계정 권한을 손에 넣으면, 그 계정으로 클라우드에서 호스팅되는 고가 LLM을 직접 호출하고 그 접근권을 다른 사람에게 되팔기도 합니다. 이것을 보안업체 Sysdig가 LLMjacking이라고 불렀습니다.

③ SMS 펌핑. 회원가입·로그인 인증 문자 발송 API를 반복 호출하게 만들어, 공격자와 결탁한 통신 경로가 문자 발송 수익을 가져가는 방식입니다. 우리 서비스는 존재하지 않는 사용자에게 문자를 보내고, 문자 요금을 내고, 인증은 한 건도 완료되지 않습니다.

왜 OWASP Top 10에 이름이 없나

Top 10:2025에는 비용 고갈에 직접 대응하는 항목이 없습니다. 설계 문제로 보면 A06, 자격 증명 유출 경로로 보면 A02·A07에 걸치는 정도입니다. 이유는 Top 10이 2004년판의 "Denial of Service" 이후 가용성 문제를 독립 항목으로 두지 않는다는 데 있습니다. 비용 고갈은 가용성 문제의 변형이고, 데이터가 새지 않기 때문에 "취약점"으로 잘 분류되지 않습니다.

이름으로 다루는 곳은 API Security Top 10 2023의 API4:2023 Unrestricted Resource Consumption(무제한 자원 소비)입니다. 이 항목은 CPU·메모리뿐 아니라 SMS·이메일 같은 제3자 유료 연동 비용도 명시적으로 포함합니다. OAT 목록의 OAT-015 Denial of Service와도 관련됩니다.

실제 사례

Sysdig "LLMjacking"(2024-05 공개). 공격자는 패치하지 않은 Laravel(CVE-2021-3129)을 통해 클라우드 자격 증명을 탈취했고, 그 자격 증명으로 10개의 클라우드 호스팅 LLM 서비스를 노렸습니다. 목적은 접근권 재판매였던 것으로 보입니다. Sysdig는 피해자가 떠안을 수 있는 비용을 하루 4만 6천 달러 이상으로 추산했는데, 모델·리전 쿼터를 꽉 채워 쓴다고 가정한 계산치입니다. 후속 보고서에서는 최신 모델 기준으로 하루 10만 달러 이상으로 올렸습니다. 침입 경로는 오래된 웹 취약점이었고, 피해는 청구서로 나타났습니다.

Twitter SMS 펌핑(2023-02 발언). 당시 Twitter CEO는 봇이 2단계 인증 문자를 반복 발송시키는 방식으로 연 6천만 달러의 손실이 난다고 주장했습니다. 회사는 2023년 3월 유료 구독자가 아닌 사용자의 SMS 2단계 인증을 중단했습니다. 수치는 회사 측 주장으로 독립 검증되지 않았습니다 [추정]. 문자 발송 서비스 업체 Twilio는 SMS 펌핑 대응 가이드(국가 제한, 발송 속도 제한, 사기 탐지 기능 Fraud Guard)를 공개했습니다.

Netlify 무료 플랜 약 10만 4천 달러 청구(2024-02). 정적 사이트를 무료 플랜으로 운영하던 사용자가 DDoS로 대역폭이 폭증해 약 10만 4천 달러를 청구받았습니다. 커뮤니티에서 논란이 된 뒤 Netlify가 대응을 밝혔고, 최종 처리는 출처마다 다르게 전해집니다 [추정: 면제]. 무료 플랜이라도 사용량 초과 과금 구조라면 공격 트래픽이 그대로 비용이 된다는 사례입니다.

서버에서 막기

비용 방어 다섯 겹. 엣지 속도 제한, 사용자 단위 예산, 발송·호출 조건, 큐로 소비 속도 고정, 지출 상한과 자동 조치

출발점은 계산 하나입니다. "요청 1건이 우리에게 얼마인가?" LLM 호출 1건, 문자 1건, 함수 실행 1건의 단가를 알면 사용자별 상한을 얼마로 둘지 정할 수 있습니다.

① 사용자 단위 예산: 비싼 작업 앞에서 먼저 차감한다

LLM 기능은 사용자·API 키별 일일 토큰 한도, 요청당 출력 토큰(max_tokens) 상한, 입력 길이 상한을 둡니다. 가장 중요한 동작은 호출 전에 예상 사용량을 원자적으로 차감하는 것입니다. 호출 후에 더하면 동시에 들어온 요청들이 한도를 함께 넘습니다.

# LLM 호출 전 사용자 예산 차감 (방어 예시, 값은 가짜)
import datetime

DAILY_TOKEN_LIMIT = 200_000        # 사용자당 하루 토큰
MAX_INPUT_CHARS   = 8_000          # 입력 길이 상한
MAX_OUTPUT_TOKENS = 1_024          # 요청당 출력 상한

def call_llm_with_budget(user_id: str, prompt: str):
    if len(prompt) > MAX_INPUT_CHARS:
        raise InputTooLong()
    est = estimate_tokens(prompt) + MAX_OUTPUT_TOKENS          # 출력 상한까지 넣어 넉넉히 추정
    key = f"llm:tokens:{user_id}:{datetime.date.today():%Y%m%d}"
    used = r.incrby(key, est)                                  # 원자적 차감
    r.expire(key, 2 * 24 * 3600)
    if used > DAILY_TOKEN_LIMIT:
        r.decrby(key, est)                                     # 거절한 몫은 되돌린다
        raise QuotaExceeded()
    resp = llm_client.generate(prompt, max_tokens=MAX_OUTPUT_TOKENS)
    r.decrby(key, est - resp.usage.total_tokens)               # 실제 사용량으로 정산
    metrics.incr("llm.tokens", resp.usage.total_tokens, tags={"user_tier": tier_of(user_id)})
    return resp
  • 익명 사용자에게는 LLM 기능을 열지 않거나, 열더라도 아주 작은 예산과 결과 캐시를 둡니다. 익명 엔드포인트가 비싼 다운스트림을 직접 호출하지 않게 하는 것이 원칙입니다.
  • 사용자 등급(무료·유료·사내)마다 예산을 다르게 둡니다. 유료 사용자도 상한은 둡니다. 계정이 탈취되면 그 계정의 예산이 그대로 공격자 몫이 됩니다.

② SMS 인증 발송: 보내기 전에 다섯 가지를 확인한다

# 인증 문자 발송 전 검사 (방어 예시, 값은 가짜)
ALLOWED_COUNTRY_CODES = {"82"}                   # 국내 서비스면 +82만
LIMITS = {"phone": (3, 3600), "ip": (10, 3600), "device": (5, 3600), "prefix": (50, 600)}
RESEND_COOLDOWN = 60                             # 같은 번호 재발송 간격(초)

def hit(key: str, limit: int, window: int) -> bool:
    n = r.incr(key)
    if n == 1:
        r.expire(key, window)
    return n > limit

def before_send_sms(phone_e164: str, ip: str, device_id: str, risk_score: float):
    cc = country_code_of(phone_e164)                         # 예: "82"
    if cc not in ALLOWED_COUNTRY_CODES:
        raise SmsRejected("country")
    if risk_score >= 0.8:
        raise ChallengeRequired("captcha")                   # 발송 전 CAPTCHA·위험 점수
    if not r.set(f"sms:cool:{phone_e164}", 1, nx=True, ex=RESEND_COOLDOWN):
        raise SmsRejected("cooldown")
    prefix = phone_e164[:-4]                                 # 같은 번호 대역 연속 발송
    for axis, value in (("phone", phone_e164), ("ip", ip), ("device", device_id), ("prefix", prefix)):
        limit, window = LIMITS[axis]
        if hit(f"sms:{axis}:{value}", limit, window):
            if axis == "prefix":
                alert("sms_prefix_burst", prefix=prefix)     # 번호 대역 몰림은 운영자에게
            raise SmsRejected(axis)
  • 허용 국가 목록은 효과가 가장 큰 설정입니다. 국내 서비스라면 +82 외의 번호로 인증 문자를 보낼 이유가 거의 없습니다. 해외 사용자가 있다면 국가별로 한도를 따로 둡니다.
  • 같은 번호 대역 연속 발송을 보는 이유는, 펌핑이 한 번호보다 연속된 번호 묶음을 쓰는 경우가 많기 때문입니다. 끝자리 몇 개만 다른 번호로 발송이 몰리면 대역 단위로 막습니다.
  • 인증 완료율(발송 대비 인증 성공 비율)을 국가·대역별로 계속 봅니다. 정상 사용자는 받은 문자로 인증을 마치고, 펌핑은 인증을 하지 않습니다.

③ 설정 수준

  • 클라우드 예산 경보 + 자동 조치: 예산 경보는 메일만 보내면 주말 밤에는 소용이 없습니다. 예산을 넘으면 함수 동시성을 0으로 내리거나 API 키를 비활성화하는 자동 조치까지 연결합니다. 서버리스 함수에는 예약 동시성 상한을 둡니다.
  • 엣지 속도 제한과 대역폭 상한: CDN·WAF 앞단에서 경로별 속도 제한을 걸고, 가능하면 대역폭 상한이 있는 요금제를 고릅니다(Netlify 유형).
  • LLM 제공사 지출 한도: 대부분의 LLM 제공사 콘솔에 조직·프로젝트 단위 지출 한도가 있습니다. 코드의 사용자 예산과 별도로, 이 상한을 마지막 안전장치로 겁니다.
  • 자격 증명: 클라우드 접근에는 장기 액세스 키 대신 역할 기반 단기 자격 증명을 씁니다. 사용하지 않는 리전의 고가 서비스(LLM 모델 접근 포함)는 정책으로 차단합니다. LLMjacking은 이 두 가지가 없을 때 피해가 커집니다. 액세스 키처럼 코드와 로그 밖에 숨겨야 하는 값, 곧 시크릿이 유출된 순간부터 비용은 공격자가 정합니다.

④ 아키텍처 수준

  • 비싼 작업은 큐로: LLM 요약, 대량 이미지 처리 같은 작업은 요청 경로에서 바로 호출하지 않고 큐로 보냅니다. 소비자 수를 고정하면 공격이 와도 큐가 쌓일 뿐 비용은 일정한 속도로만 늘어납니다. 큐 길이 자체가 좋은 탐지 신호가 됩니다.
  • 비용을 가용성 지표처럼: 시간당 토큰, 시간당 SMS 건수, 시간당 예상 비용을 CPU·지연 시간과 같은 대시보드에 올리고 경보를 겁니다. 많은 팀이 서버 응답 시간에는 분 단위 경보를 걸면서, 비용은 다음 달 청구서로 처음 봅니다.
# 비용 지표 경보 규칙 예시 (Prometheus 규칙 형식, 값은 가짜)
groups:
- name: cost
  rules:
  - alert: LlmTokenSpike
    expr: sum(rate(llm_tokens_total[10m])) * 3600 > 2 * avg_over_time((sum(rate(llm_tokens_total[1h])) * 3600)[7d:1h])
    for: 10m
    labels: {severity: page}
  - alert: SmsLowCompletion
    expr: sum by (country) (rate(sms_sent_total[30m])) > 50
          and sum by (country) (rate(sms_verified_total[30m])) / sum by (country) (rate(sms_sent_total[30m])) < 0.2
    for: 15m
    labels: {severity: page}

탐지 신호

  • 시간당 비용 지표 급증
  • 평소 쓰지 않는 리전·모델 호출
  • 특정 국가·번호 대역으로 SMS 발송 급증, 그리고 낮은 인증 완료율
  • 단일 API 키·사용자의 토큰 사용 급증
  • 새 IAM 사용자·액세스 키 생성(LLMjacking의 앞 단계)

설계 원칙으로 정리

  1. 흐름 단위로 센다. 결제 한 번, 문자 한 통은 정상입니다. "이 계정이 10분 동안 몇 장의 카드를 썼나", "이 번호 대역으로 한 시간에 몇 통 나갔나"를 셉니다.
  2. 늘리기 어려운 축으로 센다. IP와 계정은 쉽게 늘어납니다. 카드 지문, 본인 확인 수단, 기기, 배송지처럼 공격자가 늘리기 어려운 값에 한도를 묶습니다.
  3. 요청 1건의 값을 안다. 쿠폰 1장의 할인액, LLM 호출 1건의 단가, 문자 1통의 요금을 알아야 상한을 정할 수 있습니다. 기능 기획서에 "봇이 1만 번 누르면?"과 "1건에 얼마?"를 함께 적습니다.
  4. 돈이 맞는지 계속 본다. 환불 대 승인, 발송 대 인증 완료, 예산 대 사용량을 준실시간으로 대조합니다. 불일치는 공격을 가장 먼저 알려 주는 신호입니다.
  5. 사람이 확인하기 전에 먼저 멈추는 장치를 둔다. 예산 초과 시 자동으로 동시성을 낮추고 키를 끄는 장치가 있어야, 새벽에 시작된 공격이 아침 청구서가 되지 않습니다.

점검 체크리스트

  • 한정 판매·쿠폰·결제 흐름에 계정·결제수단·기기·배송지 축의 서버 측 한도가 있고, 검사와 증가가 원자적이다
  • 결제 실패를 IP 대신 카드 지문·계정·BIN 대역 축으로 세고, 넘으면 3DS나 보류로 넘긴다
  • 0원·소액 인증 API는 로그인·결제 이력 같은 사전 조건 없이 호출할 수 없다
  • 장바구니 재고 점유에 짧은 TTL과 계정별 점유 수 상한이 있다
  • 환불은 원 거래가 승인·정산된 상태인지 원장에서 확인한 뒤에만 나간다
  • 인기 판매의 구매 API는 서버가 서명한 대기열 입장 토큰 없이는 호출되지 않는다
  • LLM 기능에 사용자별 일일 토큰 예산, max_tokens 상한, 입력 길이 상한이 있다
  • 인증 문자에 허용 국가 목록, 번호·IP·기기·대역별 한도, 재발송 쿨다운이 있다
  • 클라우드 예산 경보가 동시성 0·키 비활성화 같은 자동 조치와 연결돼 있고, LLM 제공사 지출 한도가 설정돼 있다
  • 클라우드 접근은 단기 자격 증명이고, 쓰지 않는 리전의 고가 서비스는 정책으로 막혀 있다
  • 시간당 토큰·SMS 건수·비용이 대시보드와 경보에 올라가 있다
  • 환불 대 승인, SMS 발송 대 인증 완료를 준실시간으로 대사한다

정리

  • 자동화 위협은 요청 하나하나가 정상이라 Top 10식 점검에 걸리지 않습니다. 막는 단위는 개별 요청이 아니라 흐름이고, 세는 축은 공격자가 늘리기 어려운 값입니다.
  • 비용 고갈은 서비스를 멈추지 않고 청구서를 불립니다. 오토스케일링이 가용성을 지켜 주는 만큼, 사용자별 예산과 지출 상한으로 비용을 따로 지켜야 합니다.
  • 두 공격 모두 "요청 1건이 얼마인가"를 아는 데서 방어가 시작됩니다. 할인액과 단가를 알면 한도를 정할 수 있고, 한도가 있으면 경보를 걸 수 있습니다.
  • 마지막 겹은 자동으로 멈추는 장치입니다. 환불·발송·토큰 지표가 어긋나면 사람이 확인하기 전에 먼저 멈춥니다.

더 깊게

보안을 더 알고 싶다면

오늘 본 사고의 원인은 대부분 서버와 코드의 기본기에서 막을 수 있는 것들이었습니다. 직접 손으로 막아 보고 싶다면 이런 강의가 있습니다.

실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩

  • AI로 자동화된 해킹 시대, 오픈소스 쇼핑몰 코드에서 실제 사고 22건을 재연하고 직접 취약점을 찾아 한 건씩 막습니다.
  • OWASP Top 10:2025 전체와 목록 밖 위험 20가지를 102개 수업으로 다루고, 취약점 37개를 모두 막은 최종 소스를 가져갑니다. Spring Boot와 Express 모두 다룹니다.
  • 이 글과 이어지는 수업: 4-3, 10-6, 10-7

서버 보안 기초부터 차근차근 시작하고 싶다면 이 강의도 있습니다.

백엔드 개발자를 위한 서버 보안 실무: 해킹 방어부터 정보유출 방지까지

  • 서버를 공격하는 흐름을 로그로 보고, 내 서버와 Spring·Express 코드에서 직접 막아 봅니다.
  • SSH 하드닝, 인젝션, 인증·인가, 시크릿과 개인정보 보호, 침해사고 첫 한 시간까지 8개 섹션 54개 수업입니다.
  • 이 글과 이어지는 수업: 4-4, 5-3, 6-2, 6-7

출처와 확인 날짜

수치와 사실은 2026년 11월 1일 기준 공개 자료입니다. Ticketmaster의 요청 수, Twitter의 SMS 펌핑 손실액은 회사·경영진 주장으로 독립 검증되지 않았고, Revolut 손실액은 익명 소스 기반 보도입니다. 공연법 추가 개정의 시행일은 법제처 원문으로 확인하지 못했습니다. 이 글은 법률 자문이 아닙니다.


이전 글 쿠폰 한 장으로 열 번 할인: 경쟁 조건과 웹훅 멱등성을 DB로 막기
다음 글 로그인 말고 복구로 들어온다: 재설정 오염, MFA 피로, SMS 의존, 타이밍 공격
시리즈 전체 목록 해킹 방식 VS 방어 방법 전체 목록

profile
고인물 개발자(OG)

0개의 댓글