병렬 처리하면 무조건 좋을까? : BlockingQueue로 파일 다운로드 성능 & OOM 개선하기

2025. 10. 7.

배경

피드줍줍 서비스를 운영하며 다음과 같은 피드백을 받았습니다.

  • 단체 관리자를 위해 피드백 데이터를 외부에서 활용 가능한 형태로 제공되면 좋겠음
  • 현재 웹 화면 만으론 데이터 분석, 보고서 작성, 외부 도구 연동이 어려움

이를 해결하기 위해 관리자들이 수집한 피드백 데이터를 엑셀 파일로 내보내는 기능을 개발했습니다.

피드백에는 이미지도 포함되어 있기 때문에 수십개의 이미지를 어떻게 안정적이게 다운받고 엑셀 파일에 첨부시킬지 고민하게 되었습니다.

 

기능은 아래와 같이 피드백 추출 버튼을 누를 시 엑셀 파일이 다운되도록 동작합니다.

관리자 피드백 조회 페이지

 

다운을 받고 나면

 

위와 같이 피드백 데이터가 정리된 엑셀 파일을 볼 수 있습니다.

 

엑셀 생성 방식 선택

먼저 자바에서 엑셀을 생성하는 방법에 대해 조사한 결과, 대표적으로 두 라이브러리가 있었습니다.

 

후보 라이브러리

Apache Poi

Apache Poi는 Apache 재단의 오픈소스 라이브러리입니다.

장점으로는

  1. 가장 널리 사용되어 레퍼런스가 많다.
  2. 엑셀의 거의 모든 기능을 지원한다. (이미지 삽입, 셀 스타일, 수식, 차트 등)

단점으로는

  1. API 구조가 복잡하다.
  2. 상대적으로(FastExcel 에 비해) 처리 속도가 느리다.

가 있었습니다.

 

FastExcel

FastExcel은 대용량 엑셀 생성에 최적화된 라이브러리입니다.

장점으로는

  1. 빠른 처리 속도 (apache poi 대비 5~10배)
  2. 단순한 API

단점으로는

  1. 기본 기능만 제공 (텍스트, 숫자, 날짜)
  2. 이미지 삽입 미지원

가 있습니다.

 

저는 이 중 Apache Poi 라이브러리를 선택했습니다.

 

가장 큰 이유는 피드백 데이터에는 이미지가 포함되어 있기 때문에, FastExcel은 이미지를 지원하지 않기 때문입니다. Apahe Poi는 자바에서 엑셀 파일을 다룰 때 정말 많이 사용되는 라이브러리이기 때문에 레퍼런스가 많이 존재한다는 점도 있었습니다.

 

Apache POI의 XSSF vs SXSSF

앞서 Apache POI 라이브러리를 사용하기로 결정했습니다. poi에서는 엑셀 파일 생성을 위해 대표적으로 두 클래스를 지원합니다.

 

XSSFWorkbook (XML Spreadsheet Format)

XSSFWorkbook을 사용해 엑셀을 다음과 같이 생성할 수 있습니다.

XSSFWorkbook workbook = new XSSFWorkbook();
Sheet sheet = workbook.createSheet();

for (int i = 0; i < 10000; i++) {
    Row row = sheet.createRow(i);
    // ...
}

구현이 단순하다는 장점이 있지만 엑셀로 만드는 행을 메모리에 올린다는 단점이 존재합니다. 따라서 데이터가 많을수록 메모리 점유율이 선형적으로 증가하며, 1만 개 행을 생성하면 1만 개 행이 모두 메모리에 띄워지게 됩니다.

 

SXSSF (Streaming XML Spreadsheet Format)

이번에는 SXSSFWorkbook을 사용해 엑셀을 생성해보겠습니다. SXSSFWorkbook은 일정 개수의 행만 메모리에 유지하고, 나머지는 디스크로 내보냅니다.

int windowSize = 10;

SXSSFWorkbook workbook = new SXSSFWorkbook(windowSize);
Sheet sheet = workbook.createSheet();

for (int i = 0; i < 10000; i++) {
    Row row = sheet.createRow(i);
    // 11번째 행 생성 시 1번째 행은 자동으로 디스크로 이동
}

