정규식 하나, 재시도 하나가 서비스를 멈춘다: ReDoS, HTTP/2 공격, 재시도 폭주

아프로·2026년 11월 1일

정규식 하나, 재시도 하나가 서비스를 멈춘다 표지

2019년 7월 2일 13시 42분(UTC), Cloudflare가 WAF 관리 규칙을 업데이트했습니다. 몇 분 안에 전 세계 HTTP 처리 서버의 CPU가 소진됐고, 방문자는 502 오류를 받았습니다. 한때 트래픽의 약 80%를 처리하지 못했고, 장애는 14시 09분까지 27분 동안 이어졌습니다. 원인은 새 규칙에 들어간 정규식 하나였습니다. 외부 공격자는 없었습니다.

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

이 글의 범위. 서비스를 멈추게 하는 네 가지 기법의 원리와 서버 쪽 방어를 다룹니다. ReDoS는 위험한 정규식의 모양을 말로만 설명하고 패턴 문자열은 싣지 않습니다. HTTP/2 Rapid Reset·MadeYouReset·CONTINUATION 플러드는 원리와 패치·설정 확인 방법만 다루고, 트래픽을 만드는 방법이나 도구는 다루지 않습니다. 코드는 모두 방어 쪽이고 값은 가짜입니다. 사실관계는 Cloudflare, Google, AWS, CERT/CC, NVD, GitHub 권고문의 공개 자료(2026-11-01 기준)를 따릅니다.

한눈에 보기

기법한 줄 정의대표 사례막는 핵심가장 가까운 OWASP
ReDoS·압축 폭탄작은 입력 하나로 CPU나 메모리를 소진시킨다Cloudflare 27분 전 세계 장애(2019-07-02), path-to-regexp CVE-2024-45296입력 길이 상한, 선형 시간 정규식, 해제 크기 상한A06·A10 (API4)
HTTP/2 Rapid Reset·MadeYouReset스트림을 열고 바로 취소하거나 서버가 취소하게 만들어, 동시 스트림 상한을 무력화한다초당 3억 9,800만 요청(Google, 2023), CVE-2025-8671런타임 패치, 연결당 리셋 비율 제한A10·A03
HTTP/2 CONTINUATION 플러드헤더를 끝없이 이어 보내 서버가 버퍼링하다 소진되게 한다CERT/CC VU#421644(2024-04)런타임 패치, 헤더 총량·수신 시간 상한A03·A10
재시도 폭주·연쇄 장애장애 때 재시도가 부하를 몇 배로 키워 작은 문제를 전체 장애로 만든다AWS us-east-1(2021-12, 2025-10), Cloudflare 대시보드(2025-09)백오프+지터, 재시도 예산, 서킷 브레이커A10

네 기법은 모두 데이터를 훔치지 않습니다. 서비스를 멈추게 합니다. 그리고 그중 둘(ReDoS 사례와 재시도 폭주)은 외부 공격 없이 우리 코드와 설정만으로 일어났습니다.

왜 OWASP Top 10에 이름이 없나: 가용성이라는 빈칸

OWASP Top 10은 2004년판의 "Denial of Service" 이후 가용성을 독립 항목으로 두지 않습니다. 2025년판에서 새로 생긴 A10(예외 상황 처리 미흡)이 가장 가깝지만, A10은 개별 예외 처리 결함이 중심입니다. "정규식 엔진의 시간 복잡도", "프로토콜 구현의 상한", "재시도가 계층마다 곱해지는 구조"는 이름으로 다루지 않습니다.

비교할 만한 목록이 있습니다. OWASP API Security Top 10(2023)에는 API4 "제한 없는 자원 소비(Unrestricted Resource Consumption)"가 있습니다. API 쪽에서는 자원 상한을 독립된 위험으로 본다는 뜻입니다. 이 글의 방어도 대부분 "상한을 어디에 거느냐"로 정리됩니다.

가용성 방어 다섯 겹. 앞단 CDN·프록시, 런타임, 앱 코드, 서비스 호출, 관측·배포가 각자 상한을 건다


1. ReDoS와 압축 폭탄: 작은 입력, 큰 비용

원리: 정규식 엔진이 되짚는 길

Java, JavaScript, Python, PCRE의 정규식 엔진은 대부분 백트래킹 방식입니다. 정규식에 갈림길이 있으면 한쪽을 먼저 시도하고, 끝에서 맞지 않으면 돌아와 다른 쪽을 시도합니다. 보통은 갈림길이 몇 개 없어서 빠릅니다.

문제는 특정 모양에서 생깁니다. 반복되는 묶음 안에 또 반복이 있고, 같은 글자를 안쪽 반복과 바깥 반복이 모두 받을 수 있는 경우입니다. 이 모양에서 입력이 마지막 글자 하나 때문에 맞지 않으면, 엔진은 "앞의 글자들을 안쪽과 바깥쪽에 나눠 담는 모든 방법"을 하나씩 되짚습니다. 나누는 방법의 수는 입력 길이에 따라 다항식이나 지수로 늘어납니다. 입력이 20자에서 30자로 늘었을 뿐인데 처리 시간이 몇 밀리초에서 몇 초, 몇 분으로 뛸 수 있습니다.

개념 그림. 입력 길이가 늘 때 선형 엔진은 처리 시간이 완만하게 늘고, 백트래킹 엔진은 특정 모양에서 급격히 늘어난다. 입력 길이 상한이 그 앞을 자른다

