| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 | 31 |
- 데이터유실방지
- 장애복구
- SaaS
- blockingqueue
- god object
- 임베딩
- OOP
- 비동기처리
- 메세지브로커
- DLT
- 코사인
- redis
- jedis
- 자연어캐싱
- redissearch
- 메시지브로커
- 객체지향적사고
- rdb
- springboot
- aof
- 레디스스트림
- retry
- 시맨틱캐싱
- 배치처리
- 백엔드
- 마케팅 #퍼플카우 #새스고딘 #혁신 #독서 #이북
- Kafka
- redisstreams
- 레디스
- 테스트코드
- Today
- Total
pandaterry's 개발로그
[Saas개발로그] JVM 2GB로 1,000만 행 Excel 처리는 가능한가? 직접 실험해봤습니다 본문

배경
요즘 SQL ResultSet을 받아 데스크톱 앱에서 바로 엑셀 파일로 내려주는 서비스를 개발 중입니다.
초기에는 수만 건 단위의 다운로드만 고려했지만,
“고객사의 요구에 따라 향후 수백만 건, 심지어 수천만 건까지 조회량이 늘어날 수도 있겠다”
는 생각이 들었습니다.
아직 실제로 그런 대규모 요청이 들어온 것은 아니지만, 미리 대비하지 않으면 나중에 갑작스러운 부하에 허용하지 못할 수 있습니다.
그래서 이번에는 JVM 힙 2 GB 환경에서 잠재적인 극한 부하를 가정해 보고자 합니다.
다음 세 가지 기법을 동일 조건에서 비교·검증하며, “어떤 구조가 가장 안정적으로 견딜 수 있을까?”를 확인해 보겠습니다.
근데 왜 JVM 힙을 2GB로 제한했는가?
이번 실험에서 JVM 힙을 2GB로 제한한 이유는 단순한 테스트 편의를 위한 것이 아니라, 실제 배포될 환경의 자원 제약 조건을 정확히 반영한 설정이기 때문입니다.
제가 설계한 구조는 다음과 같은 특수한 실행 환경을 가집니다:
- Tauri 기반 데스크탑 앱이 실행될 때
- 내부적으로 Micronaut 서버가 로컬에서 함께 실행되며
- 사용자가 요청한 데이터를 기반으로 1,000만 건 Excel 파일을 생성합니다.
즉, 이 서버는 전용 백엔드 서버가 아니라,
일반 사무용 PC에서 Tauri 앱과 함께 동작하는 로컬 서버입니다.
이런 환경에서는 다음과 같은 제약 조건을 가집니다:
현실적인 메모리 사용 환경
구성 요소 평균 메모리 사용량 출처 요약
| 구성요소 | 평균 메모리 사용량 | 검증 내용 |
| 운영체제 (Windows/macOS) | 3~5GB | Windows 11 유휴 시 3~5GB 이상 증가 |
| Tauri 앱 | 0.5~1.5GB | WebView 기반이라 Electron보다 적지만, 복잡도 따라 증가 가능 |
| 기타 앱 (Teams, 브라우저 등) | 1~2GB | Microsoft Teams 단독으로 0.7~1.3GB, Slack 등 추가 시 합산 |
| 여유 메모리 (Total 16GB 기준) | 약 7~10GB | 사용자 앱과 시스템 포함 계산 |
JVM 힙 설정의 상한선: 2~4GB가 현실적
JVM 힙은 사용 가능한 여유 메모리 중에서 절반 이상을 차지하면 시스템에 부담을 줍니다.
- 너무 큰 힙은 GC 일시정지(Pause Time)를 늘리고
- OS 스와핑이 발생해 전체 성능을 저하시킬 수 있으며
- 메모리 단편화, 과도한 메타영역 사용까지 유발할 수 있습니다
그래서!
Micronaut 서버는 Tauri와 동시에 로컬에서 동작하며, 사무용 PC 환경에서 배포됩니다.
이 조건에서 JVM 힙을 2GB로 제한하는 것은,
실제 배포 환경에서 감내할 수 있는 리소스의 현실적인 상한을 반영한 실험 조건입니다.
왜 더 높게 잡지 않았는가?
메모리를 6GB, 8GB 이상 할당하면 1,000만 건 Excel 생성은 좀 더 쉽게 처리될 수 있습니다.
하지만 이는 다음과 같은 문제를 초래합니다:
- 고사양 장비에서만 동작 → 일반 회사 환경에서는 실패하거나 버벅임
- 사용자 입장에서는 앱이 갑자기 꺼지거나 시스템이 느려지는 것처럼 보일 수 있음
- 메모리 부족으로 인한 OutOfMemoryError보다 스와핑 + GC 병목 현상이 더 위험
따라서 이번 실험의 목적은
“가능한 최소한의 JVM 힙 구성에서 얼마나 안전하게 Excel을 생성할 수 있는가”를 검증하는 것입니다.
Excel/CSV 처리 3가지 라이브러리 비교
1. Apache POI SXSSF 스트리밍
- 무엇인가?
POI 라이브러리의 SXSSFWorkbook을 사용해
메모리에 모든 데이터를 올리지 않고
내부 디스크 기반 버퍼에 일정 행 단위로만 유지하면서
스트리밍 방식으로 엑셀 파일을 생성하는 기법입니다. - 선정 이유
- 표준성: Java 진영에서 가장 널리 쓰이는 엑셀 처리 라이브러리
- 유연성: 셀 스타일, 수식, 차트 삽입 등 고급 기능 지원
- 메모리 절약: 기본 POI보다 낮은 메모리 사용
2. Alibaba EasyExcel 버퍼 기반 매핑
- 무엇인가?
Alibaba에서 만든 EasyExcel 라이브러리는
도메인 모델 클래스에 @ExcelProperty 어노테이션을 붙여
한 번에 일정 크기의 버퍼(List<T>)로 데이터를 모은 뒤
SAX 이벤트 방식으로 엑셀을 생성하는 방식입니다. - 선정 이유
- 간결성: 코드 몇 줄로 모델 → 엑셀 매핑 가능
- 성능: 내부적으로 메모리 버퍼 크기를 조절해 대용량 처리 최적화
- 실제 사례: 대규모 서비스(Alibaba Cloud)에서 검증
3. Univocity-parsers CSV 스트리밍
- 무엇인가?
CSV 형식으로 ResultSet을 한 행씩 읽어
CsvWriter를 통해 즉시 네트워크에 쓰는 방식입니다.
Apache Commons CSV 대비 높은 처리량과 낮은 메모리 사용을 자랑합니다. - 선정 이유
- 극한 경량: 텍스트 기반이므로 바이너리 포맷보다 파일 크기·메모리 사용량이 작음
- 단순성: 셀 스타일이나 복잡 포맷이 필요 없는 상황에서 최적
- 성능: 벤치마크에서 초당 수백만 행 처리 성능 입증
이 세 가지는
- Java에서 가장 흔히 쓰이는 엑셀 처리 기법,
- 코드 간결성과 성능을 모두 잡은 대안,
- 엑셀 대신 최경량 CSV로 접근하는 옵션
으로 서로 메모리 사용, 처리 속도, 개발·운영 복잡도 면에서 합리적인 비교 대상으로 판단하여 선정했습니다.
이제 실험을 해보죠. 실험은 우선 요약하자면, 1,000만건의 DB에 있는 데이터를 서버가 받아서 제한적인 스펙에서 엑셀을 다운로드하는 시나리오입니다.
1. 실험 개요
- 목적: 1,000만 건 orders 테이블을 내보낼 때
- Apache POI SXSSF(스트리밍)
- EasyExcel(버퍼 메모리)
- Univocity-parsers(CSV 스트리밍)
세 방식의 메모리 사용량, 처리 시간, 파일 크기, 코드 복잡도를 비교
- 환경
- Spring Boot, Java 17
- JVM 힙 2 GB 고정 (-Xms2g -Xmx2g)
- 동시 사용자 1명(부하 없이 순차 테스트)
- Linux 서버, 1 vCPU, 4 GB 메모리
2. 실험시작
우선 시작하기 전에 측정 지표 선정을 해봤습니다. 제한된 리소스에서 어떤게 가장 성능이 좋은지 판가름하는 것이라 기준이라 생각합니다.
| 지표 | 단위 | 선정이유 |
| 배치 처리 속도 | batches/sec | 초당 몇 개의 배치가 처리되고 있는지를 나타냅니다. 실시간 처리 성능을 모니터링하기 위한 가장 직접적인 지표로, 속도 저하나 중단 시 즉시 감지할 수 있습니다. |
| 배치 처리 흐름 | batches/interval | 일정 시간 구간 내에 얼마나 많은 배치가 처리되었는지를 보여줍니다. 시점별 처리 분포를 확인하여 작업의 집중 시간대나 공백 구간을 시각화할 수 있습니다. |
| 평균 처리 시간 | seconds | Export 1건을 완료하는 데 걸린 평균 시간을 나타냅니다. 라이브러리 간 처리 효율 비교나 병목 파악에 핵심적인 기준입니다. |
| 최대 처리 시간 | seconds | 일정 시간 내 export 중 가장 오래 걸린 작업의 처리 시간입니다. SLA 위반 여부나 간헐적 병목을 탐지하기 위한 지표입니다. |
| 평균 파일 크기 | bytes | 생성된 엑셀 파일의 평균 크기를 나타냅니다. 처리된 데이터 양이나 압축 효율을 간접적으로 파악할 수 있으며, 비정상적으로 큰 결과물도 감지할 수 있습니다. |
Apache-POI SXSSF Streaming
엑셀 추출 메트릭