SXSSFWorkbook을 생성할 땐 windowSize라는 변수를 설정할 수 있습니다.

windowSize만큼만 메모리에 유지하고, 초과분은 자동으로 디스크로 플러시되는 방식입니다.

 

디스크 플러시 과정

SXSSF가 어떻게 메모리를 관리하는지 코드를 확인해보겠습니다. Sheet에서 하나의 행을 만드는 메서드는 createRow 입니다.

SXSSFSheet의 createRow는 다음과 같이 구현되어 있습니다.

현재 row의 size가 windowSize보다 크다면, flushRows를 수행합니다.

 

flushRows는 내부에서 writer.writeRow를 실행하는데, 이 메서드 내부를 타고 들어가면 스트림에 write하는 메서드가 등장합니다.

_out은 java.io의 Writer 타입이며 createWriter 메서드를 살펴보면

위와 같이 기본 파일 출력 스트림인 FileOutputStream을 보조 스트림이 감싸는 형태로 생성하고 있음을 알 수 있습니다.

 

정리하자면 XSSF와 SXSSF의 차이는 다음과 같습니다.

XSSFWorkbook

- 모든 엑셀 행 메모리에 로드

- 따라서 OOM이 발생할 위험이 존재한다.

- 플러시가 일어나지 않기 때문에 상대적으로 빠르다.

 

SXSSFWorkbook

- windowSize을 초과하는 행에 대하여 디스크 플러시 지원

- OOM을 해결할 수 있다.

- 행을 생성할 때마다 플러시가 발생하기 때문에 XSSFWorkbook에 비해 상대적으로 느리다.

 

피드줍줍 서비스에서 생성할 엑셀 파일의 크기를 예상해보면, 피드백 300개가 쌓인 단체 기준으로 300 * 0.5MB(이미지 1개당 최대 크기) = 150MB 입니다.

 

반면, 현재 서비스를 운영 중인 EC2는 t4g.small 사양으로 메모리가 2GB입니다. JVM, Alloy 구동 등에 필요한 메모리 사용량을 제외하면 사용 가능한 메모리는 1GB가 되지 않습니다.

따라서 파일 다운로드 요청 시 JVM이 운영체제로부터 할당받은 총 메모리를 초과할 수 있는 위험이 있었습니다.

 

피드백 조회처럼 요청이 자주 발생하는 기능은 아니지만, 만일 동시 요청이 발생할 경우 요청 수만큼 메모리 차지량은 선형적으로 증가합니다. 150MB의 크기라면 메모리에 그대로 올리기엔 상당히 위험한 크기이기 때문에 속도 차이를 감안하고도 디스크로 flush되는 기능은 필수라고 생각했고 SXSSFWorkbook을 사용하여 엑셀 파일을 생성하기로 결정했습니다.

 

순차 다운로드의 문제점

각 피드백의 이미지를 순차로 다운로드하여 엑셀에 추가한다면 어떤 문제가 있을까요?

흐름은 다음과 같습니다.

  1. 사용자의 파일 다운로드 요청
  2. 해당 범위 기간의 피드백 DB 조회
  3. 피드백 하나 당 엑셀 열 생성
    이때 이미지가 포함된 피드백이라면 S3에서 다운로드 후 셀에 첨부
// 이미지를 하나씩 다운로드
for (Feedback feedback : feedbacks) {
    byte[] imageData = s3DownloadService.downloadFile(feedback.getImageUrl());
    addImageToExcel(sheet, imageData);
}

하지만 이렇게 구현하면 엑셀을 생성하는 스레드는 이미지를 다운로드 받을동안 아무 일도 하지 못하고 S3와의 Network I/O가 완료되기를 대기합니다.

 

애플리케이션 서버의 Trace를 확인하여 실제 io에 걸리는 시간을 확인해보았습니다.

다음은 파일 다운로드 API 요청에 대한 트레이스입니다.

위와 같이 하나의 이미지 다운로드 요청에 대해 100ms 정도가 소요됨을 확인할 수 있었습니다.

즉, 이미지가 포함된 피드백이 50개인 단체 기준으로 파일 다운로드 요청 시 이미지 다운로드에만 5s가 소요됩니다. 300개라면 30s가 되겠죠.

