대용량 JSON 엑셀 변환: 스트리밍 파이프라인 구축

JH.KIM·2025년 4월 28일
post-thumbnail

📚 목차

  1. 프로젝트 개요

  2. Stream & Pipeline 기본 개념

  3. 구현 전략

  4. 코드 예제 (MVP)

  5. 마무리


프로젝트 개요

배경 & 고민

사내 서비스가 쑥쑥 성장하면서 주문 건수가 갈수록 늘어났고, 백 오피스 엑셀 생성 기능에 문제들이 터지기 시작했다.

  • 메모리 부족(Out of Memory)
  • CPU 감당 불가

그동안 조회 범위를 제한해 데이터가 커지지 못하도록 막아왔지만 결국 서버를 분리 및 개선하기로 결정 하였다.

1️⃣ 초기 MVP: Buffer 방식

처음 MVP 개발에서는 독립 서버 구성과 빠른 실행이라는 키워드로 개발을 진행 하였다.
빠른 실행을 위해 여전히 Buffer 방식을 통해 개발하였지만 여전히 Out of Memory 현상이 나타났다.
독립 서버는 Serverless 환경으로 구축하기 위해 AWS Lambda 를 통해 구성하였는데 Lambda 의 기본 memory 제공량이 부족했기 때문이다.
Lambda 메모리를 증설할 수는 있었지만, 서버 비용 부담이 커지고 추후 데이터가 증가하면 다시 문제가 생길 수 있기 때문에 다른 해결책이 필요 했다.

AWS Lambda 는 128MB에서 10,240MB 사이의 메모리 값을 할당 할 수 있고 단순 비용은 선형적으로 증가한다.

Lambda 메모리 늘리면 비용도 쑥쑥 올라가고, 데이터가 더 늘어나면 OOM 또 터지고… 😭

2️⃣ 개선된 접근: Stream 방식 전환

우선 JSON 파일을 사용하게 된 배경을 설명하자면

  • 여러 도메인에 분산된 스키마 때문에 단일 쿼리가 복잡해지며
  • 복잡한 쿼리는 DB 부하를 키우며
  • 별도 스키마나 캐시 구축은 쓰기 및 서버 비용 부담이 발생 하고...

그래서 API 서버에서 데이터를 JSON 파일로 생성 후 스트림으로 읽고,
엑셀 행을 스트림으로 작성하는 전략을 사용 하였다.

3️⃣ 프로세스 전체 흐름

  1. API 서버
    • 데이터 조회 → json (rows.json, meta.json) 생성 → S3 업로드 → SQS에 알림 발행
  2. Lambda 함수
    1. SQS 메시지 받아서
    2. S3에서 rows.json을 스트림으로 읽고
    3. 한 줄씩 exceljs.addRow().commit()
    4. WorkbookWriter (stream 모드)으로 엑셀 파일 스트리밍 생성
    5. 엑셀 완성되면 S3에 업로드 → presigned URL 생성
    6. 클라이언트(or Slack)에 다운로드 링크 전송

rows.json >> excel row data
meta.json >> 시트명, 컬럼 정보 등 메타데이터 포함

흐름도

API → S3(json file) → SQS → Lambda(스트림→엑셀) → S3(result.xlsx) → URL

4️⃣ 성능 & 결과

  • 약 30만 건 rows 2분 내외 생성
  • 안정적인 메모리사용

단점:

  • exceljs stream 모드는 스타일(font, 배경색 등) 변경을 지원하지 않는다.
  • data 증가에 따라 생성 시간은 증가한다

2. Stream & Pipeline 기본 개념

2.1 스트림(Stream)이란?

  • 데이터를 청크(chunk) 단위로 조금씩 읽거나 쓰는 추상화 인터페이스
  • 메모리에 전체 올리지 않고 처리 가능 → 대용량에 유리
  • 주요 타입
    • Readable: 읽기 전용
    • Writable: 쓰기 전용
    • Transform: 읽어서 가공 후 다음으로 넘기는 양방향 스트림

