Skip to content

파싱 no-data + CSR 셸을 헤드리스 에스컬레이션 대상으로 재분류 - #22

Merged
m-a-king merged 3 commits into
mainfrom
feat/csr-shell-escalation
Aug 6, 2026
Merged

파싱 no-data + CSR 셸을 헤드리스 에스컬레이션 대상으로 재분류#22
m-a-king merged 3 commits into
mainfrom
feat/csr-shell-escalation

Conversation

@m-a-king

@m-a-king m-a-king commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Situation

  • 카카오 톡딜 등록 실패를 추적해 보니, 톡딜 상품 페이지(store.kakao.com·clink 공유 브리지)는 HTTP 200 으로 응답하면서 본문은 JS 부트스트랩뿐인 빈 CSR 셸이었다 (실측 5.8KB, 상품 데이터 0).
  • 헤드리스 에스컬레이션은 fetch 예외(403·500 등)에서만 발동하도록 설계돼 있어, "200 인데 데이터 없음" 은 차단으로 보이지 않았다. 그래서 LLM fallback 까지 내려간 뒤 확정 실패(NOT_PRODUCT_PAGE)로 끝났다.
  • 같은 사각의 다른 얼굴도 있었다: dev DB 초기화로 HEADLESS_FIRST 정책 행이 사라지자 kream 추출이 깨졌다. 헤드리스로 가는 길이 정책 행(런타임 DB 상태)에만 의존하면, 그 상태가 소실될 때 정확성이 함께 무너진다.

Task

  • "200 이지만 데이터 없는 CSR 셸" 을 fetch 차단과 같은 축의 실패로 인식시켜 헤드리스가 이어받게 한다.
  • 원칙은 하나: 어차피 실패하던 요청에만 추가 비용을 쓴다. 성공 경로는 코드 경로조차 바뀌지 않아야 한다.
  • 새 CSR 몰이 나타나도 백오피스 등록 없이 자동 커버되게 해, HEADLESS_FIRST 정책 행을 "정확성의 전제" 에서 "느린 실패를 건너뛰는 latency 최적화" 로 강등한다.

Action

  • 재분류 규칙: plain 전략에서 파싱이 no-data 로 실패했을 때 본문 가시 텍스트가 300자 미만이면, 원래 예외를 cause 로 보존한 채 escalatable 한 fetch 계약 예외(code EMPTY_SHELL, 확정 실패)로 바꿔 던진다. 기존 에스컬레이션 판정(escalatable 인 fetch 예외만 헤드리스로)은 그대로라, 재분류만으로 헤드리스 폴백이 이어진다.
  • 판정 기준: 가시 텍스트 길이 단일 기준이다. 실측한 셸(카카오 store·clink 브리지)은 수십 자, 상품이 아닌 진짜 콘텐츠 페이지(블로그 등)는 수백 자 이상이라 간극이 넓다. 짧은 정상 페이지가 셸로 오탐돼도 헤드리스 1회 낭비로 그치므로 fail-open 이 싸다 (escalation 메트릭 category=EMPTY_SHELL 로 관측).
실패 상황 재분류 이유
파싱 no-data + 본문이 셸 (텍스트 300자 미만) EMPTY_SHELL 로 재분류, 헤드리스 에스컬레이트 정적 fetch 는 몇 번을 받아도 같은 셸. 브라우저만 답
파싱 no-data + 본문 텍스트 충분 원형 그대로 (NOT_PRODUCT_PAGE 등) 진짜 "상품 페이지 아님". 헤드리스 무의미
LLM 일시 오류 (Gemini 5xx 등) 재분류 없음 페이지 문제가 아님. 호출자 재시도 축이 흡수
READY 필드 미달 (UNTRUSTWORTHY_VALUE) 영향 없음 응답 계층에서 던져져 이 경로 밖
  • 구현: EmptyShellDetector(jsoup body 텍스트 길이) 신설, PageFetchException.emptyShell(cause) 팩토리와 ExtractionErrorCode.EMPTY_SHELL 추가, DefaultProductLinkExtractorProductSnapshotException 만 잡아 재분류. 헤드리스 전략 쪽 파이프라인은 건드리지 않는다 (이미 헤드리스인데 또 에스컬레이트할 곳이 없다).
  • 계약 반영: docs/api-contract.md 의 422 code 표에 EMPTY_SHELL 추가 (additive, 호출자 core 는 status 만 보므로 tolerant).