피드백 개수가 늘어날수록 응답속도는 선형적으로 증가합니다.

 

응답 속도를 줄이려면 어떻게 해야할까요?

출처: https://techblog.woowahan.com/2667/

수십개의 동작이 동기로 이루어질 때 수행시간은 전체 요청 수행시간의 합이지만, 비동기로 이루어질 때 수행시간은 전체 요청 수행시간 중 최대가 됩니다.

 

따라서 이미지를 병렬로 다운로드하면 된다고 생각했습니다.

 

첫 번째 시도: 병렬 다운로드

불필요한 대기를 줄이고, 응답 속도를 개선하기 위해 이미지 다운로드 병렬화를 시도했습니다.

 

먼저 aws s3에서 여러 파일을 한 번에 다운받을 수 있는 API를 제공하는지 찾아봤지만, 제공하고 있지 않았습니다. 따라서 병렬 다운로드를 위해 멀티 스레딩이 필요하다고 생각했습니다.

// 모든 이미지를 동시에 다운로드
ExecutorService executor = Executors.newFixedThreadPool(10);
List<CompletableFuture<byte[]>> futures = feedbacks.stream()
    .map(feedback -> CompletableFuture.supplyAsync(
        () -> s3DownloadService.downloadFile(feedback.getImageUrl()),
        executor
    ))
    .toList();

// 모든 다운로드가 완료될 때까지 대기
CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join();

이미지 병렬 다운로드 과정은 다음과 같습니다.

 

1. 스레드 풀 생성

- 이 스레드가 작업을 나눠서 처리하게 됩니다.

 

2. 각 피드백마다 다운로드 작업 생성

- 예를 들어 피드백이 30개가 있으면, 30개의 다운로드 작업이 생성됩니다.

- 각 작업을 CompletableFuture라는 비동기 작업 객체로 래핑합니다.

- 모든 작업이 스레드 풀의 10개 스레드에 분배되어 동시에 시작되고, 작업이 끝나는대로 다음 작업을 수행합니다.

 

3. 모든 다운로드 완료 대기

- 30개 작업이 모두 완료될 때까지 메인 스레드가 대기합니다.

- 다운로드가 완료되면 엑셀 생성을 시작합니다.

 

이렇게 하면 불필요한 대기가 최소화되고, 이미지 다운로드 응답 시간이 빨라집니다.

하지만 모든 이미지가 한 번에 메모리에 로드된다는 문제가 발생합니다.

 

즉, XSSFWorkbook을 사용했을 경우와 별다르지 않은 상황으로 다시 돌아가게 된 것이라 생각했습니다.

 

두 번째 시도: BlockingQueue에 이미지 담기

모든 이미지가 한 번에 메모리에 올라가는 것이 문제라면, 그 크기를 제한할 수는 없을지 고민하게 되었습니다.

 

예를 들어 서버에 10개의 이미지만 유지되도록 제한할 수 있다면 OOM을 방지할 수 있을 것입니다. 그리고 엑셀 열을 생성한 후 9개가 되면 이미지를 다운로드 받아와 다시 10개를 채워두는 것입니다.

 

이를 어떻게 구현할 수 있을지 생각해보았고 두 가지 조건이 있었습니다.

 

1. 이미지 병렬 다운로드 작업, 엑셀 생성 작업. 이 두 작업이 비동기로 이뤄져야 한다.

기존에는 병렬 다운로드가 끝나야(동기) 엑셀 생성을 수행했습니다.

하지만 두 작업은 이제 서로 직접 의존하는 것이 아니라, 각각이 이미지가 담긴 자료구조를 바라보고 수행되어야 합니다.

예를 들어 액셀 생성 스레드는 자료구조에 이미지가 존재하면 행을 생성하는 작업을 수행해야 합니다. 동시에 이미지 다운로드 스레드는 자료구조에 이미지가 가득 차있지 않으면 이미지를 다운로드 하는 작업을 수행해야 합니다. 그게 아니라면 작업을 대기합니다.

 

2. 이미지를 담을 고정 크기의 자료구조가 필요하다.

 

