대용량 엑셀 다운로드 이슈 해결하기

고객이 엑셀을 다운로드하면 브라우저가 죽는다는 CS가 들어왔습니다. 기존에는 3만 건 이하를 처리했는데, 문제가 된 데이터는 18만 건이었어요. DB에서 읽은 데이터를 클라이언트로 모두 전달한 뒤 브라우저에서 XLSX를 생성하는 구조였습니다.

해결은 두 단계로 진행했습니다. 생성을 서버의 스트리밍 처리로 옮겨 브라우저 메모리 문제를 해결하고, 작업을 비동기로 분리해 사용자가 화면에서 기다리는 문제를 줄였습니다. 두 변경이 해결한 문제는 서로 다릅니다.

18만 행이 원인인가, 처리 방식이 원인인가?

XLSX는 워크시트·서식 등의 XML과 관련 파일을 ZIP 패키지로 묶은 OOXML 형식입니다. 확장자를 .zip으로 바꾸어 압축을 풀면 내부를 볼 수 있어요. 행·셀 데이터는 보통 xl/worksheets/sheet1.xml 같은 워크시트 파트에 저장되며, 파일명이 사용자가 지정한 시트 이름과 같지는 않습니다. Microsoft의 SpreadsheetML 구조

XLSX 파일을 압축 해제한 내부 구조 XLSX 안의 워크시트와 관련 파일

브라우저에서 파일을 만들면 다음 데이터가 일부 겹쳐 메모리에 존재할 수 있습니다.

처리 단계메모리를 사용하는 것
데이터 수신·변환응답 데이터, 파싱한 객체와 가공한 배열
워크북 구성라이브러리의 행·셀·서식 객체
직렬화·압축XML, ZIP 처리용 버퍼
다운로드 준비완성 파일의 Buffer 또는 Blob 등

따라서 최종 파일이 작다고 생성 중 필요한 메모리도 작은 것은 아닙니다. 행 수 외에 열 수, 문자열 길이, 서식과 라이브러리 구현도 영향을 줘요. “18만 행부터 브라우저가 죽는다”는 보편적 한계가 확인된 것은 아닙니다.

클라이언트에서 엑셀을 생성하던 기존 구조 DB의 전체 조회 결과를 브라우저에서 파일로 변환하던 흐름

당시 기록에는 브라우저 종료와 처리 구조, 데이터 건수는 있지만 메모리 프로파일 수치는 없습니다. 그래서 여기서 특정 객체가 몇 MB를 차지했다고 단정할 수는 없어요. 다만 클라이언트 생성 단계를 제거한 뒤 증상이 해소된 것은 이 경로를 개선한 판단을 뒷받침합니다.

비슷한 문제를 조사한다면 같은 데이터로 수신 완료, 워크북 작성, 압축, 다운로드 준비 시점을 나누어 관찰하는 편이 좋습니다. 탭이 종료되는지, 화면만 멈추는지도 구분해야 합니다. CPU 작업으로 인한 UI 정지는 Web Worker가 도움될 수 있지만, 데이터를 전부 보관하는 방식의 메모리 부담이 자동으로 사라지는 것은 아닙니다.

첫 번째 개선: 서버에서 조금씩 읽고 쓰기

요청이 오면 DB의 데이터를 Stream으로 읽고, 서버에서 엑셀의 행을 순차적으로 기록한 뒤 클라우드 스토리지에 저장해 내려주도록 바꿨습니다.

서버 스트리밍 엑셀 생성 구조 DB 조회와 엑셀 생성을 서버로 옮긴 첫 번째 개선

여기서 중요한 것은 DB 조회만 스트리밍으로 만드는 것이 아닙니다. 입력부터 파일 저장까지 중간 단계가 전체 데이터를 다시 쌓지 않아야 메모리 증가를 제한할 수 있습니다.

  • DB에서는 cursor 또는 제한된 크기의 배치로 읽습니다. 조회 결과를 다시 배열에 모두 모으면 스트리밍의 이점이 사라집니다.
  • 엑셀 라이브러리도 스트리밍 쓰기를 지원해야 합니다. 예를 들어 ExcelJS는 streaming writer와 행 commit을 제공하며, commit한 행을 나중에 수정하는 데 제약이 있습니다. 이는 구현 참고이며 당시 사용한 라이브러리를 뜻하지는 않습니다. ExcelJS의 스트리밍 설명
  • 저장 쪽이 느리면 입력 속도도 제한해야 합니다. 출력 버퍼가 차는데 계속 읽어 넣으면 메모리가 다시 증가합니다. Node.js에서는 write() 반환값과 drain, 스트림 연결 방식 등 backpressure를 확인해야 합니다. 라이브러리의 중간 버퍼도 함께 살펴야 해요. Node.js Streams

파일을 만들면서 모든 문자열을 별도 집합에 저장하거나, 마지막에 파일 전체를 메모리 버퍼로 읽어 업로드하는 단계가 있어도 효과가 줄어듭니다. 그래서 성공 여부는 “stream API를 사용했다”가 아니라 데이터 증가에 따른 실제 프로세스 메모리와 완료 파일의 정합성으로 판단해야 합니다.

이 변경으로 브라우저 OOM 문제는 해결됐습니다. 하지만 데이터가 많을수록 파일 생성까지 기다리는 시간이 길어졌어요. 메모리 문제는 줄였지만 사용자의 작업이 긴 요청에 묶이는 문제는 남은 것입니다.

두 번째 개선: 접수와 완료를 분리하기

