대용량 엑셀 OOM 해결기

대용량 엑셀 OOM 해결기

- SXSSF, FastExcel, StreamingResponseBody 적용 과정 -

1. 들어가며

사내에서 개발한 시스템은 Kafka, NATS JetStream과 유사한 사용성을 제공하는 경량 메시지 브로커 플랫폼의 모니터링 대시보드입니다. Event 메시지의 Pub/Sub 상태를 모니터링하고, Event Entry 로그를 조회하는 기능을 제공합니다.

이 중 Event Entry 로그를 엑셀로 다운로드하는 기능을 개발하면서 OOM 문제가 발생했습니다. 초기에는 일반적인 Apache POI 기반 방식으로 구현되어 있었고, 데이터 건수가 많지 않을 때는 큰 문제가 없었습니다.

하지만 운영 과정에서 다운로드 대상 데이터가 점점 증가하기 시작했고, 약 8만 건 이상의 데이터를 다운로드하는 순간부터 다음과 같은 문제가 발생했습니다.

  • 응답 시간 급증

  • Full GC 반복 발생

  • Heap 메모리 사용량 급증

  • Kubernetes Pod 재시작 및 OOMKilled 발생

당시 Kubernetes 환경의 메모리 제한(800Mi)으로 인해 Pod가 OOMKilled 되는 상황이 발생했습니다. 급한 불을 끄기 위해 우선 메모리를 800Mi → 1500Mi로 증설했지만, 이는 임시 조치였습니다. 데이터가 계속 늘어나는 이상 언젠가 같은 문제가 반복될 것이 분명했고, 근본 원인은 명확했습니다.

"전체 데이터를 메모리에 올려서 엑셀을 생성하는 구조 자체"

본 문서는 단번에 정답을 찾은 이야기가 아닙니다. 라이브러리 교체, chunkSize(청크 크기) 조정, 구조 변경까지 여러 번의 시도를 거치면서 무엇이 진짜 문제였는지를 순서대로 기록합니다.

2. 기존 구조의 문제점

2-1. 전체 데이터를 메모리에 적재하는 구조

기존 다운로드 방식은 다음과 같은 흐름이었습니다.

DB 전체 조회
→ List 메모리 적재
→ XSSFWorkbook 생성
→ ByteArrayOutputStream 생성
→ byte[] 변환
→ HTTP 응답 반환

이 과정의 거의 모든 단계가 JVM Heap을 기반으로 동작하고 있었습니다. 조회 데이터 List, Workbook 객체 및 Row 데이터, ByteArrayOutputStream 버퍼, 최종 byte[]까지 모두 메모리에 유지된 채로 응답을 반환하는 구조였습니다. 데이터 건수가 늘어날수록 Heap 사용량이 선형 이상으로 증가했고, Full GC가 반복적으로 발생했습니다.

2-2. 화면 조회 로직 재사용으로 인한 N+1 문제

엑셀 다운로드 로직이 화면 조회용 로직을 그대로 재사용하고 있었습니다. 이로 인해 Lazy Loading 기반의 연관 엔티티 추가 조회가 발생하면서, 데이터 건수가 많아질수록 SQL 실행 횟수가 급격하게 증가하는 N+1 문제가 함께 발생하고 있었습니다.

이를 해결하기 위해 엑셀 다운로드 전용 쿼리를 별도로 작성하였고, JOIN을 통해 연관 데이터를 한 번에 가져온 뒤 DTO로 직접 매핑하도록 변경하였습니다. 또한 일반적인 LIMIT / OFFSET 방식은 offset이 커질수록 DB가 앞 데이터를 다시 스캔하는 비용이 선형으로 증가하는 문제가 있어, lastOffset > ? 조건 기반 커서 방식으로 변경하였습니다. 커서 방식은 항상 인덱스 범위 스캔으로 처리되어 대용량에서도 조회 성능이 일정하게 유지됩니다.

3. 1차 시도: SXSSFWorkbook + chunkSize

3-1. 접근 방향

처음에는 "Apache POI 자체가 문제"라고 판단했습니다. POI에서 공식으로 제공하는 스트리밍 방식인 SXSSFWorkbook으로 교체하고, 전체 데이터를 한 번에 조회하는 대신 일정 건수(chunkSize)씩 나눠서 DB에서 조회한 뒤 엑셀 Row에 삽입하는 방식으로 변경했습니다.