배열과 BlockingQueue 두 가지 모두 용량을 고정할 수 있습니다. 한 쪽에서는 이미지를 넣어주고, 반대쪽에선 이미지를 꺼내 엑셀을 생성하는 구조인 Queue 형태 자료구조가 적합하다고 생각하여 BlockingQueue로 결정했습니다.

또한 가득 찼으면 대기하도록 하는 put, 요소가 없으면 대기하도록 하는 take 메서드를 지원한다는 장점이 있었습니다.

 

다이어그램으로 표현하면 다음과 같습니다.

정리하자면, 두 작업은 비동기로 수행되며 서로가 Queue를 통해 동기화 됩니다.

  • 이미지 다운로드 시 → 큐에 이미지 추가
  • 엑셀 생성 시 → 큐에서 이미지 꺼내기

 

BlockingQueue가 해결하는 문제

BlockingQueue는 메모리에 올릴 이미지 개수를 제한하는 과정에서 도입했지만, 여러 부수적인 문제들도 해결해줍니다.

 

https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/BlockingQueue.html

Queue that additionally supports operations that wait for the queue to become non-empty when retrieving an element, and wait for space to become available in the queue when storing an element.

 

Java 공식 문서에 따르면, BlockingQueue는 "요소를 꺼낼 때 큐가 비어있으면 대기하고, 요소를 저장할 때 큐에 공간이 생길 때까지 대기하는 작업을 지원하는 큐"입니다.

 

정리하자면

  • 요소 추출 (Retrieval): take() 호출 시 큐가 비어있으면 요소가 들어올 때까지 대기
  • 요소 저장 (Storage): put() 호출 시 큐가 가득 차면 공간이 생길 때까지 대기

하는 동작을 지원합니다.

 

문제1: 속도 불일치

일반적인 Queue는 크기 제한이 없기 때문에, add() 메서드로 계속 추가할 수 있어 엑셀 생성 스레드와 이미지 다운로드 스레드의 작업 속도 차이를 해소할 방법이 없습니다.

 

해결: 백프레셔 제어

BlockingQueue implementations are designed to be used primarily for producer-consumer queues.
BlockingQueue는 producer-consumer 패턴을 주요 목적으로 설계되었기 때문에, put()과 take()의 블로킹 메커니즘으로 속도 차이를 조절합니다.

현재 상황에서는

  • Producer (생산자): 이미지를 다운로드하는 10개 워커 스레드
  • Consumer (소비자): 엑셀 행을 생성하는 메인 스레드

로 이해할 수 있습니다.

 

따라서 동작 원리는 다음과 같습니다.

  • 큐가 가득 차면 → 이미지 다운로드는 자동으로 대기
  • 큐에 여유가 생기면 → Producer가 다시 다운로드 시작
  • 큐가 비면 → 엑셀 생성 대기

 큐가 가득 차면 이미지 다운로드 스레드는 대기하게 됩니다.

 

이러한 방식을 백프레셔(Backpressure)라고 합니다.

"The main goal of Reactive Streams is to govern the exchange of stream data across an asynchronous boundary—while ensuring that the receiving side is not forced to buffer arbitrary amounts of data. In other words, back pressure is an integral part of this model in order to allow the queues which mediate between threads to be bounded."

Reactive Streams 명세에 따르면, 백프레셔는 "시스템이 처리할 수 있는 속도에 맞춰 수신자가 처리할 수 있는 요소의 양을 제어하는 메커니즘"입니다. 즉, Producer가 Consumer의 처리 속도에 자동으로 맞춰지기 때문에, 메모리 오버플로우를 사전에 방지하고 시스템이 과부하 아래에서 무너지지 않고 대응할 수 있습니다.

 

문제2: Race Condition

1. 이미지 다운로드 스레드 간 충돌

 

이미지 다운로드는 여러 개의 워커 스레드가 병렬로 실행되는 구조입니다. 모든 스레드가 동시에 같은 큐에 이미지를 추가하려고 하면 동시성 문제가 발생할 수 있습니다.

