Next.js에서 pdf다운하기
그동안 PDF 다운로드 기능은 html2canvas와 jspdf를 조합해서 클라이언트에서 처리해왔습니다. 이번 사이드 프로젝트에서는 서버에서 PDF를 생성하는 방식을 써보고 싶었고, 그 과정에서 Puppeteer를 쓰게 됐습니다.
렌더링을 직접 그리지 않고 브라우저에 맡기기로 했다
html2canvas는 DOM을 캔버스에 그리는 방식으로 동작합니다. CSS 속성 중 일부는 지원하지 않고, 렌더링 결과가 실제 브라우저 화면과 미묘하게 다를 때가 있기 때문입니다. 특히 복잡한 레이아웃이나 웹폰트, 그림자 처리에서 품질 차이가 눈에 띕니다.
Puppeteer는 Headless Chrome을 직접 띄워서 페이지를 렌더링하니까 이런 문제가 없습니다. 사용자가 Chrome에서 보는 것과 동일한 결과물을 PDF로 뽑아낼 수 있고, @media print CSS도 그대로 적용됩니다. 렌더링을 직접 구현하는 게 아니라 브라우저한테 맡기는 방식이라, 지원하지 않는 CSS 속성 같은 문제가 구조적으로 발생하지 않습니다.
기본 흐름
흐름 자체는 단순합니다. 클라이언트에서 PDF로 변환할 DOM 요소의 HTML 문자열을 만들어 서버로 전송하면, 서버가 Puppeteer로 그 HTML을 렌더링해서 PDF 파일을 생성한 뒤 응답으로 돌려줍니다.
클라이언트 구현
대상 요소의 outerHTML을 꺼내서 POST 요청으로 서버에 보내고, 응답으로 받은 Blob을 임시 앵커 태그를 통해 다운로드합니다.
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");
}
}
};
서버 구현
Next.js 15 App Router 기준 Route Handler입니다. Puppeteer로 Headless Chrome을 띄우고, setContent()로 HTML을 주입한 뒤 page.pdf()로 PDF 버퍼를 생성하는 흐름입니다.
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 }
);
}
}
트러블슈팅
코드를 처음 실행하고 나서 두 가지 문제가 있었습니다.
PDF를 열었더니 이미지가 전부 깨져 있었다
PDF를 열어보니 이미지가 모두 깨져 있었습니다. 원인은 Puppeteer가 별도 프로세스로 Headless Chrome을 실행하는 방식에 있습니다. 클라이언트 HTML에서 이미지 경로는 /icons/testImage.svg 같은 상대 경로로 돼 있는데, Puppeteer 프로세스 입장에서 이 경로의 기준이 될 서버 origin이 없기 때문입니다. setContent()로 HTML 문자열을 직접 주입하면 base URL이 설정되지 않으니까, 상대 경로를 해석하지 못하고 이미지 로딩에 실패하는 겁니다.
const baseUrl = "http://서버주소";
htmlContent = htmlContent.replace(/src="\/(.*?)"/g, `src="${baseUrl}/$1"`);
Puppeteer에 HTML을 넘기기 전에 상대 경로를 절대 경로로 변환해주면 해결됩니다.
이미지를 고쳤더니 이번엔 스타일이 통째로 사라졌다
이미지를 고치고 나니까 이번엔 스타일이 전혀 적용되지 않았습니다. 프로젝트가 Tailwind를 쓰고 있었는데, Puppeteer가 받는 HTML에는 Tailwind 클래스명만 있고 실제 스타일 시트가 없는 거였죠.
Next.js 빌드 결과에서 Tailwind 스타일은 클래스 이름과 CSS 규칙이 매핑된 번들 파일로 존재합니다. 클라이언트의 outerHTML에는 클래스명 문자열만 포함될 뿐, 그 클래스가 어떤 스타일을 갖는지에 대한 정보는 들어 있지 않기 때문입니다. 서버의 Puppeteer 프로세스는 Next.js 빌드 결과물에 대해 아무것도 모르니까, 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>
`;
html2canvas와 jspdf 조합과 비교하면, Puppeteer 방식은 실제 Chrome 렌더링 결과를 그대로 PDF로 뽑아낼 수 있어서 결과물 품질이 훨씬 좋습니다. 다만 서버에서 Chromium을 실행하는 만큼 메모리와 실행 시간을 고려해야 하고, 이미지 경로와 CSS 주입처럼 서버와 클라이언트의 맥락 차이에서 오는 문제들을 직접 처리해야 합니다.
로컬에선 되던 게 Vercel에 올리니 죽었다
로컬에서는 잘 동작하던 Puppeteer가 Vercel 배포 후 갑자기 실패하는 경우가 있습니다.
Vercel의 서버리스 함수(AWS Lambda 기반)는 파일 시스템과 실행 환경에 제약이 있습니다. 표준 puppeteer 패키지는 Chromium 바이너리를 함께 설치하는데, 이 바이너리만 수백 MB에 달하기 때문입니다. 서버리스 함수는 압축 기준 50MB(풀기 250MB) 제한이 있어서 그냥은 안 올라갑니다.
해결책은 서버리스용으로 최적화된 Chromium 패키지를 쓰는 것입니다.
npm install puppeteer-core @sparticuz/chromium
import chromium from '@sparticuz/chromium';
import puppeteer from 'puppeteer-core';
export async function POST(req: NextRequest) {
const browser = await puppeteer.launch({
args: chromium.args,
defaultViewport: chromium.defaultViewport,
executablePath: await chromium.executablePath(),
headless: chromium.headless,
});
// 이하 동일
}
@sparticuz/chromium은 서버리스 환경에 맞게 경량화된 Chromium 빌드를 제공합니다. 압축 기준 50MB 이하로 들어오고, Lambda나 Vercel 함수에서 실행할 수 있습니다. 로컬에서는 기존 puppeteer로, 프로덕션에서는 이 조합으로 분기하는 방식이 일반적입니다.
const browser = await puppeteer.launch(
process.env.NODE_ENV === 'production'
? {
args: chromium.args,
executablePath: await chromium.executablePath(),
headless: chromium.headless,
}
: { headless: true }
);
방식별 비교
| 방식 | 품질 | 실행 환경 | 복잡도 |
|---|---|---|---|
| html2canvas + jspdf | 중 (CSS 일부 미지원) | 클라이언트 | 낮음 |
| Puppeteer (일반) | 높음 | 서버 (non-serverless) | 중간 |
| Puppeteer + @sparticuz/chromium | 높음 | 서버리스 가능 | 높음 |
마치며
Puppeteer는 "브라우저한테 맡기는" 방식이라 렌더링 품질 문제가 구조적으로 없다는 게 가장 큰 장점입니다. 직접 DOM을 캔버스에 그리는 html2canvas와는 접근 방식 자체가 다르니까요.
다만 실행 환경에 따른 제약이 있고, 서버리스 배포 환경이라면 @sparticuz/chromium으로 우회해야 하는 번거로움이 있습니다. 사내 서버처럼 전용 인스턴스가 있는 환경이라면 표준 Puppeteer로 단순하게 가는 편이 낫고, Vercel처럼 서버리스 기반이라면 이 조합이 현실적인 선택이지 않을까 싶습니다. PDF 품질이 아쉬웠던 적이 있다면 한 번쯤 서버 쪽으로 옮겨보시길 권합니다.