Spring Boot로 대용량 PDF 파일 효율적으로 처리하는 방법 완벽 가이드
웹 애플리케이션을 개발하다 보면 PDF 파일을 처리해야 하는 상황이 자주 발생합니다. 특히 수십 MB 이상의 대용량 PDF 파일을 다뤄야 할 경우, 메모리 부족이나 성능 저하와 같은 문제에 직면할 수 있습니다. Spring Boot 환경에서 대용량 PDF 파일을 효율적으로 읽고 처리하는 방법을 자세히 알아보겠습니다.
목차
- 대용량 PDF 처리 시 발생하는 문제점
- 스트림 기반 접근법
- Apache PDFBox 활용하기
- PDF 내용 추출 및 처리
- 대용량 PDF 메모리 최적화 기법
- 파일 업로드 처리 설정
- 비동기 처리로 성능 개선하기
- 실제 구현 예제와 성능 비교
- 자주 발생하는 문제와 해결 방법
대용량 PDF 처리 시 발생하는 문제점
대용량 PDF 파일을 처리할 때 가장 흔히 마주치는 문제들은 다음과 같습니다:
- OutOfMemoryError: PDF를 한 번에 메모리에 로드하면 JVM 힙 메모리 부족 현상이 발생합니다.
- 응답 시간 지연: 큰 파일을 처리하는 동안 다른 요청에 대한 응답이 지연될 수 있습니다.
- 타임아웃 발생: 처리 시간이 너무 길어져 클라이언트 또는 서버 타임아웃이 발생할 수 있습니다.
- 리소스 관리 문제: 파일 처리 후 자원이 제대로 해제되지 않아 메모리 누수가 발생할 수 있습니다.
이러한 문제를 해결하기 위해 스트림 기반의 처리 방식과 메모리 최적화 기법을 적용해야 합니다.
스트림 기반 접근법
대용량 파일을 처리할 때 가장 기본적인 전략은 전체 파일을 메모리에 로드하지 않고 스트림 방식으로 처리하는 것입니다.
기본 설정
먼저 Spring Boot 프로젝트에 필요한 의존성을 추가합니다.
<!-- pom.xml -->
<dependencies>
<!-- Spring Boot 기본 의존성 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Apache PDFBox 라이브러리 -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>2.0.27</version>
</dependency>
</dependencies>
스트림 기반 파일 읽기
@Service
public class PdfService {
public void processPdfStream(InputStream inputStream) throws IOException {
try (BufferedInputStream bis = new BufferedInputStream(inputStream)) {
// 스트림에서 일정 크기의 버퍼로 데이터를 읽어 처리
byte[] buffer = new byte[8192]; // 8KB 버퍼
int bytesRead;
while ((bytesRead = bis.read(buffer)) != -1) {
// 읽은 데이터 처리
processBuffer(buffer, bytesRead);
}
}
}
private void processBuffer(byte[] buffer, int bytesRead) {
// 버퍼 데이터 처리 로직
}
}
이 방식은 파일 데이터를 작은 버퍼 단위로 나누어 처리하므로 메모리 사용량을 크게 줄일 수 있습니다.
Apache PDFBox 활용하기
Apache PDFBox는 자바 기반의 오픈소스 PDF 라이브러리로, 대용량 PDF 파일을 처리하는 데 유용한 기능을 제공합니다.
기본 PDF 읽기
@Service
public class PdfService {
public String extractText(InputStream inputStream) throws IOException {
try (PDDocument document = PDDocument.load(inputStream)) {
PDFTextStripper stripper = new PDFTextStripper();
return stripper.getText(document);
}
}
}
메모리 설정 최적화
PDFBox의 메모리 사용량을 최적화하기 위한 설정:
@Service
public class PdfService {
public String extractTextFromLargePdf(InputStream inputStream) throws IOException {
// 메모리 사용량 최적화를 위한 설정
MemoryUsageSetting memoryUsageSetting = MemoryUsageSetting.setupTempFileOnly();
try (PDDocument document = PDDocument.load(inputStream, memoryUsageSetting)) {
PDFTextStripper stripper = new PDFTextStripper();
return stripper.getText(document);
}
}
}
MemoryUsageSetting.setupTempFileOnly()
를 사용하면 PDFBox가 임시 파일을 활용하여 메모리 사용량을 줄입니다.
PDF 내용 추출 및 처리
페이지별 처리하기
대용량 PDF 파일은 전체를 한 번에 처리하기보다 페이지별로 나누어 처리하는 것이 효율적입니다.
@Service
public class PdfService {
public List<String> extractTextByPages(InputStream inputStream) throws IOException {
List<String> pageContents = new ArrayList<>();
MemoryUsageSetting memoryUsageSetting = MemoryUsageSetting.setupTempFileOnly();
try (PDDocument document = PDDocument.load(inputStream, memoryUsageSetting)) {
PDFTextStripper stripper = new PDFTextStripper();
for (int i = 1; i <= document.getNumberOfPages(); i++) {
stripper.setStartPage(i);
stripper.setEndPage(i);
String text = stripper.getText(document);
pageContents.add(text);
// 메모리 관리를 위해 가비지 컬렉션 명시적 호출 (큰 PDF의 경우)
if (i % 100 == 0) {
System.gc();
}
}
}
return pageContents;
}
}
이미지 추출하기
PDF에서 이미지를 추출할 때도 페이지별로 처리하는 것이 좋습니다.
@Service
public class PdfImageService {
public List<BufferedImage> extractImages(InputStream inputStream) throws IOException {
List<BufferedImage> images = new ArrayList<>();
MemoryUsageSetting memoryUsageSetting = MemoryUsageSetting.setupTempFileOnly();
try (PDDocument document = PDDocument.load(inputStream, memoryUsageSetting)) {
PDFRenderer renderer = new PDFRenderer(document);
for (int i = 0; i < document.getNumberOfPages(); i++) {
BufferedImage image = renderer.renderImageWithDPI(i, 300); // 300 DPI
images.add(image);
// 큰 PDF에서는 이미지 처리 후 메모리 정리
if (i % 10 == 0) {
System.gc();
}
}
}
return images;
}
}
대용량 PDF 메모리 최적화 기법
1. 청크 단위 처리
대용량 PDF를 작은 청크 단위로 나누어 처리하는 방법입니다.
@Service
public class ChunkProcessingService {
private static final int CHUNK_SIZE = 10; // 한 번에 처리할 페이지 수
public void processLargePdfInChunks(InputStream inputStream) throws IOException {
MemoryUsageSetting memoryUsageSetting = MemoryUsageSetting.setupTempFileOnly();
try (PDDocument document = PDDocument.load(inputStream, memoryUsageSetting)) {
int totalPages = document.getNumberOfPages();
PDFTextStripper stripper = new PDFTextStripper();
for (int i = 0; i < totalPages; i += CHUNK_SIZE) {
int startPage = i + 1;
int endPage = Math.min(i + CHUNK_SIZE, totalPages);
stripper.setStartPage(startPage);
stripper.setEndPage(endPage);
String text = stripper.getText(document);
// 텍스트 처리 로직
processTextChunk(text, startPage, endPage);
// 메모리 정리
System.gc();
}
}
}
private void processTextChunk(String text, int startPage, int endPage) {
// 텍스트 청크 처리 로직
System.out.println("Processing pages " + startPage + " to " + endPage);
}
}
2. 비파괴적 메모리 관리
PDF 처리 중 메모리 사용량을 모니터링하고 필요할 때만 자원을 해제하는 방법입니다.
@Service
public class MemoryAwarePdfService {
private static final long MEMORY_THRESHOLD = 100 * 1024 * 1024; // 100MB
public void processWithMemoryAwareness(InputStream inputStream) throws IOException {
MemoryUsageSetting memoryUsageSetting = MemoryUsageSetting.setupMainMemoryOnly()
.setTempDir(new File(System.getProperty("java.io.tmpdir")));
try (PDDocument document = PDDocument.load(inputStream, memoryUsageSetting)) {
PDFTextStripper stripper = new PDFTextStripper();
for (int i = 1; i <= document.getNumberOfPages(); i++) {
stripper.setStartPage(i);
stripper.setEndPage(i);
String text = stripper.getText(document);
// 텍스트 처리
processText(text, i);
// 메모리 사용량 확인 및 관리
Runtime runtime = Runtime.getRuntime();
long usedMemory = runtime.totalMemory() - runtime.freeMemory();
if (usedMemory > MEMORY_THRESHOLD) {
System.gc(); // 필요할 때만 GC 실행
}
}
}
}
private void processText(String text, int pageNum) {
// 텍스트 처리 로직
}
}
파일 업로드 처리 설정
Spring Boot에서 대용량 파일 업로드를 처리하기 위한 설정을 추가해야 합니다.
application.properties 설정
# 파일 업로드 최대 크기 설정
spring.servlet.multipart.max-file-size=100MB
spring.servlet.multipart.max-request-size=100MB
# 임시 디렉토리 설정 (선택 사항)
spring.servlet.multipart.location=/tmp
파일 업로드 컨트롤러
@RestController
@RequestMapping("/api/pdf")
public class PdfController {
private final PdfService pdfService;
@Autowired
public PdfController(PdfService pdfService) {
this.pdfService = pdfService;
}
@PostMapping("/upload")
public ResponseEntity<String> uploadAndProcessPdf(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
return ResponseEntity.badRequest().body("PDF 파일을 업로드해주세요.");
}
try (InputStream inputStream = file.getInputStream()) {
String text = pdfService.extractTextFromLargePdf(inputStream);
return ResponseEntity.ok("PDF 처리 완료. 텍스트 길이: " + text.length());
} catch (IOException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("PDF 처리 중 오류 발생: " + e.getMessage());
}
}
}
비동기 처리로 성능 개선하기
대용량 PDF 처리는 시간이 오래 걸릴 수 있으므로, 비동기 처리를 통해 사용자 경험을 개선할 수 있습니다.
비동기 처리 설정
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("PdfAsync-");
executor.initialize();
return executor;
}
}
비동기 서비스 구현
@Service
public class AsyncPdfService {
private static final Logger logger = LoggerFactory.getLogger(AsyncPdfService.class);
@Async("taskExecutor")
public CompletableFuture<String> processLargePdfAsync(InputStream inputStream) {
try {
// 임시 파일로 저장
Path tempFile = Files.createTempFile("async-pdf-", ".pdf");
Files.copy(inputStream, tempFile, StandardCopyOption.REPLACE_EXISTING);
logger.info("PDF 임시 저장 완료: {}", tempFile);
// 비동기 처리 로직
try (InputStream fileStream = Files.newInputStream(tempFile)) {
PDDocument document = PDDocument.load(fileStream, MemoryUsageSetting.setupTempFileOnly());
PDFTextStripper stripper = new PDFTextStripper();
String text = stripper.getText(document);
document.close();
// 임시 파일 삭제
Files.deleteIfExists(tempFile);
return CompletableFuture.completedFuture("PDF 처리 완료. 텍스트 길이: " + text.length());
}
} catch (IOException e) {
logger.error("PDF 처리 중 오류 발생", e);
return CompletableFuture.failedFuture(e);
}
}
}
비동기 컨트롤러 구현
@RestController
@RequestMapping("/api/pdf")
public class AsyncPdfController {
private final AsyncPdfService asyncPdfService;
@Autowired
public AsyncPdfController(AsyncPdfService asyncPdfService) {
this.asyncPdfService = asyncPdfService;
}
@PostMapping("/async-upload")
public ResponseEntity<String> uploadPdfAsync(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
return ResponseEntity.badRequest().body("PDF 파일을 업로드해주세요.");
}
try {
// 파일 처리 시작
asyncPdfService.processLargePdfAsync(file.getInputStream());
return ResponseEntity.ok("PDF 처리가 시작되었습니다. 백그라운드에서 처리 중입니다.");
} catch (IOException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("PDF 처리 중 오류 발생: " + e.getMessage());
}
}
}
실제 구현 예제와 성능 비교
다양한 PDF 처리 방식의 성능을 비교해보겠습니다.
테스트 환경 설정
@SpringBootTest
public class PdfPerformanceTest {
@Autowired
private PdfService regularPdfService;
@Autowired
private ChunkProcessingService chunkProcessingService;
@Autowired
private MemoryAwarePdfService memoryAwarePdfService;
@Test
public void comparePerformance() throws IOException {
// 테스트용 대용량 PDF 파일 경로
File largeFile = new File("path/to/large.pdf");
// 일반 처리 방식
long startTime1 = System.currentTimeMillis();
try (FileInputStream fis = new FileInputStream(largeFile)) {
regularPdfService.extractText(fis);
}
long endTime1 = System.currentTimeMillis();
// 청크 처리 방식
long startTime2 = System.currentTimeMillis();
try (FileInputStream fis = new FileInputStream(largeFile)) {
chunkProcessingService.processLargePdfInChunks(fis);
}
long endTime2 = System.currentTimeMillis();
// 메모리 인식 처리 방식
long startTime3 = System.currentTimeMillis();
try (FileInputStream fis = new FileInputStream(largeFile)) {
memoryAwarePdfService.processWithMemoryAwareness(fis);
}
long endTime3 = System.currentTimeMillis();
// 결과 출력
System.out.println("일반 처리 시간: " + (endTime1 - startTime1) + "ms");
System.out.println("청크 처리 시간: " + (endTime2 - startTime2) + "ms");
System.out.println("메모리 인식 처리 시간: " + (endTime3 - startTime3) + "ms");
}
}
성능 결과 예시
처리 방식 | 10MB PDF | 50MB PDF | 100MB PDF |
---|---|---|---|
일반 처리 | 2.3초 | 15.7초 | OutOfMemoryError |
청크 처리 | 3.1초 | 12.8초 | 26.5초 |
메모리 인식 처리 | 2.8초 | 11.2초 | 24.1초 |
청크 처리와 메모리 인식 처리 방식은 대용량 PDF 파일에서 훨씬 더 안정적으로 작동하며, OutOfMemoryError 발생을 방지합니다.
자주 발생하는 문제와 해결 방법
1. OutOfMemoryError
문제: 대용량 PDF 처리 중 OutOfMemoryError가 발생합니다.
해결 방법:
- JVM 힙 메모리 증가:
-Xmx2g
옵션으로 최대 힙 크기를 늘립니다. - 임시 파일 사용:
MemoryUsageSetting.setupTempFileOnly()
설정을 사용합니다. - 페이지 단위 처리: 한 번에 모든 페이지가 아닌 페이지별로 처리합니다.
2. 처리 속도 저하
문제: 대용량 PDF 처리 속도가 너무 느립니다.
해결 방법:
- 병렬 처리: 멀티스레드로 청크를 병렬 처리합니다.
- 필요한 데이터만 추출: 전체 텍스트가 아닌 필요한 부분만 추출합니다.
- 하드웨어 리소스 최적화: 서버 사양을 개선하거나 클라우드 리소스를 확장합니다.
3. 응답 시간 문제
문제: 웹 요청이 타임아웃됩니다.
해결 방법:
- 비동기 처리: 위에서 설명한 비동기 처리 방식을 적용합니다.
- 웹소켓 활용: 진행 상황을 실시간으로 클라이언트에 알립니다.
- 타임아웃 설정 조정: 서버 타임아웃 설정을 늘립니다.
4. 메모리 누수
문제: PDF 처리 후에도 메모리가 해제되지 않습니다.
해결 방법:
- try-with-resources 사용: 자원 자동 해제를 위해 try-with-resources 구문을 사용합니다.
- 명시적 close() 호출: PDDocument 객체를 명시적으로 닫습니다.
- 주기적인 GC 호출: 메모리 임계값에 도달하면 System.gc()를 호출합니다.
결론
Spring Boot 환경에서 대용량 PDF 파일을 효율적으로 처리하려면 스트림 기반 접근법, 청크 처리, 메모리 최적화, 비동기 처리 등 다양한 기법을 적용해야 합니다. Apache PDFBox와 같은 라이브러리의 최적화 설정을 활용하면 안정적이고 효율적인 PDF 처리가 가능합니다.
대용량 PDF 처리는 단순히 코드를 작성하는 것 이상으로 시스템 설계와 메모리 관리 전략이 중요합니다. 실제 프로덕션 환경에서는 이 글에서 제시한 여러 방법을 조합하여 최적의 솔루션을 구성하는 것이 좋습니다.
메모리 사용량, 처리 속도, 안정성 간의 적절한 균형을 찾는 것이 핵심입니다. 특히 대용량 파일을 다루는 서비스라면 무중단 운영을 위해 메모리 최적화와 비동기 처리 방식을 반드시 고려해야 합니다.
구글 SEO 핵심 키워드
- Spring Boot PDF 파일 처리 방법
- 대용량 PDF 읽기 최적화
- Apache PDFBox 메모리 설정
- PDF 스트림 처리 Java
- Spring Web 파일 업로드 설정
- OutOfMemoryError PDF 해결
- 비동기 PDF 처리 Spring
- 대용량 파일 청크 처리
- PDF 텍스트 추출 성능 최적화
- Spring Boot 파일 처리 메모리 관리
'Development > Error' 카테고리의 다른 글
[Error] Android OS 10 Target 시 파일 조회 원인과 해결 방법 완벽 가이드 (0) | 2020.03.24 |
---|---|
[Error] Data-Scheme 설정 후 앱 서랍에서, 앱 아이콘이 사라지는 문제 원인과 해결 방법 완벽 가이드 (0) | 2020.03.22 |
[Error] ListView를 드래그 하면 검게 보이는 현상 (0) | 2019.10.01 |
[Error] A SQLiteConnection object for database was leaked! (0) | 2019.09.23 |
[Error] Build failed with an exception. (0) | 2019.09.06 |