예를 들어 일반 LinkedList 기반의 Queue는 내부적으로 링크드 리스트 노드를 수정합니다. 두 스레드가 동시에 add()를 호출하면 “기존 tail 노드의 next를 새 노드로 변경”하는 작업이 동시에 일어나 하나의 데이터가 손실될 수 있는 것입니다.

 

2. 엑셀 생성 스레드와 이미지 다운로드 스레드 간 충돌

 

메인 스레드가 큐에서 이미지를 꺼내는 동안(poll()), 워커 스레드가 이미지를 넣으려고 하면(add()) 큐의 내부 상태(size, head/tail 포인터 등)가 동시에 수정됩니다.

이 과정에서 size 카운트가 실제 원소 개수와 맞지 않거나, 손상된 데이터를 읽을 수 있습니다.

 

따라서 일반 큐를 사용한다면, 메인 스레드와 비동기 스레드가 동시에 큐에 접근하는 상황에서 명시적인 synchronized 처리가 필요합니다.

synchronized(queue) {
    queue.add(new FeedbackWithImage(...));
}

synchronized(queue) {
    FeedbackWithImage item = queue.poll();
}

 

해결: ReentrantLock 구현

BlockingQueue 구현은 내부적으로 동시성을 제어하고 있습니다. 모든 큐 작업이 스레드 안전하고 원자적으로 작동하므로, 개발자가 명시적인 synchronized를 작성할 필요가 없습니다.

 

결과적으로 코드가 간단해지고, 동시성 문제의 위험도 없어집니다.

queue.put(imageData);      // 워커 스레드
imageData = queue.take();  // 메인 스레드

 

BlockingQueue를 활용한 경우도 Trace를 확인하여 응답속도를 측정해보았습니다.

순차 다운로드 시와 비슷하게 하나의 요청 당 100ms 정도 소요됩니다. 하지만 10개의 스레드가 병렬로 다운로드 작업을 수행한 덕분에, 피드백 50개 기준 500ms 정도의 시간이 소요됩니다. (순차 다운로드의 경우 5s 소요)

50개를 기준으로 확인한 이유는 실제 운영 서버의 단체들의 평균 피드백 개수가 약 50개이기 때문입니다.

 

그럼에도 OOM이 발생한다..

BlockingQueue를 구현해 이미지 개수를 제한한 후, 기존 병렬 다운로드 시 이미지 크기 그대로 메모리에 올라가던 문제를 해결했을 것이라 생각했습니다.

 

따라서 한 개에 400KB 쯤 되는 이미지 900개를 임의로 삽입하여 엑셀 파일 다운로드 API를 실행해보았습니다.

(완성되는 엑셀 파일의 크기는 320MB 이며, JVM의 heap 할당 크기를 512MB로 설정하여 실행했습니다.)

 

하지만 예상과는 다르게 800번째 엑셀 행을 생성했을 쯤에 서버가 다운되었습니다

원인은 OOM이었습니다.

 

SXSSFWorkbook이라고 하더라도 이미지는 디스크에 스트리밍되지 않는 건가..? 라는 의심이 들었습니다.

 

문제 분석

그래서 workbook의 addPicture 메서드의 구현을 살펴보았습니다.

_wb이라는 곳에 바이패스하고 있네요. _wb은 뭘까요?

XSSFWorkbook 입니다.

 

네.. 그렇습니다.

이미지 삽입같은 경우는 SXSSFWorkbook의 디스크 스트리밍과 무관하게 동작합니다. XSSFWorkbook의 addPicture를 바이패스하여 실행할 뿐이며 같은 로직을 실행합니다.

 

희망을 잃지 않고 내부 로직을 이어서 살펴보았습니다.

addPicture에서는 이미지 데이터를 배열로 전달받고 크게 두 가지 경로로 전달하는 것을 확인할 수 있습니다.

  1. out.write(pictureData);
  2. pictures.add(img);

먼저 out.write(pictureData); 이 부분을 보고 살짝 기대감이 생겼습니다. out에 어떤 구현체가 주입되느냐에 따라 이미지도 디스크에 저장시킬 수 있지 않을까?! 하구요.

 

그래서 breaking point를 설정하고 api 요청을 해보았습니다.

살펴보니 MemoryPackagePart로부터 MemoryPackagePartOutputStream이 주입되는 것을 확인해볼 수 있습니다.

 