Result

  • 카카오 톡딜류 CSR 셸 몰은 정책 행 등록 없이 plain 실패 → 셸 재분류 → 헤드리스 경로로 동작한다. DB 초기화로 정책 행이 사라져도 (kream 사례) 추출 정확성은 유지되고 latency 만 손해 본다.
  • 표면 계약 변화는 헤드리스가 꺼진 구성 한정: 셸 페이지의 no-data 가 422 NOT_PRODUCT_PAGE 대신 422 EMPTY_SHELL 로 나간다 (status 동일, 통합 테스트로 고정).
  • 배포 순서: renderer 의 신호 기반 정착 PR (TeamPiKi/renderer) 이 먼저 나가야 실효가 있다. 이 PR 만 먼저 나가면 렌더러가 브리지 셸을 그대로 돌려줘 헛렌더 1회가 생길 뿐 해는 없다.

연관 이슈

Summary by CodeRabbit

  • 개선 사항

    • 상품 링크 추출 시 본문 데이터가 없는 CSR 셸 페이지를 자동으로 식별합니다.
    • CSR 셸은 EMPTY_SHELL 오류로 분류되며, 헤드리스 추출이 활성화된 경우 헤드리스 결과를 우선 제공합니다.
    • 실제 상품이 아닌 페이지는 기존 오류 유형으로 처리되어 추출 정확도가 향상됩니다.
  • 문서

    • 링크 추출 API의 422 오류 코드 목록에 EMPTY_SHELL이 추가되었습니다.

- 헤드리스 에스컬레이션이 fetch 예외(403·500 등)에서만 발동해, 카카오 톡딜(store.kakao.com·clink 브리지)처럼 200 으로 데이터 없는 SPA 셸을 주는 몰은 LLM 까지 가서 확정 실패했다(실측). 파싱 no-data(ProductSnapshotException) 이면서 본문 가시 텍스트가 셸 수준(300자 미만)이면 PageFetchException.emptyShell(escalatable, EMPTY_SHELL)로 재분류해 헤드리스가 이어받게 한다
- 판정은 가시 텍스트 길이 단일 기준 — 셸은 수십 자, 콘텐츠 페이지(블로그 등)는 수백 자 이상이라 간극이 넓고, 짧은 정상 페이지의 오탐은 헤드리스 1회 낭비로 그쳐 fail-open 이 싸다(escalation 메트릭 category=EMPTY_SHELL 로 관측)
- LLM 일시 오류(GeminiApiException)는 페이지 문제가 아니라 재분류하지 않고 호출자 재시도 축에 남긴다. READY 필드 미달(UNTRUSTWORTHY_VALUE)의 응답 계층 throw 는 이 경로 밖이라 영향 없다
- 이로써 HEADLESS_FIRST 정책 행은 정확성의 전제에서 느린 실패를 건너뛰는 latency 최적화로 강등된다 - DB 초기화로 정책 행이 사라져도(kream 사례) 추출 자체는 동작한다
@m-a-king m-a-king added the feat 외부 가시적 새 기능 label Aug 5, 2026
@m-a-king m-a-king self-assigned this Aug 5, 2026
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

CSR 빈 셸을 감지하는 EmptyShellDetector를 추가했습니다. no-data 파싱 오류를 EMPTY_SHELL로 재분류하고 헤드리스 에스컬레이션 상태를 설정합니다. API 계약과 단위·통합 테스트를 갱신했습니다.

Changes

CSR 빈 셸 처리