반대로 선형 시간 엔진(RE2 계열)은 되짚지 않습니다. 입력을 한 번 읽으면서 가능한 상태를 모두 동시에 들고 갑니다. 그래서 처리 시간이 입력 길이에 비례합니다. 대신 일부 기능(역참조 같은)을 지원하지 않습니다.

런타임 구조가 피해를 키우기도 합니다. Node.js처럼 이벤트 루프가 하나인 런타임에서는 정규식 하나가 CPU를 쥐고 있는 동안 다른 모든 요청이 기다립니다. 요청 하나가 서버 전체를 멈추게 하는 셈입니다. 스레드 풀 기반 서버도 같은 요청이 여러 개 오면 스레드가 모두 정규식에 묶입니다.

원리: 압축 폭탄

압축은 반복되는 데이터를 짧게 줄입니다. 같은 바이트가 끝없이 반복되는 데이터는 수 KB로 압축되지만, 풀면 수 GB가 됩니다. 서버가 이런 데이터를 한 번에 메모리로 풀면 메모리가 바닥납니다. 들어오는 경로는 셋입니다.

  • 요청 본문: Content-Encoding: gzip으로 온 요청을 서버나 프레임워크가 자동으로 풉니다.
  • 업로드 파일: ZIP 안의 엔트리를 풀거나, 엔트리 수가 수십만 개인 압축 파일을 엽니다.
  • 외부 응답: 우리 서버가 부른 외부 API의 압축 응답을 HTTP 클라이언트가 풉니다. 우리가 요청하는 쪽이어도 폭탄을 받을 수 있습니다.

왜 OWASP Top 10에 이름이 없나

ReDoS와 압축 폭탄은 A06(안전하지 않은 설계: 자원 상한을 설계하지 않음)이나 A10(예외 상황 처리)에 느슨하게 들어갑니다. 입력 검증 코드 자체는 "올바른" 코드라서 인젝션처럼 보이지 않고, 결과도 데이터 유출 대신 지연과 장애로 나타납니다. API 쪽 목록의 API4가 가장 정확한 이름입니다.

실제 사례

Cloudflare 전 세계 장애 (2019-07-02, 13:42~14:09 UTC). WAF 관리 규칙 업데이트에 들어간 정규식 하나가 과도한 백트래킹을 일으켜 전 세계 HTTP 처리 CPU를 소진했습니다. 방문자는 502를 받았고 한때 트래픽 약 80%를 잃었습니다. Cloudflare가 밝힌 대책은 네 가지입니다. CPU 보호 장치 복구, 규칙 3,868개 수동 검토, 정규식 엔진을 RE2나 Rust regex 같은 선형 시간 엔진으로 교체, 규칙도 단계적으로 배포.

path-to-regexp CVE-2024-45296 (2024-09-09). Express 등이 쓰는 라우트 패턴 라이브러리가 특정 라우트 정의에서 백트래킹 정규식을 만들어 냈습니다. 개발자가 정규식을 직접 쓰지 않았는데도, 라우트 문자열이 내부에서 위험한 모양의 정규식으로 바뀐 경우입니다. 권고문 실험에서 평균 지연이 약 1ms에서 약 600ms로 늘었습니다.

urllib3 CVE-2025-66471·CVE-2025-66418 (2025-12-05, 2.6.0에서 수정). 파이썬 HTTP 클라이언트 urllib3의 스트리밍 API가 고압축 데이터를 한 번에 풀었고, 압축 단계를 몇 겹으로 쌓을 수 있는지에도 제한이 없었습니다. 이후 Brotli 경로의 우회(CVE-2026-9375)가 2.7.0에서 수정됐습니다. 서버가 외부 응답을 받는 쪽에서도 압축 폭탄을 맞을 수 있다는 사례입니다.

서버에서 막기: 코드 수준

① 정규식 전에 길이 상한부터. 대부분의 ReDoS는 입력 길이를 제한하면 실용적 위험이 크게 줄어듭니다. 백트래킹 시간은 길이에 따라 폭증하므로, 길이를 짧게 자르면 가장 나쁜 경우의 시간도 짧아집니다.

② 사용자 입력에는 선형 시간 엔진. 언어별 선택지는 이렇습니다.

언어선형 시간 선택지
JavaRE2/J(com.google.re2j)
Go표준 regexp(RE2 계열, 기본값이 선형)
Rustregex 크레이트
Node.jsre2 패키지
.NETRegexOptions.NonBacktracking 또는 matchTimeout
// 사용자 입력 검증: 길이 상한 + 선형 시간 엔진 (방어 예시, 값은 가짜)
import com.google.re2j.Pattern;

public final class OrderNoValidator {
    private static final int MAX_LEN = 32;
    // 단순한 고정 형식만 허용. 반복 안의 반복 같은 모양은 쓰지 않는다
    private static final Pattern ORDER_NO = Pattern.compile("[A-Z]{2}-[0-9]{6,10}");

    public static boolean isValid(String s) {
        if (s == null || s.length() > MAX_LEN) return false;   // 정규식보다 길이 검사가 먼저
        return ORDER_NO.matcher(s).matches();                  // RE2/J는 되짚지 않는다
    }
}
// Node.js: 사용자 입력에는 re2, 이벤트 루프 지연은 지표로 (방어 예시)
const RE2 = require("re2");
const { monitorEventLoopDelay } = require("node:perf_hooks");

