Skip to content

상품 페이지를 스트리밍 가지치기로 받아 수신 단계에서 메모리 바운드 - #48

Open
m-a-king wants to merge 1 commit into
mainfrom
fix/47-streaming-bounded-fetch
Open

상품 페이지를 스트리밍 가지치기로 받아 수신 단계에서 메모리 바운드#48
m-a-king wants to merge 1 commit into
mainfrom
fix/47-streaming-bounded-fetch

Conversation

@m-a-king

Copy link
Copy Markdown
Collaborator

Situation

  • 상품 페이지를 가져올 때 응답을 통째로 메모리에 올린 뒤 잘랐다. 자르기 직전 피크가 두 배로 뛴다. 원본 바이트와 문자열 사본이 동시에 살아 있기 때문이고, 동시 파싱 수만큼 곱해진다
  • 코드를 열어 보니 같은 자리에 두 가지가 더 있었다
    • Content-Type 검사 부재: 링크가 50MB 영상이나 압축파일을 가리켜도 전부 받아 HTML 로 디코딩하려 들었다
    • 이중 파싱: 같은 HTML 을 파이프라인이 한 번, 빈 셸 판정이 또 한 번 통째로 팠다

Task

원래 이슈는 "받은 뒤 자르는 지점을 앞당기자"였다. 그런데 자르는 것 자체가 손해라는 게 드러나면서, "다 받은 뒤 어떻게 자르나"가 아니라 "다 안 받는다" 로 문제를 다시 잡았다.

제약 둘: 절단도 실패도 recall 을 깎는다. 그리고 새 실패 code 를 만들면 3 repo 순차 PR 이 된다.

Action

접근 선택

상한을 넘겼을 때 비용 판단
초과 시 실패 (원안) 확정 실패로 종결 새 permanent code 라 카탈로그, core 매핑, extractor 로 3 repo 순차 PR 채택 안 함. 지금 앞부분으로 추출에 성공하던 페이지가 실패로 바뀐다
수신 단계에서 절단 앞부분만 남김 단일 PR 채택 안 함. JS 와 style 을 걷어내기 전에 자르면 같은 길이에 담기는 상품 정보가 준다
흘리며 가지치기 애초에 안 넘김 단일 PR 채택. 버리는 대상이 어차피 하류가 버릴 것들이라 잃는 정보가 없다

수신 단계

  • 흘리며 가지치기: 응답을 스트림으로 읽으며 태그가 닫히는 순간마다 남길지 정한다. style, 주석, 데이터가 아닌 script 는 그 자리에서 버린다. 거대 inline JS 가 메모리에 한 번에 하나씩만 스쳐 간다
  • Content-Type 게이트: 영상, 음성, 이미지, 압축파일 등 명백한 바이너리는 본문을 한 바이트도 안 읽고 끊는다. 다만 HTML 을 text/plain 이나 헤더 없이 주는 몰이 실재해, 거부는 명백한 것만 하고 미상은 통과시킨다
  • charset 우선순위 보존: 앞 4KB 만 엿봐 인코딩을 정한 뒤 그 인코딩으로 나머지를 읽는다. 헤더, 문서 내 선언, UTF-8 순서는 그대로다
  • 상한 둘: 읽을 바이트 상한(끝나지 않는 스트림용, 압축 해제 후 기준)과 남긴 분량 상한(가지쳐도 안 줄어드는 문서용). 정상 페이지는 어느 쪽에도 안 닿는다

조용히 깨질 뻔한 자리

  • 구조화 파서에 평범한 JS script 를 훑어 가격을 찾는 경로가 있었다(유니클로 계열의 전역 상태 변수). "데이터 script 가 아니면 버린다"로 짰다면 그 사이트의 가격 추출이 컴파일도 테스트도 안 깨진 채 죽었다
  • 반대로 LLM 입력에서는 같은 script 를 일부러 버린다. 코드 덩어리라 토큰만 먹고 오판을 부른다
  • 그래서 보존 규칙이 두 벌이라는 사실 자체를 한 곳에 박고, 무엇을 찾는지 아는 쪽이 표지 상수를 소유하게 했다
규칙 남기는 것
수신 가지치기 데이터 script + 전역 상태 변수를 담은 JS script
LLM 입력 데이터 script 만

구조 정리

  • 페이지 표현을 문자열에서 파싱된 문서로 바꿨다. 하류(구조화 파서, LLM 게이트, sanitize, 셸 판정)가 전부 문서를 원하고 있어 문자열은 중간 표현일 뿐이었다. 그 결과 위에 적은 이중 파싱이 함께 사라졌다
  • 헤드리스 렌더 경로도 같은 가지치기를 통과시켜, 두 전략이 하류에 넘기는 문서 모양이 갈리지 않게 했다

