왜
HttpPageFetcher 가 외부 페이지 응답을 전부 메모리에 올린 뒤 크기를 절단한다. request() 가 .toEntity(byte[].class) 로 본문 전체를 materialize 하고, decodeHtml 로 String 변환한 다음에야 maxFetchChars(기본 300만자) 로 substring 한다.
따라서 거대 HTML 이나 압축 폭탄(decompression bomb) 응답이 오면 절단 전에 메모리를 크게 점유해 OOM / 긴 GC 위험이 있다. 절단 전 피크는 두 배로 뛴다 - 원본 바이트 배열이 살아 있는 상태에서 String(UTF-16) 사본이 만들어지고, 그 다음에 잘린다. 동시 파싱 수만큼 곱해진다.
코드를 열어 보니 같은 자리에 두 가지가 더 있다.
- Content-Type 검사가 아예 없다. 링크가 50MB mp4 나 zip 을 가리켜도 전부 받는다.
- 같은 HTML 을 두 번 판다.
HtmlSnapshotPipeline 이 Jsoup.parse 하고, DefaultProductLinkExtractor 의 EmptyShellDetector.isEmptyShell(page.html()) 가 또 한 번 전체를 판다.
무엇을
"다 받은 뒤 자른다"를 "다 안 받는다" 로 바꾼다. 절단은 정상 경로가 아니라 백스톱으로만 남긴다.
1. Content-Type 게이트
video/* audio/* image/* application/zip application/octet-stream 등 명백한 바이너리는 body 를 읽지 않고 끊는다.
HTML 을 text/plain 이나 무헤더로 주는 사이트가 있으므로 거부는 명백한 바이너리만 한다. HTML 계열과 미상은 통과시킨다(recall 보호).
2. 스트리밍 가지치기 파싱
jsoup StreamParser(1.22.2 에 있음)로 응답 스트림을 흘리면서, 닫히는 Element 마다 보존 여부를 정해 버릴 것은 remove() 한다.
<style> / 주석 / 보존 대상 아닌 <script> 는 버린다
- 나머지 마크업은 유지한다
거대 inline JS 와 CSS 가 메모리에 한 번에 하나씩만 스쳐 간다. 문서 끝까지 훑되(body 하단 JSON-LD 를 놓치지 않는다) 들고 있는 것은 가지친 결과뿐이라, 상한을 낮출 필요도 자를 필요도 없어진다.
charset 은 스트림 앞 4KB 를 mark/reset 으로 엿봐 현행 detectMetaCharset 으로 판정한 뒤 그 charset 으로 Reader 를 연다. 우선순위(응답 Content-Type -> HTML meta -> UTF-8)는 그대로다.
3. 보존 규칙은 두 벌이고, 그 사실을 코드에 박는다
함정: StructuredDataExtractor.embeddedStateJson 은 일반 JS <script> 를 훑어 window.__PRELOADED_STATE__ 를 찾는다(유니클로식 가격). "데이터 script 아니면 버린다"로 짜면 그 경로가 조용히 깨진다. 반대로 GeminiHtmlExtractor.sanitize 는 같은 script 를 일부러 버린다(코드 덩어리라 토큰만 먹고 오판을 부른다).
- 수신 가지치기가 보존할 것: 데이터 script(
application/ld+json, application/json) + __PRELOADED_STATE__ 를 담은 script
- LLM 입력에 남길 것: 데이터 script 만
두 술어를 DataScripts 한 곳에 둬, 규칙이 갈린다는 사실 자체가 코드에 드러나게 한다.
4. 백스톱 두 개
- 와이어 바이트 상한 - 무한 스트림이나 거대 응답을 끊는다
- 보존분 문자 상한 - 가지쳐도 안 줄어드는 병리적 문서를 끊는다
정상 페이지는 어느 쪽에도 닿지 않는다. 닿았을 때는 앞부분에 이미 데이터가 들어 있으므로 절단으로 끝낸다. 새 실패 code 를 만들지 않는다.
원안의 Content-Length 사전 검사는 두지 않는다. 초과를 실패로 만들지 않으니 미리 알아도 할 일이 같고, 압축 응답에서는 그 헤더가 제거되거나 압축 크기라 판단 근거로도 약하다.
5. PageContent 가 Document 를 든다
하류(StructuredDataExtractor LlmInputGate GeminiHtmlExtractor EmptyShellDetector)가 전부 Document 를 원하고 있고 String 은 중간 표현일 뿐이다. String html 을 Document document 로 바꾸면 스트리밍이 자연스럽게 붙고 위에 적은 이중 파싱도 같이 사라진다.
PageContent.of(link, String) 은 파싱 + 가지치기 헬퍼로 남겨 테스트 호출부를 보존한다. 헤드리스 경로(HttpHeadlessRenderer)도 같은 헬퍼를 거치고, capHtml 은 보존분 상한으로 흡수한다.
제약 (유지해야 하는 것)
- hop 마다
internalHostGuard.verify 로 재검증하는 SSRF 가드
RequestScopedDnsResolver 를 연결과 공유해 성립하는 IP pin (DNS rebinding / TOCTOU 차단)
- 3xx 를 정상 수신해 Location 을 읽는 현재 구조. 단 3xx 는 body 를 읽지 않고 끊는다
- charset 우선순위: 응답 Content-Type -> HTML meta -> UTF-8
- 유니클로식
__PRELOADED_STATE__ 가격 추출
- 응답 계약 무변경. 새 code 가 없으므로 infra 카탈로그와 core 매핑을 안 건드리고 extractor 단일 PR 로 닫는다
이관
core#495 에서 옮겨왔다. 파싱이 extractor 로 이관되면서(2026-07-08) core 에는 fetch 코드가 남아 있지 않아 그쪽 이슈로는 무효였다.
원 출처는 core PR #494 의 CodeRabbit Major 지적이며, 본문 보관 상한을 3MB 로 올린 core #476 이후 노출이 커진다는 지적이었다. 그 상한은 현재 FetchProperties.maxFetchChars 기본값 3,000,000 으로 이어져 있다.
설계 변경 기록
원안은 "Content-Length 검사 + 바이트 바운드 read, 상한 초과 시 payload-too-large 실패"였다. 두 가지 이유로 바꿨다.
- 실패는 recall 을 깎는다. 지금 상한을 넘겨도 앞부분으로 추출에 성공하던 페이지가 확정 실패로 바뀐다. 게다가 새 permanent code 는 infra 카탈로그 -> core 매핑 -> extractor 로 3 repo 순차 PR 이 된다.
- 절단도 recall 을 깎는다.
sanitize 가 절단을 LLM 직전으로 미뤄 둔 이유가 코드에 적혀 있다 - JS 와 style 을 걷어낸 뒤라야 같은 길이에 실제 상품 정보가 훨씬 더 담긴다. 수신 단계에서 raw 로 자르면 그 이득을 버린다.
가지치기는 두 손실을 다 피한다. 걷어내는 대상이 어차피 하류가 버릴 것들이라 잃는 정보가 없고, 그래서 "넘치면 어쩌나"라는 질문 자체가 정상 경로에서 사라진다.
왜
HttpPageFetcher가 외부 페이지 응답을 전부 메모리에 올린 뒤 크기를 절단한다.request()가.toEntity(byte[].class)로 본문 전체를 materialize 하고,decodeHtml로 String 변환한 다음에야maxFetchChars(기본 300만자) 로substring한다.따라서 거대 HTML 이나 압축 폭탄(decompression bomb) 응답이 오면 절단 전에 메모리를 크게 점유해 OOM / 긴 GC 위험이 있다. 절단 전 피크는 두 배로 뛴다 - 원본 바이트 배열이 살아 있는 상태에서 String(UTF-16) 사본이 만들어지고, 그 다음에 잘린다. 동시 파싱 수만큼 곱해진다.
코드를 열어 보니 같은 자리에 두 가지가 더 있다.
HtmlSnapshotPipeline이Jsoup.parse하고,DefaultProductLinkExtractor의EmptyShellDetector.isEmptyShell(page.html())가 또 한 번 전체를 판다.무엇을
"다 받은 뒤 자른다"를 "다 안 받는다" 로 바꾼다. 절단은 정상 경로가 아니라 백스톱으로만 남긴다.
1. Content-Type 게이트
video/*audio/*image/*application/zipapplication/octet-stream등 명백한 바이너리는 body 를 읽지 않고 끊는다.HTML 을
text/plain이나 무헤더로 주는 사이트가 있으므로 거부는 명백한 바이너리만 한다. HTML 계열과 미상은 통과시킨다(recall 보호).2. 스트리밍 가지치기 파싱
jsoup
StreamParser(1.22.2 에 있음)로 응답 스트림을 흘리면서, 닫히는 Element 마다 보존 여부를 정해 버릴 것은remove()한다.<style>/ 주석 / 보존 대상 아닌<script>는 버린다거대 inline JS 와 CSS 가 메모리에 한 번에 하나씩만 스쳐 간다. 문서 끝까지 훑되(body 하단 JSON-LD 를 놓치지 않는다) 들고 있는 것은 가지친 결과뿐이라, 상한을 낮출 필요도 자를 필요도 없어진다.
charset 은 스트림 앞 4KB 를
mark/reset으로 엿봐 현행detectMetaCharset으로 판정한 뒤 그 charset 으로 Reader 를 연다. 우선순위(응답 Content-Type -> HTML meta -> UTF-8)는 그대로다.3. 보존 규칙은 두 벌이고, 그 사실을 코드에 박는다
함정:
StructuredDataExtractor.embeddedStateJson은 일반 JS<script>를 훑어window.__PRELOADED_STATE__를 찾는다(유니클로식 가격). "데이터 script 아니면 버린다"로 짜면 그 경로가 조용히 깨진다. 반대로GeminiHtmlExtractor.sanitize는 같은 script 를 일부러 버린다(코드 덩어리라 토큰만 먹고 오판을 부른다).application/ld+json,application/json) +__PRELOADED_STATE__를 담은 script두 술어를
DataScripts한 곳에 둬, 규칙이 갈린다는 사실 자체가 코드에 드러나게 한다.4. 백스톱 두 개
정상 페이지는 어느 쪽에도 닿지 않는다. 닿았을 때는 앞부분에 이미 데이터가 들어 있으므로 절단으로 끝낸다. 새 실패 code 를 만들지 않는다.
원안의
Content-Length사전 검사는 두지 않는다. 초과를 실패로 만들지 않으니 미리 알아도 할 일이 같고, 압축 응답에서는 그 헤더가 제거되거나 압축 크기라 판단 근거로도 약하다.5.
PageContent가 Document 를 든다하류(
StructuredDataExtractorLlmInputGateGeminiHtmlExtractorEmptyShellDetector)가 전부 Document 를 원하고 있고 String 은 중간 표현일 뿐이다.String html을Document document로 바꾸면 스트리밍이 자연스럽게 붙고 위에 적은 이중 파싱도 같이 사라진다.PageContent.of(link, String)은 파싱 + 가지치기 헬퍼로 남겨 테스트 호출부를 보존한다. 헤드리스 경로(HttpHeadlessRenderer)도 같은 헬퍼를 거치고,capHtml은 보존분 상한으로 흡수한다.제약 (유지해야 하는 것)
internalHostGuard.verify로 재검증하는 SSRF 가드RequestScopedDnsResolver를 연결과 공유해 성립하는 IP pin (DNS rebinding / TOCTOU 차단)__PRELOADED_STATE__가격 추출이관
core#495 에서 옮겨왔다. 파싱이 extractor 로 이관되면서(2026-07-08) core 에는 fetch 코드가 남아 있지 않아 그쪽 이슈로는 무효였다.
원 출처는 core PR #494 의 CodeRabbit Major 지적이며, 본문 보관 상한을 3MB 로 올린 core #476 이후 노출이 커진다는 지적이었다. 그 상한은 현재
FetchProperties.maxFetchChars기본값 3,000,000 으로 이어져 있다.설계 변경 기록
원안은 "Content-Length 검사 + 바이트 바운드 read, 상한 초과 시 payload-too-large 실패"였다. 두 가지 이유로 바꿨다.
sanitize가 절단을 LLM 직전으로 미뤄 둔 이유가 코드에 적혀 있다 - JS 와 style 을 걷어낸 뒤라야 같은 길이에 실제 상품 정보가 훨씬 더 담긴다. 수신 단계에서 raw 로 자르면 그 이득을 버린다.가지치기는 두 손실을 다 피한다. 걷어내는 대상이 어차피 하류가 버릴 것들이라 잃는 정보가 없고, 그래서 "넘치면 어쩌나"라는 질문 자체가 정상 경로에서 사라진다.