Next.js에서 pdf다운하기
2025-01-16그동안 PDF 다운로드 기능은 html2canvas와 jspdf를 사용해서 클라이언트에서 구현했습니다. 하지만 이번 사이드 프로젝트에서는 서버에서 구현하고 싶어서, ChatGPT의 도움을 받아 로직을 구성했습니다.
1. 기본 흐름
서버 사이드 PDF 생성의 기본 흐름은 다음과 같습니다:
- 프론트에서 HTML 생성
- 생성된 HTML을 서버로 전송
- 서버에서 PDF 파일 생성 후 프론트로 전달
GPT에게 요청하니 puppeteer 라이브러리를 추천해주었습니다.
puppeteer란?
Puppeteer는 Headless Chrome 또는 Chromium 브라우저를 제어하기 위한 Node.js 라이브러리로 Google이 만들었습니다.
주요 용도:
- 웹 스크래핑
- 자동화된 테스트
- PDF 생성
PDF 생성에도 딱 맞아서 사용하기로 결정했습니다!
2. 클라이언트 구현
클라이언트 측 로직은 간단합니다. PDF로 만들 HTML을 서버로 전송하고, response를 받아 다운로드하면 됩니다.
const handleDownloadPDF = async () => {
const input = document.getElementById("pdf-content");
if (input) {
const htmlContent = input.outerHTML;
const response = await fetch("/api/pdf", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ htmlContent }),
});
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "download.pdf";
document.body.appendChild(a);
a.click();
a.remove();
} else {
console.error("Error generating PDF");
}
}
};
3. 서버 구현
서버는 조금 복잡합니다. Next.js 15 버전으로 처음 구현하느라 공식 문서를 참고하는 시간이 더 길었네요.
전체 코드는 다음과 같습니다:
import { NextRequest, NextResponse } from "next/server";
import puppeteer from "puppeteer";
// 응답을 캐싱하지 않고 매번 재응답
export const dynamic = "force-static";
export async function POST(req: NextRequest) {
let { htmlContent } = await req.json();
if (!htmlContent) {
return NextResponse.json(
{ message: "HTML content is required" },
{ status: 400 }
);
}
try {
const baseUrl = "http://서버주소"; // 실제 서버 주소로 변경 필요
htmlContent = htmlContent.replace(/src="\/(.*?)"/g, `src="${baseUrl}/$1"`);
const tailwindCDN = `
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
</style>
`;
htmlContent = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
${tailwindCDN}
</head>
<body>
${htmlContent}
</body>
</html>
`;
const browser = await puppeteer.launch({
headless: true,
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
const page = await browser.newPage();
// HTML 콘텐츠 설정
await page.setContent(htmlContent, { waitUntil: "load" });
// PDF 생성
const pdfBuffer = await page.pdf({
format: "A4",
printBackground: true,
margin: { top: "20mm", bottom: "20mm", left: "10mm", right: "10mm" },
});
await browser.close();
// PDF 반환
const response = new NextResponse(pdfBuffer, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": "attachment; filename=generated.pdf",
},
});
return response;
} catch (error) {
console.error("Error generating PDF:", error);
return NextResponse.json(
{ message: "Failed to generate PDF" },
{ status: 500 }
);
}
}
4. 트러블 슈팅
위 코드는 언뜻 보면 잘 작동하는 것 같지만, 두 가지 문제가 있었습니다.
문제 1: 이미지가 안 보이네?
PDF를 생성하니 이미지가 모두 깨져서 출력되는 문제가 발생했습니다.
원인: 프론트 로직 구조상 이미지는 모두 내부 파일을 사용하고 있었고, 경로가 상대 경로로 되어 있었습니다.
<img src="/icons/testImage.svg" />
서버로 넘겨주는 HTML의 img 태그 src 경로가 상대 경로로 되어 있어 이미지를 인식하지 못했던 것입니다.
해결 방법: 서버에서 이미지 경로를 절대 경로로 변경해줍니다.
const baseUrl = "http://서버주소";
htmlContent = htmlContent.replace(/src="\/(.*?)"/g, `src="${baseUrl}/$1"`);
문제 2: CSS와 폰트 어디 갔니?
해당 프로젝트는 Tailwind를 사용하고 있었는데, PDF를 출력하면 CSS가 모두 초기화되는 현상이 나타났습니다.
원인: 서버에는 Tailwind가 없어서 className을 인식하지 못하는 당연한 현상이었죠.
해결 방법: Tailwind CDN을 HTML에 추가하고, 원하는 폰트도 스타일에 추가합니다.
const tailwindCDN = `
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
</style>
`;
정리
서버 사이드 PDF 생성의 장점:
- 클라이언트 부하 감소: 무거운 작업을 서버에서 처리합니다
- 일관된 결과: 브라우저별 차이가 없습니다
- 더 나은 품질: Puppeteer는 실제 Chrome 엔진을 사용합니다
주의사항:
- 이미지는 절대 경로로 변환해야 합니다
- CSS 프레임워크(Tailwind 등)는 CDN을 추가해야 합니다
- 폰트도 명시적으로 로드해야 합니다
html2canvas + jspdf 대신 Puppeteer를 사용하면 더 깔끔한 PDF를 생성할 수 있습니다!