Hayden's Archive

서버에서 Gorgeous한 청구서 만들기 본문

개발노트

서버에서 Gorgeous한 청구서 만들기

_hayden 2022. 2. 27. 10:52

회사에서 고객이 작성한 내용을 바탕으로 청구서를 만드는데 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/

 

How to capture/take Selenium Screenshot as Full Page or Element?

Why is Screenshot required in Automation testing? How to take a Selenium screenshot Full page & of a particular element in Selenium ?

www.toolsqa.com

 

 

 

나의 경우 재사용성을 위해 크롬드라이버 설정과 자주 쓸 기능을 따로 컴포넌트로 만들었다. 

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를 고객의 생년월일로 암호화한 후 메일 발송