int chunkSize = 1000;
int offset = 0;
while (true) {
    List<EventEntryDto> chunk = repository.findByChunk(offset, chunkSize);
    if (chunk.isEmpty()) break;
    for (EventEntryDto item : chunk) {
        Row row = sheet.createRow(rowIndex++);
        row.createCell(0).setCellValue(item.getOffset());
        row.createCell(1).setCellValue(item.getPartitionNo());
        // ...
    }
    offset += chunkSize;
}

3-2. 결과 및 문제

로컬 환경 테스트에서는 OOM 없이 동작했습니다. 로컬 기준 8만 건 약 1분 48초로 속도 문제는 있었지만, 일단 동작은 했습니다.

그런데 개발계(Docker 환경)에 배포하자 예상치 못한 에러가 발생했습니다.

java.lang.NullPointerException
    at sun.awt.FontConfiguration.getVersion(FontConfiguration.java:1264)
    at sun.awt.FontConfiguration.readFontConfigFile(FontConfiguration.java:219)
    at sun.awt.FontConfiguration.init(FontConfiguration.java:107)
    at sun.awt.X11FontManager.createFontConfiguration(X11FontManager.java:774)
    at sun.font.SunFontManager$2.run(SunFontManager.java:431)
    ...

SXSSFWorkbook이 내부적으로 시스템 폰트를 참조하고 있었는데, Docker 컨테이너에는 해당 폰트가 설치되어 있지 않아 에러가 발생한 것입니다.

해결 방법으로 컨테이너에 폰트를 직접 설치하는 방법이 있었지만, 이 방향은 선택하지 않았습니다. 로컬과 개발계·운영계 환경 간 의존성 차이가 생기고, 폰트 설치 누락 시 장애 원인 복잡도가 높아지기 때문입니다. 운영 환경에 외부 의존성을 추가하는 것보다 폰트 의존성 자체가 없는 라이브러리로 교체하는 것이 근본적인 해결책이라고 판단했습니다.

1차 시도 결론: 로컬에서는 동작했지만 개발계 배포 시 font error 발생. 속도 문제와 환경 의존성 문제가 함께 남았습니다.

4. 2차 시도: FastExcel + StreamingResponseBody

4-1. 라이브러리 선택 배경

font error를 해결하면서 속도 문제도 함께 잡을 수 있는 라이브러리를 검토했습니다.

  • EasyExcel: 스트리밍 처리 자체는 좋았지만, 개발계에 배포했을 때 SXSSFWorkbook과 동일한 font error가 발생했습니다. 내부적으로 시스템 폰트를 참조하는 구조가 동일했기 때문입니다.

  • FastExcel (dhatim/fastexcel): Row 단위 스트리밍 기반으로 동작하고, 시스템 폰트 의존성이 거의 없어 Docker 환경에서 별도 설정 없이 정상 동작했습니다. 공식 문서에 따르면 Apache POI(non-streaming) 대비 약 12배 적은 Heap 메모리를 사용합니다.

4-2. 추가로 발견한 문제: byte[] 변환

라이브러리 교체 과정에서 기존 구조의 또 다른 문제를 발견했습니다. 엑셀 생성이 완료된 후 ByteArrayOutputStream → byte[]로 변환하는 과정 자체가 대용량에서 메모리를 한 번 더 급증시키고 있었습니다.

기존: 전체 생성 완료 → byte[] 변환 → 응답 시작
개선: Row 생성 → 즉시 OutputStream write → 클라이언트 전송 시작

이를 StreamingResponseBody로 해결했습니다. 엑셀 Row를 OutputStream에 직접 write하면서 동시에 HTTP 응답으로 흘려보내는 구조입니다. StreamingResponseBody를 사용하면 엑셀이 완전히 생성되기 전에 클라이언트에서 다운로드가 시작됩니다. 전체 완료 시간 자체는 동일하지만, 브라우저에서 파일이 내려받히기 시작하는 시점이 앞당겨지기 때문에 사용자가 체감하는 속도가 크게 개선됩니다.

