Skip to content

상품 페이지 fetch 를 스트리밍 가지치기로 바꿔 수신 단계에서 메모리를 바운드한다 #47

Description

@m-a-king

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 을 두 번 판다. HtmlSnapshotPipelineJsoup.parse 하고, DefaultProductLinkExtractorEmptyShellDetector.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 htmlDocument 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 로 자르면 그 이득을 버린다.

가지치기는 두 손실을 다 피한다. 걷어내는 대상이 어차피 하류가 버릴 것들이라 잃는 정보가 없고, 그래서 "넘치면 어쩌나"라는 질문 자체가 정상 경로에서 사라진다.

Metadata

Metadata

Assignees

Labels

fix외부 가시적 결함 수정

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions