Spring Boot + Playwright + Docker로 PDF 생성하기

조제·2025년 4월 23일
1

Playwright를 활용해 웹페이지를 PDF로 변환해야 할 일이 생겼다.
단순한 HTML → PDF 변환이 아니라 실제 프론트 웹페이지를 브라우저처럼 렌더링해서 캡처해야 했다.

Playwright는 Node.js 기반 도구지만, Java 바인딩도 꽤 잘 되어 있다.
문제는 Docker 환경에서 브라우저 설치, 실행 경로, 캐시 관리가 다소 까다롭다는 점.

이번 글에선:

  • Spring Boot에서 Playwright를 연동하는 구조
  • Docker 이미지 빌드시 Playwright 브라우저를 미리 설치하는 방식
  • 실제로 삽질한 부분과 해결 방법

을 간단하게 정리했다.

"PDF 렌더링 때문에 백엔드에서 브라우저를 띄운다고?" 싶은 분들에게 도움이 될 수 있다.


Spring Boot에서 Playwright를 연동하는 구조

Playwright는 원래 Node.js 생태계에서 사용하는 브라우저 자동화 도구다.
하지만 공식 Java 바인딩도 제공하기 때문에 Spring Boot 프로젝트에서도 충분히 쓸 수 있다.

1. 의존성 추가

dependencies {
    implementation 'com.microsoft.playwright:playwright:1.51.0'
}

버전은 공식 Playwright 릴리즈에 맞춰서 최신 걸 쓰면 된다.

2. 서비스 사용

@Service
@RequiredArgsConstructor
public class PdfRenderService {

    public byte[] renderToPdf(String url) {
        try (
                Playwright playwright = Playwright.create();
                Browser browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setChannel("chromium"));
                BrowserContext context = browser.newContext(new Browser.NewContextOptions()
                        .setViewportSize(command.getWidth(), 1080)
                        .setJavaScriptEnabled(true));
                Page page = context.newPage()
        ) {

            page.navigate(url);
            return page.pdf();
        }
    }
}
  • 요청 시마다 매번 새로운 Playwright와 브라우저 객체, 브라우저 컨텍스트, 페이지를 생성하는 구조
  • 페이지를 열고 .navigate(url) → .pdf()로 PDF 바이트 배열 반환

Docker 이미지 빌드시 Playwright 브라우저를 미리 설치하는 방식

Playwright는 단순히 jar만 있다고 돌아가는 게 아니다.
브라우저 바이너리 설치가 되어 있어야 실행이 된다.
즉, Docker 환경에선 브라우저 설치까지 직접 해줘야 한다.

1. Playwright 설치 task 추가

tasks.register('installPlaywright', JavaExec) {
	description = 'Install Playwright browsers'
	mainClass = 'com.microsoft.playwright.CLI'
	args = ['install-deps']
	classpath = configurations.runtimeClasspath
}

gradle로 Playwright를 설치하기 위해 build.gradle에 설치 task를 추가해준다.
아래 명령어를 gradle task로 만든것이다.
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install-deps"

install-deps 명령어를 통해 종속성 라이브러리를 같이 설치할 수 있다.
참고: https://playwright.dev/java/docs/browsers#install-system-dependencies

2. DockerFile 작성

FROM eclipse-temurin:21-jdk

### Playwright 시작
# Gradle 빌드 스테이지
COPY . /app
WORKDIR /app

# Gradle 래퍼를 사용하여 Playwright 설치
RUN chmod +x ./gradlew
RUN ./gradlew installPlaywright
### Playwright 끝

ARG JAR_FILE=/build/libs/spring-playwright-pdf-sample-0.0.1-SNAPSHOT.jar

COPY ${JAR_FILE} /spring-playwright-pdf-sample.jar

ENTRYPOINT ["sh", "-c", "java -Xmx1g -jar /spring-playwright-pdf-sample.jar"]

위에서 만들어둔 installPlaywright task를 통해 도커 이미지에 Playwright를 미리 설치해둔다.

마무리

이렇게 구성하면 다음이 보장된다:

  • 매번 ec2 마다 Playwright 설치해줄 필요 없음
  • 인프라 이식성 높음 (어디서든 동일한 환경 보장)

실제로 삽질한 부분

1. 최초 PDF 생성시 오래걸림