Layer / File(s) Summary
빈 셸 감지 기준과 오류 코드
src/main/java/com/depromeet/piki/extractor/extraction/EmptyShellDetector.java, src/main/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCode.java, src/test/java/com/depromeet/piki/extractor/extraction/EmptyShellDetectorTest.java
본문의 가시 텍스트가 300자 미만이면 CSR 빈 셸로 판정합니다. SPA 셸, JavaScript 리다이렉트 페이지, 충분한 본문에 대한 테스트를 추가했습니다.
파싱 오류 재분류
src/main/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractor.java, src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchException.java, src/test/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractorTest.java
빈 셸의 no-data 파싱 예외를 EMPTY_SHELL로 변환합니다. 원인 예외를 보존하고 헤드리스 에스컬레이션 및 확정 실패 상태를 설정합니다. 충분한 본문과 Gemini 오류는 기존 방식으로 처리합니다.
API 계약과 통합 검증
src/test/java/com/depromeet/piki/extractor/api/ExtractionLinkIntegrationTest.java, docs/api-contract.md
CSR 빈 셸 결과가 422와 EMPTY_SHELL로 반환되는 테스트를 추가했습니다. 422 오류 코드 문서에 EMPTY_SHELL을 추가했습니다.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DefaultProductLinkExtractor
  participant EmptyShellDetector
  participant PageFetchException
  participant ExtractionLinkAPI

  DefaultProductLinkExtractor->>EmptyShellDetector: HTML 가시 텍스트 검사
  EmptyShellDetector-->>DefaultProductLinkExtractor: 빈 셸 여부 반환
  DefaultProductLinkExtractor->>PageFetchException: EMPTY_SHELL 예외 생성
  PageFetchException-->>ExtractionLinkAPI: 422 EMPTY_SHELL 응답
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 제목은 파싱 no-data와 CSR 셸을 헤드리스 에스컬레이션 대상으로 재분류하는 PR의 핵심 변경을 정확하고 간결하게 설명합니다.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/main/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractor.java`:
- Around line 29-35: Restrict the EMPTY_SHELL conversion in the
ProductSnapshotException catch block to NOT_PRODUCT_PAGE errors only. Before
calling EmptyShellDetector.isEmptyShell(page.html()) and throwing
PageFetchException.emptyShell(e), validate the exception’s classification;
propagate UNTRUSTWORTHY_VALUE and other non-no-data errors unchanged.

In
`@src/main/java/com/depromeet/piki/extractor/extraction/EmptyShellDetector.java`:
- Around line 25-26: Enforce the non-null contract at both method boundaries: in
EmptyShellDetector.isEmptyShell, validate html with Objects.requireNonNull and
an appropriate message before Jsoup.parse; in PageFetchException, validate cause
with Objects.requireNonNull and an appropriate message before constructing the
exception. Apply the requested changes in
src/main/java/com/depromeet/piki/extractor/extraction/EmptyShellDetector.java
lines 25-26 and
src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchException.java
lines 78-79.
- Line 20: Externalize the MIN_VISIBLE_TEXT_CHARS policy value from
EmptyShellDetector into a `@ConfigurationProperties-backed` configuration
property, preserving the Kotlin source’s default value of 300. Update
EmptyShellDetector to inject and use that property instead of the hardcoded
constant, following the project’s existing configuration-property conventions.

In
`@src/test/java/com/depromeet/piki/extractor/extraction/EmptyShellDetectorTest.java`:
- Around line 37-45: Update contentRichPageIsNotEmptyShell to directly cover the
299/300-character boundary: assert that a 299-character page is an empty shell
and a 300-character page is not, while retaining the existing representative
content case if appropriate.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2c084d44-3254-4a62-96b4-60994e49fdeb

📥 Commits

Reviewing files that changed from the base of the PR and between e8f5687 and 604eb82.

📒 Files selected for processing (8)
  • docs/api-contract.md
  • src/main/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCode.java
  • src/main/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractor.java
  • src/main/java/com/depromeet/piki/extractor/extraction/EmptyShellDetector.java
  • src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchException.java
  • src/test/java/com/depromeet/piki/extractor/api/ExtractionLinkIntegrationTest.java
  • src/test/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractorTest.java
  • src/test/java/com/depromeet/piki/extractor/extraction/EmptyShellDetectorTest.java

- isEmptyShell(html)·emptyShell(cause) 는 non-null 이 전제인데 호출 체인 내부 예외(jsoup NPE 등)에 기대고 있었다 - repo 규약(Objects.requireNonNull 로 경계 강제)대로 각 메서드 진입부에서 검증
- emptyShell 은 다른 팩토리와 달리 cause 필수(재분류 예외라 원래의 파싱 실패 없이 만들어질 수 없음)임을 주석으로 명시
- 대표 사례만 있던 셸 판정에 임계값 경계(299자=셸, 300자=아님)를 직접 단언해 분기 망라

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/test/java/com/depromeet/piki/extractor/extraction/EmptyShellDetectorTest.java (1)

9-35: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

null 입력 계약을 직접 검증하세요.

EmptyShellDetector.isEmptyShell은 진입부에서 Objects.requireNonNull(html, "html")을 호출합니다. 현재 테스트는 정상적인 HTML만 검증합니다. null 입력이 NullPointerException"html" 메시지를 발생시키는지 테스트를 추가하세요.

테스트 예시
+    `@Test`
+    `@DisplayName`("html이 null이면 예외를 발생시킨다")
+    void nullHtmlIsRejected() {
+        NullPointerException exception = assertThrows(
+            NullPointerException.class,
+            () -> EmptyShellDetector.isEmptyShell(null)
+        );
+        assertEquals("html", exception.getMessage());
+    }

As per coding guidelines, “단위는 Spring 없이 분기를 망라한다.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/test/java/com/depromeet/piki/extractor/extraction/EmptyShellDetectorTest.java`
around lines 9 - 35, EmptyShellDetectorTest에 null 입력 계약 검증을 추가하세요.
EmptyShellDetector.isEmptyShell(null)을 호출할 때 NullPointerException이 발생하고 예외 메시지가
"html"인지 함께 검증하며, 기존 정상 HTML 테스트는 유지하세요.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In
`@src/test/java/com/depromeet/piki/extractor/extraction/EmptyShellDetectorTest.java`:
- Around line 9-35: EmptyShellDetectorTest에 null 입력 계약 검증을 추가하세요.
EmptyShellDetector.isEmptyShell(null)을 호출할 때 NullPointerException이 발생하고 예외 메시지가
"html"인지 함께 검증하며, 기존 정상 HTML 테스트는 유지하세요.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3bf9da99-5df5-4d6c-81c0-b59a50cbdb30