이어서 MemoryPackagePartOutputStream의 write 메서드를 보면 아래와 같이 ByteArrayOutputStream에 write함을 알 수 있었고 내부에는 List<byte[]>가 버퍼로 존재했습니다.

따라서 이미지가 계속 메모리에 저장이 되고 있던 것이 확실했습니다.

 

하지만 디스크에 저장하는 것이 필요했기 때문에 혹시 다른 outputStream을 주입할 수 있는 방법은 없을까 궁금했고, MemoryPackagePart가 아닌 TempFilePackagePart라는 구현체가 있음을 알아냈습니다.

 

그럼 어떻게 TempFilePackagePart를 사용할 수 있을까 찾아보니, ZipPackage.setUseTempFilePackageParts(true); 라는 옵션이 존재했습니다. 정적 변수이기 때문에 애플리케이션 실행 시 1회만 수행해주면 됩니다.

 

해치웠나..?

옵션을 설정한 뒤 애플리케이션을 다시 실행하고 API 요청을 해보았습니다.

이제는 TempFilePackagePart가 주입되었습니다. 그리고 out으로는 ChannelOutputStream과 FileChannelImpl이 사용되고 있었습니다. 이미지를 임시 파일로 저장하는 것입니다.

 

다시 900개의 피드백을 가진 단체의 엑셀 다운로드 API 요청을 수행해보았습니다.

이번엔 피드백 엑셀 다운로드가 완료 되었습니다!

그리고 성공할 줄 알았지만 몇초 뒤 실패했다는 에러 로그가 찍힙니다.

 

이번에는 엑셀 생성까진 완료했지만 SXSSFWorkbook을 close하는 과정에서 에러가 발생했습니다. 데이터를 쓰는 건 문제없었는데 파일을 닫을 때 실패한 것입니다.

 

 

 

 

스택 트레이스를 따라가보니 workbook.close() → ZipPackage.saveImpl() 로 이어지고 있었습니다.

 

이를 보고 먼저 든 의문은 close 과정에서 save? 뭘 save한다는 거지?.. 였습니다.

이것을 이해하기 위해서는 먼저 .xlsx 파일의 구조를 알아야 합니다.

 

xlsx는 zip 파일이다

엑셀 파일의 확장자를 zip으로 바꾸고

unzip을 해보면

 

위와 같이 xml 파일들이 압축되어 있는 것을 확인할 수 있습니다.

맞습니다.. XLSX는 사실 ZIP 파일입니다.

 

문제 분석2

다시 돌아가서, workbook.close()는 어떤 일을 할까요? ZIP 파일을 생성할 땐 마지막에 반드시 Central Directory, End of Central Directory(EOCD) 를 써야 ZIP 파일 완성이 됩니다. Central Directory는 ZIP 안에 어떤 엔트리(파일)가 있고, 각 엔트리가 ZIP 파일의 어디에 있고, 크기/압축 방식/CRC 같은 메타정보가 무엇인지 전부 모아서 기록한 테이블입니다.

 

 

이를 코드로 보면 ZipPackage.saveImpl() → ZipPartMarshaller.marshall() 순서로 수행합니다.

내부에서는 part.getInputStream()으로 저장했던 임시 파일들을 읽어서 ZipArchiveOutputStream에 출력합니다.

 

ZipArchiveOutputStream은 뭘까요?

UnsynchronizedByteArrayOutputStream에 write하며, 내부의 buffer(메모리)에 이미지가 쌓이는 것을 확인할 수 있습니다.

 

힙 덤프를 떠본 결과 버퍼 크기가 끊임없이 확장되는 것을 확인할 수 있었습니다. UnsynchronizedByteArrayOutputStream는 총 270MB 크기의 메모리를 붙잡고 있었습니다.

 

정리하자면 ZipPackage.setUseTempFilePackageParts(true); 를 사용하여 엑셀 생성 과정에서 이미지를 임시 파일로 저장하는 것에는 성공했습니다. 하지만 workbook close 과정에서 결국 Zip 패키징을 해야하는데, 이 때 임시 파일로 저장했던 이미지를 메모리로 다 올려버리기 때문에 oom이 발생하는 것입니다.

 

