-
대용량 Excel 다운로드 설계: OOM피하기백엔드 : 서버공부 2025. 12. 14. 16:27728x90
주주총회 전자표결 시스템을 개발하는 과정에서, 주주/위임/출석 등 여러 도메인에서 엑셀 다운로드 기능에 대한 요구가 반복적으로 발생했다.
엑셀 생성 로직을 도메인별로 분산 관리하는 대신, 백엔드 팀 내부 논의를 통해 이를 공통 기능으로 분리해 관리하기로 결정했고, 해당 공통 Excel 다운로드 기능의 설계와 구현을 맡게 되었다.
처음에는 단순한 기능처럼 보였지만, 실제 데이터 규모와 운영 환경을 고려하면서 메모리 구조, 스트리밍, 트랜잭션 경계, 계층별 책임 분리까지 연쇄적인 설계 문제를 마주하게 되었다.
이 글은 Excel 다운로드 기능을 구현하며 실제로 문제가 되었던 지점과, 그 문제를 어떻게 구조적으로 해결했는지를 정리한 기록이다.
초기 구조: List<List<Object>> 기반 행렬 설계
초기 구현 단계에서는 별다른 고민 없이, 가장 직관적인 방식으로 설계를 시작했다.
일반적으로 우리가 인식하는 엑셀 데이터는 아래와 같은 행(Row) × 열(Column)의 행렬 구조를 가진다.

이러한 구조에 맞춰, 엑셀 데이터를 List<List<Object>> 형태로 표현하는 방식으로 메서드를 구현했다.
구현은 다음과 같은 전형적인 방식으로 진행했다.
- 도메인 DTO 리스트를 입력으로 받는다.
- Reflection을 사용해 필드를 읽는다.
- 각 DTO를 List<Object>로 변환한다.
- 전체 데이터를 List<List<Object>> 형태로 메모리에 적재한다.
- Apache POI로 Workbook을 생성하고 한 번에 write 한다.
- 결과를 byte[]로 반환한다.

이 방식은 엑셀을 코드 상에서도 이차원 배열 구조로 그대로 관리할 수 있어, 데이터 구조를 직관적으로 이해할 수 있었고 가독성과 구현 난이도 측면에서 장점이 있었다.
하지만 모든 데이터를 List<List<Object>> 형태로 메모리에 한 번에 적재하는 구조이기 때문에, 데이터 규모가 커지는 순간 메모리 사용량이 급격히 증가한다는 치명적인 한계를 가지고 있었다.
이중 리스트로 인한 메모리 폭증

oom 터짐 이 구조의 가장 큰 문제는 동시에 여러 단계의 데이터가 메모리에 존재한다는 점이다.
- 원본 DTO 리스트
- DTO를 변환한 List<List<Object>>
- Workbook 내부 버퍼
- 최종 결과 byte[]
즉, 데이터가 N건일 때:
- O(N) DTO
- O(N) 행렬 데이터
- O(N) Workbook 데이터
- O(N) byte[]
가 동시에 메모리에 적재된다. 데이터가 수만 건만 되어도 힙 사용량이 빠르게 증가했고, 수십만 건 단위에서는 OutOfMemoryError가 발생할 수밖에 없는 구조였다.
구조 개선 1단계: List<List<Object>> 제거
내가 첫번째로 떠올린 방법은 List<List<Object>> 제거였다. 사실 처음 구현때부터 이중리스트 이놈이 찝찝하긴했다.
엑셀 파일은 본질적으로 Row 단위로 작성 가능한 포맷이다. 굳이 전체 행렬을 메모리에 쌓아둘 이유가 없었다.
그래서 구조를 다음과 같이 변경했다.
- DTO 하나를 읽는다
- 해당 DTO를 Excel Row로 변환한다
- 즉시 Sheet에 write 한다
- 다음 DTO로 넘어간다
int rowIndex = 1; for (Object rowObj : rows) { Row excelRow = sheet.createRow(rowIndex++); List<Object> row = excelParserPort.extractRow(rowObj); for (int i = 0; i < row.size(); i++) { Object cell = row.get(i); excelRow.createCell(i) .setCellValue(cell == null ? "" : cell.toString()); } }List<Object> row = excelParserPort.extractRow(rowObj);위와 같이 배열을 한줄씩 만들어서 바로 지정한 순서에 맞게 cell에 채워넣었다.
이 변경만으로도:
- List<List<Object>> 메모리 적재가 사라졌고
- 데이터 크기에 따른 메모리 증가 폭이 크게 줄었다
이렇게 oom을 완벽하게 방지했다.

