개요
인터넷 쇼핑몰에서 견적서 같은 걸 자동으로 생성해주는 것을 볼 수 있는데요, 우리도 한번 구현해 봅시다!
전체적으로 Node.js 바탕으로 돌아갑니다. 우선 node 를 설치해주세요. 필자의 버전 기준은 node 14 입니다. docker 를 사용하고자 한다면, docker 와 docker-compose 까지 설치해주세요.
각 기술에 대한 간략한 설명
- pug: HTML 템플릿 엔진입니다. 직접 html 파일을 작성하는 것보다 훨씬 간편하고, 반복적인 작업을 수월하게 해줍니다. 그리고 파라미터를 받아서 달라지는 값들을 손쉽게 바꿀 수 있습니다. 실제로 사용되는 페이지를 구성하고 설계하려면 vue 또는 React 같은 프론트엔드 라이브러리를 쓰는 게 편하겠지만, 우리는 인터렉션이 전혀 필요없는 문서를 Html 로 만들 뿐입니다. 실제 html 에서 돌아가는 자바스크립트는 하나도 없으므로 pug 같은 템플릿 툴을 사용하면 편리합니다!
- tailwind : 개발자 친화적인 CSS 모음입니다. CDN 에서 기본적으로 제공해주는 css 만을 사용할 것입니다. 본래 Tailwind 는 각종 커스터마이징을 지원하기도 하고
@apply
와 같은 편리한 CSS 기능을 사용할 수도 있으나, 지금은 사용할 수 없습니다. - Puppeteer: 크롬을 다룰 수 있게 해주는 Node.js 라이브러리입니다. HTML 문서를 PDF 로 바꾸는 데에는 애로사항이 많습니다. PDF 가 구성되는 방식이 HTML 과 완전히 다르기 때문인데, 그래서 우리는 검증된 출력 시스템을 이용해야 합니다. 인터넷을 찾아보면 생각보다 제대로 된 변환기가 없다는 사실을 알 수 있을 것입니다. 돈이 많다면 애초에 서버에서 돌아가는 프로그램 목적으로 제작된 Prince 와 같은 전문 소프트웨어를 사용할 수도 있지만, 음.. 네.. 380만원을 바로 태우기는 쉽지가 않지요? 그래서 크롬의 PDF 출력 시스템을 이용해봅시다.
- 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
저 템플릿에는 날인(도장)이 없습니다. 직접 잘 추가해주세요.. ㅎ.. 뭐.. 템플릿은 어차피 참고용이니까 하하.
안녕하세요!
블로그 잘 보고있습니다.
다름이 아니고 이번에 저도 견적서를 간단한 입력만으로 자동으로 작성하는 프로그램을 만드려고 하는데요.
써주신 글이 딱 제가 찾고 구현하려는 모양이네요.
다만 올려주신 글을 보고도 해보는데 막힘이 많은데요.
혹시 저 표를 그리는 부분에 대해 혹시 참조하신 git repository 나 사이트가 있을까요?
감사합니다.
안녕하세요. 블로그 글 잘 보고 있습니다.
위 글을 보고 견적서 작성 양식을 만들어 보고 있는데요.
node index.js 를 실행하면 “require is not defined in ES module scope” 메시지가 뜨는데.. 혹시 참조할 만한 git repository 나 사이트가 없을까요?