Next.js에서 pdf다운하기
2025-01-16그동안 PDF 다운로드 기능은 html2canvas와 jspdf를 조합해서 클라이언트에서 처리해왔어요. 이번 사이드 프로젝트에서는 서버에서 PDF를 생성하는 방식을 써보고 싶었고, 그 과정에서 Puppeteer를 쓰게 됐어요.
왜 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를 열어보니 이미지가 모두 깨져 있었어요. 원인은 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을 넘기기 전에 상대 경로를 절대 경로로 변환해주면 해결돼요.
CSS와 폰트가 모두 사라지는 문제
이미지를 고치고 나니까 이번엔 스타일이 전혀 적용되지 않았어요. 프로젝트가 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처럼 서버리스 기반이라면 이 조합이 현실적인 선택이에요.