엑셀 생성 요청을 대기열에 넣고 백그라운드에서 처리하도록 바꿨습니다. 사용자는 요청 후 다른 일을 하고, 다운로드 페이지에서 완료된 파일을 받습니다.

백그라운드 작업과 다운로드 페이지를 분리한 구조 파일 생성 완료를 기다리던 요청을 작업 접수로 바꾼 두 번째 개선

당시 구현은 요청을 받으면 HTTP 200으로 응답했습니다. 같은 API를 다시 설계한다면 아직 생성이 끝나지 않았다는 의미를 드러내도록 202 Accepted와 작업 ID, 상태 조회 주소를 반환하는 방식을 고려할 수 있어요. 202는 성공적인 파일 생성까지 보장하는 상태가 아닙니다. RFC 9110의 202 Accepted

다음은 당시 실제 API가 아니라 그 구분을 보여주는 응답 예시입니다.

1
2
3
4
5
6
7
8
9
HTTP/1.1 202 Accepted
Content-Type: application/json
Location: /exports/export-123

{
  "id": "export-123",
  "status": "queued",
  "statusUrl": "/exports/export-123"
}

최소한 상태 조회에는 대기, 실행 중, 완료, 실패를 구분해야 합니다. 완료로 표시한 파일은 실제로 저장과 검증이 끝나 있어야 하고, 실패하면 사용자가 재요청할 수 있어야 해요. 진행률을 정확히 계산할 근거가 없다면 추정 백분율보다 현재 단계와 접수 시각을 보여주는 편이 낫습니다.

비동기로 바꾼다고 전체 생성 시간이 저절로 줄어드는 것은 아닙니다. 대기열이 추가되면 파일을 받기까지 더 오래 걸릴 수도 있어요. 대신 사용자는 페이지에서 기다리지 않아도 되고, 서버는 동시에 실행할 작업 수를 제한할 수 있습니다.

작은 규모에서도 빠뜨리기 쉬운 운영 조건

당시 대상은 10명 미만의 소규모 사용자였습니다. 다만 사용자 수만으로 작업 부담을 판단할 수는 없어요. 한 사람이 큰 요청을 여러 번 제출하면 동시에 많은 메모리·DB 연결·저장 공간을 사용할 수 있기 때문입니다.

처음부터 분산 큐를 도입하기보다 다음 질문에 답할 수 있는 가장 작은 구조로 시작하는 편이 좋습니다. 아래 항목은 당시 모두 구현했다고 주장하는 내용이 아니라, 이 설계를 확장할 때 확인할 조건입니다.

질문필요한 처리
서버가 재시작되면 접수한 작업은 어떻게 되는가?작업 기록을 남기고 실행 중이던 작업을 복구하거나 실패로 전환
같은 요청을 연속으로 누르면?중복 접수 정책과 실행 동시성 제한
중간에 업로드가 실패하면?실패 상태, 재시도 기준, 임시 파일 정리
다른 사용자가 작업 ID를 알면?작업 조회·파일 다운로드 시 소유자와 권한 확인
오래된 파일은 언제까지 남기는가?만료 시각 표시와 파일 삭제 정책
생성 도중 원본 데이터가 바뀌면?요청 시점과 생성 시점 중 어느 데이터를 내보낼지 정의

DB를 여러 번 나누어 읽는 경우에는 안정적인 정렬 키와 페이지 경계도 필요합니다. 중간에 행이 추가·삭제될 때 누락이나 중복이 없는지 확인해야 하죠. 반대로 긴 snapshot을 유지하면 DB에 어떤 부담이 생기는지 검토해야 합니다. “서버에서 생성”이라는 배치 위치만 바꿔서는 이 데이터 정합성 문제가 해결되지 않습니다.

완료 파일은 가능하면 브라우저의 일반 다운로드 경로로 받도록 합니다. 전체 파일을 다시 fetch로 받은 뒤 거대한 Blob을 만드는 방식이라면 클라이언트에 큰 버퍼를 잡는 단계가 남습니다. 파일 다운로드 권한을 확인한 뒤 제한된 유효기간의 URL을 제공하는 방식 등을 제품 요구에 맞춰 선택할 수 있어요.

개선 효과를 어떻게 검증할까?

이 사례에서 실제로 확인한 결과는 브라우저 OOM 해소와 별도 다운로드 페이지를 통한 비동기 흐름입니다. 처리 시간이나 메모리 감소율의 측정값은 남아 있지 않아 숫자로 제시하지 않습니다.

다시 검증한다면 기존 3만 건과 문제를 일으킨 18만 건을 같은 열 구성으로 비교하겠습니다. 확인할 것은 파일이 열리는지만이 아닙니다.

검증 대상확인할 내용
파일 내용행 수, 누락·중복, 날짜·숫자·문자열 형식
자원 사용브라우저·서버 최대 메모리, 임시 디스크, DB 연결 점유
사용자 시간요청 접수 시간, 대기열 시간, 생성 시간, 다운로드 시간
실패 복구실행 중 재시작·업로드 실패·중복 요청 때의 상태와 파일

이번 변경은 브라우저가 감당하던 파일 생성을 서버로 옮기고, 사용자가 감당하던 대기를 작업 상태로 바꾼 것입니다. 스트리밍은 한 번에 보관하는 데이터 양을, 비동기는 사용자와 서버의 작업 생명주기를 다룹니다. 두 문제를 나눠 보면 필요한 개선의 순서도 더 명확해집니다.

관련 사례로 NAVER D2의 대용량 엑셀 다운로드 서비스 개발기도 함께 남겨둡니다.