Hayden's Archive
서버에서 Gorgeous한 청구서 만들기 본문
회사에서 고객이 작성한 내용을 바탕으로 청구서를 만드는데 HTML을 PDF로 변환해야 하는 일이 생겼다.
원래는 클라이언트에서 자바스크립트 라이브러리를 사용하여 청구서 파일을 생성하고 서버에서는 생성된 파일만 받아서 S3에 업로드한 후 PDF를 암호화하여 메일로 발송해주기로 했다.
그런데 이 작업이 클라이언트에서 15초 가량이나 소요되고 클라이언트의 기기에 따라 HTML 화면이 조금씩 달라서 복잡한 청구서의 디자인을 잘 살릴 수 없다는 문제점이 있었다.
결국 해당 이슈는 서버로 넘어와서 비동기로 처리하게 됐다.
고객이 작성한 내용은 Thymeleaf를 통해 HTML에 파싱하면 되는데 문제는 HTML을 PDF로 변환하는 작업이었다.
자바에서 사용 가능한 관련 라이브러리를 다 찾아보고 그나마 파싱이 잘 되는 라이브러리가 iText7였는데 이마저도 깨졌다.
한글 폰트를 적용하기 전에는 완전 형태를 알아볼 수 없게 일그러졌고 한글 폰트 중 나눔고딕을 적용한 후에도 복잡한 UI는 여지없이 깨졌다. 간단한 CSS로 테스트했을 때는 인라인, 내부 스타일 모두 다 적용이 되었는데 복잡한 CSS는 적용이 안 되는 경우도 더러 있는 모양이다.
게다가 iText7 라이센스는 AGPL 라이센스로 소스 코드를 공개하거나 라이센스를 구매해야 한다. 방법이 따로 없고 정 필요하면 돈을 써야겠지만 그나마 잘 적용된다고 한들 깨지는 부분이 있는데 다른 방법이 필요했다.
그 다음으로 발견한 곳은 https://grabz.it/ 였다.
해당 회사는 2012년에 설립된 런던 회사로 보이며 캡처 기능을 제공하는데 HTML을 PDF로 변환할 때도 먼저 이미지로 변환한 후 PDF로 변환해주는 듯했다.
언어별로 API 문서가 잘 정리되어 있어서 API 문서만 읽고 너무 쉽게 HTML을 PDF로 변환하는 것을 성공했다. 심지어 깔끔하게 HTML 문서 그대로 PDF 변환되었고 화질 또한 좋았다.
성공 후 기뻐하다가 다시 침착하게 사이트를 둘러보는데 뒤늦게 Pricing 메뉴가 보였다. 그럼 그렇지. 이것 또한 사용량에 제한이 있었고 일정 사용량을 넘어가게 될 경우 돈을 지불해야 했다. 우선은 이 방법은 킵해두자.
고민 끝에 https://grabz.it/ 에서 힌트를 얻어 조금 우회해서 직접 PDF를 만들어보기로 했다.
HTML을 바로 PDF로 만들 게 아니라 HTML을 캡처해서 이미지 파일로 만든 뒤 이미지 파일을 PDF로 만들면 안 깨지지 않을까? 물론 그 전에 HTML을 PNG 확장자로 변환하는 작업도 해보긴 했는데 이 작업은 CSS가 왕창 깨졌다.
스크린샷, 그러니까 캡처가 필요했다!
자바에서 스크린샷을 어떻게 찍을 수 있지? 게다가 단순 HTML 파일도 아니고 사용자가 입력한 데이터가 반영된 HTML 파일이어야 하는데?
결국 이 작업은 웹 브라우저 쪽에서 이루어져야 할 작업이었다.
열심히 서치하다가 찾아낸 방법이 Selenium을 사용하는 것이었다. 웹 크롤링과 관련하여 JSoup은 사용해봤지만 셀레니움은 들어보기만 했을 뿐 직접 사용해본 적은 없었다. 이번 기회에 이렇게 사용해보는 거지. 심지어 오픈소스인데 사용 안 할 이유가 없었다.
우선 웹 브라우저쪽에서 작업이 이루어지므로 자바와 웹 브라우저를 연결해주는 드라이버가 필요했다.
나는 보편적으로 많이 사용하는 크롬을 선택했고 그에 따라 chromedriver를 이용했다.( 크롬드라이버 다운로드 : https://chromedriver.chromium.org/downloads ) 또 Full Screenshot을 찍기 위해 ashot 라이브러리가 필요하다. 거기에 스크린샷 파일을 생성할 때 난 Apache Commons IO를 사용했다.
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-chrome-driver</artifactId>
<version>3.141.5</version>
</dependency>
<dependency>
<groupId>ru.yandex.qatools.ashot</groupId>
<artifactId>ashot</artifactId>
<version>1.5.4</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.11.0</version>
</dependency>
다음 코드를 참고하여 응용했다.
https://www.toolsqa.com/selenium-webdriver/screenshot-in-selenium/
나의 경우 재사용성을 위해 크롬드라이버 설정과 자주 쓸 기능을 따로 컴포넌트로 만들었다.
package com.mycompany.common.components;
import org.apache.commons.io.FileUtils;
import org.openqa.selenium.*;
import org.openqa.selenium.chrome.ChromeDriver;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import ru.yandex.qatools.ashot.AShot;
import ru.yandex.qatools.ashot.Screenshot;
import ru.yandex.qatools.ashot.shooting.ShootingStrategies;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
@Component
public class MyWebDriver {
@Value("${base.url}")
private String baseUrl;
@Value("${user.username}")
private String username;
@Value("${user.password}")
private String password;
@Value("${web.driver.id}")
private String webDriverId;
@Value("${web.driver.path}")
private String webDriverPath;
public WebDriver init(String uri) {
System.setProperty(webDriverId, webDriverPath);
WebDriver driver = new ChromeDriver();
driver.get(baseUrl + uri);
WebElement usernameTextBox = driver.findElement(By.id("username"));
usernameTextBox.sendKeys(username);
WebElement passwordTextBox = driver.findElement(By.id("password"));
passwordTextBox.sendKeys(password);
WebElement loginForm = driver.findElement(By.className("form-signin"));
WebElement loginButton = loginForm.findElement(By.tagName("button"));
loginButton.click();
return driver;
}
public void takeDoubleSizeScreenshot(WebDriver driver, WebElement[] elements, String[] imgPaths) {
try {
hideScroll(driver);
driver.manage().window().maximize();
JavascriptExecutor executor = (JavascriptExecutor) driver;
executor.executeScript("document.body.style.zoom = '2'");
Screenshot fullScreenshot = new AShot().shootingStrategy(ShootingStrategies.viewportPasting(1000)).takeScreenshot(driver);
File screenshot = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE);
int y = 0;
for (int i = 0; i < elements.length; i++) {
int width = elements[i].getSize().getWidth();
int height = elements[i].getSize().getHeight();
BufferedImage elementImage = fullScreenshot.getImage().getSubimage(0, y, width * 2, height * 2);
ImageIO.write(elementImage, "PNG", screenshot);
FileUtils.copyFile(screenshot, new File(imgPaths[i]));
y += height * 2;
}
} catch (FileNotFoundException e) {
System.out.println("FileNotFoundException 발생 : " + e);
} catch (IOException e) {
System.out.println("IOException 발생 : " + e);
}
}
private void hideScroll(WebDriver driver) {
((JavascriptExecutor) driver).executeScript("document.documentElement.style.overflow = 'hidden';");
}
private void showScroll(WebDriver driver) {
((JavascriptExecutor) driver).executeScript("document.documentElement.style.overflow = 'visible';");
}
public void close(WebDriver driver) {
driver.quit();
}
}
- init()
- 먼저 사용할 웹 드라이버로 chromedriver 설정을 해준다. webDriverId는 webdriver.chrome.driver 이고 webDriverPath는 chromedriver.exe(윈도우) 또는 chromedriver(리눅스) 파일이 있는 경로로 지정하면 된다.
- 내가 캡처하려는 페이지는 스프링 시큐리티가 적용된 페이지로 로그인이 필요하다. 해당되는 DOM 요소를 선택하여 해당되는 값을 입력해주고 로그인 버튼을 클릭하여 로그인한다.
- 모든 설정이 끝나면 웹 드라이버를 반환한다.
- takeDoubleSizeScreenshot()
- 먼저 스크롤을 숨긴다. (숨기지 않으면 스크린샷에 스크롤이 나타난다)
- 브라우저 창을 최대화한다.
- 브라우저 화면을 2배 확대한다. (원래 크기로 캡처한 후 PDF로 만들어봤는데 화질이 너무 떨어졌다... 그래서 선택한 방법. 해보니까 2배 확대해서 캡처하고 PDF로 변환하면 화질이 어느 정도 보장된다.)
- Full Screenshot을 찍고 파일 객체를 생성한다.
- DOM 요소의 위치에 맞게 x 좌표, y 좌표, 가로, 세로 길이를 찾아서 Full Screenshot에서 자르고 이를 이미지 파일로 저장한다. (화면을 2배 확대하더라도 DOM 요소의 x 좌표, y 좌표, 가로, 세로 길이는 이전 값 그대로이므로 확대한 배율에 맞게 따로 계산해줘야 한다.)
- hideScroll()
- 스크롤을 숨겨준다.
- showScroll()
- 스크롤을 보여준다.
- close()
- 웹 드라이버를 종료한다.
위 컴포넌트를 실행하는 테스트 코드이다.
package com.mycompany.test;
import com.mycompany.common.components.MyWebDriver;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import java.io.File;
@RunWith(SpringRunner.class)
@SpringBootTest
public class MyWebDriverTest {
@Autowired private MyWebDriver myWebDriver;
@Test
public void takeScreenshot() {
final String URI = "/test";
final String PATH = "C:\\home\\ec2-user\\temp\\resource";
WebDriver driver = myWebDriver.init(URI);
final WebElement[] ELEMENTS = {driver.findElement(By.id("page1")),
driver.findElement(By.id("page2")),
driver.findElement(By.id("page3")),
driver.findElement(By.id("page4"))};
final String[] IMG_PATHS = {PATH + File.separator + "screenshot1.png",
PATH + File.separator + "screenshot2.png",
PATH + File.separator + "screenshot3.png",
PATH + File.separator + "screenshot4.png"};
myWebDriver.takeDoubleSizeScreenshot(driver, ELEMENTS, IMG_PATHS);
myWebDriver.close(driver);
}
}
이해를 돕기 위해 캡처하려는 청구서 페이지를 극도로 단순화시킨 것이다. 실제로는 CSS도 많이 쓰이고 구조도 복잡하다.
<html>
<body>
<div id="page1">
1페이지
</div>
<div id="page2">
2페이지
</div>
<div id="page3">
3페이지
</div>
<div id="page4">
4페이지
</div>
</body>
</html>
위와 같이 실행하면 4개의 스크린샷이 나오게 된다. 이 4개의 이미지를 모아서 1개의 PDF 파일로 만들기 위해 Apache PDFbox를 사용했다.
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>2.0.25</version>
</dependency>
편리하게 PDF로 변환하기 위해 PdfGenerator 객체를 만들었다. PDF 화면에 이미지를 그릴 때 쓰일 x 좌표, y 좌표, 가로 길이, 세로 길이를 지정해주면 총 4장의 PDF 파일 1개가 나오게 된다. 난 가로 길이, 세로 길이 자체를 따로 변수로 정하기보다 원래 가로, 세로 길이에서 몇 배를 하여 크기를 조정할 수 있게 하였다.
package com.mycompany.common.utils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
public class PdfGenerator {
public static void makeImgToPdf(float x, float y, float widthRatio, float heightRatio, String[] imgPaths, String pdfPath) {
PDDocument doc = null;
doc = new PDDocument();
try{
for(String imgPath : imgPaths) {
PDPage page = new PDPage();
doc.addPage(page);
BufferedImage awtImage = ImageIO.read( new File(imgPath) );
PDImageXObject pdImageXObject = LosslessFactory.createFromImage(doc, awtImage);
PDPageContentStream contentStream = new PDPageContentStream(doc, page, true, true);
contentStream.drawImage(pdImageXObject, x, y, (float)(awtImage.getWidth() * widthRatio), (float)(awtImage.getHeight() * heightRatio));
contentStream.close();
}
doc.save(pdfPath);
doc.close();
} catch (Exception e){
System.out.println("Exception : " + e);
}
}
}
이렇게 적절한 수치를 조정하여 실행하면 PDF 파일이 도출된다.
final String PATH = "C:\\home\\ec2-user\\temp\\resource";
final String[] IMG_PATHS = {PATH + File.separator + "screenshot1.png",
PATH + File.separator + "screenshot2.png",
PATH + File.separator + "screenshot3.png",
PATH + File.separator + "screenshot4.png"};
float x = 35;
float y = 20;
float widthRatio = 0.45f;
float heightRatio = 0.45f;
PdfGenerator.makeImgToPdf(x, y, widthRatio, heightRatio, IMG_PATHS, (PATH + File.separator + "result.pdf"));
여러 방법을 찾아보느라 엄청 서치하고 삽질도 많이 했지만 어떻게든 의지를 가지고 하니까 결국은 되네...ㅎㅎ
청구서가 제작되어 메일로 발송되는 프로세스는 다음과 같이 될 듯하다.
- 클라이언트에서 청구 내용을 입력하고 청구 신청 버튼 클릭
- 아래 일련의 과정들은 비동기로 처리하므로 고객은 청구 신청 버튼을 거의 클릭하자마자 청구 신청 완료 화면을 보게 된다.
- 서버로 요청이 날아오고 크롬드라이버로 고객이 입력한 청구 내용을 파싱한 페이지 접속 후 캡처
- 캡처한 스크린샷을 모아서 PDF로 생성
- 생성된 PDF를 고객의 생년월일로 암호화한 후 메일 발송