엑셀 내보내기 더 빠르게 개발하기
테스트로 살펴보는 엑셀 내보내기 성능 개선
  • nodejs

왜 사용자는 excel을 원할까? txt, csv는 안될까?

엑셀은 간편합니다. 고객 입장에서 매우 간편합니다.

image

고객은 귀찮은 걸 싫어합니다.

조건별 매출정보를 한 번에 받아보고 싶어 합니다.
많은 담당자의 경우 모든 데이터를 엑셀로 받고 즉시 보고서를 만들기를 원합니다. txt, csv는 그런 면에서 매우 불편합니다.
xlsx라는 확장자를 가진 파일을 만들기 위해 얼마나 많은 비용이 드는지는 관심 없죠.

비용을 내고 사용 중인 서비스에 귀찮음의 해결을 요구하는 건 어쩌면 당연합니다.

xlsx 은 왜 느릴까

왜 xlsx는 만드는데 시간이 오래 걸릴까요?

image [엑셀 파일]

5x5 빙고판을 만들어보았어요.

image 이걸 csv로 다운로드해 보겠습니다. 용량이 69 bytes입니다.

xlsx로 받아볼까요?

image

5KB가 나옵니다. 5x1024 = 5,120bytes이고 이는 csv 보다 74배 이상 큽니다.

왜 이런 차이가 있을까요?

.csv 확장자를 가진 파일은 확장자를 .txt 로 변경하여 파일을 열어볼 수 있습니다.
csv는 탭, 쉼표 등 특정 문자열을 기준으로 값을 구분하는 구조로 매우 간단합니다.

image .xlsx 확장자를 가진 파일은 확장자를 .zip 으로 변경하여 압축을 풀어볼 수 있습니다.

unzip [파일명.zip] -d [압축 풀 경로]

폴더 내에는 sheet 정보, sheet에 들어있는 데이터, 각 sheet 에 들어있는 style 정보 등
xlsx 하나를 구성하기 위해서 수많은 파일들이 존재하는 걸 볼 수 있습니다.

image

따라서 간단한 txt (혹은 csv) 파일을 만드는 것에 비해 같은 데이터라도 xlsx 는 훨씬 많은 비용이 필요합니다.
자 이제 고객은 왜 xlsx를 좋아하는지 이해했고 용량이 왜 큰지도 이해했습니다. 그러면 어떻게 효율적으로 이 xlsx를 만들 수 있을까요?

엑셀 내보내기

image

  1. user - 10,000명
  2. product - 100가지
  3. order - 50만건
  4. payment - 40만건

고객은 나눠서 보고싶어하지 않아요. 한 번에 보고싶어합니다.

SELECT
	`o`.`orders_id` AS `order_id`,
	`u`.`name` AS `orderer_name`,
	`u`.`age` AS `orderer_age`,
	`u`.`address` AS `orderer_address`,
	count(o.orders_id) AS orderCouunt,
	`o`.`origin_price` AS `order_origin_price`,
	`o`.`price` AS `order_price`,
	`pa`.`price` AS `paid_price`,
	group_concat(pd.name) AS `product_names`,
	group_concat(c.name) AS `product_category_names`,
	`o`.`created_at` AS `order_date`
FROM
	`orders` AS `o`
	INNER JOIN `orders_detail` AS `od` ON `o`.`orders_id` = `od`.`orders_id`
	INNER JOIN `payment` AS `pa` ON `o`.`orders_id` = `pa`.`orders_id`
	INNER JOIN `product` AS `p` ON `od`.`product_id` = `p`.`product_id`
	INNER JOIN `product_detail` AS `pd` ON `p`.`product_id` = `pd`.`product_id`
	INNER JOIN `category` AS `c` ON `p`.`category_id` = `c`.`category_id`
	INNER JOIN `user` AS `u` ON `o`.`user_id` = `u`.`user_id`
GROUP BY `o`.`orders_id`

주문에 따른 주문상세, 상품정보, 결제정보 그리고 주문자 정보까지 다운받으려고 합니다.
주문이 50만건이기때문에 xlsx에 50만rows 가 들어갑니다.

image

(예상되는 결제과화면)

excel

<--- Last few GCs --->