const MAX_LEN = 64;
const slug = new RE2("^[a-z0-9-]{1,64}$");

function isValidSlug(s) {
  return typeof s === "string" && s.length <= MAX_LEN && slug.test(s);
}

const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => {
  const p99ms = h.percentile(99) / 1e6;
  metrics.gauge("event_loop_delay_p99_ms", p99ms);   // 정규식 하나가 루프를 잡으면 여기서 보인다
  h.reset();
}, 10_000);

③ 라이브러리가 만드는 정규식도 정규식이다. path-to-regexp 사례처럼 라우터, 유효성 검사 라이브러리, 템플릿 엔진이 내부에서 정규식을 만듭니다. 이런 의존성의 ReDoS 권고는 SCA(의존성 취약점 검사)로 잡고 빨리 올립니다.

④ 압축 해제는 스트리밍으로, 누적 크기를 세면서. 한 번에 풀지 않고 조금씩 풀면서 "지금까지 푼 크기"가 상한을 넘으면 멈춥니다.

// 요청 본문 gzip 해제 상한 (방어 예시, 값은 가짜)
try (InputStream in = new GZIPInputStream(body.getInputStream())) {
    byte[] buf = new byte[8192];
    long total = 0, LIMIT = 10L * 1024 * 1024;     // 해제 후 10MB
    int n;
    while ((n = in.read(buf)) != -1) {
        total += n;
        if (total > LIMIT) throw new PayloadTooLargeException();
        sink.write(buf, 0, n);
    }
}
# 업로드 ZIP: 엔트리 수, 해제 총량, 압축률 상한 (방어 예시, 값은 가짜)
import zipfile

MAX_ENTRIES, MAX_TOTAL, MAX_RATIO = 1_000, 200 * 1024 * 1024, 100

def safe_extract(path, sink):
    with zipfile.ZipFile(path) as z:
        infos = z.infolist()
        if len(infos) > MAX_ENTRIES:
            raise ValueError("too_many_entries")
        total = 0
        for info in infos:
            written = 0
            with z.open(info) as src:                 # 헤더의 크기 값은 믿지 않고 실제로 센다
                while chunk := src.read(64 * 1024):
                    written += len(chunk); total += len(chunk)
                    if total > MAX_TOTAL:
                        raise ValueError("total_too_large")
                    if info.compress_size and written > info.compress_size * MAX_RATIO:
                        raise ValueError("ratio_too_high")
                    sink.write(info.filename, chunk)
  • 외부 API 응답도 같은 원칙입니다. HTTP 클라이언트 버전을 패치 이상으로 올리고(urllib3는 2.7.0 이상), 응답을 스트리밍으로 읽으면서 누적 크기 상한을 둡니다.

서버에서 막기: 설정 수준

요청이 앱에 닿기 전에 크기와 시간에 상한을 겁니다.

# 앞단 프록시 (방어 예시, 값은 서비스에 맞게)
client_max_body_size 10m;
client_body_timeout 10s;
# Spring Boot (방어 예시, 값은 가짜)
spring:
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 12MB
server:
  tomcat:
    max-http-form-post-size: 2MB
  • 요청 처리 타임아웃을 둡니다. 정규식 하나가 오래 걸려도 요청이 끝없이 붙잡지 않게 합니다.
  • 컨테이너에 CPU·메모리 limit을 겁니다. 한 인스턴스가 소진돼도 노드 전체로 번지지 않습니다.

서버에서 막기: 아키텍처 수준

  • 규칙과 정규식도 코드처럼 배포합니다. Cloudflare의 교훈 그대로, WAF 규칙·검증 규칙 변경을 카나리·단계 배포로 내보냅니다. 한 번에 전 세계에 내보내지 않습니다.
  • CI에 정규식 정적 분석을 붙입니다. Semgrep 규칙이나 safe-regex 계열 검사로 위험한 모양을 머지 전에 잡습니다. 길이 상한까지 무작위 입력을 넣고 처리 시간이 예산(예: 50ms) 안인지 확인하는 테스트도 함께 둡니다.

탐지 신호

  • 특정 엔드포인트의 p99 지연만 튀고 요청 수는 평범함
  • 스레드 덤프에 정규식 매칭 프레임이 쌓임
  • Node.js 이벤트 루프 지연 지표 급증
  • 압축된 요청의 본문 크기에 비해 메모리 사용이 급증

2. HTTP/2 Rapid Reset과 MadeYouReset: 상한이 세지 않는 일

원리: 동시 스트림 상한의 틈

HTTP/2는 연결 하나에 여러 요청을 동시에 실어 보냅니다. 요청 하나가 스트림 하나입니다. 서버는 연결 하나가 지나치게 많은 일을 시키지 못하게 "동시에 열린 스트림 수"에 상한(SETTINGS_MAX_CONCURRENT_STREAMS, 보통 100)을 둡니다. 이 상한이 곧 "연결 하나가 서버에 시킬 수 있는 일의 상한"이라는 가정입니다.

HTTP/2에는 스트림을 중간에 취소하는 기능(RST_STREAM)도 있습니다. 사용자가 페이지를 떠나면 브라우저가 진행 중인 요청을 취소하는 정상 기능입니다.