- 초당 처리 행수 : 대략 10만rows(101,814 rows)
- 한번 엑셀 추출시 시간 : 127초 정도 소요
- 파일 용량 : 625MB
JVM 메트릭

- (노랑) Heap 메모리(G1 Old Gen) 사용량 : 평균 2,205,628,416 (2.2GB)
- (초록) Heap 메모리(G1 Eden Space) 사용량 : 평균 752,877,568 (0.7GB)
- (파랑) Heap 메모리(G1 Survivor Space) 사용량 : 평균 11,881,704 (120MB)
Apache-POI (SXSSF Streaming) vs EasyExcel
: 우선 Prometheus, Grafana를 통해 매트릭을 시각화했습니다. 비교는 각 요청별로 어떤 라이브러리인지와 요청마다 UUID를 달아서 비교를 했습니다.
처리량(노랑 - Apache POI, 빨강 - EasyExcel)

=> 지표를 보면 최대 피크에서 EasyExcel 이 Apache POI 보다 6배가량 더 높은 처리량을 보여줍니다.
처리시간 및 파일크기(노랑 - Apache POI, 빨강 - EasyExcel)

처리시간은 Apache POI가 3분정도 더 빠르고, 용량도 EasyExcel에 비해 30MB 정도 더 가볍습니다.
참고로 30MB면 1/10인 100만건을 처리할 때의 파일크기이며, 3분이라는 시간도 100만건을 테스트해봤을 때 평균적으로 나오는 시간대였습니다.
그러므로, 100만건 정도 Apache POI가 빠르고 가볍게 처리한다는 걸 의미합니다.
이걸 보면서 다음과 같은 궁금증이 들겁니다.
위에서 처리량은 EasyExcel이 더 높은데, Apache POI가 처리시간과 파일크기에서 더 나은 이유가 뭐지?
이에 대해 답변을 드리자면,
우선 Apache POI는 압축한 Workbook에 write가 가능합니다. 하지만 EasyExcel은 그렇지 않죠.
그래서 Apache POI는 처리량은 적었으나 용량을 30MB정도 줄일 수 있습니다. 그리고 그렇기 때문에 디스크 IO에서 3분정도 단축이 가능한거죠.
JVM 힙 메모리(노랑 - Apache POI, 빨강 - EasyExcel)
: 높이가 사용량이며 byte단위입니다. 예를 들어, 20억이면 2GB입니다.

