back

Posts

Next.js에서 pdf다운하기

2025-01-16

그동안 PDF다운로드 기능은 html2canvas와 jspdf를 사용해서 클라이언트측에서 구현했지만, 이번에 사이드 프로젝트를 하면서 서버에서 구현을 하고 싶어 ChatGPT도움을 받아 로직을 구성했습니다.

기본적인 흐름은 다음과 같은데요

  1. 프론트에서 HTML생성
  2. 생성된 HTML을 서버로 전송
  3. 서버에서 PDF파일 구성 후 프론트로 다시 전달

위와 같은 흐름을 생각하고 GPT에게 요청하니 puppeteer 라이브러리를 추천해주었습니다.

puppeteer란?

puppeteer는 Headless Chrome 또는 Chromium 브라우저를 제어하기 위한 Node.js 라이브러리로 구글이 만들었습니다. 주로 웹 스크래핑 혹은 테스트에 사용한다고 하는데 pdf생성해도 딱 맞아서 이번에 사용해보기로 결정했습니다.

클라이언트 로직 구성

클라이언트 측 로직은 간단한데요, pdf로 만들기 원하는 HTML을 서버로 전송 후, response를 a태그를 생성해서 다운로르 하면 끝입니다.

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버전으로 처음 구현하느라 공식문서를 참고하는 시간이 더 길었네요)

전체코드는 아래와 같습니다.

import { NextRequest, NextResponse } from "next/server";
import puppeteer from "puppeteer";

// 응답을 캐싱하지 않고 매번 재응답
export const dynamic = "force-static";

export async function POST(req: NextRequest) {
  const { htmlContent } = await req.json();
  if (!htmlContent) {
    return NextResponse.json(
      { message: "HTML content is required" },
      { status: 400 }
    );
  }

  try {
    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 }
    );
  }
}

위 코드는 언핏보면 잘 동작하는 것 같지만 두가지 문제가 있었어요

이미지가 안 보이네?

위 코드를 실행하니 이미지가 모두 깨져서 출력되는 문제가 발생했습니다. 우선 프론트 로직 구조상 이미지는 모두 내부파일을 사용하고 있었습니다. 그래서 경로는 "/icons/testImage.svg"와 같이 상대경로 되어있었죠. 여기서 문제가 발생했습니다.

<img src="/techstack/back/nestjs.svg"> 서버로 넘겨주는 HTML의 img태그의 src경로가 상대경로로 되어있어 이미지 인식을 못했던겁니다. 즉, 서버에서 이미지 경로를 절대경로로 변경해야 하는거죠

htmlContent변수를 let으로 수정하고 다음과 같이 상대경로를 절대경로로 변경해주면 됩니다.

  const baseUrl = "http://서버주소"; //
  htmlContent = htmlContent.replace(/src="\/(.*?)"/g, `src="${baseUrl}/$1"`);

CSS와 폰트 어디갔니?

해당 프로젝트는 tailwind를 사용하고 있었는데요. 이 때문에 pdf를 출력하면 CSS가 모두 초기화되는 현상이 나타났습니다. 서버에선 tailwind가 없어서 className을 인식하지 못해서 당연한 현상이였죠

수정은 간단합니다. tawilwind CDN을 HTML에 추가해주고, 원하는 폰트 또한 스타일에 추가시켜주면 됩니다.

// Tailwind CDN 추가
  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>
 `;

이로써 서버측에서 pdf를 생성하는 로직을 구현 완료했습니다. 아직 다듬을 부분이 더 있지만, 이는 추후 다시 수정해봐야겠네요.