최근 진행한 프로젝트에서 PDF 문서를 웹 상에서 미리보기, 전자서명, 그리고 다운로드까지 가능한 기능을 개발했습니다. 이 포스트에서는 어떤 기술을 사용했는지, 그리고 어떤 고민과 해결 과정을 거쳤는지 기록해보려 합니다.
PDF의 각 페이지를 이미지로 변환하기 위해 pdfjs-dist
의 getViewport
와 page.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초 이상이 걸렸습니다. 해결을 위해:
사용자가 업로드한 도장 이미지를 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);
};
});
};
그리고 사용자가 도장을 찍으면 해당 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' });
};
도장이 찍혀 최종 업데이트된 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, 안정적인 다운로드를 구현하는 것이 얼마나 중요한지를 체감했고, 다음 프로젝트에도 잘 적용해보고 싶습니다.