왜 이렇게 만들었을까.. 짐작을 해보자면 ZIP 파일의 구조 때문이라고 생각합니다. Central Directory를 만들기 위해서는 각 파일의 정확한 위치(offset)를 알아야 합니다.

따라서 모든 파일을 버퍼에 쓰면서 크기를 기록하고 offset이 확정되는 과정을 거치는 것이 Central Directory 작성이 편하다고 할 수 있습니다.

 

물론 이 과정을 스트리밍으로 구현한다면 할 수 있었겠지만(?) 이미지에 한해서 Apache Poi는 그렇게 구현되어 있지 않았습니다. 개발자 분께서 들이는 노력에 비해 크게 가치있는 작업이 아니라고 생각하셨을 것 같기도.. 합니다.

 

여기까지 와서는 xlsx 형식에서 벗어나는 것이 합리적이라는 생각이 들었습니다.

 

실제로 Apache Poi에 비슷한 이슈들을 찾아보다가 메인테이너님의 생각도 다르지 않다는 것을 알게 되었습니다.

저는 Excel 파일이 1000개의 이미지를 표시하기 위한 최적의 형식이라고 생각하지 않습니다. 사용자가 선택했지만 일반적인 사용 사례는 아닙니다. 실제로는 극단적인 경우입니다.

 

 

pdf로 전환

결국 이미지 삽입에 더 적합한 형식은 pdf라는 생각이 들었습니다.

엑셀은 분명 필터링, 정렬, 통계 내기가 편하다는 장점이 있었습니다. 하지만 단점도 느껴졌습니다.

 

1. 행 높이의 종속성

엑셀은 행 높이가 다른 행들과 연결되어 있습니다. 한 행의 높이를 키우면 그 행의 모든 셀 높이가 함께 변경됩니다. 이미지가 큰 경우 행 높이를 늘리면 옆의 텍스트 셀도 함께 늘어나서 레이아웃이 어색해집니다.

 

2. 이미지 비율 유지 어려움

이미지를 셀에 맞추려면 수동으로 크기를 조정해야 하고, 비율이 맞지 않으면 이미지가 찌그러집니다.

 

3. 확대/축소 제한

엑셀의 확대/축소는 전체 시트에 적용되므로, 이미지만 크게 보고 싶어도 모든 셀이 함께 커집니다.

 

반면 PDF에는 다음과 같은 장점을 기대했습니다.

 

1. 독립적인 레이아웃

 

각 페이지가 독립적이므로 이미지 크기에 맞춰 자유롭게 레이아웃을 구성할 수 있습니다.

예를 들어, 페이지 1에

  • 텍스트 (3줄)
  • 이미지 (원본 비율 유지)
  • 이미지 (다른 크기, 원본 비율 유지)

이런식으로 이미지 비율을 각각 다르게 유지하기 편합니다.

 

2. 이미지 품질

PDF는 이미지를 원본 비율로 유지하면서 페이지에 맞춰 배치할 수 있습니다. 확대해도 깨지지 않고 선명하게 볼 수 있습니다.

 

3. 마지막으로, PDF는 ZIP 형식이 아닙니다.

따라서 페이지 하나가 완성되면 이를 디스크에 스트리밍하고 다음 페이지를 만드는 식으로 구현이 가능할것이라 생각했습니다.

 

 

라이브러리 선택

PDF 생성 라이브러리로는 Apache PDFBox, iText 7 등이 있었습니다. 두 라이브러리는 큰 차이가 없었고 Apache PDFBox를 사용하는 것이 기존에 Apache Poi를 사용하는 코드 스타일과 유사하게 짜기 편하지 않을까 싶어 pdfbox를 선택했습니다.

 

구현

결과적으로 이런 pdf 형식으로 내보낼 수 있게 구현했습니다. 

초기 형식이라 이쁘진 않지만..

그래도 pdf의 장점이 느껴진 게 확대했을 때 이미지의 글씨 같은 것들이 너무 선명하게 잘 보였습니다.

 

