LLM 을 직접 서빙하거나 여러 고객이 한 GPU 를 나눠 쓰는 API 를 운영한다면, "다른 사람의 프롬프트가 내 응답에 섞일 수 있나"를 한 번은 따져 봐야 해요. 답부터 말하면 섞일 수 있어요. 추론 엔진은 성능 때문에 KV 캐시 페이지를 요청 사이에 0으로 지우지 않고, 한 요청이 예약한 페이지보다 적게 쓴 채 전체를 읽으면 이전 요청의 값이 그대로 어텐션에 들어가요. 막는 핵심은 "KV 풀 크기 ≥ 컨텍스트 길이"를 배포 전에 확인하고, 거부된 요청이 할당 전에 끝나는지, 출력 내용까지 검사하는 테스트가 있는지 보는 거예요.
근거는 2026년 10월 26일 Cloudflare 가 공개한 Workers AI GLM-5.3 KV 캐시 노출 사후 보고서와 엔진 코드 변경이고, 사건 요약 대신 어느 서빙 스택에서든 쓸 수 있는 점검 기준을 정리해요. 메모리 용량 계산 자체는 vLLM KV 캐시 용량 계산 글에서 다뤘고, 여기서는 격리(남의 데이터를 읽지 않는 것)만 봐요.
KV 캐시 페이지 재사용, 왜 지우지 않을까
트랜스포머는 토큰을 하나 만들 때마다 앞선 모든 토큰의 Key·Value 를 다시 봐요. 이걸 매번 계산하지 않으려고 저장해 두는 것이 KV 캐시예요. 요즘 엔진은 PagedAttention 논문이 제안한 방식처럼 GPU 메모리를 고정 크기 페이지(블록) 로 잘라 풀로 관리해요. 요청이 들어오면 필요한 만큼 페이지를 빌려주고, 끝나면 반납받아 다음 요청에 다시 빌려줘요.
여기서 반납은 "빈 칸 목록에 번호를 돌려놓는 것"일 뿐, 내용을 지우는 동작이 아니에요. Cloudflare 보고서도 페이지를 요청 사이에 0으로 지우지 않으며, 이는 추론 엔진에서 흔한 방식이고 최고 성능을 내는 데 필요하다고 적었어요. 그 대신 페이지의 주인이 누구인지 정확히 추적할 책임이 엔진에 생겨요.
지우지 않는 이유는 비용이에요. 긴 요청 하나가 수십 GB 의 페이지를 쓰는데, 요청마다 그만큼을 0으로 채우면 메모리 대역폭을 계속 잡아먹어요. 운영체제는 새 프로세스에 메모리를 줄 때 0으로 채워 주지만, 한 프로세스 안에서 돌려 쓰는 풀 할당자는 보통 그렇게 하지 않아요. 이 버그 유형은 CWE-226 재사용 전 민감 정보 미삭제와 CWE-908 초기화되지 않은 자원 사용에 해당해요. 데이터베이스 버퍼 풀, 커넥션 풀, 객체 풀도 같은 위험 구조를 가져요.
정리하면 위험 조건은 하나예요. 예약한 칸 수와 실제로 기록한 칸 수가 다른데, 읽기는 예약한 칸 전체를 대상으로 할 때예요.
분리형 서빙(prefill·decode 분리)에서 더 위험한 이유
분리형 서빙은 프롬프트를 처리하는 prefill 노드와 토큰을 하나씩 생성하는 decode 노드를 나눠요. 두 단계의 연산 성격이 달라서 따로 늘리면 처리량이 좋아진다는 것이 DistServe 논문의 출발점이고, SGLang 은 PD Disaggregation 문서로 이 구성을 지원해요.
문제는 페이지의 주인이 두 대의 기계에 걸쳐 있다는 점이에요. decode 가 페이지를 예약하고 prefill 이 KV 를 RDMA 로 그 페이지에 직접 써 넣으니, 두 기계가 "몇 칸을, 어디에, 언제까지"에 대해 같은 그림을 가져야 해요. 어긋나면 decode 는 덜 채워진 페이지를 다 채워졌다고 믿고 읽어요.
Cloudflare 사례가 바로 이 어긋남이었어요. 풀보다 긴 프롬프트는 거부 표시와 함께 1토큰 자리표시자로 바뀌었는데, 거부 상태가 finished_reason 이 아닌 다른 필드에 저장돼 입장 큐의 "끝났나?" 검사를 통과했어요. decode 는 원래 길이만큼 예약해 두고 1토큰 분량의 "전송 완료"를 받은 뒤 예약 전체를 읽었어요. 단일 노드였다면 자리표시자가 돌고 오류가 나갔을 텐데, 노드가 나뉘면서 "거부"라는 사실이 건너가지 못한 거예요. 보고서는 같은 계열 버그를 두 갈래로 더 꼽아요. 한 노드 안에서 여러 작업(생성, 호스트 메모리 오프로드, 전송 수신, 중단 처리)이 한 페이지를 쥐고 있는데 하나가 먼저 반납하는 경우, 그리고 요청이 중단됐는데 이미 보낸 RDMA 쓰기가 늦게 도착해 다른 요청의 페이지를 덮는 경우예요.
KV 풀 크기와 컨텍스트 길이, 무엇을 비교하나
먼저 볼 숫자는 광고한 컨텍스트 길이와 노드별 KV 풀이 담을 수 있는 토큰 수예요. Cloudflare 는 이 차이를 출시 전에 알았지만, 엔진이 못 담는 요청엔 오류를 낼 거라고 가정했어요.
풀 크기가 설정 파일에 숫자로 적혀 있지 않다는 점이 함정이에요. 보고서에 따르면 SGLang 은 시작할 때 가중치를 올리고 남은 GPU 메모리로 풀 크기를 정하고, 컨텍스트 길이는 별도 설정값이에요. 둘을 맞추라고 강제하는 장치가 없어요. 그래서 양자화 방식, 텐서 병렬 수, CUDA 그래프 크기, 메모리 비율 하나만 바꿔도 풀이 조용히 줄어들어요.
토큰 하나가 차지하는 KV 바이트는 GQA 구조 기준으로 2 × 레이어 수 × KV 헤드 수 × 헤드 차원 × 자료형 바이트예요. 아래는 레이어 60개, KV 헤드 8개, 헤드 차원 128인 가상의 모델로 계산한 예시예요(MLA 처럼 KV 를 압축하는 구조는 공식이 달라요).
KV 를 FP16 에서 FP8 로 바꾸면 풀이 두 배가 되고, 가중치를 덜 양자화하면 풀이 줄어요. 그러니 설정을 바꿀 때마다 실행 중인 엔진의 값으로 비교해야 해요. 배포 파이프라인에 넣으면 이런 모양이에요.
def kv_bytes_per_token(layers: int, kv_heads: int, head_dim: int, dtype_bytes: int) -> int:
"""GQA 구조에서 토큰 1개가 차지하는 KV 캐시 바이트 (K와 V 두 벌)."""
return 2 * layers * kv_heads * head_dim * dtype_bytes
def pool_tokens(free_gib: float, per_token: int) -> int:
return int(free_gib * 1024**3 // per_token)
def preflight(context_len: int, nodes: dict[str, int]) -> None:
short = {n: p for n, p in nodes.items() if p < context_len}
if short:
raise SystemExit(f"배포 중단: 풀이 컨텍스트({context_len:,})보다 작음 {short}")
print("통과: 모든 노드의 KV 풀이 컨텍스트 이상")
per = kv_bytes_per_token(60, 8, 128, 2)
print(per, pool_tokens(80, per)) # 245760 349525
preflight(262_144, {"prefill-0": pool_tokens(80, per), "decode-0": pool_tokens(70, per)})
실제 배포에서는 pool_tokens 대신 엔진 로그·메트릭의 값을 넣어요. 계산값은 어림이에요. 풀이 모자라면 컨텍스트 길이를 풀에 맞춰 낮춰요. 앞단 프록시가 길이를 막아 줄 거라 믿지 말라는 게 보고서의 권고예요.
거부 경로 점검, "끝났나"와 "거부됐나"는 다른 질문이에요
설정을 맞춰도 거부 경로는 따로 안전해야 해요. 풀 크기는 언젠가 다시 틀어지고, 길이 말고도 거부 이유는 많기 때문이에요.
SGLang 에서는 이 사건을 고치는 변경이 두 단계로 들어갔어요. PR #36988은 중단된 prefill 요청이 decode 로 넘어가기 전에 끝내도록 했고(v0.5.20), PR #38935는 거부된 요청을 입장 단계에서 부트스트랩·할당·KV 전송 전에 막아요(v0.5.21). 보고서는 분리형 SGLang 운영자에게 v0.5.22 이상으로 올리거나 해당 패치를 백포트하라고 권해요. 같은 버그는 2026년 7월 6일 이슈 #30233으로 먼저 보고돼 있었어요.
다른 엔진을 쓰거나 직접 스케줄러를 짤 때도 확인할 질문은 같아요.
| 확인 질문 | 위험한 답 | 안전한 답 |
|---|---|---|
| 거부 상태를 어느 필드에 저장하나 | 완료 상태와 다른 필드, 일부 검사만 봄 | 모든 단계가 같은 "종료됨" 판정 함수를 씀 |
| 거부된 요청이 자원을 받나 | 자리표시자로 바꿔 정상 흐름에 태움 | 할당 전에 원래 오류로 바로 종료 |
| 받는 쪽이 기록 수를 확인하나 | "전송 완료" 신호만 믿음 | 예약 토큰 수와 받은 토큰 수를 비교 |
| 거부 사실이 다른 노드에 전달되나 | 한 노드 안에서만 기록 | 라우터가 양쪽 반쪽을 함께 취소 |
| 클라이언트가 받는 상태 코드 | 내용이 틀려도 200 | 한도 초과는 4xx, 프롬프트 토큰 수도 정직하게 |
Cloudflare 의 초안 패치(#38704)에는 decode 가 전송 토큰 수와 예약 토큰 수를 비교하는 검사도 있었어요. 위험 조건이 "예약 ≠ 기록"이니, 둘을 직접 비교하는 게 가장 값싼 방어예요.
전송·반납 단계, 체크섬과 지연된 반납
노드 사이 전송에는 경쟁 조건이 남아요. 이미 보낸 RDMA 쓰기는 되돌릴 수 없어서, 중단된 요청의 페이지를 decode 가 바로 반납하면 늦게 도착한 쓰기가 남의 KV 를 덮어요. 장치는 둘이에요.
- 지연된 반납(deferred release): 전송이 다 비워졌다는 확인을 받을 때까지 페이지를 풀에 돌려놓지 않아요. SGLang 은 이후 이를 기본값으로 켰어요.
- 전송 배치 상한: 한 번의 전송 호출이 반납 대기 시간을 넘지 않게 묶음 크기를 제한해요.
받은 데이터가 보낸 데이터와 같은지는 체크섬으로 확인해요. PR #39500은 --disaggregation-enable-kv-checksum 옵션을 추가해서 decode 가 생성 전에 받은 KV 의 체크섬을 검증해요. 체크섬이 내용 일치를 어떻게 보증하는지는 해시 생성기에 한 글자만 다른 두 문장을 넣어 보면 감이 와요. 다만 KV 체크섬과 --kv-canary 는 아직 모든 모델 구조를 지원하지 않으니, 로그에서 검증이 실제로 도는지 확인해요.
로드밸런서도 봐야 해요. 클라이언트가 끊었다고 전송 도중 요청을 중단시키면 노드 간 조율이 필요한 중단이 생겨요. Cloudflare 는 prefill 이 끝날 때까지, decode 는 첫 토큰이 나올 때까지 연결을 유지한 뒤 닫도록 바꿨어요.
읽기 단계 방어, 카나리와 페이지 태그
가장 근본적인 방어는 읽는 순간에 "이 페이지가 내 것인가"를 확인하는 거예요. 앞 단계 검사는 알려진 경로만 막기 때문이에요. 보고서는 페이지마다 재할당 때 바뀌는 태그를 달고 요청이 기대하는 태그와 대조하는 시제품을 소개했어요. 처리량·꼬리 지연 비용은 1% 미만이었지만 안정성 문제와 특정 어텐션 구현 의존 때문에 껐고, SGLang 팀과 범용 버전을 만드는 중이라고 해요. 반납 시 페이지를 0으로 지우는 초안 제안도 진행 중이에요.
그 전까지 운영자가 할 수 있는 건 카나리 테스트예요. 계정 A 요청에 추측 불가능한 표식을 심고, 계정 B 로 경계 근처 길이를 보내 표식이 나오는지 봐요. Cloudflare 도 모델을 다시 열기 전에 연구자의 재현 스크립트와 자체 카나리 표식 테스트로 검증했어요.
import secrets
def isolation_test(send_a, send_b, make_prompt, limit: int) -> list[str]:
"""계정 A 에 비밀 표식을 심고, 계정 B 로 경계 근처 요청을 보내 섞이는지 본다.
make_prompt(n) 은 토크나이저 기준 n 토큰짜리 프롬프트를 만든다."""
marker = f"CANARY-{secrets.token_hex(8)}"
send_a(f"다음 코드를 세 번 반복해 줘: {marker}")
problems = []
for n in (limit - 1_000, limit, limit + 1, limit * 2):
status, text = send_b(make_prompt(n) + "\n위 글을 한 줄로 요약해 줘.")
if marker in text:
problems.append(f"{n:,}토큰: 다른 계정의 표식이 응답에 나옴")
if n > limit and status == 200:
problems.append(f"{n:,}토큰: 한도를 넘었는데 200")
return problems
표식이 안 나왔다고 안전이 증명되진 않아요. 표식이 든 페이지가 B 에게 배정돼야 드러나니, A 요청을 여러 번 보내 풀을 채운 뒤 B 를 보내면 검출 확률이 올라가요. 한도를 넘었는데 200 이 오는 것 자체도 실패로 봐요.
테스트가 놓친 것, 상태 코드만 보면 안 돼요
출시 전 긴 컨텍스트 테스트는 HTTP 상태 코드와 스트림 오류 프레임만 확인했고 출력은 보지 않았어요. 사고 응답은 전부 200 이었어요. 단서는 둘이었어요. 응답이 그 길이의 prefill 로는 불가능할 만큼 빨리 왔고, 그 안에 다른 사용자의 내용이 섞여 있었어요.
"데이터가 이상한데 에러는 없다"를 좁히는 일반 순서는 API 응답 디버깅 6단계 글에 있고, LLM 서빙에서는 아래를 더해요.
| 테스트 항목 | 무엇을 보나 | 놓치면 생기는 일 |
|---|---|---|
| 출력 내용 검사 | 프롬프트에 심은 사실을 응답이 되짚는지 | 남의 KV 를 읽어도 통과 |
| 길이 경계 | 풀 −1, 풀, 풀 +1, 컨텍스트 한도에서의 상태 코드 | 한도 초과가 200 으로 나감 |
| 지연 시간 하한 | 프롬프트 길이 대비 첫 토큰 시간이 너무 짧은지 | 계산을 건너뛴 응답을 못 알아챔 |
| 사용량 일치 | 청구 토큰 수와 실제 처리량 | 거부된 요청이 정상 과금 |
배포 전 KV 캐시 격리 점검표
멀티테넌트 LLM 서빙을 배포하기 전에 확인할 것이에요.
- 노드별 KV 풀 ≥ 광고한 컨텍스트 길이: prefill·decode 각각, 실행 중인 엔진 로그·메트릭에서 읽은 값으로. 가중치·양자화·병렬화·메모리 비율을 바꿀 때마다 다시.
- 엔진 버전: SGLang 분리형이면 v0.5.22 이상 또는 #36988·#38935 백포트. 다른 엔진은 거부 경로가 할당 전에 끊기는지 코드로 확인.
- 예약 수 = 기록 수 검증: 받는 쪽이 전송 완료 신호만 믿지 않는지.
- 지연된 반납과 전송 배치 상한: 두 역할 모두에서 켜져 있는지.
- 손상 감지: KV 체크섬·카나리가 지원되는 모델이면 켜고, 로그에서 검증 건수가 0이 아닌지.
- 로드밸런서: 첫 토큰 전 중단을 만들지 않는지.
- 테스트: 상태 코드가 아니라 출력 내용, 길이 경계, 지연 시간 하한, 다른 계정 카나리.
자주 묻는 질문
KV 캐시 노출은 API 사용자 쪽에서 막을 수 있나요?
거의 없어요. 원인이 서빙 쪽 엔진과 설정이라, 사용자는 민감한 데이터를 프롬프트에 덜 넣는 정도만 할 수 있어요. 다만 컨텍스트 한도 근처의 아주 긴 요청에서 프롬프트와 무관한 내용이 나오면, 서비스의 보안 신고 창구에 재현 방법과 함께 알리는 게 맞아요. Cloudflare 사례도 외부 연구자의 신고로 발견됐어요.
페이지를 매번 0으로 지우면 간단하지 않나요?
가장 확실하지만 비용이 커요. 긴 요청 하나가 수십 GB 의 페이지를 쓰는데, 반납마다 이를 0으로 채우면 메모리 대역폭을 계속 써요. 그래서 엔진들은 지우는 대신 소유권을 추적하는 쪽을 택해 왔어요. SGLang 에도 반납 시 지우는 초안 제안이 올라와 있으니, 성능 손실을 받아들일 수 있는 환경이라면 옵션이 생기는지 지켜볼 만해요.
단일 노드 서빙이면 이 문제와 상관없나요?
이번 거부 경로 버그는 분리형에서만 드러났어요. 하지만 한 노드 안에서도 생성·오프로드·중단 처리가 같은 페이지를 다른 수명으로 쥐고 있어 먼저 반납하는 버그는 생길 수 있어요. 카나리 테스트와 출력 검사는 구성과 상관없이 돌려요.
KV 풀 크기는 어디서 확인하나요?
SGLang 은 시작 로그에 풀 크기를 찍고 메트릭으로도 내보내요. vLLM 등 다른 엔진도 시작할 때 KV 캐시 블록 수나 토큰 수를 로그에 남겨요. 계산식 어림이 아니라 실행 중인 엔진이 보고한 값을 기준으로 삼아요.
체크섬을 켜면 노출을 완전히 막을 수 있나요?
아니에요. 전송 체크섬은 prefill 에서 decode 로 넘어가는 순간의 데이터가 같은지만 확인해요. 전송이 끝난 뒤 다른 경로로 페이지가 잘못 읽히는 경우는 잡지 못해요. 그래서 보고서도 다음 단계로 매 읽기마다 페이지 주인을 확인하는 태그 방식을 꼽았어요.
정리
KV 캐시 격리의 핵심은 "페이지는 지워지지 않는다"는 전제에서 출발하는 거예요. 그 전제 위에서 위험 조건은 예약한 칸보다 적게 쓰고 전부 읽는 것 하나이고, 막는 방법은 그 조건이 생길 수 있는 네 지점(입장·기록·전송·읽기)에 검사를 겹쳐 두는 거예요. 당장 할 일은 노드별 KV 풀과 컨텍스트 길이를 실행 중인 엔진에서 비교하고, 엔진을 패치된 버전으로 올리고, 상태 코드가 아니라 출력 내용을 보는 카나리 테스트를 배포 파이프라인에 넣는 것이에요.
풀 크기에 필요한 메모리 계산은 vLLM KV 캐시 용량 계산 글에서 이어서 볼 수 있어요.
더 깊이 읽기
이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 KV 캐시 노출·GPT-6.1 Terra·TSP — IT 뉴스에서 도식과 코드로 단계별로 풀었어요.