들어가며
외부에서 전달받은 PDF 문서에 텍스트나 이미지를 추가로 삽입해야 하는 업무가 있었고 이를 처리하기 위해 PDFBox를 사용해 구현을 시작했습니다.
처음에는 단순히 newLineAtOffset(x, y)로 좌표를 이동하고 showText()로 텍스트를 출력하는 방식으로 접근했습니다.
하지만 실제로는 다양한 툴에서 생성된 PDF 문서를 다루게 되었고, 각 툴마다 내부 좌표계(CTM, Current Transformation Matrix)가 조금씩 달랐습니다. 일부 문서는 좌표계가 회전된 상태로 저장되어 있기도 했습니다.
그래서 제가 의도한 위치가 아닌 전혀 다른 곳에 텍스트나 이미지가 삽입되는 문제가 발생했습니다.
이번 글에서는 이 문제를 어떻게 마주했고, 어떤 방식으로 해결했는지를 정리해 보려고 합니다.
문제 상황: 좌표계 꼬임
아래 코드는 실제 업무 코드라기보다는 이해를 돕기 위한 간단한 예시입니다.
contentStream.beginText();
contentStream.newLineAtOffset(x, y);
contentStream.showText("Hello PDF");
contentStream.endText();
이 방식은 단순히 텍스트를 원하는 좌표에 출력하는 데는 문제가 없습니다.
예상과 다르게 좌표계가 회전된 상태로 저장된 문서도 존재했습니다.
그래서 제가 입력한 (x, y) 좌표는 화면상의 기대 위치와 맞지 않았고, 텍스트가 전혀 엉뚱한 곳에 출력되는 현상이 발생했습니다.
첫 번째 해결: 상태 저장과 복구
PDF의 좌표계는 결과적으로 원본과 동일하게 유지되어야 한다고 생각했습니다.
만약 제가 원하는 방식에 맞춰 좌표계를 직접 바꿔버리면, 이후 이 PDF를 다른 사람이 사용할 때 예기치 못한 문제가 생길 수 있기 때문입니다.
그래서 선택한 방법은 출력하는 순간에만 좌표계를 변환하고, 작업이 끝나면 다시 원래 좌표계로 복구하는 것이었습니다. 다행히 PDFBox는 이를 위해 saveGraphicsState()와 restoreGraphicsState() 메서드를 제공합니다.
PDPageContentStream contentStream = new PDPageContentStream(doc, page);
// 현재 그래픽 상태 저장
contentStream.saveGraphicsState();
// 좌표계 변환(예시 코드)
contentStream.transform(Matrix.getTranslateInstance(0, page.getMediaBox().getHeight()));
contentStream.transform(Matrix.getScaleInstance(1, -1));
// 텍스트 출력
contentStream.beginText();
contentStream.newLineAtOffset(x, y);
contentStream.showText("Hello PDF");
contentStream.endText();
// 저장한 그래픽 상태로 복구
contentStream.restoreGraphicsState();
이 방식으로 문제는 일단 해결할 수 있지만 몇 가지 아쉬운 점이 있었습니다.
- 중복된 코드 발생
– 텍스트나 이미지를 출력할 때마다 동일한 패턴을 반복 작성 - 누락 위험
– restoreGraphicsState()를 한 번이라도 빼먹으면 PDF 전체의 좌표계가 틀어져 심각한 문제 발생 - 입력과 동일한 라이프사이클
– 좌표계 저장, 변환, 복구는 하나의 입력 행위와 같은 생명주기를 가져야 합니다. 즉, “좌표계 저장 → 변환 → 출력 → 복구”가 항상 하나의 원자적 단위로 묶여야 안정성이 보장됩니다.
근본적인 해결: 래퍼 클래스(FixedCtmContentStream)
앞서 살펴본 방식은 좌표계 문제를 해결할 수는 있었지만, 매번 같은 패턴을 반복해야 하고 restoreGraphicsState()를 빼먹으면 심각한 오류가 발생하는 등 여러 한계가 있었습니다.
그래서 최종적으로 선택한 방법은 그래픽 상태 저장, 복구, 좌표계 초기화만 전담하는 래퍼 클래스를 만드는 것이었습니다. 이 클래스는 오직 좌표계 관리만 책임지고, 텍스트 출력이나 이미지 삽입 같은 기능은 여전히 원래의 PDPageContentStream을 사용합니다.
처음에는 글꼴 기울임, 취소선, 좌우 정렬 같은 기능까지 넣을까 고민했지만, 그것까지 맡기면 클래스의 책임이 과도해질 수 있다고 생각했습니다. 따라서 좌표계 초기화와 상태 저장/복구에만 집중시키고, 나머지 기능은 다른 곳에서 처리하도록 했습니다.
public class FixedCtmContentStream implements Closeable {
private final PDPageContentStream contentStream;
private final PDPage page;
public FixedCtmContentStream(PDDocument doc, PDPage page) throws IOException {
this.contentStream = new PDPageContentStream(doc, page,
PDPageContentStream.AppendMode.APPEND, true, true);
this.page = page;
// 그래픽 상태 저장
this.contentStream.saveGraphicsState();
// 좌표계 초기화
initializeCTM();
}
private void initializeCTM() throws IOException {
// 회전 값을 항상 0 ~ 360 범위로 보정
int rot = ((this.page.getRotation()%360)+360)%360;
// 실제 동작 예시는 단순화
switch (rot) {
case 0 -> this.contentStream.transform(new Matrix(1, 0, 0, -1, 0, 0));
case 90 -> this.contentStream.transform(new Matrix(1, 0, 0, -1, 0, 0));
case 180 -> this.contentStream.transform(new Matrix(1, 0, 0, -1, 0, 0));
case 270 -> this.contentStream.transform(new Matrix(1, 0, 0, -1, 0, 0));
}
}
public PDPageContentStream getStream() {
return this.contentStream;
}
@Override
public void close() throws IOException {
// 그래픽 상태 복구
this.contentStream.restoreGraphicsState();
// 자원 해제
this.contentStream.close();
}
}
사용 방법:
try (FixedCtmContentStream fcs = new FixedCtmContentStream(doc, page)) {
PDPageContentStream contentStream = fcs.getStream();
contentStream.beginText();
contentStream.newLineAtOffset(100, 100);
contentStream.showText("Hello PDF");
contentStream.endText();
}
Closeable을 인터페이스을 상속하여 try-with-resources 구문을 그대로 사용할 수 있습니다.
이제 출력하는 코드는 좌표계나 그래픽 상태 관리에 대해 전혀 신경 쓸 필요가 없고, 오직 비즈니스 로직에만 집중할 수 있게 되었습니다.
마무리
이번 글에서는 PDFBox를 사용하다가 겪었던 좌표계 꼬임 문제와, 이를 해결하기 위해 만든 상태 관리 래퍼 클래스를 소개했습니다.
좌표계와 그래픽 상태를 안전하게 관리하는 구조를 직접 만들어보는 과정은 저에게 많은 공부가 되었습니다.
PDF 관련 자료를 찾아보면 보통 간단한 사용 예제만 정리된 경우가 많아, 좌표계나 응용 기능과 같은 부분은 실제로 부딪혀 보지 않으면 접하기 어려웠습니다. 앞으로 기회가 된다면, 이번에 함께 살펴본 래퍼클래스뿐만 아니라 좌표계 변환, 텍스트 글꼴 기울이기, 취소선 같은 다양한 PDFBox 활용법도 정리해 공유하고 싶습니다.
지금까지 읽어주셔서 감사합니다.
'Java' 카테고리의 다른 글
| [Java] Stream 생성하는 방법들 (0) | 2025.05.06 |
|---|---|
| java Optional 문법 정리 (0) | 2025.03.01 |