생성 과정에서는 다행히 이전에 구축했던 BlockingQueue를 이용한 이미지 다운로드 구조를 그대로 활용할 수 있었기에 이미지 다운로드 과정에서 oom은 걱정할 필요가 없었습니다.

 

그럼에도 걱정이 되었던 건 엑셀 파일 생성 시 ZIP 패키징 과정에서 실패했던 것처럼, pdf 생성 과정에서도 예상치 못한 실패 상황이 일어날 수 있지 않을까 생각이 들었습니다.

 

PDF 파일 생성하기

Pdfbox의 생성 과정을 살펴보겠습니다.

try (final PDDocument document = new PDDocument(IOUtils.createTempFileOnlyStreamCache())) {

    createPdfPages(document, font, feedbacks, executor, jobId);

    document.save(outputStream);

    log.info("피드백 PDF 다운로드 완료");
}

 

먼저 엑셀에서 xlsx를 생성하기 위해 SXSSFWorkbook을 만들어줬던 것처럼 PDF를 생성하기 위해 PDDocument를 생성해주어야 합니다.

 

그리고 이 때 파라미터로 IOUtils.createTempFileOnlyStreamCache()로 생성한 StreamCacheCreateFunction을 이미지를 임시파일에 저장되도록 할 수 있습니다.

 

하지만 엑셀 파일 생성 시 zip 패키징 과정에서 결국 실패했던 것처럼, pdf 생성 과정에서도 이미지를 임시파일에 저장하긴 했지만 완전한 PDF를 완성하는 과정에서 OOM이 발생할 수도 있을거라 생각했습니다.

 

PDF를 만들어 하나의 완전한 파일로 저장하는 과정은 그 다음 줄인 document.save(outputStream); 가 수행합니다.

 

save 메서드를 타고 들어가면 COSWriter.write → doWriteObject 흐름으로 실행하고 있습니다.

 

COS는 Carousel Object Structure라고 해서 PDF 문서의 저수준 객체 모델을 의미합니다. PDF 스펙에 정의된 기본 데이터 타입들을 Java 클래스로 표현한 것입니다.

 

즉, COSWriter는 COS 객체들을 PDF 파일로 직렬화하는 Writer입니다.

 

디버거를 통해 visitFromStream 메서드를 보면 임시 파일을 스트림(inputStream)으로 읽어서 OutputStream에 출력하는 것을 알 수 있습니다.

 

OutputStream은 어떤 구현체가 사용될까요?

 

document.save 시 직접 전달했던 FileOutputStream과 같은 객체가 사용됩니다.

 

따라서 임시 파일을 읽어 버퍼에 올리고, 즉시 완성할 PDF 파일로 직렬화한다는 것을 알 수 있습니다.

 

 

모니터링

 

이제 실제로 OOM이 발생하지 않고 파일이 생성되는지 확인해볼 차례입니다.

 

이번에는 900개에서 더 늘려서 1200개의 피드백이 존재하는 단체에 대해 다운로드를 진행해보았습니다. 완성되는 파일의 크기는 500MB로 JVM Heap 크기(500MB)와 맞먹습니다.

 

드디어 다운로드가 정상적으로 완료되었습니다.

 

 

마무리

이번 작업을 하며 로그, 메트릭, 트레이스를 보며 문제를 구체화할 수 있었습니다. 관측 가능성은 문제 해결을 위해 정말 필수적인 요소임을 다시금 느낀 것 같습니다..

Apache POI와 PDFBox의 내부 구현을 살펴보는 과정도 재밌었습니다. createRow()가 windowSize를 넘으면 어떻게 플러시하는지, addPicture()가 왜 메모리에 올라가는지 직접 확인하니 라이브러리의 동작 방식이 이해됐고, 왜 문제가 생기는지 원인 파악도 할 수 있었습니다.

결국 XLSX의 ZIP 구조라는 근본적인 한계를 만나 PDF로 방향을 틀었습니다. 돌아간 것 같지만, 그 과정에서 만든 BlockingQueue 구조가 PDF에서도 그대로 활용됐으니 헛된 시간은 아니었던 것 같네요!

 

 

참고 자료

https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/BlockingQueue.html

https://www.reactive-streams.org

https://poi.apache.org/apidocs/index.html

https://pdfbox.apache.org/