Playwright를 활용해 웹페이지를 PDF로 변환해야 할 일이 생겼다.
단순한 HTML → PDF 변환이 아니라 실제 프론트 웹페이지를 브라우저처럼 렌더링해서 캡처해야 했다.
Playwright는 Node.js 기반 도구지만, Java 바인딩도 꽤 잘 되어 있다.
문제는 Docker 환경에서 브라우저 설치, 실행 경로, 캐시 관리가 다소 까다롭다는 점.
이번 글에선:
을 간단하게 정리했다.
"PDF 렌더링 때문에 백엔드에서 브라우저를 띄운다고?" 싶은 분들에게 도움이 될 수 있다.
Playwright는 원래 Node.js 생태계에서 사용하는 브라우저 자동화 도구다.
하지만 공식 Java 바인딩도 제공하기 때문에 Spring Boot 프로젝트에서도 충분히 쓸 수 있다.
dependencies {
implementation 'com.microsoft.playwright:playwright:1.51.0'
}
버전은 공식 Playwright 릴리즈에 맞춰서 최신 걸 쓰면 된다.
@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는 단순히 jar만 있다고 돌아가는 게 아니다.
브라우저 바이너리 설치가 되어 있어야 실행이 된다.
즉, Docker 환경에선 브라우저 설치까지 직접 해줘야 한다.
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
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를 미리 설치해둔다.
이렇게 구성하면 다음이 보장된다:
도커환경에서 프로젝트를 실행한 후 처음 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)
단점은 부트앱 부팅시간이 길어졌다.
실제로 아래 샘플 프로젝트에서 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
명령어를 통해 이미지를 정리해줘야했다.
처음엔 성능 최적화를 위해
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 쪽에서 아무리 멀티 스레드로 만들어도 병렬 효과가 나지 않는다.
결론적으로 멀티스레드 처리보다 요청마다 새로 만들어서 쓰고 버리는 게 더 단순하고 안정적이었다.
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 한 페이지로 만들때 활용할 수 있다.
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가 반드시 호출되고 끝났는지 확인하고 싶을 때 유용함.
예시)
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