NodeJs에서 streaming을 활용한 대용량 엑셀 생성하기
메모리 사용량을 취소화하면서 대용량 엑셀파일 생성하기
  • JavaScript

streaming을 사용한 대용량 엑셀 파일 다운로드 구현

어떠한 데이터를 사용자에게 보여주고자 할 때, 일반적인 웹에서는 paging을 한다거나 limit을 걸어서 적당한 양의 데이터를 화면에 표시한다.

하지만 데이터 다운로드의 경우 개인의 모든 데이터를 다운로드한다거나
개인이 아닌 그룹이라면 그룹에 속해있는 모든 사용자의 모든 기록을 다운로드하게 된다.
(여기서 기록이라 함은 로그인 기록, 사용자의 접속기록 등이 모두 포함될 수 있다)

그룹 내의 사용자가 10~20명 정도면 당연히 큰 문제가 없겠지만 수천 명이 되고 다운로드해야 할 데이터가 많아질수록 엑셀의 용량 또한 엄청 많아질 것이다.
이러한 엑셀 데이터다운로드를 위해서 request -> db -> xlsx -> response 를 하게 된다면 OOM (Out Of Memory)가 발생할 가능성이 아주 높다.

이 글에서는 이러한 대용량 엑셀 다운로드에 대해서 어떻게 OOM을 피해 갈 수 있고 좀 더 빠른 속도를 만들어내기 위한 어떤 작업이 있을 수 있는지 살펴본다.

XLSX

왜 xlsx여야 할까?

회사 내부 임직원의 경우라면 csv로 다운로드한다거나 paging 혹은 limit을 걸어서 여러번 다운로드받으세요! 라고 할 수 있을 것이다.
(정말 할 수 있나..?)