[80029:0x160078000]   318976 ms: Mark-sweep (reduce) 4247.7 (4310.6) -> 3970.1 (4033.0) MB, 1148.4 / 0.0 ms  (average mu = 0.499, current mu = 0.148) allocation failure; scavenge might not succeed
[80029:0x160078000]   320612 ms: Mark-sweep (reduce) 4594.7 (4657.7) -> 4178.3 (4241.2) MB, 1326.8 / 0.0 ms  (average mu = 0.385, current mu = 0.189) allocation failure; scavenge might not succeed

<--- JS stacktrace --->

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
 1: 0x1029a3c40 node::Abort() [/Users/byun/.nvm/versions/node/v18.18.2/bin/node]
 2: 0x1029a3e24 node::ModifyCodeGenerationFromStrings(v8::Local<v8::Context>, v8::Local<v8::Value>, bool) [/Users/byun/.nvm/versions/node/v18.18.2/bin/node]
 3: 0x102afb608 v8::internal::V8::FatalProcessOutOfMemory(v8::internal::Isolate*, char const*, bool) [/Users/byun/.nvm/versions/node/v18.18.2/bin/node]
 4: 0x102ca5eec v8::internal::EmbedderStackStateScope::EmbedderStackStateScope(v8::internal::Heap*, v8::internal::EmbedderStackStateScope::Origin, cppgc::EmbedderStackState) [/Users/byun/.nvm/versions/node/v18.18.2/bin/node]
 5: 0x102ca4894 v8::internal::Heap::CollectGarbage(v8::internal::AllocationSpace, v8::internal::GarbageCollectionReason, v8::GCCallbackFlags) [/Users/byun/.nvm/versions/node/v18.18.2/bin/node]

무려 4G의 메모리를 사용하다가 그만 터져버립니다.
서버를 실행할 때 memory 옵션을 추가해보겠습니다.

--max-old-space-size=8092

ex) nestjs 실행 컴맨드 수정

"start:dev": "NODE_OPTIONS=\"--max-old-space-size=8092 --inspect\" nest start --watch"

내보내기 결과

curl --output excel.xlsx http://localhost:3000/excel

query time: 40.344s excel time: 2:45.393 (m:ss.mmm)

image

이번엔 메모리는 문제가 없었습니다.
결과를 보면 쿼리에 40초 그리고 그 데이터를 xlsx로 변환하는데 2분 45초가 걸렸어요.

총 3분 25초 정도가 걸렸네요.

stream

image

stream 을 사용하면 chunk 로 쪼개진 데이터를 buffer 에 모았다가 buffer 의 사이즈가 꽉 차면 데이터를 전달하게 됩니다.
최근에는 인터넷이 워낙 빨라져서 그런경우가 잘 없지만 예전에는 영상을 보다가 로딩 스피너가 뜰 때 버퍼링 중이다 라고 표현을 했었는데요. 이 Buffer 안에 chunk 가 모두 차길 기다리고있는 상태라고 보시면 됩니다.

Readable stream

image

소스로부터 데이터를 읽기 위해 사용합니다. readable stream은 paused 와 flowing 두 가지 모드가 있습니다.

  • Flowing 모드: 데이터가 읽혀지고 buffer에 가득 찬 데이터를 ‘data’ 이벤트를 통해 전달합니다.
  • Paused 모드: 데이터가 Buffer에 저장되고 명시적으로 읽을 때까지 기다립니다.
rs.on('data', (data) => { // 1
	console.log('start'); // 5
});

rs.pause(); // 2

setTimeout(() => { // 3
	rs.resume(); // 4
}, 1000);

이 코드에서 start가 찍히기 위해서는 setTimeout 이 실행되는 1초 뒤부터 가능합니다.

Writable stream

image

Readable stream의 반대입니다. Writable stream 역시 chunk 단위로 쓰고 buffer 가 존재합니다. buffer 가 가득차길 기다렸다가 full 상태가 되면 더이상 데이터를 받아들이지 않고 파일로 쓰게 됩니다.
파일이 모두 쓰여지고 나면 drain 이벤트를 발행하고 drain 이벤트가 발행되면 다시 데이터를 buffer 로 받아들이게 됩니다.

node/doc/api/stream.md at main · nodejs/node [drain event]

Duplex Stream

앞에서 살펴본 Readable 과 Writable 둘 다 가능한 stream입니다. 대표적으로 net.Socket 에서 사용됩니다.

Transform