Rapid Reset은 이 두 기능 사이의 틈을 씁니다. 클라이언트가 요청을 보내고 곧바로 취소합니다. 취소된 스트림은 "열린 스트림 수"에서 빠지므로, 클라이언트는 상한에 걸리지 않고 다음 요청을 계속 보낼 수 있습니다. 그런데 서버는 요청을 받는 순간 이미 파싱, 라우팅, 백엔드 호출을 시작했습니다. 상한은 "열린 스트림"을 세는데, 서버의 실제 비용은 "시작한 요청"에서 나옵니다. 상한이 세는 것과 비용이 생기는 곳이 어긋나는 것입니다.

MadeYouReset은 2년 뒤 나온 변형입니다. Rapid Reset 이후 많은 서버가 "클라이언트가 보낸 RST_STREAM 수"를 세서 막았습니다. MadeYouReset은 클라이언트가 직접 취소하지 않고, 서버가 스스로 스트림을 리셋하게 만드는 프로토콜 오류를 유도합니다. 서버의 프로토콜 계층은 스트림을 닫힌 것으로 세지만, 그 뒤의 백엔드 처리는 계속됩니다. 클라이언트 리셋을 세는 대책을 우회해, 연결 하나로 사실상 제한 없는 동시 요청을 만듭니다.

HTTP/2 리셋 공격. 정상은 동시 스트림 상한이 서버 일의 상한이지만, Rapid Reset은 열고 바로 취소하고 MadeYouReset은 서버가 리셋하게 유도한다. 2023년 공격 최대치는 Google 초당 3억 9,800만 요청

왜 OWASP Top 10에 이름이 없나

가용성 공격이라 Top 10 어디에도 맞지 않습니다. 억지로 붙이면 A10(비정상 프레임 처리)이나 A03(소프트웨어 공급망 실패: 취약한 런타임 컴포넌트)입니다. 결함이 앱 코드가 아니라 프로토콜 구현에 있어서, 앱 코드 리뷰로는 찾을 수 없습니다.

실제 사례

Rapid Reset 제로데이 공격 (2023-08-25부터 관측, 2023-10-10 공동 공개, CVE-2023-44487). 주요 클라우드 회사들이 같은 시기에 기록적인 공격을 받았습니다. Google은 최대 초당 3억 9,800만 요청을 받았는데, 이전 기록(4,600만)의 약 7.5배입니다. Cloudflare는 약 2만 대 규모 봇넷에서 초당 2억 100만 요청을, AWS는 초당 1억 5,500만 요청을 받았습니다. 세 회사는 공개 날짜를 맞춰 함께 발표했습니다.