=> 그래프에선 둘의 Old Gen 메모리 사용량은 큰 차이가 없어보입니다. 하지만 EasyExcel에서 G1 Eden Space 사용량이 올라가는걸 볼 수 있는데 3배 정도 Apache POI에 비해 더 높음을 알 수 있습니다.
Apache POI에서 SXSSFWorkbook을 사용했다면 Old Gen 사용량이 비슷한 것이 정상입니다.
SXSSFWorkbook은 스트리밍 방식으로 일부 데이터만 메모리에 유지하기 때문입니다.
EasyExcel은 EasyExcel은 행별로 새로운 객체를 생성하고 즉시 해제합니다.
그래서 따끈따끈한 갓 태어난 객체 위주로 다루기 때문에 Eden Space가 높은 구조를 갖고 있습니다.
Univocity CSV Parsers
: 여기도 Prometheus, Grafana를 통해 매트릭을 시각화했습니다. 빨강 화살표만 보면 됩니다.
Excel 처리량

- 초반에 38까지 튀어오르다가 25부근에 횡보하는 것을 볼 수 있습니다.
- Univocity는 Apache POI, EasyExcel과 다르게 매번 디스크 IO를 쓰기 때문에 점점 IO가 늘어나면서 처리량이 어느 수준에 머무는 것을 볼 수 있습니다.
Excel 처리시간 및 파일크기