도커환경에서 프로젝트를 실행한 후 처음 PDF 생성하면 첫 요청만 오래걸리고 이후 요청은 빠르게 생성되는 이슈가 있었다.

첫 요청시에 프로젝트에서 Playwright 브라우저를 설치하는게 원인인데 도커 이미지를 만들때 이미 Playwright를 설치했는데 부트앱에서 또 설치하는 이유는 모르겠다.

나는 첫 요청시에만 오래걸리니까 부트앱 실행시에 바로 PDF를 생성하는 Warmup 방식으로 해결했다.

@Slf4j
@Component
public class PlaywrightWarmup {

    @PostConstruct
    public void warmUp() {
        log.info("🔄 Playwright Warm-up 시작...");

        long startTime = System.currentTimeMillis();

        try (Playwright playwright = Playwright.create();
                Browser browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setChannel("chromium"));
                BrowserContext context = browser.newContext();
                Page page = context.newPage()) {

            String libVersion = Playwright.class.getPackage().getImplementationVersion();
            log.info("📦 [Playwright] 라이브러리 버전: {}", libVersion != null ? libVersion : "알 수 없음");
            log.info("✅ [Playwright] 브라우저 실행 완료: version={}", browser.version());

            page.navigate("about:blank");
            page.waitForLoadState(LoadState.NETWORKIDLE);
            page.pdf(new Page.PdfOptions().setPrintBackground(true).setWidth("800px").setHeight("1000px"));

            log.info("✅ Playwright Warm-up 완료! ({}ms)", (System.currentTimeMillis() - startTime));
        } catch (Exception e) {
            log.error("❌ Playwright Warm-up 실패", e);
        }
    }

}

실행로그

project-app       | 2025-04-23T06:32:09.615Z  INFO 7 --- [spring-playwright-pdf-sample] [           main] c.p.project.config.PlaywrightWarmup      : ✅ [Playwright] 브라우저 실행 완료: version=134.0.6998.35
project-app       | 2025-04-23T06:32:10.132Z  INFO 7 --- [spring-playwright-pdf-sample] [           main] c.p.project.config.PlaywrightWarmup      : ✅ Playwright Warm-up 완료! (72586ms)
project-app       | 2025-04-23T06:32:10.475Z  INFO 7 --- [spring-playwright-pdf-sample] [           main] p.p.SpringPlaywrightPdfSampleApplication : Started SpringPlaywrightPdfSampleApplication in 74.433 seconds (process running for 74.689)

단점은 부트앱 부팅시간이 길어졌다.

2. 배포 시간 문제

실제로 아래 샘플 프로젝트에서 docker-compose up --build 명령어를 실행할경우 이미지를 만드는데 대략 3분정도 소요된다.

실무에서 실제 배포하는데 이미지 생성 3분 + 최초 설치 1분 해서 대략 5분 정도 소요되었다.
게다가 도커 이미지에 Playwright가 같이 포함되기 때문에 이미지 용량도 컸다.

REPOSITORY                                          TAG                    IMAGE ID       CREATED          SIZE
spring-playwright-pdf-sample-project-app            latest                 f9bfc0e42d57   4 minutes ago    2.16GB

그래서 로컬에서 테스트한 후에는 docker system prune 명령어를 통해 이미지를 정리해줘야했다.

3. 멀티 스레딩

처음엔 성능 최적화를 위해
Playwright 객체를 미리 쓰레드풀에 생성해두고
멀티스레드로 처리하려고 했다.

@Getter
public class PlaywrightThread extends Thread {

    private final Playwright playwright;
    private final Browser chromium;
    private Runnable r;

	...
}

참고: https://github.com/DennisOchulor/playwright-java-multithread

그런데 결과는?
성능 테스트를 해봤다.
요청마다 Playwright 객체를 새로 만드는 방식과 큰 차이가 없었다.

이유는?
도커 이미지에 설치된 Playwright 브라우저가 싱글 인스턴스로 동작하기 때문에
Java 쪽에서 아무리 멀티 스레드로 만들어도 병렬 효과가 나지 않는다.

결론적으로 멀티스레드 처리보다 요청마다 새로 만들어서 쓰고 버리는 게 더 단순하고 안정적이었다.

4. PDF 생성 시 한글 간격 깨짐 이슈