MadeYouReset (2025-08-13 공개, CVE-2025-8671). 텔아비브대학교와 Imperva 연구진이 공개했고, CERT/CC(VU#767506)가 100개 넘는 벤더와 조율했습니다. 영향이 확인된 구현에는 Apache Tomcat(9.0.108, 10.1.44, 11.0.10에서 수정), Varnish, Fastly(2025-06-02 배포 완료), Netty, gRPC, SUSE 등이 있습니다. 공개 시점 기준으로 실제 대규모 악용 보고는 확인하지 못했습니다.

서버에서 막기: 런타임과 코드 수준

이 공격은 앱 코드로는 막지 못합니다. 프로토콜 계층에서 일어나기 때문입니다. 런타임 버전 관리가 곧 방어입니다.

  • Tomcat, Netty, Jetty, Go net/http, Node.js, nginx, Envoy를 패치 버전 이상으로 고정합니다.
  • SCA로 이 런타임들의 버전을 계속 감시합니다. Spring Boot의 내장 Tomcat, Go 바이너리의 Go 버전처럼 "앱 안에 들어 있는 런타임"이 빠지기 쉽습니다.

아래는 트래픽을 만들지 않고 지금 무엇이 돌고 있는지 확인하는 점검입니다.

# 런타임 버전 점검 (방어 점검, 요청을 보내지 않는다)
nginx -v                                        # 앞단 프록시 버전
go version -m ./bin/api-server | head -1        # Go 바이너리를 빌드한 Go 버전
node --version
./mvnw -q dependency:tree -Dincludes=org.apache.tomcat.embed   # 내장 Tomcat 버전
trivy fs --scanners vuln .                      # 의존성에서 CVE-2023-44487·CVE-2025-8671 등 확인
  • Tomcat을 쓴다면 MadeYouReset 수정 버전(9.0.108, 10.1.44, 11.0.10) 이상인지 확인합니다.
  • 결과를 사람이 매번 보지 않게, CI에서 SCA를 돌리고 고위험 CVE가 있으면 빌드를 멈춥니다.

서버에서 막기: 설정 수준

최신 nginx, Envoy, HAProxy는 연결당 리셋 비율을 세서 넘으면 GOAWAY로 연결을 닫는 로직을 패치에 포함했습니다. 그 위에 연결 하나가 할 수 있는 일의 총량을 보수적으로 줄입니다.

# 예시(값은 서비스에 맞게 조정)
http2_max_concurrent_streams 64;   # 기본값보다 보수적으로
keepalive_requests 1000;           # 연결 하나의 총 요청 수 상한
limit_conn perip 50;               # IP당 동시 연결 수 (perip 영역은 limit_conn_zone으로 정의)

서버에서 막기: 아키텍처 수준

  • 앱 서버를 인터넷에 직접 노출하지 않습니다. Tomcat 내장 HTTP/2 같은 앱 서버는 DDoS 완화 계층(CDN·WAF) 뒤에 두고, 앞단과만 통신하게 합니다. 프로토콜 공격을 앞단에서 먼저 받아 내게 하는 구조입니다.
  • Spring Boot 내장 Tomcat의 HTTP/2를 인터넷에 직접 열어 둔 서비스라면, 이 계열 공격에 대한 방어선은 "Tomcat 버전" 하나뿐입니다.

탐지 신호

  • 요청 수에 비해 완료된 응답 비율이 비정상적으로 낮음
  • 연결당 RST_STREAM 수 급증
  • 499(클라이언트가 먼저 끊음)·취소 로그 급증
  • CPU는 높은데 실제로 처리한 2xx 응답 수는 평소 수준

3. HTTP/2 CONTINUATION 플러드: 끝나지 않는 헤더

원리: END_HEADERS가 오지 않으면

HTTP/2에서 요청 헤더는 HEADERS 프레임으로 시작합니다. 헤더가 한 프레임에 다 들어가지 않으면 CONTINUATION 프레임으로 이어서 보냅니다. 헤더가 끝났다는 표시(END_HEADERS 플래그)가 올 때까지 서버는 헤더 블록을 모읍니다.

많은 구현이 "한 요청의 헤더 총량"이나 "CONTINUATION 프레임 수"를 제한하지 않았습니다. 끝 표시가 오지 않는 CONTINUATION이 계속 이어지면 서버는 헤더를 계속 버퍼링하거나 디코딩하다가 메모리나 CPU를 소진합니다. 연결 하나로도 서버가 쓰러질 수 있습니다.

운영자에게 더 곤란한 점은 로그에 남지 않는다는 것입니다. 요청이 완료되지 않았으므로 일반 액세스 로그에는 아무것도 기록되지 않습니다. "액세스 로그는 평범한데 서버가 죽었다"는 상황이 됩니다.

CONTINUATION 플러드. HEADERS 뒤로 CONTINUATION이 끝 표시 없이 이어지고, 서버는 버퍼링하다 소진되며 액세스 로그에는 남지 않는다. 막는 법은 런타임 패치, 헤더 총량과 수신 시간 상한

왜 OWASP Top 10에 이름이 없나

Rapid Reset과 같은 이유입니다. 런타임 컴포넌트의 취약점이라 A03(소프트웨어 공급망 실패), 비정상 입력 처리라 A10에 느슨하게 들어갑니다.

실제 사례

CERT/CC VU#421644 (2024-04-03 공개). 여러 HTTP/2 구현에서 같은 문제가 한꺼번에 공개됐고, CVE 10개가 붙었습니다. Go net/http·x/net/http2(CVE-2023-45288), Apache httpd(CVE-2024-27316), nghttp2(CVE-2024-28182), Node.js(CVE-2024-27983), AMPHP(CVE-2024-2653), Envoy(CVE-2024-30255) 등입니다.

실제 공격 규모는 공개된 자료가 없습니다[추정 불가]. 그래서 이 사례는 "대규모 공격"보다 "공개 직후의 패치 경쟁"으로 보는 편이 정확합니다. 공개 직후 여러 구현이 수정판을 내놓았고, 우리 서비스가 그 수정판을 얼마나 빨리 받았는지가 방어였습니다.

서버에서 막기

① 런타임 패치. Go, Node.js, Apache httpd, nghttp2, Envoy를 수정판 이상으로 올립니다. Go로 만든 API 서버는 라이브러리 교체로 끝나지 않고, Go 버전 자체를 올리고 다시 빌드해야 합니다. 표준 라이브러리에 HTTP/2 구현이 들어 있기 때문입니다.

# Go 서버의 표준 라이브러리 취약점 점검 (방어 점검)
govulncheck ./...        # 빌드에 쓰인 Go 버전의 net/http 취약점까지 보고한다

② 헤더 총량과 수신 시간 상한. 패치와 함께, 헤더에 쓸 수 있는 메모리와 시간을 설정으로 묶습니다.

// Go HTTP 서버 (방어 예시)
srv := &http.Server{
    Addr:              ":8443",
    ReadHeaderTimeout: 5 * time.Second,   // 헤더를 다 받기까지의 시간 상한
    MaxHeaderBytes:    64 << 10,          // 헤더 총량 64KB
}
# Spring Boot 내장 Tomcat (방어 예시, 값은 가짜)
server:
  max-http-request-header-size: 16KB    # Tomcat maxHttpHeaderSize에 해당
# 앞단 프록시 (방어 예시)
client_header_timeout 10s;
large_client_header_buffers 4 16k;

③ 패치가 불가능하면 HTTP/2를 잠시 끕니다. CERT/CC도 임시 대책으로 언급했습니다. HTTP/1.1로 내려가면 성능은 조금 손해지만 이 공격면은 사라집니다.

탐지 신호

  • 요청 완료 로그는 평범한데 메모리·CPU가 급증
  • 완료되지 않은 연결이 오래 유지됨

두 신호 모두 액세스 로그에는 보이지 않고 연결 수준 지표에서 보입니다. 열린 연결 수, 연결당 수신 바이트, 헤더 수신 중인 연결 수를 따로 수집합니다.


4. 재시도 폭주와 연쇄 장애: 우리 코드가 우리를 멈춘다

원리: 재시도는 곱해진다

하위 서비스가 느려지면 상위 서비스는 타임아웃 뒤 재시도합니다. 한 번 실패했으니 한 번 더 해 보는 것은 합리적으로 보입니다. 문제는 계층마다 그렇게 한다는 것입니다.

재시도 폭주. 게이트웨이, 주문 서비스, 재고 서비스가 각각 3번씩 시도하면 맨 아래 서비스에는 27배의 요청이 몰린다. 재시도는 한 계층에서만, 백오프와 지터, 재시도 예산, 서킷 브레이커로 막는다

게이트웨이가 3번, 주문 서비스가 3번, 재고 서비스 호출이 3번 시도하면, 사용자 요청 하나가 맨 아래에서 27번의 호출이 됩니다. 하필 맨 아래 서비스가 느려서 생긴 일이라, 가장 약한 곳에 가장 많은 부하가 몰립니다.

여기에 두 가지 현상이 겹칩니다.

  • 몰려드는 무리(thundering herd). 모든 클라이언트가 같은 간격으로 재시도하면 재시도가 같은 순간에 몰립니다. 서버는 조용하다가 일정 간격으로 파도를 맞습니다.
  • 혼잡 붕괴(congestive collapse). 하위 서비스가 잠시 회복해도, 그동안 쌓인 재시도 작업과 재인증 요청이 한꺼번에 들어와 다시 넘어집니다. 복구가 복구를 막습니다.

외부 공격자가 일부러 느린 요청을 보내 이 증폭을 유도할 수도 있습니다. 하지만 아래 사례들은 모두 공격 없이 일어났습니다.

왜 OWASP Top 10에 이름이 없나

A10:2025(예외 상황 처리 미흡)가 가장 가깝습니다. 하지만 A10은 개별 예외 처리 결함 중심이고, 재시도 증폭이나 서킷 브레이커 같은 시스템 차원의 회복 탄력성 설계는 이름으로 다루지 않습니다. 가용성은 보안의 세 축(기밀성·무결성·가용성) 중 하나지만, 보안 점검표에서는 자주 빠집니다.

실제 사례

AWS us-east-1 (2021-12-07). 자동 스케일링 활동이 내부 네트워크 클라이언트의 예기치 않은 동작을 일으켜 네트워크 장비가 혼잡해졌습니다. 지연이 더 많은 연결 시도와 재시도를 낳아 혼잡이 이어졌습니다. 원래 혼잡할 때 물러서야(백오프) 할 클라이언트가 잠재 버그 때문에 충분히 물러서지 못했습니다.

AWS us-east-1 (2025-10-19~20). DynamoDB DNS의 경쟁 조건으로 시작된 장애 뒤, EC2의 DropletWorkflow Manager에 대량의 리스 재수립 작업과 재시도 작업이 쌓여 혼잡 붕괴 상태에 빠졌습니다. 엔지니어가 작업을 스로틀링하고 호스트를 재시작한 뒤에야 회복했고, NLB 헬스체크가 오락가락하면서(플래핑) 영향이 길어졌습니다.

Cloudflare 대시보드·API (2025-09-12). 대시보드의 React useEffect 의존성 배열 실수로 Tenant Service API를 반복 호출했고, 같은 시기 Tenant Service 배포가 겹쳐 과부하가 났습니다. Tenant Service가 API 인가 경로에 있어서 API 전반이 영향을 받았습니다. 서비스를 재시작하자 모든 대시보드가 동시에 재인증하면서 다시 불안정해졌습니다. Cloudflare는 자동 롤백이 Tenant Service에 적용돼 있었다면 두 번째 장애를 줄였을 것이라고 밝혔습니다.

사례증폭을 만든 것회복을 늦춘 것
AWS 2021-12백오프가 충분하지 않은 클라이언트의 재시도혼잡이 재시도를 낳는 순환
AWS 2025-10쌓인 리스 재수립·재시도 작업혼잡 붕괴, 헬스체크 플래핑
Cloudflare 2025-09프런트엔드의 반복 호출재시작 직후 동시 재인증

서버에서 막기: 코드 수준

① 지수 백오프 + 지터 + 최대 횟수 + 전체 데드라인. 재시도 간격을 지수로 늘리고, 무작위(지터)로 흩어 같은 순간에 몰리지 않게 합니다. 재시도를 포함한 전체 시간에도 상한을 둡니다.

// Resilience4j 예시(가짜 값)
RetryConfig cfg = RetryConfig.custom()
    .maxAttempts(3)
    .intervalFunction(IntervalFunction.ofExponentialRandomBackoff(200, 2.0, 0.5))
    .retryExceptions(TimeoutException.class)   // 4xx는 재시도 금지
    .build();
  • 4xx(요청 자체가 틀림)는 재시도해도 결과가 같습니다. 재시도는 타임아웃과 일부 5xx에만 둡니다.

② 재시도는 한 계층에서만. 보통 가장 바깥이나 가장 안쪽 중 한 곳만 재시도하고, 나머지 계층은 실패를 그대로 위로 올립니다. 그래야 곱셈이 생기지 않습니다.

③ 멱등하지 않은 요청은 멱등성 키 없이 재시도하지 않습니다. 결제 요청을 재시도했는데 첫 요청도 사실 성공했다면 이중 결제가 됩니다. 멱등성 키와 중복 처리는 05편 쿠폰 한 장으로 열 번 할인: 경쟁 조건과 웹훅 멱등성에서 다룹니다.

④ 재시도 예산. 전체 요청 대비 재시도 비율에 상한(예: 10%)을 둡니다. 평소에는 재시도가 거의 없어 예산이 남고, 장애 때는 예산이 바닥나 재시도가 자동으로 멈춥니다. 장애가 클수록 재시도가 줄어드는 구조입니다.

// 재시도 예산: 최근 10초 요청의 10%까지만 재시도 (방어 예시, 값은 가짜)
final class RetryBudget {
    private static final double RATIO = 0.10;
    private static final int MIN_PER_WINDOW = 10;          // 트래픽이 적을 때도 최소한은 허용
    private final SlidingCounter requests = new SlidingCounter(Duration.ofSeconds(10));
    private final SlidingCounter retries  = new SlidingCounter(Duration.ofSeconds(10));

    void onRequest() { requests.increment(); }

    boolean tryAcquireRetry() {
        double allowed = Math.max(MIN_PER_WINDOW, requests.sum() * RATIO);
        if (retries.sum() + 1 > allowed) {
            metrics.counter("retry_budget_exhausted").increment();
            return false;                                  // 재시도하지 않고 실패를 올린다
        }
        retries.increment();
        return true;
    }
}

⑤ 프런트엔드의 폴링·재요청에도 백오프와 상한. Cloudflare 사례는 브라우저 코드에서 시작했습니다.

// 브라우저 재요청: 백오프 + 지터 + 최대 횟수 (방어 예시)
async function fetchWithBackoff(url, { max = 4, base = 500, cap = 8000 } = {}) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url).catch(() => null);
    if (res && (res.ok || res.status < 500)) return res;          // 4xx는 재시도하지 않는다
    if (attempt + 1 >= max) throw new Error("gave_up");
    const retryAfter = Number(res?.headers.get("Retry-After")) * 1000 || 0;
    const backoff = Math.random() * Math.min(cap, base * 2 ** attempt); // full jitter
    await new Promise((r) => setTimeout(r, Math.max(retryAfter, backoff)));
  }
}
  • useEffect 안에서 API를 부를 때는 의존성 배열이 매 렌더마다 바뀌는 값(새로 만든 객체·배열)을 담지 않는지 리뷰합니다.