- 솔직히 처리시간과 파일크기에서 실망했던 부분이었습니다.
- Apache POI, EasyExcel에 비해 천만건일 때는 처리시간과 파일크기 모두 거의 2배 수준으로 비효율적이었습니다.
- 그러나 건수가 적었을 때는 확실히 빨랐는데, 데이터가 많으면 많을수록 비효율적인 구조임을 알 수 있었습니다.
JVM 힙 메모리

- 빨강 화살표가 있는 노란선을 보면 2GB가 넘게 G1 Old Gen을 사용하고 있는 것을 확인가능합니다.
- 아래 초록선은 G1 Eden Space인데, 지금 계속 줄어들었다가 늘었다가를 반복하는 중인데, 이건 배치단위로 배열을 만들고 해제하느라 발생하는 상황입니다.(어느정도 속도를 줄이는 과정에서 이 방식이 가장 빨랐습니다.)
- 전반적으로 총 G1 Old Gen 은 조금 덜 사용하고, G1 Eden Space는 적게 사용하는 편이었습니다. 하지만 반복적인 디스크 IO때문에 속도가 느리니 트레이드오프라 생각합니다.
3. 실험결과
: 'Apache-POI (SXSSF Streaming)' vs 'EasyExcel' vs 'Univocity CSV Parsers' 3가지를 한번에 비교해봤습니다.
Excel 처리량(빨강 - Univocity, 노랑 - Apache POI, 파랑 - EasyExcel)

- Univocity : 초당 0.4배치(4,000행) 처리, 분당 24배치(240,000 행)
- Apache POI : 초당 9배치(90,000행), 분당 549배치(5,490,000행)
- EasyExcel : 초당 12배치(120,000행), 분당 748배치(7,480,000행)
EasyExcel > Apache POI > Univocity
천만건에서 처리량은 EasyExcel이 가장 좋으며,
Univocity 대비 30배정도 높습니다.
Excel 처리시간 및 파일크기(빨강 - Univocity, 노랑 - Apache POI, 파랑 - EasyExcel)

- Univocity : 39분 / 1210MB
- Apache POI : 22분 / 625MB
- EasyExcel : 24분 / 651MB
Apache POI > EasyExcel > Univocity
Apache POI가 처리시간이나 파일크기면에서 가장 효율적입니다.
가장 느린 Univocity 대비 2배정도 효율적입니다.
JVM 힙 메모리(빨강 - Univocity, 노랑 - Apache POI, 파랑 - EasyExcel)