하지만 일반 고객이 다운로드 받는 데이터의 경우 고객편의를 최대한 제공해야한다.
txt로 나의 로그데이터를 내려받는다거나 내려받고 또 가공을 해야한다면 얼마나 당황할까. (!##%%@@)

xlsx는 왜 메모리 사용량이 더 많을까?

image

엑셀 시트를 위 사진과 같이 아주 간단하게 만들어봤다.

위의 데이터를 다른이름으로 저장 => csv형태로 저장을 해보자.
csv파일로 생성되어진 파일에서 확장자를 .txt로 바꾸면 notepad 등으로 열 수 있다.

image

csv에서 데이터를 구분하는 방식은 탭이나 쉼표 등 여러가지가 있겠지만,
위 스크린샷에서는 csv저장할 때 구분 조건을 쉼표로 선택하였고 데이터가 쉼표로 구분되고 있음을 볼 수 있다.

*.xslx 즉 엑셀은 그렇다면 어떨까?

이번엔 xlsx 확장자로 저장하자.
그리고 저장된 xlsx파일의 확장자를 .zip로 바꾸면 압축을 풀 수 있다.

image

csv에서 단순히 데이터에 쉼표만 추가된것과는 달리 뭔가 파일이 많다.
xlsx파일의 형식은 xml기반으로 데이터를 저장한다. 설정에 필요한 여러개의 xml파일과 본 데이터가 담겨있는 worksheets의 xml들이 합쳐져서 하나의 xlsx파일이 표현이 되어지게 된다.

worksheets의 sheet파일을 열어보아도 내가 만들어 둔 데이터보다 훨씬 복잡하고 많은 태그들이 보여지는것을 확인할 수 있다.

이 때문에 같은 위 식단표의 데이터라도 csv와 xlsx의 저장데이터 구조는 엄청나게 다르고, 파일생성에 필요한 메모리 역시 많은 차이가 있다.

기존의 xlsx 파일 생성 구조

현재 Node.js를 사용하는 백엔드 아키텍쳐에서 XLSX의 생성/다운로드 요청에 대한 구조는 다음과 같다.

image

  1. client가 엑셀 다운로드를 요청하고 api는 worker서버로 해당 요청을 전달한다.

  2. worker는 xlsx생성을 완료한 후 gcp (google cloud storeage)에 xlsx저장한다.

  3. client는 엑셀 다운로드 요청 시 response로 전달받은 code값을 가지고 polling방식으로 다운로드 서버에 다운로드 요청을 보낸다.

streaming을 사용하면 client의 request에 대해 api에서 바로 처리해도되지 않는가? 라고 생각해볼 수 있는데, 문제는 xlsx파일이 만들어지는동안 api의 connection을 연결하고 있다는 것이다.

server -> client 에게 이벤트를 주기위해 socket이나 브라우저 push 등 별다른 기능이 개발되어있지 않아 아쉽지만 polling을 사용한다.
구식으로는 polling, 신식으로는 server push 혹은 socket 등을 사용해볼 수 있다.

얼마나 많은 메모리를 사용하길래?

image

xlsx생성을 요청받은 후의 worker서버의 메모리 사용량이다. 무려 3G에 육박하는 메로리를 사용하고있었다.

주요 원인은

  1. xlsx에 들어가는 모든 데이터를 메모리에 로드
  2. 업로드를 위해 메모리에서 array => xlsx 변환
  3. axios 혹은 기타 방법을 이용해 저장소로 업로드

의 작업을 하기 때문이다.

너무나 당연하게도 이렇게 worker를 괴롭하면 오래못가 죽고만다..

image

새로운 xlsx 파일 생성 구조

모든 구조적인 방식은 동일하다. 즉 client의 코드수정 없이 xlsx생성만을 개선해야하는 과제가 있다.

그렇다면 worker에서 xlsx생성하고 gcp로 업로드하는 과정을 개선해야 한다.

모든 데이터를 streaming 하기 위해서는 모든 데이터가 한번에 pipe를 통해 흘러야하고, 그러기 위해서는 모든 데이터를 query한번으로 조회해야 한다.

여기서 문제가 발생한다.

query 한번에

query한 번으로 모든 데이터를 조회한다는것은 db에 많은 부담을 줄 수 있다. (특히나 left join이 많아지면..)

DB부하를 무시하더라도 left join으로 무겁고 많은 테이블들을 연결하다보면 쿼리 실행만 한세월 걸리고, 어쩌면 DB에서 롱커넥션으로 끊어버릴지도 모른다.

그래서 query가 느려지는 원인이 되는 데이터들은 메모리에 미리 로드하고 나머지 데이터는 streaming하는 방식을 채택했다.

모든 데이터를 streaming으로 처리할 수 있으면 물론 제일 좋다.
하지만 현재 데이터 저장구조로는 그것이 불가능했고 메모리를 일부 사용하게 되지만 쿼리의 성능을 끌어올릴 수 있는 적절한 타협점을 찾아야했다.

그렇다면 async/await (메모리에 데이터 올리기)와 pipe를 동시에 처리하려면 어떻게 해야할까?

재미있게도 Node.js에서는 Transform이라는것을 이용해서 pipe를 통해 흐르고있는 데이터에 대한 변형을 할 수 있다.
(이 때 메모리에 로드해두었던 데이터와 합쳐줄 수 있다)

dependency

우선 streaming write를 지원하는 xlsx모듈이 필요했다.
npm에서 유일하게 지원하는것이 ExcelJS 이다.

image

등록된 이슈가 좀 많아보이긴 하지만 최근까지 계속 배포가 이루어지고있고 주간 다운로드수도 충분했다.

Node.js 에서 xslx streming 생성

위에서 언급했듯이 streaming처리를 위해서는 데이터를 pipe로 흐르게 해야한다.

return foo().stream();

예를들면 위와 같다.
foo() 자리에는 아마도 쿼리가 들어갈 것이다.
예를 들어본다면

return conn.query('select * from foo');

그리고 불러들여진 데이터는 Transform을 통해 미리 로드해둔 데이터와 합쳐줄 수 있다.

const user = await bar();

foo().pipe(new Transform({
    transform(row, encoding, done) {
        row.user = user;
    }
}));

예를들면 이렇게.

그리고 마지막으로 pipe로 stream write까지 하면 처리가 완료된다.

다시 정리해보면

data 조회 (stream) -> pipe -> transform -> stream write

이렇게하면 메모리를 적게 사용하고 대용량의 xlsx파일을 생성할 수 있게 된다.

마지막으로 개선 후 메모리 사용량

image

단순 메모리 로드에서 streaming방식으로 바꿨을 때 메모리사용량이다. 거의 90%가까이 줄어든것을 확인했다.
물론 여기서 개선할 수 있는 부분은 더 많다.

본 글의 내용에서는 코드의 개선만을 소개했는데 인프라작업도 함께 한다면 350MB 마저도 더 개선할 수 있을것이다.