pipe라는 메서드와 함께 사용되며 쓰여지고 있거나 읽어지고 있는 데이터를 수정할 수 있습니다.

예를들어 Readable stream을 통해 파일을 읽어온 후 transform을 사용해 파일을 압축하고 Writable stream을 사용해 파일을 저장할 수 있습니다.
https://nodejs.org/api/stream.html#readablepipedestination-options

image

여기까지 읽으셨으면 저장소에 있는 100G 파일을 4G 메모리를 가진 서버를 통해서 사용자에게 전달이 가능한 이유를 아시겠죠?

nodejs 로 개발하면서 stream을 많이 사용하시나요?
아니라고? 아닐거에요.

여러분은 이미 stream 을 거의 매일 사용하고 있습니다.

console.log <docs>

https://github.com/nodejs/node/blob/main/lib/internal/console/constructor.js#L266-L313

console.log 는 내부적으로 process.stdout.write 를 사용하고 있고 stdout 은 stream 으로 동작합니다.
log라는 함수를 통해 얼마나 많은 데이터를 출력할지 모르는 상황이기때문에 stream을 통해 효과적으로 출력할 수 있습니다.

그럼 이제 스트림을 통해 엑셀 내보내기를 시도해보겠습니다.

내보내기 결과

curl --output -stream.xlsx http://localhost:3000/stream

query time: 1.41ms
excel time: 56.403s

image

스트림으로 처리하면서 쿼리 실행시간이 엄청나게 줄었습니다.
즉시 모든 데이터를 가져오지 않고 스트림으로 읽어오는 데이터를 순서대로 처리하기 때문입니다.

stream + transform

SELECT
	`o`.`orders_id` AS `order_id`,
	`u`.`name` AS `orderer_name`,
	`u`.`age` AS `orderer_age`,
	`u`.`address` AS `orderer_address`,
	count(o.orders_id) AS orderCouunt,
	`o`.`origin_price` AS `order_origin_price`,
	`o`.`price` AS `order_price`,
	`pa`.`price` AS `paid_price`,
	group_concat(pd.name) AS `product_names`,
	group_concat(c.name) AS `product_category_names`,
	`o`.`created_at` AS `order_date`
FROM
	`orders` AS `o`
	INNER JOIN `orders_detail` AS `od` ON `o`.`orders_id` = `od`.`orders_id`
	INNER JOIN `payment` AS `pa` ON `o`.`orders_id` = `pa`.`orders_id`
	INNER JOIN `product` AS `p` ON `od`.`product_id` = `p`.`product_id`
	INNER JOIN `product_detail` AS `pd` ON `p`.`product_id` = `pd`.`product_id`
	INNER JOIN `category` AS `c` ON `p`.`category_id` = `c`.`category_id`
	INNER JOIN `user` AS `u` ON `o`.`user_id` = `u`.`user_id`
GROUP BY `o`.`orders_id`

쿼리를 통해 전체 데이터를 받아오는 시간 자체가 오래 걸립니다.

select o.user_id
from orders o
join user u on o.user_id = u.user_id; // 1.382s

select * from category c; -- 103ms

꼭 데이터를 쿼리 한번에 가져와야할까요?

그렇지 않습니다. 앞에서 살펴봤던 Transform 을 사용하면 스트림으로 pipe에 흐르는 상태에서 데이터를 조작할 수 있습니다.
그렇다면 별도로 조회하면 빠르게 가져올 수 있는 데이터를 별도로 가져오고 Transform 을 통해 합쳐주면 어떨까요?

SELECT
	`o`.`orders_id` AS `order_id`,
--	`u`.`name` AS `orderer_name`,
--	`u`.`age` AS `orderer_age`,
--	`u`.`address` AS `orderer_address`,
	count(o.orders_id) AS orderCouunt,
	`o`.`origin_price` AS `order_origin_price`,
	`o`.`price` AS `order_price`,
 	`pa`.`price` AS `paid_price`,
 	group_concat(pd.name) AS `product_names`,
-- 	group_concat(c.name) AS `product_category_names`,
	`o`.`created_at` AS `order_date`
FROM `orders` AS `o`
	INNER JOIN `orders_detail` AS `od` ON `o`.`orders_id` = `od`.`orders_id`
	INNER JOIN `payment` AS `pa` ON `o`.`orders_id` = `pa`.`orders_id`
 	INNER JOIN `product` AS `p` ON `od`.`product_id` = `p`.`product_id`
 	INNER JOIN `product_detail` AS `pd` ON `p`.`product_id` = `pd`.`product_id`
-- 	INNER JOIN `category` AS `c` ON `p`.`category_id` = `c`.`category_id`
--	INNER JOIN `user` AS `u` ON `o`.`user_id` = `u`.`user_id`
GROUP BY `o`.`orders_id`, `u`.`name`, `u`.`age`, `u`.`address`, `o`.`origin_price`, `o`.`price`
// 1. 데이터 조회
const [orderUserIds, categories] = await Promise.all([
	this.databaseService.findOrderUserIds(),
	this.databaseService.findCategories(),
])

// 2. 데이터 병합
const pipe = this.databaseRepository.getTransformData().pipe(
  new Transform({
    readableObjectMode: true,
    writableObjectMode: true,
    transform(row, encoding, callback) {
      try {
        const user = userMap.get(row.userId);

        const rowCategories = row.categoryIds.split(',')
        const targetCategories = categories.filter(c => rowCategories.includes(c.categoryId.toString()))

        const line = {
          ...row,
          ordererName: user?.name ?? '-',
          ordererAge: user?.age ?? '-',
          ordererAddress: user?.address ?? '-',
          productCategoryNames: targetCategories.map(c => c.name).join(',') ?? '-',
        };

        this.push(line);
        callback();
      } catch(err) {
        console.log('err', err);
      }
    },
  })
);

내보내기 결과

curl --output -transform.xlsx http://localhost:3000/transform

query time: 0.758ms
excel time: 38.189s

image

데이터를 분리하여 읽음으로써 쿼리를 통해 읽는 전체 데이터양이 줄어들었습니다.
디비 부하가 줄어들었고 엑셀 생성시간 또한 줄어들었습니다.

성능 테스트

그렇다면 단순히 다운로드 속도만 빨라졌을까요?
테스트를 위해 데이터의 제한을 걸고 성능 테스트를 해보겠습니다.

WRK 를 사용해 테스트합니다.

limit 100
wrk -t 10 -c 100 -d 10s --timeout 1m {url}

스레드 개수 10개, 동시성 100개 로 설정하고 10초동안 실행해보았습니다.

excel

wrk -t 10 -c 100 -d 10s --timeout 1m http://localhost:3000/excel

image

초당 99개의 요청을 처리할 수 있었습니다.

stream

wrk -t 10 -c 100 -d 10s --timeout 1m http://localhost:3000/stream

image

stream을 사용하면 초당 144개 처리로 이전보다 더 빨라진걸 확인할 수 있습니다.

stream + transform

wrk -t 10 -c 100 -d 10s --timeout 1m http://localhost:3000/transform

image

transform을 사용했을 때 초당 133개의 요청을 처리할 수 있었으며, 이는 동기 방식보다는 빠르지만 스트림 방식보다는 약간 느린 결과입니다.
이는 로직 중간에 async/await가 사용되어 일부 동시 처리량에 손해가 발생했기 때문입니다.

결론

image
image

stream에 비해 async/await 와 함께 transform 을 사용한 api는 단일 반환속도는 빨라졌지만 초당 처리량은 감소하는것을 볼 수 있었습니다.

메모리를 많이 사용하게되는 단순 내보내기를 제외하더라도 stream와 stream + transform 방식 중 어떤게 더 좋다고 생각하시나요?

여기서 우리는 한가지 함정에 빠진게 있습니다.

엑셀 내보내기를 구현하는 대부분의 시스템은 엑셀 생성을 요청하는 서버와 실제 엑셀파일을 만들어내는 서버는 별도일 가능성이 매우 높습니다.

image

엑셀 만들기를 요청받는 서버는 메시지큐에 이벤트를 발행하는 등의 매우 단순한 구조일 가능성이 높습니다.
그렇다면 stream조차 사용하지 않는 내보내기 방식과 stream에 transform 까지 사용한 방식에 대해 초당 처리 가능한 Request 수는 거의 비슷할겁니다.

결국 우리가 고려해야 할 내용은 한정된 메모리 안에서 얼마나 많은 메모리를 사용할 수 있을것인가와 얼마나 빠르게 처리할 수 있느냐 입니다.

참고

  • 테스트에 사용된 모든 코드는 Repository를 참고해주세요