서버에서 막기: 설정 수준

계층별 타임아웃은 바깥이 더 길게. 하위 서비스의 p99 지연을 기준으로, 안쪽으로 갈수록 짧게 줄여 갑니다. 바깥 타임아웃이 안쪽보다 짧으면 안쪽은 아직 일하는데 바깥은 포기하고 재시도해서, 같은 일이 두 번 돌아갑니다.

계층타임아웃(예시, 가짜 값)재시도
게이트웨이3초하지 않음
주문 서비스 → 재고 서비스800ms최대 2회, 백오프+지터, 예산 10%
재고 서비스 → DB300ms하지 않음

부하 차단(load shedding). 큐 길이나 동시 처리 수가 상한을 넘으면 기다리게 하지 말고 바로 503과 Retry-After를 돌려줍니다. 기다리는 요청은 메모리와 연결을 붙잡고, 타임아웃이 나면 다시 재시도를 부릅니다.

// 동시 처리 수 상한을 넘으면 즉시 503 (Spring 필터, 방어 예시, 값은 가짜)
@Component
public class LoadSheddingFilter extends OncePerRequestFilter {
    private final Semaphore slots = new Semaphore(200);

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        if (!slots.tryAcquire()) {
            res.setStatus(503);
            res.setHeader("Retry-After", String.valueOf(1 + ThreadLocalRandom.current().nextInt(5)));
            return;                                   // 기다리게 하지 않는다
        }
        try { chain.doFilter(req, res); } finally { slots.release(); }
    }
}
  • Retry-After 값도 흩어 줍니다. 모든 클라이언트에게 같은 값을 주면 같은 순간에 돌아옵니다.

