
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은 2004년판의 "Denial of Service" 이후 가용성을 독립 항목으로 두지 않습니다. 2025년판에서 새로 생긴 A10(예외 상황 처리 미흡)이 가장 가깝지만, A10은 개별 예외 처리 결함이 중심입니다. "정규식 엔진의 시간 복잡도", "프로토콜 구현의 상한", "재시도가 계층마다 곱해지는 구조"는 이름으로 다루지 않습니다.
비교할 만한 목록이 있습니다. OWASP API Security Top 10(2023)에는 API4 "제한 없는 자원 소비(Unrestricted Resource Consumption)"가 있습니다. API 쪽에서는 자원 상한을 독립된 위험으로 본다는 뜻입니다. 이 글의 방어도 대부분 "상한을 어디에 거느냐"로 정리됩니다.

Java, JavaScript, Python, PCRE의 정규식 엔진은 대부분 백트래킹 방식입니다. 정규식에 갈림길이 있으면 한쪽을 먼저 시도하고, 끝에서 맞지 않으면 돌아와 다른 쪽을 시도합니다. 보통은 갈림길이 몇 개 없어서 빠릅니다.
문제는 특정 모양에서 생깁니다. 반복되는 묶음 안에 또 반복이 있고, 같은 글자를 안쪽 반복과 바깥 반복이 모두 받을 수 있는 경우입니다. 이 모양에서 입력이 마지막 글자 하나 때문에 맞지 않으면, 엔진은 "앞의 글자들을 안쪽과 바깥쪽에 나눠 담는 모든 방법"을 하나씩 되짚습니다. 나누는 방법의 수는 입력 길이에 따라 다항식이나 지수로 늘어납니다. 입력이 20자에서 30자로 늘었을 뿐인데 처리 시간이 몇 밀리초에서 몇 초, 몇 분으로 뛸 수 있습니다.

반대로 선형 시간 엔진(RE2 계열)은 되짚지 않습니다. 입력을 한 번 읽으면서 가능한 상태를 모두 동시에 들고 갑니다. 그래서 처리 시간이 입력 길이에 비례합니다. 대신 일부 기능(역참조 같은)을 지원하지 않습니다.
런타임 구조가 피해를 키우기도 합니다. Node.js처럼 이벤트 루프가 하나인 런타임에서는 정규식 하나가 CPU를 쥐고 있는 동안 다른 모든 요청이 기다립니다. 요청 하나가 서버 전체를 멈추게 하는 셈입니다. 스레드 풀 기반 서버도 같은 요청이 여러 개 오면 스레드가 모두 정규식에 묶입니다.
압축은 반복되는 데이터를 짧게 줄입니다. 같은 바이트가 끝없이 반복되는 데이터는 수 KB로 압축되지만, 풀면 수 GB가 됩니다. 서버가 이런 데이터를 한 번에 메모리로 풀면 메모리가 바닥납니다. 들어오는 경로는 셋입니다.
Content-Encoding: gzip으로 온 요청을 서버나 프레임워크가 자동으로 풉니다.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는 입력 길이를 제한하면 실용적 위험이 크게 줄어듭니다. 백트래킹 시간은 길이에 따라 폭증하므로, 길이를 짧게 자르면 가장 나쁜 경우의 시간도 짧아집니다.
② 사용자 입력에는 선형 시간 엔진. 언어별 선택지는 이렇습니다.
| 언어 | 선형 시간 선택지 |
|---|---|
| Java | RE2/J(com.google.re2j) |
| Go | 표준 regexp(RE2 계열, 기본값이 선형) |
| Rust | regex 크레이트 |
| Node.js | re2 패키지 |
| .NET | RegexOptions.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)
요청이 앱에 닿기 전에 크기와 시간에 상한을 겁니다.
# 앞단 프록시 (방어 예시, 값은 서비스에 맞게)
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
safe-regex 계열 검사로 위험한 모양을 머지 전에 잡습니다. 길이 상한까지 무작위 입력을 넣고 처리 시간이 예산(예: 50ms) 안인지 확인하는 테스트도 함께 둡니다.HTTP/2는 연결 하나에 여러 요청을 동시에 실어 보냅니다. 요청 하나가 스트림 하나입니다. 서버는 연결 하나가 지나치게 많은 일을 시키지 못하게 "동시에 열린 스트림 수"에 상한(SETTINGS_MAX_CONCURRENT_STREAMS, 보통 100)을 둡니다. 이 상한이 곧 "연결 하나가 서버에 시킬 수 있는 일의 상한"이라는 가정입니다.
HTTP/2에는 스트림을 중간에 취소하는 기능(RST_STREAM)도 있습니다. 사용자가 페이지를 떠나면 브라우저가 진행 중인 요청을 취소하는 정상 기능입니다.
Rapid Reset은 이 두 기능 사이의 틈을 씁니다. 클라이언트가 요청을 보내고 곧바로 취소합니다. 취소된 스트림은 "열린 스트림 수"에서 빠지므로, 클라이언트는 상한에 걸리지 않고 다음 요청을 계속 보낼 수 있습니다. 그런데 서버는 요청을 받는 순간 이미 파싱, 라우팅, 백엔드 호출을 시작했습니다. 상한은 "열린 스트림"을 세는데, 서버의 실제 비용은 "시작한 요청"에서 나옵니다. 상한이 세는 것과 비용이 생기는 곳이 어긋나는 것입니다.
MadeYouReset은 2년 뒤 나온 변형입니다. Rapid Reset 이후 많은 서버가 "클라이언트가 보낸 RST_STREAM 수"를 세서 막았습니다. MadeYouReset은 클라이언트가 직접 취소하지 않고, 서버가 스스로 스트림을 리셋하게 만드는 프로토콜 오류를 유도합니다. 서버의 프로토콜 계층은 스트림을 닫힌 것으로 세지만, 그 뒤의 백엔드 처리는 계속됩니다. 클라이언트 리셋을 세는 대책을 우회해, 연결 하나로 사실상 제한 없는 동시 요청을 만듭니다.