📥 Commits

Reviewing files that changed from the base of the PR and between 604eb82 and c54924f.

📒 Files selected for processing (3)
  • src/main/java/com/depromeet/piki/extractor/extraction/EmptyShellDetector.java
  • src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchException.java
  • src/test/java/com/depromeet/piki/extractor/extraction/EmptyShellDetectorTest.java
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/main/java/com/depromeet/piki/extractor/extraction/EmptyShellDetector.java
  • src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchException.java

@m-a-king
m-a-king merged commit 27381cd into main Aug 6, 2026
2 checks passed
@m-a-king
m-a-king deleted the feat/csr-shell-escalation branch August 6, 2026 03:26
m-a-king added a commit that referenced this pull request Aug 6, 2026
- DefaultProductLinkExtractor 충돌 해소: #22 의 CSR 셸 재분류 try-catch 안에서 파이프라인에 model 을 넘긴다
- #22 가 추가한 DefaultProductLinkExtractorTest 를 바뀐 시그니처에 맞췄다
m-a-king added a commit that referenced this pull request Aug 9, 2026
* feat: LLM 이 볼 게 없는 빈 셸을 LLM 호출 전에 확정 실패로 차단

- 렌더 후에도 빈 CSR 셸을 LLM 에 넘기면 실존하지 않는 상품을 지어낸다(에이블리 mobile.* 실측: 같은 URL 15회 중 200 이 8회, 전부 서로 다른 가짜 상품에 이미지 URL 은 NXDOMAIN·403). 지어낸 값은 형식이 유효해 응답 경계도 호출자(core) 검증도 통과해 READY 로 저장됐다 - 입력이 비었음을 아는 파이프라인이 LLM 호출 전에 NO_EXTRACTABLE_CONTENT 확정 실패로 끊는다
- 판정(LlmInputGate)은 가시 텍스트 50자 미만 AND 데이터 script 부재. 에스컬레이션 축(EmptyShellDetector 300자)의 임계를 재사용하지 않는다 - 그쪽 오탐은 헤드리스 1회 낭비지만 게이트 오탐은 확정 422 로 굳어 비용이 다르다. 가시 텍스트가 없어도 JSON-LD·data island 가 있으면 LLM 이 읽을 수 있으므로 통과시키며, 그 데이터 script 판정은 DataScripts 로 이관해 sanitize 와 single source 를 공유한다
- 전략 코드는 무수정: plain 은 기존 셸 재분류 catch(#22)가 게이트 예외를 받아 그대로 헤드리스로 승격하고, 헤드리스 결과까지 셸일 때만 새 code 가 422 로 표면화된다. NOT_PRODUCT_PAGE 와 code 를 나눈 이유는 호출자 관측 - "상품 아닌 링크"와 "몰을 못 읽음"이 한 code 로 섞이면 후자를 추적할 수 없다
- 카운터 via 를 structured/skipped_shell/llm 3분기로 재구성({via,reason} 키 집합 불변, 발행 지점 한 곳 유지)하고 게이트 발동 시 host 포함 로그를 남긴다 - "가시 텍스트도 script 도 없는 정상 상품 페이지" 오탐 실재 여부는 배포 후 이 로그의 host 분포로 감시한다

* refactor: 게이트의 길이 임계를 제거하고 가시 텍스트 전무(0자)만 본다

- "50자도 결국 자의적 임계값" 지적로 재검토: 0-300자 구간에 실측된 정상 페이지가 없어 마진 크기(30이든 80이든)를 정당화할 데이터가 없다. 판단(짧다)을 사실(전혀 없다)로 치환해 임계값 논쟁 자체를 없앤다 - 상수가 사라지고 판정은 text().isEmpty() 가 된다
- 트레이드오프를 알고 내린 선택이다: 놓친 셸(몇십 자 보일러플레이트만 있는 미지의 셸)은 LLM 으로 흘러 환각 가능성이 남고 그 실패는 조용하다. 반면 임계 오탐(정상 미니멀 페이지 차단)은 영구 422 로 굳는다. 잔존 위험 감시는 via=llm 로그의 html 크기·host 분포가 담당한다(셸은 본문이 극단적으로 작아 분포에서 드러난다)
- 실측된 사고 케이스는 전부 렌더 후 0자라 차단 효과는 동일하다. 경계 테스트는 49/50 에서 "공백뿐 body 발동 / 한 글자면 통과"로 교체
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat 외부 가시적 새 기능

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant