Home Excel 다운로드 과정에서 발생한 이슈
Post
Cancel

Excel 다운로드 과정에서 발생한 이슈


1. 왜 고민하게 되었을까?


최근 엑셀 다운로드 인한 메모리 이슈 가 사내 메신저에 반복적으로 올라왔습니다. 이슈는 대략 아래와 같았는데요. 제가 직접 멘션되지 않아서 처리하지는 않았지만, 몇 가지 해결방법이 떠올라서 정리해보고 싶었습니다.

  • 대형 판매자의 한 달치 판매 목록을 엑셀 다운로드 중 OOM 발생
  • 대형 판매자가 백만 건의 데이터를 엑셀 업로드 중 OOM 발생



코드로 보면 대략 다음과 같습니다.

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
fun downloadExcel(
    startDate: LocalDate,
    endDate: LocalDate,
    outputStream: OutputStream
) {
    // 1. 전체 데이터 조회
    val allData: List<OrderProduct> =
    orderProductRepository.findByDateRange(startDate, endDate)

    // 2. XSSFWorkbook 생성
    val workbook = XSSFWorkbook()
    val sheet = workbook.createSheet("orders")

    var rowNum = 0

    for (data in allData) {
        val row = sheet.createRow(rowNum++)
        row.createCell(0).setCellValue(data.id.toLong())
        row.createCell(1).setCellValue(data.name)
        // ...
    }

    // 3. 응답
    workbook.write(outputStream)
    workbook.close()
}





2. 해결 전략


핵심은 큰 작업을 작은 단위로 나누는 것입니다. 데이터가 많을수록 조회·생성·전송을 일정 범위로 쪼개서 그 범위만 처리하고 바로 마무리한 뒤 다음 단위로 넘어가야 합니다. 한 번에 전부 들고 처리하려는 구조가 아니라, 구간별로 나눠 순차적으로 처리하는 구조로 바꾸는 것이 중요합니다.

  • 일 판매 데이터가 많은 경우
  • 월 판매 데이터가 많은 경우



2-1. 일 판매 데이터가 많은 경우

하루 판매 데이터가 수십만 건 이상이면 엑셀 한 파일로 처리하는 것은 안정적이지 않습니다. 엑셀은 최대 1,048,576행 까지 지원하지만, 실제로는 그보다 훨씬 적은 수준에서도 파일 크기 증가와 열기 성능 저하가 발생합니다. 따라서 하루 데이터가 많을 경우 10만~20만 행 단위로 파일을 분할하고, SXSSFWorkbook으로 생성 후 즉시 닫아 메모리가 누적되지 않도록 하는 방식이 안전합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
다운로드 요청 (특정 날짜)
    │
    ▼
  DB 페이징 조회 (5,000건 단위)
    │
    ▼
  Row 누적
    │
    ├─ 200,000행 도달 → 현재 엑셀 파일 저장
    │                   → zip 엔트리에 추가
    │                   → 새 엑셀 파일 생성
    │
    ▼
  남은 데이터 처리
    │
    ▼
  zip 파일 하나로 응답




코드로 보면 대략 다음과 같습니다.

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Service
class ExcelDownloadService(
    private val orderReadRepository: OrderReadRepository
) {
    
    fun downloadDailySales(
        startDate: LocalDate,
        endDate: LocalDate
    ): File {

        val zipFile = File("orders_${startDate}_$endDate.zip")
        ZipOutputStream(zipFile.outputStream()).use { zip ->
            var date = startDate
            while (!date.isAfter(endDate)) {
                var page = 0
                var rowCount = 0
                var fileIndex = 1

                var workbook = createWorkbook()
                var sheet = workbook.createSheet("orders")
                while (true) {
                    val orders = orderReadRepository.findByDate(date, page, 5_000)
                    if (orders.isEmpty()) break
                    for (order in orders) {
                        if (rowCount == 200_000) {
                            writeToZip(zip, workbook, date, fileIndex++)
                            closeWorkbook(workbook)
                            workbook = createWorkbook()
                            sheet = workbook.createSheet("orders")
                            rowCount = 0
                        }
                        writeRow(sheet, rowCount++, order)
                    }
                    page++
                }
                if (rowCount > 0) {
                    writeToZip(zip, workbook, date, fileIndex)
                    closeWorkbook(workbook)
                }
                date = date.plusDays(1)
            }
        }
        return zipFile
    }
    
    ......
    
}




참고로 XSSFWorkbook은 .xlsx 파일을 OOXML(XML 기반) 형식으로 처리하며, Apache POI는 이 구조를 스트리밍 방식이 아니라 DOM 형태의 객체 트리로 메모리에 구성합니다. 즉, 시트의 모든 Row와 Cell을 자바 객체로 생성해 힙에 유지합니다. 따라서 데이터가 증가할수록 Row, Cell, 문자열, 스타일 객체 수가 함께 늘어나고, 그에 비례해 힙 사용량도 선형적으로 증가합니다. 특히 수십만 건 이상의 데이터를 생성하거나 로드하는 경우 객체 수가 급격히 많아지면서 OutOfMemoryError가 발생하기 쉬운 구조입니다.