서버에서 막기: 아키텍처 수준

  • 서킷 브레이커. 실패하는 의존성 호출이 일정 비율을 넘으면 회로를 열어 한동안 호출 자체를 하지 않고 바로 실패를 돌려줍니다. 하위 서비스가 회복할 시간을 벌어 줍니다.
  • 벌크헤드. 의존성마다 스레드 풀·연결 풀을 나눕니다. 재고 서비스가 느려도 결제 서비스 호출용 자원은 남아 있게 합니다.
  • 점진적 트래픽 복원. 복구 직후에는 우선순위와 속도 제한을 두고 트래픽을 조금씩 엽니다. 쌓인 작업도 스로틀링하며 처리합니다. AWS 2025-10 사례에서 회복을 이끈 조치가 작업 스로틀링이었습니다.
  • 재인증 폭주 방지. 토큰 만료 시각을 무작위로 흩어, 재시작 직후 모든 클라이언트가 같은 순간에 재인증하지 않게 합니다.
  • 자동 롤백. 카나리 배포와 자동 롤백(Argo Rollouts 같은 도구)을 장애 경로에 있는 서비스부터 적용합니다.
# Resilience4j 서킷 브레이커·벌크헤드 (Spring Boot 설정, 방어 예시, 값은 가짜)
resilience4j:
  circuitbreaker:
    instances:
      inventory:
        sliding-window-size: 50
        failure-rate-threshold: 50          # 실패 50% 넘으면 열림
        slow-call-duration-threshold: 800ms
        slow-call-rate-threshold: 50        # 느린 호출도 실패로 센다
        wait-duration-in-open-state: 10s
        permitted-number-of-calls-in-half-open-state: 5
  bulkhead:
    instances:
      inventory:
        max-concurrent-calls: 40            # 재고 호출은 동시에 40개까지만

