PDF 데이터 추출이 1회 만에 멈췄던 이유: "의도치 않은 자원 연쇄 종료"
RAG 시스템 구축 중, PDF에서 표를 추출하는 로직을 실행하면 첫 번째 섹션만 성공하고 두 번째부터는 'Document is closed' 에러가 발생하며 멈추는 현상을 겪었습니다. 결론부터 말하면 범인은 Java의 **try-with-resources**였습니다.
1. 문제가 된 코드 (Before)
Java
public String extractTablesAsMarkdown(PDDocument document, int startPage, int endPage) {
// try (괄호) 안에 자원을 선언하여 자동 종료를 유도함
try (ObjectExtractor extractor = new ObjectExtractor(document)) {
// ... 추출 로직 ...
} catch (IOException e) {
return "";
}
// 이 지점에서 extractor.close()가 자동 실행됨
}
2. 원인 분석: 왜 1회만 실행되었나?
Step 1: Java의 try-with-resources 특성
Java 7부터 도입된 이 구문은 try 블록이 끝날 때 괄호 안에 선언된 객체의 .close() 메서드를 자동으로 호출합니다. 개발자가 실수로 자원을 해제하지 않아 발생하는 메모리 누수를 방지하기 위함입니다.
Step 2: 라이브러리의 자원 공유 (Cascade Close)
Tabula 라이브러리의 ObjectExtractor는 생성 시 인자로 넘겨받은 PDDocument를 내부적으로 참조하여 사용합니다. 문제는 extractor.close()가 호출될 때, 본인이 참조하고 있는 원본 PDDocument까지 같이 닫아버린다는 점입니다.
Step 3: 다음 루프에서의 절망
- 첫 번째 섹션 실행: extractor 생성 -> 표 추출 완료 -> 메서드 종료 직전 extractor.close() 자동 호출 -> 원본 document 닫힘.
- 두 번째 섹션 실행: 이미 닫혀버린 document 객체를 다음 루프에서 다시 사용하려 하니, PDFBox 엔진이 "문서가 이미 닫혀 있어 읽을 수 없다"는 에러를 뱉으며 중단된 것입니다.
3. 해결된 코드 (After)
Java
public String extractTablesAsMarkdown(PDDocument document, int startPage, int endPage) {
try {
// 1. try (괄호) 밖에서 수동으로 객체 생성
ObjectExtractor extractor = new ObjectExtractor(document);
SpreadsheetExtractionAlgorithm seAlgorithm = new SpreadsheetExtractionAlgorithm();
// 2. Iterator 대신 필요한 페이지만 직접 지정하여 효율성 제고
for (int pageNum = startPage; pageNum <= endPage; pageNum++) {
Page page = extractor.extract(pageNum);
allTables.addAll(seAlgorithm.extract(page));
}
// 3. extractor.close()를 절대 호출하지 않음!
// 원본 document는 외부(Service단)에서 관리하므로 여기서 닫으면 안 됨.
} catch (Exception e) {
log.error("에러 발생", e);
}
return convertTablesToMarkdown(allTables);
}
4. 핵심 정리 및 교훈
- 자원 생명주기(Lifecycle)의 주체 파악: PDDocument를 생성한 주체가 외부(Service)라면, 하위 메서드(Utility)에서 이를 함부로 닫아서는 안 됩니다.
- 외부 라이브러리 주의: close() 메서드가 인자로 받은 원본 객체까지 연쇄적으로 닫는지(Cascade Close) 여부를 반드시 확인해야 합니다.
- 효율적인 추출: 기존 코드처럼 PageIterator를 돌리며 전체를 스캔하는 것보다, 해결 코드처럼 extract(pageNum)으로 필요한 범위만 집어서 처리하는 것이 속도와 자원 면에서 훨씬 유리합니다.
close의 책임은 누구에게 있는건가.
Utility 클래스(TableExtractor)에서 닫지 않는 것이 설계적으로 옳으며, 메모리 누수는 발생하지 않습니다.
그 이유를 3가지 관점에서 정리해 드릴게요.
1. 자원 해제의 책임 주체 (Ownership)
프로그래밍 설계 원칙 중 **"객체를 생성한 자가 해제의 책임도 진다"**는 원칙이 있습니다.
- PDDocument: DocumentAsyncProcessor(Service)에서 생성되었습니다. 따라서 이 문서를 완전히 다 썼을 때 닫아야 하는 주체는 Service입니다.
- ObjectExtractor: Service가 넘겨준 document를 잠시 빌려서 표만 뽑아내는 '도구'입니다. 도구가 빌려온 원본(Document)을 멋대로 파괴(Close)해버리면, 그 원본을 계속 써야 하는 주인(Service)은 당황하게 됩니다.
2. 왜 메모리 누수가 발생하지 않나요?
ObjectExtractor 자체가 거대한 메모리를 들고 있는 것이 아니라, 사실상 PDDocument에 접근하는 통로 역할만 하기 때문입니다.
- ObjectExtractor 내부의 실제 데이터는 대부분 PDDocument가 들고 있습니다.
- Service 코드에서 최종적으로 try-with-resources를 사용하여 PDDocument를 닫을 때, 그 안에 연결된 모든 데이터와 통로들이 한꺼번에 메모리에서 해제됩니다.
- 즉, 마지막에 주인(Service)이 문을 닫으면 도구(Extractor)가 쓰던 공간도 자동으로 정리되는 구조입니다.
3. 이전 코드의 진짜 문제점 (메모리 낭비)
오히려 이전 코드가 메모리 효율이 더 나빴습니다. 그 이유는 Iterator 때문입니다.
- 이전 코드: extractor.extract()를 호출하여 PageIterator를 만들면, 1페이지부터 끝까지 순차적으로 스캔해야 합니다. 내가 200페이지를 원해도 앞의 199페이지를 다 훑으며 지나가야 하죠.
- 수정된 코드: extractor.extract(pageNum)은 해당 페이지의 인덱스로 **직접 접근(Random Access)**합니다. 불필요한 앞 페이지들을 메모리에 올릴 필요가 없어 훨씬 빠르고 가볍습니다.
💡 블로그 요약용 "자원 관리 규칙"
- 연쇄 종료(Cascade Close) 주의: 내부적으로 원본 자원을 공유하는 라이브러리(Tabula, PDFBox 등)는 하위 메서드에서 절대 close()를 호출하지 않는다.
- 최상위 계층에서 해제: 자원 해제는 해당 자원을 처음 생성한 최상위 서비스 계층에서 try-with-resources로 단 한 번만 수행한다.
- 직접 접근 방식 사용: 대용량 PDF 처리 시 전체를 순회하는 Iterator보다 특정 페이지를 지정하는 extract(pageNum) 방식이 메모리 점유율 면에서 훨씬 안전하다.
'Archive(완료된 내용) > 포트폴리오 강화' 카테고리의 다른 글
| [Stock101]PDF 내용 요약 구현 - 7일차 (1) | 2026.01.27 |
|---|---|
| [Stock101] RAG PDF내용 요약과 전망을 도출하자 - 6일차 (0) | 2026.01.26 |
| [stock101] pdf 업로드 및 추출 -5일차 (0) | 2026.01.26 |
| [stock101] dart연동 및 ksi 연동 그리고 리팩토링 -4일차 (1) | 2026.01.22 |
| [stock101] KIS 코스피 코스닥 종목 정보 매일 최신화하기 - 3일차 (1) | 2026.01.21 |