1
2
3
4
5
Workbook
 └─ Sheet
     └─ Row
         └─ Cell
             └─ String / Style / RichText ...




반면 SXSSFWorkbook은 모든 Row를 메모리에 올려두는 방식이 아니라, 일정 개수의 Row만 메모리에 유지하고 나머지는 임시 파일로 디스크에 flush하는 스트리밍 구조입니다. 따라서 데이터가 많아지더라도 힙 사용량이 Row 수에 비례해 계속 증가하지 않으며, 대량 데이터를 처리할 때 메모리 사용을 일정 수준으로 제한할 수 있습니다.

1
2
3
4
Workbook
 └─ Sheet
     ├─ Row (최근 N개만 메모리에 유지)
     └─ 초과 Row → temp 파일로 flush (디스크)




2-2. 한 달치 데이터가 많은 경우

월을 하나의 처리 단위로 보지 않아야 합니다. 날짜 단위로 반복 조회하고, 하루 데이터를 처리한 뒤 다음 날짜로 넘어가는 구조로 바꿉니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
다운로드 요청 (startDate ~ endDate)
        │
        ▼
    날짜별로 순회
        │
        ├─ day1 → DB 페이징 조회 → xlsx 생성
        ├─ day2 → DB 페이징 조회 → xlsx 생성
        ├─ day3 → DB 페이징 조회 → xlsx 생성
        │
        ▼
    각 날짜 파일을 zip 엔트리에 추가
        │
        ▼
    zip 파일 하나로 응답




필요하다면 일정 행 수마다 파일을 닫고 새로 생성하거나, 하루별 파일을 각각 생성한 뒤 ZIP으로 묶습니다. 이렇게 하면 메모리 사용은 현재 처리 중인 하루 데이터 수준에 머물며, 월 전체 건수에 비례해 증가하지 않습니다. 물론 이 경우에도 하루치 데이터가 많을 경우, 2-1과 같은 방법을 적용해야 할 수도 있습니다.

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
fun downloadMonthlySales(
    startDate: LocalDate,
    endDate: LocalDate
): File {

    val zipFile = File("orders_${startDate}_$endDate.zip")

    ZipOutputStream(zipFile.outputStream()).use { zip ->

        var date = startDate

        while (!date.isAfter(endDate)) {

            val workbook = createWorkbook()
            val sheet = workbook.createSheet("orders")

            var page = 0
            var rowNum = 0

            while (true) {
                val orders = orderReadRepository.findByDate(date, page, 5_000)
                if (orders.isEmpty()) break

                for (order in orders) {
                    writeRow(sheet, rowNum++, order)
                }

                page++
            }

            if (rowNum > 0) {
                zip.putNextEntry(ZipEntry("${date}.xlsx"))
                workbook.write(zip)
                zip.closeEntry()
            }

            workbook.dispose()
            workbook.close()

            date = date.plusDays(1)
        }
    }

    return zipFile
}





3. 데이터가 더 많으면 어떻게 해야 할까?


하루 수백만 건 이상이라면, 다운로드 기능을 API로 제공하는 방법 자체를 다시 생각해야 합니다. 요청 한 번에 파일을 생성해 내려주는 구조는 운영 리스크가 큽니다. 한 사용자의 요청이 전체 시스템 자원을 점유할 수 있기 때문 입니다. 이 경우에는 실시간 다운로드 기능이 아니라, 운영 추출 프로세스로 전환하는 것이 맞습니다. 사용자가 직접 대량 데이터를 내려받는 구조가 아니라, 요청을 접수하고 별도 추출 작업을 통해 파일을 생성한 뒤 전달하는 방식입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
대량 데이터 추출 요청
        │
        ▼
    배치 워커 실행
        │
        ├─ day1 → xlsx 생성 → S3 업로드
        ├─ day2 → xlsx 생성 → S3 업로드
        └─ dayN → xlsx 생성 → S3 업로드
        │
        ▼
    EC2 / Lambda / Batch에서 CSV들을 다운로드
        │
        ▼
    zip 생성
        │
        ▼
    zip 파일 S3 업로드
        │
        ▼
    presigned URL 전달 / 별도로 전달





4. 정리


데이터 규모에 따라 전략을 달리해야 합니다. 수십만 건 수준까지는 SXSSFWorkbook과 파일 분할로 대응할 수 있고, 월 단위처럼 범위가 커질 경우에는 날짜 단위로 분리해 ZIP으로 묶는 구조가 안전합니다. 수백만 건 이상이라면 실시간 다운로드를 포기하고, 비동기 배치 + 외부 스토리지(S3 등)를 활용하는 운영 추출 방식으로 전환하는 것이 현실적인 방법이죠.


This post is licensed under CC BY 4.0 by the author.

Line vs Buffer 언제, 어떤 파일 읽기 전략을 선택해야 할까?

Streaming vs Keyset Paging