[UnivAI 엔지니어링 노트] feature/pdf-upload 버그 수정 리포트

은서·2026년 1월 27일

feature/pdf-upload 버그 수정 리포트

작업 기간: 2026.01.15 ~ 2026.01.23 (약 8일)

변경 규모: 27개 파일, +1,579줄 / -259줄


🔴 버그 1: 게스트 모드 PDF가 열리지 않음

증상

  • 비로그인 상태에서 PDF 파일 선택 후 /pdf/guest 페이지로 이동하면 빈 화면
  • 콘솔에 "Failed to load PDF" 또는 "체험용 파일 정보가 없습니다" 에러

디버깅 과정

  1. 처음엔 라우팅 문제로 의심 → RouteGuard에서 guest 경로 예외 처리 추가
  2. 여전히 안됨 → URL 파라미터 확인 → blob URL이 이상하게 깨져있음
  3. URL.createObjectURL() 반환값 확인 → FileUpload에서는 정상
  4. navigate 후 PDFViewer에서 확인 → blob URL이 무효화됨

근본 원인

URL.createObjectURL()로 생성한 blob URL은 해당 Document 컨텍스트에서만 유효.

React Router의 navigate()로 페이지 이동 시 Document 컨텍스트가 변경되어 blob URL이 무효화됨.

해결 방법

  1. FileUpload에서 PDF를 base64로 변환하여 sessionStorage에 저장
  2. PDFViewer에서 sessionStorage에서 읽어 다시 blob URL로 변환
  3. 로드 성공 후 sessionStorage 정리

코드 변경

FileUpload.tsx

// Before: blob URL을 직접 전달
const fileUrl = URL.createObjectURL(file);
navigate(`/pdf/guest?url=${encodeURIComponent(fileUrl)}`);

// After: base64로 변환 후 sessionStorage에 저장
const reader = new FileReader();
reader.onload = () => {
  sessionStorage.setItem("guest_pdf_data", reader.result as string);
  sessionStorage.setItem("guest_pdf_name", file.name);
  navigate("/pdf/guest");
};
reader.readAsDataURL(file);

PDFViewer.tsx

// sessionStorage에서 복원
const guestPdfData = sessionStorage.getItem("guest_pdf_data");
const byteString = atob(guestPdfData.split(',')[1]);
// ... Uint8Array로 변환 후 Blob 생성
const blob = new Blob([ab], { type: mimeType });
const blobUrl = URL.createObjectURL(blob);

💡 교훈

  • blob URL은 생성된 페이지에서만 유효하다
  • SPA에서 페이지 간 대용량 데이터 전달 시 sessionStorage 또는 IndexedDB 활용

🔴 버그 2: 올가미(스크린샷) 캡처 영역 불일치

증상

  • 사용자가 선택한 영역과 실제 캡처된 이미지가 다름
  • 특히 스크롤된 상태에서 위치가 크게 어긋남
  • PDF 페이지 부분이 하얗게 나오거나 잘못된 위치가 캡처됨

디버깅 과정

  1. html2canvas 옵션 문제로 의심 → scale, useCORS 등 조정 → 부분 개선
  2. 여전히 위치 불일치 → 좌표 계산 로직 확인
  3. position: absolute + getBoundingClientRect() 조합 문제 발견
  4. iframe 내부 PDF.js canvas 접근 시 cross-origin 이슈 확인

근본 원인

  • 좌표 체계 혼란: absolute 포지션은 offsetParent 기준, getBoundingClientRect()는 viewport 기준
  • 스크롤 미반영: html2canvas에 스크롤 오프셋 전달 안 함
  • iframe canvas 추출 실패: PDF.js가 iframe 내부에서 렌더링되어 canvas 접근 제한

해결 방법

  1. 오버레이 이미지를 position: fixed로 변경 (viewport 기준 통일)
  2. html2canvas에 window.scrollX/Y 추가
  3. PDF canvas 직접 crop 방식 추가: iframe 내 canvas를 미리 이미지로 변환 → 선택 영역만 잘라내기

코드 변경

ScreenshotSelector.tsx

// Before
img.style.position = 'absolute';
img.style.left = `${absoluteLeft}px`;
img.style.top = `${absoluteTop}px`;

// After
img.style.position = 'fixed';
img.style.left = `${canvasRect.left}px`;  // viewport 기준
img.style.top = `${canvasRect.top}px`;

// 직접 canvas crop 로직 추가
if (hasCanvasSnapshots) {
  const outputCanvas = document.createElement('canvas');
  ctx.drawImage(
    sourceImg,
    relativeX * scaleX, relativeY * scaleY,  // 소스 좌표
    cropWidth * scaleX, cropHeight * scaleY,  // 소스 크기
    0, 0, outputCanvas.width, outputCanvas.height  // 대상
  );
  onCapture(outputCanvas.toDataURL('image/png', 1.0));
  return;
}

// html2canvas fallback
const canvas = await html2canvas(document.body, {
  x: left + window.scrollX,  // 스크롤 위치 추가
  y: top + window.scrollY,
  // ...
});

💡 교훈

  • 좌표 체계(absolute vs fixed vs viewport)를 명확히 구분해야 함
  • iframe 내부 요소는 직접 접근이 제한될 수 있으므로 우회 방법 필요
  • html2canvas는 만능이 아님 → 직접 canvas 조작이 더 정확할 수 있음

🔴 버그 3: 게스트 모드에서 AI 기능 전부 실패

증상

  • 게스트 모드에서 PDF는 정상 로드되지만 요약/개념/암기 생성 시 에러
  • 네트워크 탭에서 Firebase Function 호출은 성공하지만 백엔드에서 PDF fetch 실패

디버깅 과정

  1. 프론트엔드 요청 확인 → pdfUrl에 blob URL이 전달됨
  2. 백엔드 로그 확인 → "fetch failed" 에러
  3. blob URL을 서버에서 fetch 시도 → 불가능 (브라우저 로컬 리소스)

근본 원인

blob: URL은 브라우저 메모리에만 존재하는 리소스.

백엔드(Firebase Functions)에서는 이 URL에 접근 불가.

해결 방법

게스트 모드에서는:

  1. 프론트엔드에서 PDF.js로 텍스트를 직접 추출
  2. pdfUrl 대신 transcript 파라미터로 백엔드에 전송
  3. 백엔드에서 transcript 모드 지원 추가

코드 변경

ConceptView.tsx / MemorizeView.tsx

// blob URL 감지 시 텍스트 추출 모드로 전환
if ((fileId === 'guest' || !currentUser) && urlToUse.startsWith('blob:')) {
  console.log("[ConceptView] Guest mode: extracting text from blob URL");
  const extractedText = await extractTextFromPdf(urlToUse);
  // pdfUrl 대신 transcript로 전송
  await conceptService.extractConcepts(extractedText, { isTranscript: true });
}

functions/index.ts

// transcript 파라미터 지원 추가
const { pdfUrl, transcript, pageRanges, ... } = req.body;

if (!pdfUrl && !transcript) {
  res.status(400).send("PDF URL 또는 transcript가 필요합니다.");
  return;
}

// Transcript 모드 처리
if (transcript) {
  const stream = await extractConceptsWithGeminiService(
    null,  // PDF base64 없음
    language,
    languageInstruction,
    transcript,  // 텍스트 직접 전달
    customPrompt
  );
  // ... 스트리밍 응답
}

💡 교훈

  • 클라이언트-서버 간 리소스 공유 방식을 명확히 설계해야 함
  • blob URL은 클라이언트 전용, 서버 전송 시 base64 또는 텍스트 변환 필요

🔴 버그 4: transcript + pageRanges 파라미터 충돌

증상

  • 게스트 모드에서 페이지 범위 지정 시 400 에러
  • "페이지 범위는 transcript와 함께 사용할 수 없습니다" 메시지

근본 원인

  • 프론트엔드에서 transcript 모드임에도 pageRanges를 함께 전송
  • 백엔드에서 유효성 검사 추가 후 발생

해결 방법

  1. 백엔드에 명확한 유효성 검사 추가 (transcript + pageRanges 조합 거부)
  2. 프론트엔드에서 transcript 모드일 때 pageRanges 전송 안 함
  3. 게스트 암기 기능은 전체 PDF로 자동 생성 (페이지 범위 옵션 제거)

코드 변경

ConceptService.ts / MemorizeService.ts

// URL이면 pdfUrl + pageRanges, 아니면 transcript만
if (isUrl) {
  requestBody.pdfUrl = pdfUrl;
  requestBody.pageRanges = options?.pageRanges || null;
} else {
  requestBody.transcript = pdfUrl;  // 추출된 텍스트
  // transcript 모드에서는 pageRanges를 전송하지 않음
}

MemorizeView.tsx

// 게스트 모드: 페이지 범위 옵션 없이 바로 전체 생성
if (fileId === "guest") {
  setShowOptions(false);
  generateMemorize();  // pageRanges 없이 호출
}

🔴 버그 5: 캡처 이미지가 채팅창 미리보기에 안 나타남

증상

  • 올가미로 이미지 캡처 후 채팅 입력창에 미리보기 이미지가 안 보임
  • 콘솔에 에러 없음, 캡처 자체는 성공

디버깅 과정

  1. onCapture 콜백 호출 확인 → 정상
  2. capturedImage prop 전달 확인 → 정상
  3. setSelectedImages 호출 확인 → 호출됨
  4. 상태 업데이트 후 값 확인 → 이전 값 참조 (stale closure)

근본 원인

React의 stale closure 문제.

useCallback 내부에서 selectedImages 상태를 직접 참조하면 클로저에 캡처된 시점의 값을 사용.

해결 방법

  1. 함수형 업데이트 사용: setSelectedImages(prev => [...prev, file])
  2. useCallback 의존성 배열 정리
  3. 부모 컴포넌트에 onCapturedImageProcessed 콜백 추가하여 capturedImage 초기화

코드 변경

GeminiChatView.tsx

// Before: stale closure 발생
const handleScreenshotCapture = (imageData: string) => {
  setSelectedImages([...selectedImages, file]);  // selectedImages가 오래된 값
};

// After: 함수형 업데이트로 최신 상태 보장
const handleScreenshotCapture = useCallback((imageData: string) => {
  setSelectedImages(prev => [...prev, file]);
  setImagePreviews(prev => [...prev, imageData]);
  
  // 부모에게 처리 완료 알림
  if (onCapturedImageProcessed) {
    onCapturedImageProcessed();
  }
}, [onCapturedImageProcessed]);

💡 교훈

  • React에서 이벤트 핸들러 내 상태 업데이트는 함수형 업데이트 사용
  • useCallback 의존성 배열을 명확히 관리

📊 버그 요약 테이블

버그핵심 원인해결 난이도
PDF 로딩 실패blob URL의 Document 컨텍스트 한계⭐⭐⭐⭐⭐
캡처 영역 불일치좌표 체계 혼란 + iframe 접근 제한⭐⭐⭐⭐
AI 기능 실패blob URL 서버 전송 불가⭐⭐⭐
파라미터 충돌모드별 파라미터 분리 미흡⭐⭐
미리보기 안됨React stale closure⭐⭐

💡 이번 작업에서 얻은 인사이트

1. blob URL의 한계를 명확히 인지

  • 페이지 이동 시 무효화됨
  • 서버에서 접근 불가
  • 대안: sessionStorage, IndexedDB, base64

2. 좌표 계산은 기준을 통일

  • absolute vs fixed vs viewport
  • 스크롤 오프셋 항상 고려

3. 게스트 모드는 별도 데이터 플로우 설계 필요

  • 로그인 유저와 완전히 다른 경로
  • 클라이언트 사이드 처리 필수

4. React 상태 업데이트의 비동기성 주의

  • 함수형 업데이트 습관화
  • useCallback 의존성 관리
profile
개발자 대학생🌱

0개의 댓글