@PostMapping("/export-entries/fetch")
public ResponseEntity<StreamingResponseBody> exportEntries(@RequestBody ExportEntriesFetch fetch) {
    fetch.validate();

    StreamingResponseBody response = out ->
        entryExportService.exportToExcel(out, fetch.getStreamId(), fetch.getPartitionId(),
            fetch.getName(), fetch.getEntryOffset());

    return ResponseEntity.ok()
        .contentType(MediaType.parseMediaType(
            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
        .header(HttpHeaders.CONTENT_DISPOSITION,
            "attachment; filename=\"entries-%s.xlsx\"".formatted(fetch.getStreamId()))
        .body(response);
}
public void exportToExcel(OutputStream out, String streamId, String partitionId,
                           String name, String searchOffset) {
    try (Workbook workbook = new Workbook(out, "MyApp", "1.0")) {
        Worksheet sheet = workbook.newWorksheet("Entries");
        String[] headers = {"No", "Partition", "Offset", "Produced At",
                             "Route Key", "Compression", "Format", "Checksum", "Payload"};
        for (int i = 0; i < headers.length; i++) {
            sheet.value(0, i, headers[i]);
        }

        for (PartitionSummaryDto partition : partitions) {
            while (true) {
                List<EntryExportDto> entries = entryRepository.findExportEntries(
                    streamId, partition.getId(), name, searchOffset, lastOffset, CHUNK_SIZE);

                for (EntryExportDto entry : entries) {
                    sheet.value(rowIdx, 0, rowIdx);
                    // ... 각 컬럼 값 삽입
                    rowIdx++;
                }
                sheet.flush();
                if (entries.size() < CHUNK_SIZE) break;
                lastOffset = entries.get(entries.size() - 1).getEntryOffset();
            }
        }
        workbook.finish();
        out.flush();
    }
}

4-3. 결과 및 문제

font error가 해결되었고, 체감 속도도 크게 개선되었습니다. 로컬 기준으로는 2만 건 약 1.92s, 6만 건 약 6.03s로 1차 대비 성능이 눈에 띄게 향상되었습니다.

하지만 17만 건에서 OOM이 재발했습니다. 원인을 분석해보니, chunkSize 단위로 DB 조회는 나누고 있었지만 각 chunk 안에서 처리하는 엔티티 객체들이 GC되지 않고 누적되고 있었습니다. JPA 엔티티는 연관 관계 정보, Proxy 객체, PersistenceContext 참조 등 실제 데이터 외에도 여러 메타데이터를 함께 보유합니다. chunk를 나눠도 이 엔티티들이 참조를 유지한 채 루프를 돌면 결국 메모리가 충분히 해제되지 않습니다.

2차 시도 결론: font error 해결, 속도 개선. 하지만 엔티티 누적으로 인해 대용량에서 OOM이 재발했습니다.

5. 3차 시도 (최종): 경량 DTO 전환 + chunkSize 실측 조정

5-1. 핵심 원인: 엔티티가 chunk를 무력화하고 있었다

chunk로 조회를 나눠도 메모리가 해제되지 않는 이유는 JPA 엔티티 객체의 특성 때문이었습니다. 엔티티는 단순한 데이터 컨테이너가 아닙니다. JPA가 관리하는 엔티티는 다음과 같은 부가 정보를 함께 들고 다닙니다.

  • 연관 관계 필드 (Lazy 프록시 포함)

  • Hibernate 내부 메타데이터

  • PersistenceContext의 1차 캐시 참조

chunk 단위로 루프를 돌더라도 이전 chunk의 엔티티들이 아직 참조를 유지하고 있으면 GC 대상이 되지 않습니다. 엑셀 다운로드처럼 수만 건의 엔티티를 순회하는 경우, 이 누적이 결국 OOM으로 이어집니다.

해결책은 단순했습니다. 엑셀에 필요한 필드만 담은 경량 DTO로 조회하면, JPA 관리 객체가 생성되지 않아 chunk 처리 후 즉시 GC 대상이 됩니다.

// 기존: JPA 엔티티 조회 — 메타데이터, 연관 관계까지 메모리에 올라옴
List<EventEntry> entries = entryRepository.findByStreamId(streamId);
// 변경: 경량 DTO 조회 — 엑셀에 필요한 필드만 포함
List<EntryExportDto> entries = entryRepository.findExportEntries(...);
public record EntryExportDto(
    Integer partitionNo,
    Long entryOffset,
    String producedAt,
    String routeKey,
    String compressionType,
    String payloadFormat,
    String checksum,
    String payload
) {}

5-2. chunkSize 실측 조정 과정

DTO 전환 이후에도 chunkSize를 어떻게 설정하느냐에 따라 결과가 달랐습니다. 실측을 통해 적정값을 찾아갔습니다.

chunkSize

결과

5,000

OOM 발생 — chunk당 메모리 부담이 여전히 큼

1,500

간헐적 OOM — 엣지 케이스에서 불안정

500

OOM 없음 — 하지만 DB 조회 횟수 증가로 전체 속도가 너무 느림

1,000 (최종 채택)

OOM 없음 + 속도 허용 범위

chunkSize는 단순히 "작을수록 안전하다"가 아닙니다. 너무 작으면 DB 왕복 횟수가 늘어나고, 너무 크면 chunk 내 메모리 부담이 다시 증가합니다. 실제 데이터 크기(특히 Payload 필드처럼 크기가 가변적인 컬럼이 있는 경우)를 고려해 실측 후 결정하는 것이 중요합니다.

엑셀 다운로드는 배치성 작업입니다. 다소 시간이 걸리더라도 OOM 없이 완료되는 것이 사용자에게 더 나은 경험입니다. 2분을 기다리는 것보다 다운로드 도중 서버가 죽는 것이 훨씬 나쁩니다.

실제 운영 환경에서는 "최고 속도"보다 "안정적으로 끝까지 수행되는 것"이 더 중요했습니다.

5-3. 최종 결과

아래 표에서 로컬 수치는 1~2차 시도 당시 측정값이고, 개발계 수치는 현재 배포된 최종 버전 기준입니다.

데이터 건수

환경

1차 (SXSSF)

2차 (FastExcel)

최종 (DTO + chunk 1000)

8만 건

로컬

1분 48초

2만 건

로컬

1.92s

6만 건

로컬

6.03s

17만 건

로컬

OOM

개발계

font error

5만 건

개발계

약 4s

12만 건

개발계

약 5.5s

32만 건

개발계

OOM (개선 예정)

개발계 최종 버전 기준 성능은 다음과 같습니다.

데이터 건수

응답 시간

OOM 여부

5만 건

약 4s

없음

12만 건

약 5.5s

없음

32만 건

발생 (개선 예정)

6. 추가 개선 방향 (32만 건 OOM 분석)

현재 32만 건에서는 OOM이 재발하고 있습니다. 처음에는 단순히 chunkSize가 아직 크기 때문이라고 생각했습니다. 하지만 원인을 더 추적해보니, Payload처럼 크고 고유한 문자열을 Workbook 단위로 누적시키는 구조가 남아 있다는 점이 핵심이었습니다.

FastExcel을 포함한 대부분의 xlsx 라이브러리는 내부적으로 shared string table을 유지합니다. sheet.value()로 문자열을 기록하면 해당 문자열이 이 캐시에 등록되고, sheet.flush()로 Row 데이터는 내보내더라도 shared string table은 Workbook이 닫힐 때까지 메모리에 유지됩니다. Payload처럼 건마다 값이 다른 대용량 문자열이 계속 쌓이면, chunk를 아무리 작게 나눠도 Workbook 전체 수명 동안 메모리가 누적될 수밖에 없습니다.

chunk 단위 메모리 해제는 잘 동작하고 있었지만, Workbook 수명과 함께 살아있는 shared string cache가 별도의 누적 지점이 되고 있었습니다. sheet.value() 대신 sheet.inlineString()을 사용하면 문자열을 shared string cache에 저장하지 않고 셀 안에 직접 기록할 수 있어, flush 이후 메모리에서 해제될 가능성이 높습니다. Payload 문자열 생성 중복 제거, 출력 길이 제한 등도 함께 검토가 필요한 상태입니다. 이 부분이 다음 개선 대상입니다.

7. 마무리

이번 작업을 통해 "로컬에서 된다"와 "운영 환경에서 안정적으로 동작한다"는 전혀 다른 이야기라는 점을 직접 경험했습니다. 라이브러리 선택도 성능 수치만의 문제가 아니라, 컨테이너 환경의 폰트 의존성 같은 운영 제약이 선택 기준에 포함되어야 했습니다.

또한 chunk로 조회를 나누더라도 엔티티가 아닌 경량 DTO를 사용해야 chunk가 의미 있으며, chunkSize는 이론이 아닌 실측으로 결정해야 한다는 점도 이번 작업에서 얻은 핵심입니다.

완전히 해결된 이야기가 아니라는 점을 솔직하게 남겨두는 것이, 같은 문제를 고민하는 동료들에게 더 도움이 될 것이라 생각합니다.

참고 자료

pong

Site footer