Playwright로 PDF 만들 때
크롬에서 보는 화면이랑 한글 간격이 다르게 출력되는 문제가 있었다.

심지어 Playwright 버전을 최신으로 올려도 해결되지 않았다.
(릴리즈 기준 최신까지 다 테스트해봄)

해결방법

문제 원인은 생각보다 단순했다.

기존 headless 모드는 실제 크롬 브라우저랑 출력 방식이 달랐다.

공식문서를 찾아보니까
New Headless 모드라는 게 도입되었고,
더 신뢰할 수 있고 안정적이라고 되어 있었다.

import com.microsoft.playwright.*;

public class Example {
  public static void main(String[] args) {
    try (Playwright playwright = Playwright.create()) {

      // ✅ 기존 방식 (문제 생김)
      Browser oldBrowser = playwright.chromium()
        .launch(new BrowserType.LaunchOptions().setHeadless(true));
      
      // ✅ New Headless 방식 (화면과 PDF가 동일)
      Browser newBrowser = playwright.chromium()
        .launch(new BrowserType.LaunchOptions().setChannel("chromium"));  // 핵심 포인트
      Page page = newBrowser.newPage();
      // ...
    }
  }
}

내 로컬에서 띄운 브라우저 화면과 Playwright로 생성한 PDF 화면이 한글 간격 포함해서 완전히 일치했다.
참고: https://playwright.dev/java/docs/browsers#chromium-new-headless-mode

참고사항

페이지 높이 가져오는 스크립트

int totalHeaight = (int) page.evaluate("() => document.body.scrollHeight");

document.body.scrollHeight 쓰면 전체 높이를 바로 알 수 있다.
웹페이지를 PDF 한 페이지로 만들때 활용할 수 있다.

페이지 끝까지 스크롤 (Lazy Load 이미지 로딩용)

private void performLazyLoadScroll(Page page) {
    page.evaluate("""
        async () => {
            await new Promise((resolve) => {
                const distance = 1000;
                let totalHeight = 0;
                const scrollHeight = document.body.scrollHeight;

                const scroll = () => {
                    window.scrollBy(0, distance);
                    totalHeight += distance;

                    if (totalHeight < scrollHeight) {
                        setTimeout(scroll, 100); // 빠른 스크롤
                    } else {
                        setTimeout(resolve, 200); // 마지막 이미지 로딩 대기
                    }
                };

                scroll();
            });
        }
    """);
}

Lazy Load 이미지가 있을 경우 꼭 해야함.

설명

  • distance: 한 번에 얼마만큼 스크롤할지
  • setTimeout: 너무 빨리 스크롤하면 로딩이 안될 수 있으니 약간 텀 줌
  • resolve(): 마지막까지 스크롤 다 하면 PDF 찍도록 넘어감

주의사항 : 페이지가 길수록 스크롤에 시간이 꽤 소요됨

페이지 내 API 요청 추적 가능함

API 요청/응답도 추적 가능하다.
특히, 어떤 API가 반드시 호출되고 끝났는지 확인하고 싶을 때 유용함.

예시)

private void setupRequestTracking(Page page, List<String> pendingRequests) {
    page.onRequest(request -> {
        String requestUrl = request.url();
        if (isApiRequest(requestUrl)) {
            pendingRequests.add(requestUrl);
            log.info("API request started: {}", requestUrl);
        }
    });

    page.onResponse(response -> {
        String responseUrl = response.url();
        if (isApiRequest(responseUrl)) {
            pendingRequests.remove(responseUrl);
            log.info("API response received: {}", responseUrl);
        }
    });

    page.onRequestFailed(request -> {
        String requestUrl = request.<url();
        if (isApiRequest(requestUrl)) {
            pendingRequests.remove(requestUrl);
            log.info("API request failed: {}", requestUrl);
        }
    });
}

설명

  • page.onRequest: 요청 시작 감지
  • page.onResponse: 응답 도착 감지
  • page.onRequestFailed: 실패한 요청도 추적 가능

List<String> pendingRequests 같은 리스트로
아직 끝나지 않은 API 요청을 따로 관리할 수 있음.

PDF 생성 전에 모든 API 응답이 끝났는지 확인해야할때 활용할 수 있다.


Git: https://github.com/whwp4151/spring-playwright-pdf-sample

profile
조제

0개의 댓글