- Univocity : 2.3GB(G1 Old Gen) / 100MB(G1 Eden Space)
- Apache POI : 2.5GB(G1 Old Gen) / 230MB(G1 Eden Space)
- EasyExcel : 2.5GB(G1 Old Gen) / 620MB(G1 Eden Space)
그러나, 위 지표는 연속적으로 측정한 것이라 누적된 것일 수도 있습니다. 참고만 하세요.
Univocity > Apache POI > EasyExcel
G1 Old Gen에 대해선 그리 큰 차이는 없습니다.
하지만 G1 Eden Space를 고려하면 Univocity가
디스크IO를 주로 하기 때문에 효율적입니다.
결론
“JVM 힙 2GB로는 1,000만 건 엑셀 생성, 불가능에 가깝다”
이번 실험의 핵심 목적은 **“JVM 힙 2GB 환경에서 얼마나 안전하게 대용량 Excel 파일을 생성할 수 있는가?”**였습니다.
결론부터 말씀드리면, 현실적으로는 거의 불가능에 가깝습니다.
그 이유는 다음과 같습니다:
- 3가지 방식 모두 G1 Old Gen이 2.3GB~2.5GB까지 상승
→ 실질적으로 JVM이 허용하는 메모리 경계를 초과했고, GC 일시정지가 빈번하게 발생했습니다.
→ 특히 Tauri 앱과 동시에 구동되는 환경에서는 메모리 충돌 여지도 큽니다. - 파일 생성 시간이 20~40분 소요되며 사용자 경험에 치명적
→ UI가 멈춘 것처럼 보이거나, 작업이 실패할 가능성 존재
→ 시스템 메모리 여유가 조금만 부족해도 OOM 발생 위험 - 처리량이 아무리 좋아도, 메모리와 IO 병목으로 전체 시스템에 부담
→ EasyExcel이 빠르긴 하지만 Eden 영역 급증, GC 튜닝 없이는 지속적인 운영 불가
→ Apache POI는 용량은 적지만 처리량이 상대적으로 부족해 결국 시간 지연
실무 판단 기준
따라서 실무에서는 아래와 같은 결정을 고려해야 합니다:
- 1,000만 건 이상의 엑셀 다운로드를 허용하지 않거나,
- JVM 힙을 4GB 이상으로 확보 가능한 구조에서만 동작하도록 제한하거나,
- 백그라운드 처리 + 알림 방식(비동기, 링크 다운로드 등)으로 전환해야 합니다.
그럼, 어떤 구조가 가장 적합한가?
이번 실험을 통해 예상보다 훨씬 다양한 인사이트를 얻을 수 있었습니다.
단순히 “메모리를 적게 쓰는 구조가 좋다”거나 “처리 속도가 빠른 게 최고다”라고 말하기는 어렵습니다.
각 방식은 서로 다른 장단점을 명확하게 가지고 있었기 때문입니다.
상황별 선택 기준
1. 단기 요청 처리량이 최우선이라면 – EasyExcel
EasyExcel은 초당 처리량 기준으로 가장 우수했습니다.
특히 1,000만 건이라는 극한 상황에서도 전체 처리량이 Apache POI보다 약 36% 더 높았고,
배치 처리 흐름 또한 안정적으로 유지되었습니다.
이는 Eden Space를 많이 활용한 설계 구조와 배치 버퍼 기반의 지속적인 가비지 회수 전략이 주효했기 때문으로 보입니다.
다만, 처리 결과물의 용량이 약간 크고 JVM의 Eden 영역 소비가 급증하기 때문에,
JVM 튜닝 없이 운영한다면 갑작스런 GC Pause의 리스크가 존재합니다.
2. 파일 크기와 안정성 중심 – Apache POI (SXSSF Streaming)
처리량은 EasyExcel에 미치지 못했지만, 단일 요청에 대한 응답 시간, 생성 파일 크기, IO 효율성에서는 가장 좋은 성과를 냈습니다.
압축된 워크북 구조 덕분에 디스크 IO가 줄었고, 파일 크기는 EasyExcel 대비 약 30MB 줄었으며, 이는 다운로드 네트워크 비용, 스토리지 비용 측면에서 장점이 됩니다.
실제로 100만 건 단위의 중간급 요청에서는 EasyExcel보다 빠르고 가벼웠습니다.
스트리밍 방식이기 때문에 OutOfMemoryError에 대한 안정성도 높았습니다.
3. 극한의 경량, CSV 처리 – Univocity Parsers
Univocity는 기대했던 “극한 경량”에 비해 천만 건 단위에서는 오히려 비효율적이었습니다.
처리 시간은 Apache POI의 2배, 파일 크기는 1.9배, 게다가 JVM Old 영역 사용량은 크게 차이나지 않으면서도 처리량은 30배 이상 낮았습니다.
이는 반복적인 디스크 IO와 캐시 최적화 부족에서 기인한 결과로 보입니다.
물론, 100만 건 이하의 소형 요청에서는 여전히 강력한 선택지입니다.
간결한 구조, 빠른 응답 시간, 낮은 복잡도 측면에서 가장 우수합니다.
종합 평가표
| 항목 | EasyExcel | Apache POI (SXSSF) | Univocity Parsers |
| 처리량 (건/분) | 7,480,000 | 5,490,000 | 240,000 |
| 처리시간 (1,000만건) | 24분 | 22분 | 39분 |
| 파일 크기 | 651MB | 625MB | 1,210MB |
| G1 Old Gen | 2.5GB | 2.5GB | 2.3GB |
| G1 Eden Space | 620MB | 230MB | 100MB |
| 객체 생성 부담 | 높음 | 중간 | 낮음 |
| 스타일/차트 지원 | 약함 | 강력 | 불가능 |
| 실무 안정성 | 중간 | 높음 | 낮음 |
| 적합 환경 | 고속 처리 | 중형 안정성 | 소형 경량 |
실무에서의 선택 기준
최종적으로, 실무에서 어떤 방식을 선택해야 하는가는 다음과 같이 요약할 수 있습니다:
- 요청 크기가 크고 엑셀 기능이 필요 없다면: EasyExcel (속도 중시)
- 전체적인 안정성과 양호한 처리속도가 필요하다면: Apache POI (균형 중시)
- 요청 크기가 작고, 단순 CSV로도 충분하다면: Univocity Parsers (최소 리소스 중시)
마무리하며 – 확장 가능성에 대한 시사점
이번 실험은 어디까지나 단일 사용자, 순차 요청 기준으로 수행되었습니다.
실제로 다수의 사용자가 동시에 요청을 보낼 경우, GC 압력은 훨씬 더 크게 작용할 수 있습니다.
추후에는 다음과 같은 시나리오로도 실험을 확장해볼 예정입니다:
- 동시 5~10명 요청 시의 GC 및 처리량 변화
- 압축되지 않은 xlsx, 압축 수준별 csv 파일 비교
- FlatFile vs DatabaseStreamingReader 기반 확장 구조
또한, 이번 실험을 통해 구조적으로 ExportService를 어떻게 분리하고 관리해야 하는가에 대한 감각도 얻을 수 있었습니다.
이러한 경험은 SaaS 구조에서의 데이터 추출 모듈 설계, 그리고 비동기 처리/백오피스 자동화 설계 시나리오에도 충분히 적용될 수 있을 것입니다.
'개발 > Saas 개발로그' 카테고리의 다른 글
| [Saas 개발로그] LLM 호출비 줄이는 법, Redis Search로 검증해봤습니다 (2) | 2025.06.21 |
|---|---|
| [Saas 개발로그] Redis Streams~ 너도 kafka처럼 복구해봐 (1) | 2025.06.13 |
| [Saas 개발로그] Redis로 Kafka 없이 유실 없는 메시지 큐를 만들 수 있을까? (1) | 2025.06.12 |
| [SaaS 개발로그] Redis Pub/Sub이면 충분한 줄 알았습니다 (0) | 2025.06.11 |