가용성 공격이라 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 등이 있습니다. 공개 시점 기준으로 실제 대규모 악용 보고는 확인하지 못했습니다.
이 공격은 앱 코드로는 막지 못합니다. 프로토콜 계층에서 일어나기 때문입니다. 런타임 버전 관리가 곧 방어입니다.
net/http, Node.js, nginx, Envoy를 패치 버전 이상으로 고정합니다.아래는 트래픽을 만들지 않고 지금 무엇이 돌고 있는지 확인하는 점검입니다.
# 런타임 버전 점검 (방어 점검, 요청을 보내지 않는다)
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 등 확인
최신 nginx, Envoy, HAProxy는 연결당 리셋 비율을 세서 넘으면 GOAWAY로 연결을 닫는 로직을 패치에 포함했습니다. 그 위에 연결 하나가 할 수 있는 일의 총량을 보수적으로 줄입니다.
# 예시(값은 서비스에 맞게 조정)
http2_max_concurrent_streams 64; # 기본값보다 보수적으로
keepalive_requests 1000; # 연결 하나의 총 요청 수 상한
limit_conn perip 50; # IP당 동시 연결 수 (perip 영역은 limit_conn_zone으로 정의)
HTTP/2에서 요청 헤더는 HEADERS 프레임으로 시작합니다. 헤더가 한 프레임에 다 들어가지 않으면 CONTINUATION 프레임으로 이어서 보냅니다. 헤더가 끝났다는 표시(END_HEADERS 플래그)가 올 때까지 서버는 헤더 블록을 모읍니다.
많은 구현이 "한 요청의 헤더 총량"이나 "CONTINUATION 프레임 수"를 제한하지 않았습니다. 끝 표시가 오지 않는 CONTINUATION이 계속 이어지면 서버는 헤더를 계속 버퍼링하거나 디코딩하다가 메모리나 CPU를 소진합니다. 연결 하나로도 서버가 쓰러질 수 있습니다.
운영자에게 더 곤란한 점은 로그에 남지 않는다는 것입니다. 요청이 완료되지 않았으므로 일반 액세스 로그에는 아무것도 기록되지 않습니다. "액세스 로그는 평범한데 서버가 죽었다"는 상황이 됩니다.

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로 내려가면 성능은 조금 손해지만 이 공격면은 사라집니다.
두 신호 모두 액세스 로그에는 보이지 않고 연결 수준 지표에서 보입니다. 열린 연결 수, 연결당 수신 바이트, 헤더 수신 중인 연결 수를 따로 수집합니다.
하위 서비스가 느려지면 상위 서비스는 타임아웃 뒤 재시도합니다. 한 번 실패했으니 한 번 더 해 보는 것은 합리적으로 보입니다. 문제는 계층마다 그렇게 한다는 것입니다.

게이트웨이가 3번, 주문 서비스가 3번, 재고 서비스 호출이 3번 시도하면, 사용자 요청 하나가 맨 아래에서 27번의 호출이 됩니다. 하필 맨 아래 서비스가 느려서 생긴 일이라, 가장 약한 곳에 가장 많은 부하가 몰립니다.
여기에 두 가지 현상이 겹칩니다.
외부 공격자가 일부러 느린 요청을 보내 이 증폭을 유도할 수도 있습니다. 하지만 아래 사례들은 모두 공격 없이 일어났습니다.
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();
② 재시도는 한 계층에서만. 보통 가장 바깥이나 가장 안쪽 중 한 곳만 재시도하고, 나머지 계층은 실패를 그대로 위로 올립니다. 그래야 곱셈이 생기지 않습니다.
③ 멱등하지 않은 요청은 멱등성 키 없이 재시도하지 않습니다. 결제 요청을 재시도했는데 첫 요청도 사실 성공했다면 이중 결제가 됩니다. 멱등성 키와 중복 처리는 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% |
| 재고 서비스 → DB | 300ms | 하지 않음 |
부하 차단(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 값도 흩어 줍니다. 모든 클라이언트에게 같은 값을 주면 같은 순간에 돌아옵니다.# 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개까지만
Retry-After로 즉시 거절한다오늘 본 사고의 원인은 대부분 서버와 코드의 기본기에서 막을 수 있는 것들이었습니다. 직접 손으로 막아 보고 싶다면 이런 강의가 있습니다.
실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩
서버 보안 기초부터 차근차근 시작하고 싶다면 이 강의도 있습니다.
백엔드 개발자를 위한 서버 보안 실무: 해킹 방어부터 정보유출 방지까지
수치와 사실은 2026년 11월 1일 기준 공개 자료입니다. MadeYouReset의 Imperva 원문 주소는 2차 보도를 통해 확인한 것이라 [추정]으로 표시합니다. CONTINUATION 플러드의 실제 공격 규모는 공개 자료가 없습니다.
이전 글 연동 앱이 뚫리면 내 데이터는: OAuth 토큰 탈취와 토큰 바인딩
다음 글 남의 것이 보인다: 남의 번호 조회, 대량 할당, SSRF를 서버에서 막기
시리즈 전체 목록 해킹 방식 VS 방어 방법 전체 목록