[Node.js] pug + tailwind + Puppeteer + Docker 로 프린터 출력용 PDF 만들기

개요

인터넷 쇼핑몰에서 견적서 같은 걸 자동으로 생성해주는 것을 볼 수 있는데요, 우리도 한번 구현해 봅시다!

전체적으로 Node.js 바탕으로 돌아갑니다. 우선 node 를 설치해주세요. 필자의 버전 기준은 node 14 입니다. docker 를 사용하고자 한다면, docker 와 docker-compose 까지 설치해주세요.

각 기술에 대한 간략한 설명

  1. pug: HTML 템플릿 엔진입니다. 직접 html 파일을 작성하는 것보다 훨씬 간편하고, 반복적인 작업을 수월하게 해줍니다. 그리고 파라미터를 받아서 달라지는 값들을 손쉽게 바꿀 수 있습니다. 실제로 사용되는 페이지를 구성하고 설계하려면 vue 또는 React 같은 프론트엔드 라이브러리를 쓰는 게 편하겠지만, 우리는 인터렉션이 전혀 필요없는 문서를 Html 로 만들 뿐입니다. 실제 html 에서 돌아가는 자바스크립트는 하나도 없으므로 pug 같은 템플릿 툴을 사용하면 편리합니다!
  2. tailwind : 개발자 친화적인 CSS 모음입니다. CDN 에서 기본적으로 제공해주는 css 만을 사용할 것입니다. 본래 Tailwind 는 각종 커스터마이징을 지원하기도 하고 @apply 와 같은 편리한 CSS 기능을 사용할 수도 있으나, 지금은 사용할 수 없습니다.
  3. Puppeteer: 크롬을 다룰 수 있게 해주는 Node.js 라이브러리입니다. HTML 문서를 PDF 로 바꾸는 데에는 애로사항이 많습니다. PDF 가 구성되는 방식이 HTML 과 완전히 다르기 때문인데, 그래서 우리는 검증된 출력 시스템을 이용해야 합니다. 인터넷을 찾아보면 생각보다 제대로 된 변환기가 없다는 사실을 알 수 있을 것입니다. 돈이 많다면 애초에 서버에서 돌아가는 프로그램 목적으로 제작된 Prince 와 같은 전문 소프트웨어를 사용할 수도 있지만, 음.. 네.. 380만원을 바로 태우기는 쉽지가 않지요? 그래서 크롬의 PDF 출력 시스템을 이용해봅시다.
  4. Docker: 개발 환경 및 배포 환경을 이미지화하여 각종 앱을 컨테이너로 관리할 수 있게 해주는 툴입니다. 로컬에서의 결과와 서버에서의 결과를 동일하게 수행할 수 있습니다. 실제로 Puppeteer 가 서버에서 돌아갈 때 어떤 앱 의존성에 의해 제대로 구동되지 않을 수 있으므로 그 부분을 보완해주기 위함입니다.

결과

출력 결과

깔끔하게 나쁘지 않쥬?

폴더 구조

프로젝트 루트 폴더
├── docker-compose.yml
├── Dockerfile
├── output
│   ├── print.html
│   └── test.pdf
├── package.json
├── package-lock.json
└── src
    ├── base-print.pug
    ├── index.js
    └── estimate.pug

pug

//- src/base-print.pug

doctype html