탐지 신호

  • 재시도 비율, 요청 증폭 비율(하위 서비스 유입 요청 ÷ 사용자 요청)의 급증
  • 같은 클라이언트의 같은 요청 반복
  • 의존성 지연이 늘어나는데 상위 요청량도 함께 늘어남(정상이라면 줄어야 함)
  • 큐 길이 증가가 멈추지 않음
  • 복구 직후 재인증 요청 급증

설계 원칙으로 정리

  1. 모든 자원에 상한을 건다. 입력 길이, 해제 크기, 헤더 크기와 수신 시간, 연결당 스트림과 요청 수, 동시 처리 수, 재시도 횟수와 비율. 상한이 없는 곳이 공격면입니다.
  2. 상한은 비용이 생기는 곳을 센다. Rapid Reset은 "열린 스트림"을 셌지만 비용은 "시작한 요청"에서 나왔습니다. 무엇을 세는지와 무엇이 비싼지를 맞춥니다.
  3. 런타임 버전도 방어 코드다. 프로토콜 계층 공격은 앱 코드로 막지 못합니다. 내장 Tomcat, Go 버전, nginx 버전을 SCA로 감시하고 빨리 올립니다.
  4. 실패에 대한 반응이 실패를 키우지 않게 한다. 재시도는 한 계층에서, 백오프와 지터, 예산 안에서만. 넘치면 빨리 거절하고, 회로를 엽니다.
  5. 규칙과 설정도 단계적으로 배포한다. 정규식 하나, 설정 하나가 전 세계 장애가 될 수 있습니다. 카나리와 자동 롤백을 코드와 똑같이 적용합니다.

점검 체크리스트

  • 사용자 입력에 쓰는 정규식은 길이 검사가 먼저이고, 선형 시간 엔진(RE2 계열 등)을 쓴다
  • CI에 정규식 정적 분석과 처리 시간 예산 테스트가 있다
  • 요청 본문·업로드·외부 응답의 압축 해제에 누적 크기·압축률·엔트리 수 상한이 있다
  • 요청 본문 크기, 헤더 크기, 헤더 수신 시간 상한이 앞단과 앱 서버 양쪽에 설정돼 있다
  • Tomcat·Netty·Go·Node.js·nginx·Envoy 버전이 Rapid Reset·MadeYouReset·CONTINUATION 수정판 이상이고, SCA가 이를 계속 감시한다
  • 앱 서버의 HTTP/2는 인터넷에 직접 노출되지 않고 완화 계층 뒤에 있다
  • 완료 응답 비율, 연결당 RST 수, 열린 연결 수, 이벤트 루프 지연을 지표로 수집한다
  • 재시도는 한 계층에서만 하고, 지수 백오프+지터, 최대 횟수, 전체 데드라인, 재시도 예산이 있다
  • 계층별 타임아웃이 바깥이 더 길게 설정돼 있다
  • 과부하 시 503과 흩어진 Retry-After로 즉시 거절한다
  • 주요 의존성에 서킷 브레이커와 벌크헤드가 있다
  • WAF 규칙·검증 규칙·설정 변경도 카나리·단계 배포와 자동 롤백을 거친다

정리

  • 네 기법 모두 데이터 대신 서비스를 멈추게 합니다. OWASP Top 10에는 가용성 항목이 없어서 점검표에서 빠지기 쉽고, API Security Top 10의 API4가 가장 가까운 이름입니다.
  • ReDoS와 압축 폭탄은 작은 입력이 큰 비용을 만드는 문제입니다. 길이 상한, 선형 시간 엔진, 스트리밍 해제와 누적 상한으로 막습니다. Cloudflare는 정규식 하나로 27분 동안 전 세계 트래픽을 처리하지 못했습니다.
  • HTTP/2 Rapid Reset·MadeYouReset·CONTINUATION 플러드는 프로토콜 구현의 상한이 비용을 제대로 세지 못한 문제입니다. 앱 코드로는 막지 못하고, 런타임 패치와 연결 수준 상한, 완화 계층이 방어입니다.
  • 재시도 폭주는 외부 공격 없이 우리 코드가 만듭니다. 재시도를 한 계층으로 모으고, 백오프와 지터, 재시도 예산, 서킷 브레이커, 부하 차단으로 실패가 실패를 키우지 않게 합니다.

더 깊게

보안을 더 알고 싶다면

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

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

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

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

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

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

출처와 확인 날짜

수치와 사실은 2026년 11월 1일 기준 공개 자료입니다. MadeYouReset의 Imperva 원문 주소는 2차 보도를 통해 확인한 것이라 [추정]으로 표시합니다. CONTINUATION 플러드의 실제 공격 규모는 공개 자료가 없습니다.


이전 글 연동 앱이 뚫리면 내 데이터는: OAuth 토큰 탈취와 토큰 바인딩
다음 글 남의 것이 보인다: 남의 번호 조회, 대량 할당, SSRF를 서버에서 막기
시리즈 전체 목록 해킹 방식 VS 방어 방법 전체 목록

profile
고인물 개발자(OG)

0개의 댓글