PDF 전자서명 개발기

Sky·2025년 4월 15일
0
post-thumbnail

최근 진행한 프로젝트에서 PDF 문서를 웹 상에서 미리보기, 전자서명, 그리고 다운로드까지 가능한 기능을 개발했습니다. 이 포스트에서는 어떤 기술을 사용했는지, 그리고 어떤 고민과 해결 과정을 거쳤는지 기록해보려 합니다.

프로젝트 미리보기


🧩 목표 기능

  • PDF 파일 업로드 및 페이지별 미리보기
  • 원하는 페이지에 도장(이미지) 찍기
  • 전자서명 포함된 PDF 다운로드

🔧 사용한 기술 스택

  • React 19
  • TypeScript
  • pdf-lib – PDF 생성 및 수정
  • pdfjs-dist – PDF 페이지 렌더링
  • fabric.js – 캔버스 기반 도장 배치 및 사용자 입력
  • zustand – 전역 상태 관리
  • Vite – 번들러

📄 PDF 렌더링: pdfjs-dist와 fabric.js 조합

PDF의 각 페이지를 이미지로 변환하기 위해 pdfjs-distgetViewportpage.render를 사용했습니다. 이미지로 변환 후에는 fabric.Canvas 위에 백그라운드 이미지로 설정해 사용자 입력이 가능하게 구성했습니다.

export const getImageByPdf = async (
  pdf: pdfjsLib.PDFDocumentProxy,
  pageIndex: number,
  scale = 3
): Promise<string> => {
  const pageNumber = pageIndex + 1;

  try {
    const page = await pdf.getPage(pageNumber);
    const viewport = page.getViewport({ scale });

    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');

    canvas.width = viewport.width;
    canvas.height = viewport.height;

    await page.render({ canvasContext: context!, viewport }).promise;

    return canvas.toDataURL('image/png');
  } catch (error) {
    console.error(`Error rendering page ${pageNumber}:`, error);
    throw new Error(`Failed to render page ${pageNumber}.`);
  }
};
export const CanvasProvider = ({ children }: { children: ReactNode }) => {
  ...생략

  const initializeCanvas = async (file: File, selectedPageFileIndex: number) => {
    ...생략
    const image = await getImageByPdf(pdf, selectedPageFileIndex);
    const img = await fabric.FabricImage.fromURL(image!);

    const scaleX = FABRIC_CANVAS_WIDTH / img.width;
    const scaleY = FABRIC_CANVAS_HEIGHT / img.height;

    img.set({
      scaleX,
      scaleY,
      left: 0,
      top: 0,
      objectCaching: false
    });

    if (fabricCanvasRef.current) {
      fabricCanvasRef.current.backgroundImage = img;
      fabricCanvasRef.current.requestRenderAll();
      fabricCanvasRef.current.renderAll();
    }
  };

성능 이슈 ⚡

PDF가 50페이지 이상인 경우, 전체 페이지를 이미지로 변환하는 데 8초 이상이 걸렸습니다. 해결을 위해:

  • Promise.all을 사용한 병렬 처리
  • 렌더링 해상도 조절 (scale: 5 → 2)
  • 이미지 변환 대신 PDF 페이지 직접 썸네일로 표시하는 방법도 고려

🖼️ 도장(전자서명) 기능

사용자가 업로드한 도장 이미지를 optimizeImage 함수로 리사이징하고, fabric.js에서 드래그 & 드롭 등 배치가 가능하게 만들었습니다.

export const optimizeImage = (file: File, maxWidth = 200, maxHeight = 200): Promise<string> => {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.crossOrigin = 'anonymous';
    const objectUrl = URL.createObjectURL(file);
    img.src = objectUrl;

    const processImage = () => {
      try {
        const canvas = document.createElement('canvas');
        const ctx = canvas.getContext('2d');

        let { width, height } = img;

        if (width > maxWidth || height > maxHeight) {
          if (width / height > maxWidth / maxHeight) {
            height = (height * maxWidth) / width;
            width = maxWidth;
          } else {
            width = (width * maxHeight) / height;
            height = maxHeight;
          }
        }

        canvas.width = width;
        canvas.height = height;

        if (!ctx) {
          throw new Error('Canvas context not available');
        }
        
        ctx.drawImage(img, 0, 0, width, height);
        resolve(canvas.toDataURL('image/png'));
      } catch (error) {
        reject(error);
      } finally {
        URL.revokeObjectURL(objectUrl);
      }
    };

    img.onload = processImage;
    img.onerror = (error) => {
      reject(error);
    };
  });
};
  • 사용자의 편의를 고려하여 이미지 포맷이라면 모두 가능하도록 처리했습니다. 함수 내부에서 png로 컨버팅합니다.
  • URL.createObjectURL 사용 후 URL.revokeObjectURL()로 메모리 누수 방지

그리고 사용자가 도장을 찍으면 해당 PDF 페이지만 업데이트 되도록 처리했습니다.

export const applyStampToPdf = async ({
  canvas,
  originFile,
  pageIndex
}: {
  canvas: fabric.Canvas;
  originFile: File;
  pageIndex: number;
}) => {
  const fileArrayBuffer = await originFile.arrayBuffer();
  const pdfDoc = await PDFDocument.load(new Uint8Array(fileArrayBuffer));
  const dataUrl = canvas.toDataURL({
    format: 'png',
    multiplier: 3
  });

  const [, imageBytes] = dataUrl.split(',');
  const pngImage = await pdfDoc.embedPng(imageBytes);

  const page = pdfDoc.getPages()[pageIndex];
  const { width, height } = page.getSize();

  page.drawImage(pngImage, {
    x: 0,
    y: 0,
    width,
    height
  });

  const newPdfBytes = await pdfDoc.save();
  return new File([newPdfBytes], originFile.name, { type: 'application/pdf' });
};

📥 최종 PDF 다운로드

도장이 찍혀 최종 업데이트된 file 요소를 pdf-lib를 활용하여 다운로드 가능한 PDF 형태로 생성합니다.

export const downloadPdf = async (file: File) => {
  try {
    const pdfDoc = await PDFDocument.create();
    const { pdf, totalPages } = await loadPdf(file);

    const imageDataUrls = await Promise.all(
      Array.from({ length: totalPages }, (_, i) => getImageByPdf(pdf, i))
    );
    const imageBuffers = await Promise.all(
      imageDataUrls.map((url) => fetch(url).then((res) => res.arrayBuffer()))
    );
    const embeddedImages = await Promise.all(imageBuffers.map((bytes) => pdfDoc.embedPng(bytes)));

    for (const img of embeddedImages) {
      const { width, height } = img.scale(1);
      const page = pdfDoc.addPage([width, height]);

      page.drawImage(img, {
        x: 0,
        y: 0,
        width,
        height
      });
    }

    const pdfBytes = await pdfDoc.save();

    const blob = new Blob([pdfBytes], { type: 'application/pdf' });

    download(blob, file.name);
  } catch (error) {
    console.error('Error generating PDF:', error);
  }
};

📌 고민과 정리

항목내용
🐢 렌더링 속도 개선PDF가 50장 이상일 경우 이미지 추출에만 6~8초 소요 → Promise.all로 병렬 처리하여 500ms 속도 개선
🧼 URL 메모리 누수 방지URL.createObjectURL() 사용 후, 필요 시 URL.revokeObjectURL()로 해제하여 브라우저 메모리 누수 방지
🎯 UX & 최적화 고민도장을 찍는 액션 등으로 랜더링 전환에 병목이 걸리는 경우, 로딩을 추가하여 사용자 경험 개선

🚀 마무리

이번 경험을 통해 PDF를 다루는 데 있어 성능과 유저 경험 사이에서 어떻게 밸런스를 잡아야 할지 많은 고민을 하게 되었습니다. 실제 사용하는 유저 입장에서 빠른 렌더링, 직관적인 UI, 안정적인 다운로드를 구현하는 것이 얼마나 중요한지를 체감했고, 다음 프로젝트에도 잘 적용해보고 싶습니다.

profile
스벨트로 개발하는 것을 좋아합니다.

0개의 댓글