mixin printWrapper
  html(lang="ko")
    head
      meta(charset="UTF-8")
      meta(name="viewport" content="width=device-width, initial-scale=1.0")
      title PRINT HTML
      link(href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@100;300;400;500;700;900&display=swap" rel="stylesheet")
      link(href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet")
      
    body
      style.
        @page {
          size: A4;
          margin: 70pt 60pt 70pt;
        }
        body {
          font-size: 10pt;
          font-family: 'Noto Sans KR', Arial, Helvetica, sans-serif;
          -webkit-print-color-adjust: exact;
          line-height: 1.5;
        }
        
      .main 
        block
//- src/estimate.pug
include base-print.pug

- 
  /** 
    * @typedef {Object} EstimateContentRow
    * @property {string} type 구분
    * @property {string} name 품명
    * @property {string} standard 규격
    * @property {string} count 수량
    * @property {string} unitCostCommaed 단가
    * @property {string} suppliedCostCommaed 공급가액
    * @property {string} etc 비고
    */

  /** 
    * @typedef {Object} EstimateContent
    * @property {EstimateContentRow} 1 1번째 행
    * @property {EstimateContentRow} 2 2번째 행
    * @property {EstimateContentRow} 3 3번째 행
    * @property {EstimateContentRow} 4 4번째 행
    * @property {EstimateContentRow} 5 5번째 행
    * @property {EstimateContentRow} 6 6번째 행
    * @property {EstimateContentRow} 7 7번째 행
    * @property {EstimateContentRow} 8 8번째 행
    * @property {EstimateContentRow} 9 9번째 행
    * @property {EstimateContentRow} 10 10번째 행
    * @property {EstimateContentRow} 11 11번째 행
    */
  
  /**
    * @typedef {Object} PrintEstimateArgs
    * @property {string} dateString 날짜 
    * @property {string} repicientCompanyName 날짜 
    * @property {string} companyPlace 사업장 소재지
    * @property {string} chiefName 대표자 성명
    * @property {string} chiefPhone 대표자 전화번호
    * @property {string} totalPriceHangul 총 금액 한글표기
    * @property {string} totalPriceCommaed 총 금액 쉼표포맷팅 (예: 20,000)
    * @property {EstimateContent} estimateContent 본문 내용
    * @property {string} suppliedCostSumCommaed 공급가액 합계 (예: 120,000)
    */

- 
  const sl = [
    {
      title: '날짜',
      content: dateString,
    },
    {
      title: '수신',
      content: companyPlace
    },
    {
      title: '참조',
      content: ''
    },
  ]
  
  const estimateContentWrapper = {
    1: {},
    2: {},
    3: {},
    4: {},
    5: {},
    6: {},
    7: {},
    8: {},
    9: {},
    10: {},
    11: {},
    ...estimateContent
  }

- 
  const sr = [
    {
      title: '사업장 소재지',
      content: companyPlace
    },
    {
      title: '상호',
      content: '가나다라 회사'
    },
    {
      title: '사업자등록번호',
      content: 'xxx-xx-xxxxx', 
    },
    {
      title: '대표자 성명',
      content: `${chiefName}     (인)`,
    },
    {
      title: '전화번호',
      content: chiefPhone,
    },
  ]


+printWrapper
  style.
    .-m-1px {
      margin-left: -1px;
      margin-top: -1px;
    }
    .table {
      display: grid;
      grid-template-columns: 5fr 10fr 18fr 5fr 5fr 10fr 10fr 10fr;
      grid-template-rows: repeat(17, 1fr) 4fr;
    }
    .table div {
      border: 1px solid #000;
      padding: 5px;
      margin-left: -1px;
      margin-top: -1px;
    }

  - centerClass = ["flex", "items-center", "place-content-center"]

  mixin spans(text)
    - split = text.split('')
    each ch, index in split
      span #{ch}

  mixin center
    .flex.items-center.place-content-center&attributes(attributes)
      block
  
  h1.text-3xl.text-center.mb-16 견     적     서
  .mb-5.flex.justify-between
    .flex-0.mr-20
      each val, index in sl
        .flex.border-b.border-gray-800.p-2
          .flex-0.w-14.flex.items-center #{val.title}
          .flex-0.w-40.text-right #{val.content}
    .flex-0
      each val, index in sr
        .flex
          .flex-0.w-32.-m-1px.border.border-gray-800.font-bold.px-2.py-1.flex.items-center #{val.title}
          .flex-0.w-60.-m-1px.border.border-gray-800.px-2.py-1 #{val.content}
  p.mb-10 아래와 같이 견적합니다.
  .table.ml-1
    .col-span-2.row-span-2.bg-gray-100.font-bold.text-center(class=[...centerClass]) 합계금액<br>(공급가액 + 세액)
    .col-span-6.row-span-2.text-xl.font-bold(class=[...centerClass]) #{totalPriceHangul || '오십오만원정'} (₩ #{totalPriceCommaed || '550,000'})
    .bg-gray-100.font-bold(class=[...centerClass]) No.
    .bg-gray-100.font-bold(class=[...centerClass]) 구분
    .bg-gray-100.font-bold(class=[...centerClass]) 품 명
    .bg-gray-100.font-bold(class=[...centerClass]) 규격
    .bg-gray-100.font-bold(class=[...centerClass]) 수량
    .bg-gray-100.font-bold(class=[...centerClass]) 단가
    .bg-gray-100.font-bold(class=[...centerClass]) 공급가액
    .bg-gray-100.font-bold(class=[...centerClass]) 비고
    each row, num in estimateContentWrapper
      div(class=[...centerClass]) #{num}
      div(class=[...centerClass]) #{row.type}
      div(class=[...centerClass]) #{row.name}
      div(class=[...centerClass]) #{row.standard}
      div(class=[...centerClass]) #{row.count}
      .flex.justify-between 
        if row.unitCostCommaed
          span ₩
          span #{row.unitCostCommaed}
      .flex.justify-between 
        if row.suppliedCostCommaed
          span ₩
          span #{row.suppliedCostCommaed}
      div(class=[...centerClass]) #{row.etc}
    .col-span-6.flex(class=[...centerClass]) 
      p.w-24.flex.justify-between
        +spans('관리비')
    div
    div 
    .col-span-6.flex(class=[...centerClass]) 
      p.w-24.flex.justify-between
        +spans('기업이윤')
    div
    div
    .col-span-6.bg-gray-100.font-bold(class=[...centerClass])
      p.w-24.flex.justify-between
        +spans('합계')
    .flex.justify-between.bg-gray-100
      if suppliedCostSumCommaed
        span ₩
        span #{suppliedCostSumCommaed}
    .bg-gray-100(class=[...centerClass]) 부가세 별도
    .col-span-8.row-span-4 [MEMO]

우선 크게 두 파일로 나눴습니다. 바로 base-print.pug 파일과 estimate.pug 파일입니다.

base-print.pug 파일은 일반적인 프린트 용도로 사용할 문서 설정을 해주는 곳입니다. 글꼴 설치, tailwind css 불러오기, css 범용 설정, 프린트할 때만 적용되는 css 설정을 할 수 있습니다. @page 규칙이 프린트될 때 설정되는 것들 입니다. @page 규칙과 관련된 더 자세한 내용은 A Guide To The State Of Print Stylesheets In 2018 이런 걸 참고하면 좋습니다. 2018년이라고 해도 거의 내용은 비슷합니다.

estimate.pug 파일은 견적서 용도로 사용할 문서를 설정해주며, 앞선 파일에서 생성된 printWrapper 믹스인을 불러옵니다. 스타일은 거의 모두 tailwind 를 사용하도록 하여 css 를 불필요하게 직접 코딩하지 않도록 했습니다. 반복되는 것들은 for ... in 문을 사용하였습니다. pug 의 문법은 공식 문서를 계속 더더욱 참조해주세요.

pug 문서 내부에서는 실제로 자바스크립트가 작동될 수도 있지만, 좀 더 템플릿이라는 기능에 충실하기 위해 최대한 모든 지역 변수 arg를 string 으로 받고자 했습니다. 어떤 변수를 어떻게 받아들이는지에 대한 문서화를 검색해도 정보가 없어, 일단 - 로 자바스크립트 블록인 걸 알린 다음 jsdoc 스타일로 문서화했습니다. 뭐 나중에 복사 붙여넣든.. 사용하기 용이할 것입니다.

Javascript

// src/index.js
const puppeteer = require("puppeteer");
const fs = require("fs");
const path = require("path");
const pug = require("pug");
const fileurl = require("file-url");
const { exit } = require("process");

async function printPDF() {
  const browser = await puppeteer.launch({
    headless: true,
    args: [
      // Required for Docker version of Puppeteer
      "--no-sandbox",
      "--disable-setuid-sandbox",
      // This will write shared memory files into /tmp instead of /dev/shm,
      // because Docker’s default for /dev/shm is 64MB
      "--disable-dev-shm-usage",
    ],
  });
  const compile = pug.compileFile(path.resolve(__dirname, "estimate.pug"));
  const html = compile({
    dateString: "2021-03-20",
    repicientCompanyName: "레고 주식회사",
    companyPlace: "부산시 서구 몰라리동 맨맨",
    chiefName: "홍길동",
    chiefPhone: "010-1234-5678",
    totalPriceHangul: "육십육만원정",
    totalPriceCommaed: "660,000",
    estimateContent: {
      2: {
        type: "상품",
        name: "슈퍼 울트라 좋은 것",
        standard: "-",
        count: "1EA",
        unitCostCommaed: "600,000",
        suppliedCostCommaed: "600,000",
        etc: "",
      },
    },
    suppliedCostSumCommaed: "",
  });

  const htmlPath = path.resolve(__dirname, "..", "output", "print.html");
  fs.promises.writeFile(htmlPath, html);
  const htmluri = fileurl(htmlPath);

  const page = await browser.newPage();
  await page.goto(htmluri, {
    waitUntil: "networkidle0",
  });

  const pdf = await page.pdf({ format: "a4" });

  await browser.close();
  return pdf;
}

printPDF()
  .then((buf) => {
    const writer = fs.createWriteStream(
      path.resolve(__dirname, "../output/test.pdf")
    );
    writer.write(buf);
    writer.end();
  })
  .catch((err) => {
    console.error(err);
    exit(2);
  });

puppetter.launch 할 때 전달하는 args 에 대해서 한번 더 살펴보면, 다음과 같다는 걸 알 수 있습니다.

[
  "--no-sandbox",
  "--disable-setuid-sandbox",
  "--disable-dev-shm-usage",
]

pug 는 complie (혹은 compileFile) 함수를 통해 일단 파일을 읽어들입니다. 그 다음 render 함수에서 실제 html 로 생성할 때 arg로 값들을 전달합니다.

file-url 라이브러리는 단순히 로컬 파일을 uri 형태로 변환하기 위함입니다.

이제 node src/index.js 명령을 실행시키면 아마 제대로 실행이 될 것입니다! (만약 node . 이런 식의 간단한 명령어로 실행하고 싶다면 package.json 에서 main 키의 내용을 "src/index.js" 이런 식으로 설정해주면 됩니다.)

Docker

Docker 부분에서 보안 조치는 별로 하지 않았습니다. 어차피 컨테이너 안에서 돌아갈 것이고, 실제 웹사이트에 접속하는 것이 아닌 직접 생성한 html 파일을 출력하고자 하기 때문에 알 수 없는 페이지에 접속하는 등의 보안이 불안한 부분은 크게 생기지 않으리라 생각됩니다. 그래서 node 앱 실행될 때 root 권한으로 실행되도록 했습니다. 그래서 puppeteer 에서도 --no-sandbox 인자를 추가해줘야 제대로 동작합니다. 실제 배포 환경에서 사용할 때에는 Puppeteer 문서에서 알려주듯이 USER 를 따로 설정해주어 보안을 강화하는 것이 나을 것 같습니다. 필자는 아래와 같이 Dockerfile 을 설정했습니다.

# Dockerfile

FROM node:14 as node-install

COPY ./package*.json ./

RUN npm install

FROM node:14

RUN apt-get update \
    && apt-get install -y wget gnupg \
    && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
    && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
    && apt-get update \
    && apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-freefont-ttf libxss1 fonts-noto-cjk \
      --no-install-recommends \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

RUN ["mkdir", "output"]

COPY --from=node-install /node_modules ./node_modules
COPY --from=node-install package*.json ./
COPY ./src src


ENTRYPOINT [ "node", "."]

apt-get 등 앱 설치 단계 이후 npm install 를 넣게 되면 앱 설치 구성을 바꿀 때마다 사실은 전혀 영향이 없는 npm install 까지 다시 수행하기 때문에 multi-stage build 기능을 간단하게 이용했습니다.

한글 폰트를 설정해봅시다. 2021년 초 기준으로 범용적으로 많이 쓰이고 무난하게 예쁜 Noto Sans KR 를 설치하도록 합니다. 이는 apt-get install -y 에서 fonts-noto-cjk 로 설치하고 있는 것을 확인할 수 있습니다. 사실 글꼴을 별도로 설치할 필요가 없는데요, pug 에서 이미 웹폰트를 사용하고 있기 때문에 제대로 로딩이 됩니다. 다만 만일의 경우를 위해 시스템 기본 글꼴로써 동작할 수 있도록 해줍시다.

이제 docker-compose 로 실행을 시켜 봅시다.

# docker-compose.yml
version: '3'

services: 
  test:
    build: .
    volumes:
      - ./output:/app/output

사실 docker-compose 는 정말 별 거 없습니다. 그냥 volume 설정조차 일일히 하는 걸 줄이고자 했습니다.

이제 다음 명령어로 실행하면 앱이 구동되어 output 폴더에 원하는 결과가 등장합니다.

sudo docker-compose up --build

To do

저 템플릿에는 날인(도장)이 없습니다. 직접 잘 추가해주세요.. ㅎ.. 뭐.. 템플릿은 어차피 참고용이니까 하하.

2 thoughts on “[Node.js] pug + tailwind + Puppeteer + Docker 로 프린터 출력용 PDF 만들기

  1. 안녕하세요!

    블로그 잘 보고있습니다.

    다름이 아니고 이번에 저도 견적서를 간단한 입력만으로 자동으로 작성하는 프로그램을 만드려고 하는데요.

    써주신 글이 딱 제가 찾고 구현하려는 모양이네요.

    다만 올려주신 글을 보고도 해보는데 막힘이 많은데요.

    혹시 저 표를 그리는 부분에 대해 혹시 참조하신 git repository 나 사이트가 있을까요?

    감사합니다.

  2. 안녕하세요. 블로그 글 잘 보고 있습니다.

    위 글을 보고 견적서 작성 양식을 만들어 보고 있는데요.

    node index.js 를 실행하면 “require is not defined in ES module scope” 메시지가 뜨는데.. 혹시 참조할 만한 git repository 나 사이트가 없을까요?

gdpark에 답글 남기기 응답 취소

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

Scroll to top