2.2 Buffer vs Stream 비교

구분Buffer 방식Stream 방식
메모리전체 파일 크기만큼 메모리에 할당청크 단위로 메모리에 잠시만 보관 → 메모리 사용 적음
처리 방식fs.readFileSync / JSON.parsefs.createReadStream, JSON.parse 스트림 등
장점코드 간단, 동기적 처리대용량 안정적, 비동기·이벤트 기반 처리 가능
단점OOM 위험, GC 부담구현이 다소 복잡, 흐름(flow) 제어 필요

2.3 Paused vs Flowing 모드

모드특징
Paused.read() 또는 data 리스너 없이는 대기만 함
Flowing.pipe(), on('data'), .resume() 호출 시 동작

3. 구현 전략

  1. stream-json 으로 JSON을 토큰화(tokenize)
    • parser() → JSON 구조 분해
    • streamArray() → 배열 요소 하나씩 추출
  2. stream-chain 으로 스트림 체인 구성
    • chain([ Readable, Transform…, handler ])
    • 에러/종료 관리 일원화
  3. ExcelJS WorkbookWriter 로 메모리 최소화
    • .addRow().commit() → 한 줄씩 즉시 파일에 기록

4. 코드 예제 (MVP)

import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import * as ExcelJS from 'exceljs';
import { chain } from 'stream-chain';
import { parser } from 'stream-json';
import { streamArray } from 'stream-json/streamers/StreamArray';
import * as fs from 'fs';

const s3 = new S3Client({ region: 'ap-northeast-2' });

export async function handler(event) {
  const bucket = process.env.BUCKET_NAME!;
  const rowsKey = event.rowsKey;
  const resultKey = event.resultKey;

  // 1. S3에서 JSON 파일 스트림으로 읽기
  const { Body } = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: rowsKey }));

  // 2. Excel Streaming Writer 세팅
  const tmpFile = `/tmp/out_${Date.now()}.xlsx`;
  const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({ filename: tmpFile });
  const sheet = workbook.addWorksheet('Sheet1');

  // 3. stream-chain 구성
  const pipeline = chain([
    Body as NodeJS.ReadableStream,
    parser(),
    streamArray(),
    ({ value }) => {
      const row = Array.isArray(value) ? value : Object.values(value);
      sheet.addRow(row).commit();
    },
  ]);

  // 4. 스트림 흐름 시작
  pipeline.resume();
  await new Promise((res, rej) => {
    pipeline.on('end', res);
    pipeline.on('error', rej);
  });

  // 5. 워크북 커밋 & S3 업로드
  await workbook.commit();
  const buffer = fs.readFileSync(tmpFile);

  await s3.send(new PutObjectCommand({
    Bucket: bucket,
    Key: resultKey,
    Body: buffer,
    ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  }));
  
  const presigned = await getSignedUrl(
    this.s3,
    new GetObjectCommand({ Bucket: bucket, Key: resultKey }),
    { expiresIn: 3600 },
  );

  return { message: 'Excel 파일 생성 완료', url: presigned };
}

※ 이 코드는 추상화된 MVP 버전으로, 실제 운영 환경에서는 상세한 예외 처리, 리소스 관리, 보안 설정 등의 추가 구현이 필요합니다.

5. 마무리

Stream 기반으로 대용량 Excel 파일을 생성하는 방법에 대해 살펴보았습니다.
이번 글에서는 Lambda의 운영 및 배포, 현재 구조의 장단점, 대용량 데이터 분석에 대한 다양한 대안 등 추가로 다루고 싶은 주제들이 있었지만, 이는 추후 별도의 포스트에서 보다 깊이 있게 다뤄보겠습니다.

profile
일하며 겪은 문제를 나눠요

2개의 댓글

comment-user-thumbnail
2025년 4월 28일

잘봤습니다.
S3 업로드도 스트림으로 업로드할 수 있는 방법이 있을까요?

1개의 답글