라고 하면 얼마나 좋을까
똑똑하신 분들은 이미 눈치채셨을지 모르겠지만 근본적인 문제가 해결되지않았었다.
지금까지 이중리스트를 메모리에 올리지않고 바로 cell에 작성한 방식은 서비스로직 진행중의 oom을 막은것 뿐이었다.
두 번째 문제: byte[] 반환 구조의 근본적 한계
Row 단위로 Workbook을 작성하더라도, 기존 시그니처는 여전히 다음과 같았다.
byte[] generate(...)이 구조는 앞에서 아무리 리스트에 안쌓고 흘려보낸다고 해도 마지막엔 엑셀 전체를 메모리에 올려서 반환하는 구조이다.
즉, 내부 구현이 아무리 스트리밍처럼 보이더라도 마지막에 byte[]로 모으는 순간 모든 노력이 무용지물이 되는 것이다.
특히 대용량 데이터의 경우 SXSSFWorkbook으로 Row를 flush 하더라도 최종 결과를 byte[]로 만드는 순간 힙 메모리에 엑셀 전체가 다시 적재된다.
이로써 문제는 구현의 문제가 아니라 인터페이스 설계 문제라는 것이 명확해졌다.
구조 개선 2단계: OutputStream 기반 스트리밍 전환
대용량 다운로드에서 핵심은 "메모리에 담지말고, 바로 흘려보내는 것"이 핵심이다.
그래서 Excel Generator의 시그니처를 다음과 같이 변경했다.
void generate(String sheetName, List<?> rows, OutputStream outputStream);이 변경의 의미는 다음과 같다.
- Generator는 더 이상 결과를 보관하지 않는다
- Workbook은 직접 OutputStream에 write 된다
- 서버 메모리에 엑셀 전체가 올라가지 않는다
- 클라이언트로 바로 스트리밍된다
이제 Excel 생성은 진짜 스트리밍 작업이 되었다.
스트리밍 구조로 전환하면서, 몇가지 의 설계 의문이 생겼다.
첫번째는 트랜잭션 범위에대한 의문이었다.
트랜잭션 문제: 엑셀 생성은 트랜잭션이 아니다
스트리밍 Excel 생성은 오래 걸릴 수 있는 IO 작업이다. 이를 트랜잭션 내부에 위치시키는 것은 명백한 설계 오류다.
엑셀이 생성되는 동안 DB 트랜잭션이 열린 상태로 유지되고 DB 커넥션을 점유하게되기때문이다.
반면에 다운로드 이력은 성격이 다르다. 다운로드 이력은 반드시 기록되어야 하고, 트랜잭션 보호가 필요한 작업이다.
따라서 유스케이스를 분리했다.
- RecordSaveUseCase → @Transactional
- Excel 생성 → 트랜잭션 외부
이렇게 함으로써, 이력 저장이 완료되는 즉시 트랜잭션이 종료되며 DB 커넥션이 반환되고, 이후 수행되는 엑셀 생성 과정은 트랜잭션과 분리되어 동작하게 되었다.
트랜잭션 범위를 명확히 분리하면서, 엑셀 생성 로직 자체는 한층 안정된 구조를 갖게 되었다.
그러자 다음으로는 서비스가 어떤 책임까지 가져야 하는지에 대한 고민이 자연스럽게 이어졌다.특히, 스트리밍 구조로 전환하면서
Service가 의존하는 Port의 경계는 어디까지여야 하는가라는 질문이 떠올랐다.책임 재정의: Parser는 어디에 있어야 하는가
스트리밍 구조로 전환하면서, 또 하나의 설계 의문이 생겼다.
“ExcelParser는 Service의 책임일까?”
Parser의 역할은 다음과 같았다.
- 도메인 객체를 Reflection으로 읽고
- @ExcelColumn 메타데이터를 해석하고
- Excel Row 구조로 변환하는 것
이는 비즈니스 규칙이 아니라 Excel 생성이라는 기술 구현의 일부였다.
따라서 의존성 구조는 다음과 같이 정리하였다.

Service는 다운로드 유스케이스 orchestration만 담당하고
Generator는 Excel 포맷, 컬럼 파싱, Row 생성, Streaming write 를 전부 책임하도록 했다.
결과적으로 Service는 기술 세부사항을 전혀 모르게 되었고 Excel 구현 변경이 Service에 전파되지 않게 되었다.
나는 비즈니스랑 상관없는 외부 기술이라고 할 수 있는 엑셀 생성로직이 service로 부터 분리됬다는 점이 좀더 헥사고날 관점에 어울린다고 생각했다.최종 구조
여러 시행착오를 거쳐 아래와같이 구조가 잡혔다.

성능 검증

정상적으로 생성된 엑셀 파일 
10만 RAW 기준으로 성능테스트를 진행하였는데 엑셀 생성부터 다운로드까지 0.7초정도 소요되었다.
3개의 칼럼에 10만줄의 엑셀이라 2MB 인데 메모리 이슈없이 이정도 속도라면,
추후에 DB에서 chunk방식으로 데이터를 미뤄줬을때도 실무에서 큰 문제는 없을 것으로 예상이된다!
마무리: 설계는 문제를 통해 구체화된다
이번 Excel 다운로드 기능 구현에서 가장 큰 배움은 이것이었다.
단순히 “동작하는 코드”와 “운영 환경에서 안전한 구조”는 완전히 다르다
이중 리스트 제거, byte[] 반환 제거, 스트리밍 전환, 트랜잭션 분리, 책임 재배치까지 모든 결정은 실제 문제가 발생할 수 있는 지점을 하나씩 제거하는 과정이었다.
'백엔드 : 서버공부' 카테고리의 다른 글
스프링 DI 기반 전략 패턴 : 레지스트리 패턴(Registry Pattern) (0) 2025.12.21 헥사고날에서 QueryDSL 지키기 (0) 2025.11.30 "나야 503" : 추석 코레일 대란 (0) 2025.09.30 Caffeine Cache 로 동시성 제어하는거 알려 드릴까요? (0) 2025.06.26 그 날 AWS는 떠올렸다 : 람다 (0) 2024.11.24