구현 좌표

구분 대상
신설 PruningHtmlParser
수신 HttpPageFetcher(toEntityexchange), FetchProperties(maxFetchBytes 신설, maxFetchCharsmaxRetainedChars)
계약 PageContent(String → Document + retainedChars), DataScripts(retainForParsing), StructuredDataExtractor(표지 상수 공개)
하류 HtmlSnapshotPipeline, EmptyShellDetector, DefaultProductLinkExtractor, HttpHeadlessRenderer, HeadlessExtractionProperties

Result

  • 함정이 막혔다는 증거: 기존 유니클로, Next.js 가격 보강 테스트 2개가 가지친 문서를 먹고 통과한다. 이 둘이 그 경로의 회귀 가드다
  • "본문을 안 읽는다"와 "상한에서 끊는다"를 실제 바이트 수로 증명: 응답 스트림을 직접 쥐는 rig 를 만들어 읽힌 바이트를 셌다. 바이너리 6종 모두 0바이트
  • 메모리 주장 자체를 테스트로 고정: 5MB 짜리 inline JS 가 섞인 문서에서 남은 분량이 1,000자 미만이고 본문은 그대로다
  • 읽는 중 연결이 끊기면 일시 실패인지는 추론이 아니라 테스트로 확인했다. exchange 콜백의 IO 예외가 어떻게 번역되는지는 라이브러리 동작에 달려 있어서다
  • 상한의 알갱이 한계: 남긴 분량 상한은 태그 단위로 걸린다. 거대한 텍스트 노드 하나짜리 문서는 이쪽이 아니라 바이트 상한이 잡는다
  • 응답 계약 무변경: 새 code 가 없어 카탈로그와 core 매핑을 안 건드린다. 다만 비HTML 링크의 결과가 결정론적으로 바뀌어(전에는 받아서 파싱해 봐야 알았다), 그 서술은 NOT_PRODUCT_PAGE 가 비HTML 응답도 포함함을 계약에 명시 infra#55 로 먼저 반영했다
  • 설정 키 두 개 개명. yml 과 env 어디에도 오버라이드가 없음을 확인했다

연관 이슈

- 응답을 전부 메모리에 올린 뒤 자르던 것을, jsoup StreamParser 로 흘리며 하류가
  읽지 않을 노드(style·주석·데이터 아닌 script)를 닫히는 즉시 버리는 방식으로 교체
- 원안(#47)은 Content-Length 검사 + 상한 초과 시 payload-too-large 실패였으나
  두 이유로 전환. 실패는 지금 앞부분으로 추출에 성공하던 페이지를 확정 실패로
  바꾸고 새 permanent code 가 infra→core→extractor 3 repo 순차 PR 을 부른다.
  절단도 마찬가지로 손해다 - sanitize 가 절단을 LLM 직전으로 미뤄 둔 이유가
  코드에 적혀 있듯, JS·style 을 걷어낸 뒤라야 같은 길이에 상품 정보가 더 담긴다.
  가지치기는 버리는 대상이 어차피 하류가 버릴 것들이라 둘 다 피한다
- 작업 중 발견한 함정: StructuredDataExtractor 가 일반 JS script 를 훑어
  window.__PRELOADED_STATE__ 를 찾는다(유니클로 계열). "데이터 script 아니면
  버린다"로 짰다면 그 경로가 조용히 깨졌다. 보존 규칙이 두 벌(수신용·LLM 입력용)
  이라는 사실을 DataScripts 에 박고, 표지 상수는 그것을 찾는 쪽이 소유하게 함
- Content-Type 게이트 신설. 지금까지 검사가 0건이라 링크가 50MB mp4·zip 을
  가리켜도 전부 받았다. recall 보호를 위해 명백한 바이너리만 막고 미상은 통과
- PageContent 가 String 대신 Document 를 든다. 하류가 전부 Document 를 원해
  문자열은 중간 표현일 뿐이었고, 그 결과 HtmlSnapshotPipeline 과
  EmptyShellDetector 가 같은 HTML 을 두 번 파싱하던 것도 사라짐
- 상한은 둘. 와이어 바이트(끝나지 않는 스트림용, 해제 후 바이트에 걸린다)와
  보존분 문자(가지쳐도 안 줄어드는 문서용). 정상 페이지는 어느 쪽에도 안 닿는다
@m-a-king m-a-king added the fix 외부 가시적 결함 수정 label Aug 21, 2026
@m-a-king m-a-king self-assigned this Aug 21, 2026
@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9f53ef7b-fc9f-4f8c-a6a6-24df27b4fe74


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix 외부 가시적 결함 수정

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant