Skip to content

feat: reCAPTCHA 렌더 안정화 및 결과 페이지 404 예외 상태 처리#34

Merged
kim-subsub merged 4 commits into
developfrom
feat/33
Apr 24, 2026
Merged

feat: reCAPTCHA 렌더 안정화 및 결과 페이지 404 예외 상태 처리#34
kim-subsub merged 4 commits into
developfrom
feat/33

Conversation

@kim-subsub

@kim-subsub kim-subsub commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Summary

#33

  • reCAPTCHA Enterprise 렌더링 실패/중복 렌더 이슈를 안정화했습니다.
    • 스크립트 로드 후 API 준비 대기 로직 추가
    • google.com 실패 시 recaptcha.net 폴백 추가
    • 중복 렌더 방지를 위한 abort/reset 처리 추가
  • 결과 페이지에서 /api/v1/scan/detail 404를 일반 에러가 아닌 예외 상태로 처리했습니다.
    • 복구 가능한 URL이 있으면 detailUnavailable 상태 데이터 반환
    • 전용 안내 문구/행동(재검사)으로 UI 분기
    • 해당 상태에서는 불필요한 메트릭/리포트 토글 비노출
  • 관련 테스트를 추가/수정했습니다.

Tasks

  • CaptchaWidget, useCaptchaPage, recaptchaEnterprise 렌더 안정화
  • createResultPageFetcher에 404 예외 상태 처리 로직 추가
  • ResultPage, ResultStatusPagedetailUnavailable UI 분기 추가
  • ResultPageData 타입 확장 (detailUnavailable)
  • 테스트 추가/수정 (createResultPageFetcher.test.ts)

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 스캔 결과 세부 정보를 사용할 수 없을 때를 처리하는 기능 추가
    • reCAPTCHA Enterprise 로딩 및 렌더링 개선
  • 버그 수정

    • 스캔 세부 정보 조회 실패 시 대체 동작 구현
    • 오류 메시지 및 사용자 피드백 개선
  • 스타일

    • 오류 메시지 표시를 위한 스타일 추가

@kim-subsub kim-subsub self-assigned this Apr 24, 2026
@vercel

vercel Bot commented Apr 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
veri-q Ready Ready Preview, Comment Apr 24, 2026 1:42pm

@coderabbitai

coderabbitai Bot commented Apr 24, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@kim-subsub has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 35 minutes and 53 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 35 minutes and 53 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: f4431ad1-a252-425a-9aca-289f07ccf6db

📥 Commits

Reviewing files that changed from the base of the PR and between bd2ca51 and 2724794.

📒 Files selected for processing (5)
  • src/pages/Captcha/lib/recaptchaEnterprise.ts
  • src/pages/Captcha/ui/CaptchaWidget.tsx
  • src/pages/Result/ResultPage.tsx
  • src/pages/Result/api/createResultPageFetcher.test.ts
  • src/pages/Result/api/createResultPageFetcher.ts

Walkthrough

reCAPTCHA Enterprise 통합을 위해 새로운 로딩 및 렌더링 모듈을 추가하고, 캡차 위젯을 리팩토링했습니다. 결과 페이지에서 상세 정보를 사용할 수 없는 경우를 처리하는 기능을 추가하고, 관련 UI와 타입을 업데이트했습니다.

Changes

Cohort / File(s) Summary
환경 변수 문서
.env.example, scripts/setupEnv.mjs
VITE_RECAPTCHA_SITE_KEY 주석에서 "checkbox" 한정어를 제거하여 문서 표현을 정정했습니다.
reCAPTCHA Enterprise 모듈
src/pages/Captcha/lib/recaptchaEnterprise.ts
Google reCAPTCHA Enterprise API를 로드하고 렌더링하는 새로운 모듈을 추가했습니다. 스크립트 캐싱, 폴링 기반 API 대기, AbortSignal 지원, 그리고 위젯 리셋 기능을 포함합니다.
캡차 위젯
src/pages/Captcha/ui/CaptchaWidget.tsx, src/pages/Captcha/hooks/useCaptchaPage.ts, src/pages/Captcha/styles/captchaPage.css.ts
CaptchaWidgetrecaptchaEnterprise 모듈로 위임하도록 리팩토링하고, 로드 오류 메시지 상태를 추가했습니다. useCaptchaPage에서 제출 검증 로직과 메시지를 조정했습니다. 스타일에 오류 표시와 레이아웃 간격을 추가했습니다.
결과 페이지 상세 정보 처리
src/pages/Result/types/resultPage.types.ts, src/pages/Result/lib/toResultPageData.ts, src/pages/Result/api/createResultPageFetcher.ts, src/pages/Result/api/createResultPageFetcher.test.ts
ResultPageDatadetailUnavailable 필드를 추가했습니다. API 404 오류 시 폴백 동작을 구현하여 상세 정보를 사용할 수 없을 때 기존 세션 데이터를 반환합니다. 테스트 스위트를 추가했습니다.
결과 상태 페이지 UI
src/pages/Result/ui/ResultStatusPage.tsx, src/pages/Result/ResultPage.tsx
ResultStatusPageshowMetricsshowReportToggle 옵션을 추가했습니다. ResultPage에서 detailUnavailable 상태에 따라 경고 테마 표시와 조건부 렌더링을 구현했습니다.

Sequence Diagram(s)

sequenceDiagram
    participant CaptchaWidget
    participant recaptchaEnterprise
    participant window as window.grecaptcha<br/>.enterprise
    participant Container as DOM Container

    CaptchaWidget->>recaptchaEnterprise: renderRecaptchaEnterprise(container, options)
    activate recaptchaEnterprise
    
    recaptchaEnterprise->>recaptchaEnterprise: loadRecaptchaEnterprise()
    activate recaptchaEnterprise
    recaptchaEnterprise->>recaptchaEnterprise: Create script element<br/>google.com/recaptcha/enterprise.js
    recaptchaEnterprise->>recaptchaEnterprise: Add to document head
    recaptchaEnterprise->>window: Wait for load event<br/>+ grecaptcha.enterprise.render
    window-->>recaptchaEnterprise: API ready
    deactivate recaptchaEnterprise
    
    recaptchaEnterprise->>recaptchaEnterprise: Check AbortSignal state
    alt Signal aborted
        recaptchaEnterprise-->>CaptchaWidget: Reject with RECAPTCHA_RENDER_ABORTED
    else Signal not aborted
        recaptchaEnterprise->>window: render(container, {siteKey, theme, callbacks})
        window->>Container: Render widget UI
        window-->>recaptchaEnterprise: Widget ID
        recaptchaEnterprise-->>CaptchaWidget: Resolve with widget ID
    end
    
    deactivate recaptchaEnterprise
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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 PR 제목이 주요 변경사항을 명확하게 설명합니다: reCAPTCHA 렌더링 안정화와 결과 페이지 404 예외 상태 처리는 코드 변경사항의 핵심 내용입니다.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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 and usage tips.

@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: 3

🧹 Nitpick comments (4)
src/pages/Captcha/lib/recaptchaEnterprise.ts (1)

93-108: 기존 스크립트 경로의 드문 레이스에 대한 방어 제안

dataset.loadStatus'loaded'/'error' 둘 다 아닌 상태(= 아직 loading)에서 waitForScriptReady(existingScript)로 진입하는데, 그 사이 원본 스크립트의 load/error가 이미 발생·처리되어 dataset.loadStatus가 설정됐을 가능성이 존재합니다. 이 경우 새로 붙인 addEventListener는 영영 발화되지 않아 waitForEnterpriseApiReady의 5s 타임아웃까지 대기하게 됩니다. 리스너 부착 직후 dataset을 한 번 더 확인해 짧은 경로로 빠지도록 가드를 추가하면 안전합니다.

♻️ 제안 수정 diff
 function waitForScriptReady(script: HTMLScriptElement): Promise<void> {
   return new Promise((resolve, reject) => {
     const onLoad = () => {
       void waitForEnterpriseApiReady().then(resolve).catch(reject);
     };

     const onError = () => {
       reject(new Error('Failed to load reCAPTCHA Enterprise script.'));
     };

     script.addEventListener('load', onLoad, { once: true });
     script.addEventListener('error', onError, { once: true });
+
+    // 리스너 부착 사이에 load/error가 이미 발생·반영된 경우 복구
+    if (script.dataset.loadStatus === 'loaded') {
+      onLoad();
+    } else if (script.dataset.loadStatus === 'error') {
+      onError();
+    }
   });
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/Captcha/lib/recaptchaEnterprise.ts` around lines 93 - 108, In
loadEnterpriseScriptBySource, guard against the rare race where
existingScript.dataset.loadStatus is still neither 'loaded' nor 'error' when you
attach listeners: after you attach the load/error listeners to existingScript
(the same element used with waitForScriptReady), immediately re-check
existingScript.dataset.loadStatus and short-circuit — if it's 'loaded' or
isEnterpriseApiReady() then await waitForEnterpriseApiReady() and return; if
it's 'error' throw the same Error('Failed to load reCAPTCHA Enterprise
script.'); otherwise proceed to await waitForScriptReady(existingScript).
src/pages/Result/api/createResultPageFetcher.ts (2)

32-42: 안내 문구 문자열 중복 — 공용 상수로 추출 권장.

동일한 한국어 문구가 이 파일(L36), ResultPage.tsx(L80 hero description), 그리고 테스트 기대값에 세 번 중복되어 있습니다. 문구가 변경될 때 한 곳이라도 누락되면 UI와 API/테스트 간 불일치가 발생합니다. 공용 상수로 추출해 단일 출처로 유지하세요.

♻️ 제안 리팩터 예시
+// src/pages/Result/constants/detailUnavailable.ts
+export const DETAIL_UNAVAILABLE_MESSAGE =
+  '상세 분석 데이터를 찾지 못했습니다. 잠시 후 다시 시도하거나 다시 검사해 주세요.';
-function createDetailUnavailableResultPageData(url: string): ResultPageData {
-  return {
-    detailUnavailable: true,
-    previewUrl: url,
-    siteMeta: '상세 분석 데이터를 찾지 못했습니다. 잠시 후 다시 시도하거나 다시 검사해 주세요.',
+import { DETAIL_UNAVAILABLE_MESSAGE } from '../constants/detailUnavailable';
+
+function createDetailUnavailableResultPageData(url: string): ResultPageData {
+  return {
+    detailUnavailable: true,
+    previewUrl: url,
+    siteMeta: DETAIL_UNAVAILABLE_MESSAGE,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/Result/api/createResultPageFetcher.ts` around lines 32 - 42, 현재
createDetailUnavailableResultPageData 함수의 siteMeta 문자열이 ResultPage.tsx의 hero
description 및 테스트 기대값과 중복되어 있으므로, 이 문구를 공용 상수로 추출하여 단일 소스로 유지하도록 리팩터하세요: 새로운
상수(EXPORTed const, 예: DETAIL_UNAVAILABLE_MESSAGE)를 적절한 모듈에 생성한 뒤
createDetailUnavailableResultPageData 내 siteMeta에 상수를 사용하고 ResultPage.tsx의 hero
description과 테스트들에서 해당 상수를 import하여 사용하도록 변경하며, 테스트 기대값도 상수를 참조하도록 업데이트하여 중복된
리터럴을 제거하세요.

50-64: 타입 좁히기로 ?? '' 폴백 제거 권장.

해당 try/catchhasRecoverableUrl이 참일 때만 진입하므로 recoverableUrl은 이 시점에 항상 string입니다. 그럼에도 L60의 recoverableUrl ?? ''는 TS 타입 좁힘이 안 되는 것을 방어하기 위한 폴백인데, 빈 문자열이 detailUnavailable 데이터의 siteName/siteUrl/visitUrl 등으로 흘러 들어가면 "다시 검사하기" 동작이 빈 URL 기반으로 수행되어 혼선을 일으킬 수 있습니다. 조건문에서 recoverableUrl 자체로 판정해 좁히거나, 진입 가드에서 바로 변수로 보존하세요.

♻️ 제안 리팩터
-    const recoverableUrl = resolveRecoverableUrl(session);
-    const hasRecoverableUrl = Boolean(recoverableUrl);
-
-    if (!session.analysisDetail && hasRecoverableUrl) {
+    const recoverableUrl = resolveRecoverableUrl(session);
+
+    if (!session.analysisDetail && recoverableUrl) {
       try {
         const detailSession = await ensureScanDetail();
         return toResultPageData(detailSession, tone);
       } catch (error) {
         if (isApiError(error) && error.statusCode === 404) {
           if (hasSessionResult(session)) {
             return toResultPageData(session, tone);
           }
 
-          return createDetailUnavailableResultPageData(recoverableUrl ?? '');
+          return createDetailUnavailableResultPageData(recoverableUrl);
         }
 
         throw error;
       }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/Result/api/createResultPageFetcher.ts` around lines 50 - 64, The
catch block uses recoverableUrl with a defensive `?? ''` fallback which masks
that recoverableUrl is actually guaranteed when hasRecoverableUrl is true;
remove the `?? ''` fallback and narrow/capture recoverableUrl before the try so
TypeScript knows it's a string — e.g., check/assign recoverableUrl when
computing hasRecoverableUrl (or add an explicit guard `if (!recoverableUrl)
throw`) so inside the catch you can call
createDetailUnavailableResultPageData(recoverableUrl) directly; update
references in this async flow (ensureScanDetail, toResultPageData,
createDetailUnavailableResultPageData, hasSessionResult) accordingly.
src/pages/Result/api/createResultPageFetcher.test.ts (1)

21-66: 테스트 시나리오 커버리지 양호 — 추가 보완 제안.

404 → 세션 폴백, 404 → detailUnavailable 두 핵심 분기를 모두 검증하여 PR 목적을 잘 반영합니다. 필요 시 다음 분기도 추가 고려해볼 만합니다:

  • ensureScanDetail이 비‑404 ApiError(예: 500)를 던지는 경우 → 에러가 그대로 전파되는지.
  • !session.analysisDetail && !hasRecoverableUrl 상태 + 세션 결과 없음 → SCAN_SESSION_REQUIRED 에러 확인.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/Result/api/createResultPageFetcher.test.ts` around lines 21 - 66,
Add two tests for createResultPageFetcher: (1) mock ensureScanDetail to reject
with a non-404 ApiError (e.g. statusCode 500) and assert fetchResultPageData
propagates/throws that error (use
vi.mocked(ensureScanDetail).mockRejectedValue(...) and
expect(fetchResultPageData()).rejects.toThrow(...)); (2) simulate the case where
there is no session.analysisDetail, no recoverable URL (mock
resolveRequestedUrlFromSearch to return null/undefined), and no session final
result, then call fetchResultPageData and assert it throws the
SCAN_SESSION_REQUIRED error (or the specific error type/message your
implementation uses). Reference createResultPageFetcher, ensureScanDetail,
resolveRequestedUrlFromSearch, and the SCAN_SESSION_REQUIRED error in the new
tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/pages/Captcha/ui/CaptchaWidget.tsx`:
- Around line 47-49: The promise resolution in
renderRecaptchaEnterprise(...).then(...) only assigns widgetId, which can leak a
widget if the effect's cleanup already ran; update the .then handler to check
the disposed flag (or other cleanup indicator used in this component) when the
promise resolves and, if disposed is true, immediately call
resetRecaptchaEnterprise with the returned widgetId instead of just assigning
it, otherwise store widgetId as before; ensure the handler references the same
widgetId/ disposed symbols used in the surrounding effect so
resetRecaptchaEnterprise(widgetId) runs when necessary.

In `@src/pages/Result/api/createResultPageFetcher.test.ts`:
- Around line 16-19: The test teardown uses vi.clearAllMocks() which only clears
mock call history but leaves mockReturnValue/mockImplementation in place,
causing leaks (e.g., resolveRequestedUrlFromSearch.mockReturnValue in later
tests); update the beforeEach to call vi.resetAllMocks() instead so all mocks
(return values and implementations) are restored to their factory defaults,
keeping tests isolated alongside the existing
useScanSessionStore.getState().clearSession() call.

In `@src/pages/Result/ResultPage.tsx`:
- Around line 96-102: Update the statusLabel string on the siteCard constant in
ResultPage.tsx from the English "ANALYSIS NEEDED" to a Korean label to match the
page's UX tone (e.g., "분석 대기" or "정보 부족"); edit the siteCard object (referencing
siteCard, statusLabel, tone, visitLabel) so the value is the chosen Korean text
and keep the object typed as const.

---

Nitpick comments:
In `@src/pages/Captcha/lib/recaptchaEnterprise.ts`:
- Around line 93-108: In loadEnterpriseScriptBySource, guard against the rare
race where existingScript.dataset.loadStatus is still neither 'loaded' nor
'error' when you attach listeners: after you attach the load/error listeners to
existingScript (the same element used with waitForScriptReady), immediately
re-check existingScript.dataset.loadStatus and short-circuit — if it's 'loaded'
or isEnterpriseApiReady() then await waitForEnterpriseApiReady() and return; if
it's 'error' throw the same Error('Failed to load reCAPTCHA Enterprise
script.'); otherwise proceed to await waitForScriptReady(existingScript).

In `@src/pages/Result/api/createResultPageFetcher.test.ts`:
- Around line 21-66: Add two tests for createResultPageFetcher: (1) mock
ensureScanDetail to reject with a non-404 ApiError (e.g. statusCode 500) and
assert fetchResultPageData propagates/throws that error (use
vi.mocked(ensureScanDetail).mockRejectedValue(...) and
expect(fetchResultPageData()).rejects.toThrow(...)); (2) simulate the case where
there is no session.analysisDetail, no recoverable URL (mock
resolveRequestedUrlFromSearch to return null/undefined), and no session final
result, then call fetchResultPageData and assert it throws the
SCAN_SESSION_REQUIRED error (or the specific error type/message your
implementation uses). Reference createResultPageFetcher, ensureScanDetail,
resolveRequestedUrlFromSearch, and the SCAN_SESSION_REQUIRED error in the new
tests.

In `@src/pages/Result/api/createResultPageFetcher.ts`:
- Around line 32-42: 현재 createDetailUnavailableResultPageData 함수의 siteMeta 문자열이
ResultPage.tsx의 hero description 및 테스트 기대값과 중복되어 있으므로, 이 문구를 공용 상수로 추출하여 단일 소스로
유지하도록 리팩터하세요: 새로운 상수(EXPORTed const, 예: DETAIL_UNAVAILABLE_MESSAGE)를 적절한 모듈에 생성한
뒤 createDetailUnavailableResultPageData 내 siteMeta에 상수를 사용하고 ResultPage.tsx의
hero description과 테스트들에서 해당 상수를 import하여 사용하도록 변경하며, 테스트 기대값도 상수를 참조하도록 업데이트하여
중복된 리터럴을 제거하세요.
- Around line 50-64: The catch block uses recoverableUrl with a defensive `??
''` fallback which masks that recoverableUrl is actually guaranteed when
hasRecoverableUrl is true; remove the `?? ''` fallback and narrow/capture
recoverableUrl before the try so TypeScript knows it's a string — e.g.,
check/assign recoverableUrl when computing hasRecoverableUrl (or add an explicit
guard `if (!recoverableUrl) throw`) so inside the catch you can call
createDetailUnavailableResultPageData(recoverableUrl) directly; update
references in this async flow (ensureScanDetail, toResultPageData,
createDetailUnavailableResultPageData, hasSessionResult) accordingly.
🪄 Autofix (Beta)

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: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 49050c24-233a-45e9-b1e2-4299f760a7dd

📥 Commits

Reviewing files that changed from the base of the PR and between bcaae9d and bd2ca51.

📒 Files selected for processing (12)
  • .env.example
  • scripts/setupEnv.mjs
  • src/pages/Captcha/hooks/useCaptchaPage.ts
  • src/pages/Captcha/lib/recaptchaEnterprise.ts
  • src/pages/Captcha/styles/captchaPage.css.ts
  • src/pages/Captcha/ui/CaptchaWidget.tsx
  • src/pages/Result/ResultPage.tsx
  • src/pages/Result/api/createResultPageFetcher.test.ts
  • src/pages/Result/api/createResultPageFetcher.ts
  • src/pages/Result/lib/toResultPageData.ts
  • src/pages/Result/types/resultPage.types.ts
  • src/pages/Result/ui/ResultStatusPage.tsx

Comment thread src/pages/Captcha/ui/CaptchaWidget.tsx
Comment thread src/pages/Result/api/createResultPageFetcher.test.ts
Comment thread src/pages/Result/ResultPage.tsx
@kim-subsub kim-subsub merged commit 8b541e6 into develop Apr 24, 2026
8 checks passed
@kim-subsub kim-subsub deleted the feat/33 branch April 24, 2026 13:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant