대용량 엑셀 다운로드: 클라이언트에서 서버로

커버 이미지

내부 어드민 시스템에서 대용량 데이터를 Excel로 다운로드할 때, 브라우저가 멈추거나 다운로드 완료까지 오래 기다려야 하는 문제가 반복되고 있었습니다.

초기 방식은 클라이언트가 직접 API를 반복 호출하고 메모리에 데이터를 적재하여 Excel 파일을 생성하는 구조였습니다. 데이터가 수만 건을 넘어가면 브라우저 탭이 멈추고, "언제쯤 다운로드가 완료될까?"라는 질문에 명확한 답변을 줄 수 없다는 점이 가장 큰 문제였습니다.

이를 해결하기 위해 엑셀 생성 로직을 Server-Side로 전환하고, SSE(Server-Sent Events)로 실시간 진행 상황을 제공하는 구조로 재설계했습니다.

대용량 데이터, Server-Side로 관리하다: 배경 및 개념 설명

대용량 데이터를 웹 애플리케이션에서 다룰 때 가장 먼저 고려해야 할 것은 Client-SideServer-Side의 역할 분담입니다. 이전 방식에서는 클라이언트가 직접 모든 데이터를 조회하고, 메모리에 적재하여 Excel 파일을 생성했습니다. 하지만 이 방식은 클라이언트의 웹 브라우저가 감당할 수 있는 메모리와 CPU 자원에 한계가 있어 대용량 데이터 처리 시 성능 저하와 멈춤 현상을 유발했습니다. 이를 해결하기 위해 우리는 서버에서 이 모든 작업을 수행하도록 아키텍처를 변경했습니다.

Next.js API Routes는 이러한 Server-Side 로직을 구현하기에 아주 적합한 환경을 제공합니다. 클라이언트의 번들 크기를 줄이고, 민감한 비즈니스 로직을 서버에서 안전하게 처리할 수 있다는 장점이 있습니다. 저희는 src/api/export-stream/route.ts 같은 API Route를 활용하여 백엔드 API처럼 데이터 조회, 가공, 파일 생성 등의 무거운 작업을 서버에서 담당하도록 했습니다.

효율적인 데이터 조회를 위한 페이징 (Pagination)

대량의 데이터를 한 번에 가져오는 것은 서버에도 큰 부담을 줍니다. 그래서 저희는 페이징(Pagination) 기법을 Server-Side 데이터 수집 과정에 적용했습니다. 페이징은 전체 데이터를 여러 개의 작은 페이지로 나누어 필요한 부분만 요청하고 가져오는 방식입니다. 이를 통해 데이터베이스의 부하를 줄이고, 네트워크 트래픽을 효율적으로 관리할 수 있게 됩니다.

예를 들어, 100만 건의 데이터를 한 번에 가져오는 대신, 1,000건씩 1,000번에 걸쳐 나누어 가져오는 것입니다. 이 과정에서 서버는 각 페이지의 데이터를 조회할 때마다 클라이언트에게 진행 상황을 알려줄 수 있습니다.

// Server-side conceptual pagination logic (for illustration)
async function fetchDataFromDatabase(pageNumber, pageSize, queryParams) {
  // 실제 데이터베이스 쿼리는 여기에 작성됩니다.
  // 예를 들어, OFFSET과 LIMIT을 사용하여 특정 페이지의 데이터를 가져옵니다.
  const offset = (pageNumber - 1) * pageSize;
  const items = await db.query(
    "SELECT * FROM data_records WHERE ... LIMIT ? OFFSET ?",
    [pageSize, offset],
  );
  const totalCount = await db.query(
    "SELECT COUNT(*) FROM data_records WHERE ...",
  );
  return {
    data: items,
    total: totalCount[0].count,
    currentPage: pageNumber,
    totalPages: Math.ceil(totalCount[0].count / pageSize),
  };
}

위 코드는 데이터베이스에서 특정 페이지의 데이터를 가져오는 개념적인 서버 측 페이징 로직을 보여줍니다. offsetlimit을 사용하여 필요한 범위의 데이터만 효율적으로 조회하는 것이 핵심입니다. 실제 구현에서는 retrievePagedDataWithProgress와 같은 함수를 통해 이 로직이 추상화되어 사용됩니다.

사용자 경험의 핵심, Server-Sent Events (SSE)

대용량 데이터를 다운로드하는 동안 사용자는 진행 상황을 알 수 없어 답답함을 느낄 수 있습니다. 기존 방식에서는 다운로드 완료 알림만 있었기에, 사용자는 브라우저가 멈춘 건지, 아니면 작업이 진행 중인지 알기 어려웠습니다. 이 문제를 해결하기 위해 도입한 것이 바로 Server-Sent Events (SSE)입니다.

SSE는 서버가 클라이언트에게 단방향으로 실시간 데이터를 스트리밍할 수 있도록 하는 웹 API입니다. HTTP 프로토콜 위에서 동작하며, 한 번 연결이 수립되면 서버는 데이터를 지속적으로 클라이언트에게 푸시할 수 있습니다. WebSocket과 달리 양방향 통신이 아닌 서버-클라이언트 단방향 통신에 최적화되어 있어, 다운로드 진행률처럼 서버에서 클라이언트로만 정보를 보내는 경우에 특히 효율적입니다.

저희는 서버에서 데이터를 수집하고 Excel 파일을 생성하는 각 단계마다 SSE를 통해 진행률, 현재 처리 중인 페이지, 수집된 아이템 수, 경과 시간 등의 정보를 클라이언트에 실시간으로 전송했습니다. 클라이언트가 이 메시지를 어떻게 받아 UI에 반영하는지는 뒤의 구현 포인트에서 자세히 다룹니다.

바이너리 데이터 전송을 위한 Base64 인코딩Blob

서버에서 생성된 Excel 파일은 바이너리 데이터입니다. 이 데이터를 HTTP 응답 스트림을 통해 클라이언트에 전달하고 클라이언트에서 파일로 재구성해야 합니다. 이때 Base64 인코딩Blob 객체가 중요한 역할을 합니다.

서버는 생성된 Excel 파일의 바이너리 데이터를 Base64 문자열로 인코딩하여 SSE 메시지에 담아 클라이언트에 전송합니다. Base64 인코딩은 바이너리 데이터를 텍스트 기반의 HTTP 통신 채널을 통해 안전하게 전송하기 위한 표준 방식입니다. 다만, 인코딩 과정에서 데이터 크기가 약 33% 증가하는 오버헤드가 발생한다는 점을 고려해야 합니다.

클라이언트는 Base64 인코딩된 문자열을 수신하면 이를 다시 바이너리 데이터로 디코딩한 후 Blob(Binary Large Object) 객체를 생성합니다. Blob은 브라우저 메모리상에서 불변의 원시 바이너리 데이터를 나타내는 객체로, 이를 통해 JavaScript에서 실제 파일처럼 다룰 수 있습니다. 최종적으로 URL.createObjectURL()을 이용해 생성된 Blob 객체의 URL을 획득하고, 가상의 <a> 태그를 생성하여 download 속성을 부여한 뒤 클릭 이벤트를 강제로 발생시켜 사용자에게 파일을 다운로드시킵니다.

Excel 파일 생성을 위한 ExcelJS 라이브러리

기존에는 excel4node 라이브러리를 사용하여 Excel 파일을 생성했습니다. 하지만 이 라이브러리는 대용량 데이터 처리 시 성능상의 제약이 있었고, 복잡한 서식 적용에도 어려움이 있었습니다. 이에 우리는 더 강력하고 유연한 ExcelJS 라이브러리로 전환했습니다.

ExcelJS는 Node.js와 브라우저 환경에서 Excel 파일을 생성, 읽기, 수정할 수 있는 기능을 제공하며, 특히 대용량 데이터 처리와 복잡한 서식 지정에 강점을 보입니다. 또한, Promise 기반 API를 제공하여 비동기 작업과의 통합이 용이합니다. 이 라이브러리로의 전환을 통해 파일 생성 성능을 향상시키고, 생성 과정 중에도 sendProgress 콜백을 통해 진행률을 클라이언트에 전달할 수 있게 되었습니다.

구현 포인트: Client-Side 과부하를 넘어서 Server-Side 스트리밍으로

구체적인 코드 예시를 통해 개선 과정을 확인합니다.

1. Client-Side 과부하의 흔적 (Before)

이전 방식에서는 클라이언트가 모든 데이터를 페이지 단위로 순차적으로 가져와 collectedData.current 배열에 쌓아두었습니다. 모든 데이터 수집이 완료된 후에야 이 데이터를 서버로 다시 보내 Excel 파일을 생성하도록 요청했습니다. 이 과정에서 데이터 양이 많아지면 클라이언트 브라우저의 메모리 사용량이 급증하고, UI가 멈추는 현상이 발생했습니다.

// 기존 방식 (Client-Side 처리)
const handleDownloadClick = async (e) => {
  e.preventDefault?.();
  initData();
 
  let hasNext = true;
  while (hasNext) {
    // 1. 모든 페이지의 데이터를 순차적으로 불러옴
    const { data, hasNext: next } = await requestPageData(
      props.exportType,
      params.current,
    );
    if (data.length > 0) {
      collectedData.current = [...collectedData.current, ...data]; // 2. 클라이언트 메모리에 누적
    }
    hasNext = next;
    params.current.page = (params.current.page || 0) + 1;
  }
 
  // 3. 모든 데이터를 가져온 후 서버로 전송하여 Excel 생성
  startExportDownload();
};
 
const startExportDownload = async () => {
  const response = await request(`/api/export?exportType=${props.exportType}`, {
    method: "POST",
    body: { exportData: collectedData.current }, // 클라이언트가 수집한 모든 데이터를 서버로 전달
    responseType: "blob",
  });
 
  const url = window.URL.createObjectURL(response);
  const link = document.createElement("a");
  link.href = url;
  link.setAttribute(
    "download",
    `${exportConfig[props.exportType].filename}.xlsx`,
  );
  document.body.appendChild(link);
  link.click();
  link.remove();
  window.URL.revokeObjectURL(url);
};

위 코드는 기존 Client-Side 처리 방식의 한계를 명확히 보여줍니다.

  1. 순차적 데이터 수집: while (hasNext) 루프를 통해 모든 페이지의 데이터를 서버로부터 가져옵니다. 이 과정 자체는 비동기적이지만, 모든 데이터를 가져와야 다음 단계로 넘어갈 수 있습니다.
  2. 클라이언트 메모리 누적: collectedData.current = [...collectedData.current, ...data]; 라인에서 보듯이, 서버로부터 받은 모든 데이터를 클라이언트의 메모리에 쌓아둡니다. 데이터 양이 많아질수록 클라이언트 브라우저의 메모리 사용량이 크게 늘어나 OutOfMemory 에러나 브라우저 멈춤 현상이 발생할 수 있었습니다.
  3. 일괄 처리: 모든 데이터 수집이 완료된 후에야 startExportDownload() 함수가 호출되어 Excel 파일 생성 요청이 시작됩니다. 이 때문에 사용자는 데이터 수집 및 파일 생성 과정 동안 아무런 피드백 없이 기다려야 했습니다.
  4. 클라이언트-서버 간 데이터 이중 전송: 클라이언트가 데이터를 모두 모은 후, 다시 그 모든 데이터를 body: { exportData: collectedData.current } 형태로 서버로 전송하는 비효율적인 구조입니다.

이러한 문제점들 때문에 사용자들은 대용량 Excel 다운로드 시 긴 시간 동안 불편함을 감수해야 했고, 저희는 이 문제를 해결하기 위해 아키텍처 개선을 결정했습니다.

2. Server-Side 페이징과 SSE 스트리밍으로의 전환 (After)

이제 Server-Side에서 데이터 수집, Excel 생성, 그리고 클라이언트로의 실시간 진행 상황 전송을 담당하는 새로운 로직은 다음과 같습니다. src/api/export-stream/route.ts 파일이 이 역할을 수행합니다.

// Server-Side SSE 스트리밍 (핵심 로직)
export async function POST(request: Request) {
  const body = await request.json();
  const params = body.params || {};
 
  const stream = new ReadableStream({
    async start(controller) {
      const sendProgress = (data: any) => {
        const message = `data: ${JSON.stringify(data)}\n\n`;
        controller.enqueue(new TextEncoder().encode(message));
      };
 
      try {
        // 1단계: 서버에서 데이터 수집 (페이징 방식)
        sendProgress({
          type: "stage",
          stage: "collecting",
          message: "데이터 수집 중...",
        });
 
        const exportData = await retrievePagedDataWithProgress(
          exportConfig.dataFetcher,
          params,
          sendProgress, // 진행 상황 콜백 함수
        );
 
        if (exportData.length === 0) {
          sendProgress({ type: "error", message: "출력할 데이터가 없습니다." });
          controller.close();
          return;
        }
 
        // 2단계: 서버에서 Excel 파일 생성
        sendProgress({
          type: "stage",
          stage: "generating",
          message: "엑셀 파일 생성 중...",
        });
        await new Promise((resolve) => setImmediate(resolve)); // 이벤트 루프 양보
 
        // 아래에서 살펴볼 generateExcelContent의 호출 지점
        const workbookWrapper = await generateExcelContent(
          exportData,
          sendProgress,
        );
        const buffer = await workbookWrapper.writeToBuffer();
        const base64 = Buffer.from(buffer).toString("base64"); // Base64 인코딩
 
        // 3단계: 완료 메시지와 함께 파일 데이터 전송
        sendProgress({
          type: "complete",
          message: "다운로드 준비 완료",
          fileData: base64,
          dataType: "excel",
        });
        controller.close();
      } catch (error) {
        sendProgress({
          type: "error",
          message: "다운로드 처리 중 오류가 발생했습니다.",
        });
        controller.close();
      }
    },
  });
 
  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    },
  });
}

핵심 변화:

  1. SSE 헬퍼 함수: sendProgress 함수가 데이터를 JSON으로 변환하고 data: 프리픽스를 붙여 SSE 메시지로 전송합니다.
  2. Server-Side 페이징: 데이터를 서버에서 페이징 방식으로 수집하며, 콜백을 통해 진행 상황을 실시간으로 전달합니다.
  3. 이벤트 루프 양보: setImmediate를 활용하여 SSE 메시지가 지연 없이 전달되도록 최적화합니다.
  4. Base64 인코딩 (마지막 한 번): 진행률 메시지는 SSE 채널로 꾸준히 보내되, 완성된 Excel 파일은 마지막 complete 이벤트에 Base64 문자열로 한 번 동봉해 같은 채널로 함께 내려보냅니다. 진행률 채널과 결과물 채널을 별도의 엔드포인트로 분리하지 않아도 되므로 클라이언트 로직이 단순해지며, Base64로 인한 약 33%의 페이로드 증가는 이 단순함을 위해 감수하는 비용입니다.

이러한 변화로 클라이언트는 더 이상 대용량 데이터를 메모리에 쌓지 않아도 되며, 실시간 진행 상황을 확인할 수 있게 되었습니다.

3. 클라이언트 SSE 수신: fetch + ReadableStream

브라우저 표준 SSE 수신 API는 EventSource입니다. 가장 쉬운 선택지였지만, 저희 시나리오에는 두 가지 큰 한계가 있었습니다.

  1. HTTP 메서드가 GET으로 고정: EventSource는 GET 요청만 지원합니다. 그러나 다운로드 요청에는 필터 조건(params)과 마켓 메타데이터(marketList)처럼 길고 구조화된 JSON 페이로드가 함께 전달되어야 했고, 이를 쿼리스트링으로 풀어 보내는 방식은 길이 제한과 인코딩 측면에서 적절하지 않았습니다.
  2. 요청 취소 표준이 빈약: EventSource.close()는 클라이언트 측 연결만 끊을 뿐, AbortController처럼 표준 신호 기반 취소 흐름과 결을 맞추기 어렵습니다.

그래서 SSE의 프로토콜은 그대로 따르되 응답 헤더는 text/event-stream, 본문은 data: <json>\n\n 라인 수신 APIfetch + ReadableStream 리더로 바꿨습니다. POST 본문에 JSON을 자유롭게 실을 수 있고, AbortController.signal을 통한 표준 취소까지 한 번에 따라옵니다.

// Client-side SSE 수신 (fetch + ReadableStream reader)
const abortController = new AbortController();
 
const response = await fetch("/api/export-stream", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ params, marketList }),
  signal: abortController.signal, // 사용자가 취소 버튼을 누르면 abort()
});
 
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
 
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
 
  // 디코딩한 청크를 버퍼에 누적한 뒤 줄 단위로 분할
  buffer += decoder.decode(value, { stream: true });
  const lines = buffer.split("\n");
  buffer = lines.pop() ?? ""; // 마지막 라인은 불완전할 수 있어 다음 청크와 이어 붙임
 
  for (const line of lines) {
    if (!line.startsWith("data: ")) continue;
    const data = JSON.parse(line.slice(6));
    handleSSEMessage(data); // progress / generating_progress / complete / error 분기
  }
}

이 코드의 핵심은 두 가지입니다. 첫째, response.body.getReader()로 얻은 ReadableStreamDefaultReader를 통해 응답 본문을 청크 단위로 읽어들이며, 네트워크 청크 경계는 SSE 메시지 경계와 무관하므로 줄바꿈 기준으로 다시 자르고 마지막 라인은 버퍼에 보관해 다음 청크와 이어 붙입니다. 둘째, data: 프리픽스를 만난 라인의 JSON을 handleSSEMessage로 넘겨 메시지 타입(progress, generating_progress, complete, error)에 따라 UI를 갱신하거나 파일 다운로드를 트리거합니다.

결과적으로 사용자는 현재 다운로드 작업이 어떤 단계에 있는지, 얼마나 진행되었는지를 시각적으로 파악할 수 있게 됩니다. 동일한 fetch 요청 위에 AbortController가 그대로 얹혀 있으므로, 뒤에서 다룰 다운로드 취소 기능 또한 추가 비용 없이 자연스럽게 연결됩니다.

4. ExcelJS로의 전환과 진행률 콜백 통합

Excel 파일 생성 라이브러리를 excel4node에서 ExcelJS로 변경하면서, 대용량 파일 처리 성능을 높이고 파일 생성 중에도 진행률을 클라이언트에 전달하는 기능을 추가했습니다.

excel4node 대비 ExcelJS의 장점

기존에 사용하던 excel4node에서 ExcelJS로 전환한 이유는 다음과 같습니다:

비교 항목excel4nodeExcelJS
스트리밍 지원미지원스트리밍 API로 대용량 파일 메모리 효율적 처리
비동기 처리콜백 기반Promise 기반으로 async/await 자연스럽게 통합
유지보수업데이트 중단 (2019년 이후 미활동)활발한 커뮤니티와 지속적인 업데이트
기능 범위쓰기 전용읽기/쓰기/수정 모두 지원
서식 지원기본적인 서식만 지원조건부 서식, 데이터 검증, 이미지 삽입 등 풍부한 기능
TypeScript타입 정의 불완전완전한 TypeScript 지원

특히 우리 프로젝트에서 중요했던 점은 Promise 기반 API스트리밍 지원이었습니다. excel4node의 콜백 기반 API는 SSE 스트리밍과 결합하기 어려웠고, 대용량 데이터 처리 시 전체 워크북을 메모리에 올려야 해서 성능 병목이 발생했습니다. 반면 ExcelJSworkbook.xlsx.writeBuffer()와 같은 Promise 기반 메서드를 제공하여 비동기 흐름에 자연스럽게 통합할 수 있었고, 청크 단위로 데이터를 처리하면서 중간 진행률을 전송하는 구조를 쉽게 구현할 수 있었습니다.

// ExcelJS를 활용한 Excel 파일 생성
import ExcelJS from "exceljs";
 
const generateExcelContent = async (data, sendProgress) => {
  const workbook = new ExcelJS.Workbook();
  const worksheet = workbook.addWorksheet("sheet1");
 
  const chunkSize = 500; // 대용량 데이터 처리를 위한 청크 사이즈
  for (let i = 0; i < data.length; i += chunkSize) {
    const chunk = data.slice(i, Math.min(i + chunkSize, data.length));
    chunk.forEach((item, idx) => {
      const row = worksheet.getRow(i + idx + 2);
      // 셀에 데이터 쓰기 로직...
    });
 
    // 진행률 전송
    const progress = Math.floor(((i + chunk.length) / data.length) * 100);
    if (sendProgress) {
      sendProgress({
        type: "generating_progress",
        progress,
        message: `엑셀 파일 생성 중 (${progress}%)`,
      });
    }
    await new Promise((resolve) => setImmediate(resolve)); // 이벤트 루프 양보
  }
 
  return { writeToBuffer: async () => await workbook.xlsx.writeBuffer() };
};

ExcelJS의 주요 개선점:

  1. 청크 기반 처리: 데이터를 500개씩 나누어 처리하여 메모리 효율성을 높이고 중간 진행률 업데이트가 가능합니다.
  2. 진행률 콜백: 처리된 데이터 비율을 계산하여 generating_progress 메시지로 클라이언트에 전송합니다.
  3. 이벤트 루프 양보: setImmediate로 SSE 메시지가 지연 없이 전달되도록 합니다.

5. 다운로드 취소 기능

앞서 살펴본 클라이언트 SSE 수신은 이미 fetch 위에 올라가 있기 때문에, 동일한 요청에 AbortController.signal을 끼우는 것만으로 취소 기능이 자연스럽게 따라옵니다. 별도의 취소 프로토콜이나 서버 측 시그널링을 설계할 필요 없이, 사용자가 원하는 시점에 즉시 작업을 중단할 수 있게 되었습니다.

// 앞서 살펴본 fetch 호출에서 만든 AbortController를 ref에 보관해 둔다
const abortControllerRef = useRef<AbortController | null>(null);
abortControllerRef.current = abortController;
 
// 사용자가 취소 버튼을 누르면 ref에 보관된 컨트롤러로 abort
const abortExport = () => {
  abortControllerRef.current?.abort();
  abortControllerRef.current = null;
};
 
// fetch().catch에서 AbortError와 그 외 오류를 분기
fetchPromise.catch((error) => {
  if (error.name === "AbortError") return; // 의도적인 취소는 무시
  // 그 외 네트워크 / 파싱 오류만 사용자에게 노출
});
 
// UI: 다운로드 진행 중에만 노출되는 취소 버튼
{
  isLoading && <button onClick={abortExport}>다운로드 취소</button>;
}

여기서 챙겨야 할 디테일은 AbortError와 그 외 오류를 분기 처리하는 것입니다. AbortError는 사용자의 의도적인 행동이므로 별도 알림 없이 무시하고, 그 밖의 네트워크·파싱 오류만 사용자에게 노출해야 사용자 취소와 실제 오류가 동일한 토스트로 표시되는 혼란을 피할 수 있습니다. 이렇게 표준 Web API인 AbortController만으로도, 불필요한 네트워크 트래픽과 서버 자원을 절약하면서 사용자에게 명확한 제어권을 돌려줄 수 있게 되었습니다.

결과 및 효과: 성능과 사용성을 동시에 잡다

이번 개선의 효과를 확인하기 위해 3만 건 규모의 실제 운영 데이터로 개선 전후를 비교 측정했습니다.

측정 항목Client-Side 처리Server-Side + SSE변화
브라우저 메모리 피크1GB 내외200MB 이하5분의 1 수준으로 감소
다운로드 완료까지 소요 시간50초 이상20~30초절반 가까이 단축
브라우저 멈춤 현상3만 건부터 빈번미발생완전 해소
진행 상황 피드백없음실시간 표시신규 도입

가장 체감이 컸던 부분은 메모리입니다. 클라이언트가 전체 데이터를 들고 있을 필요가 없어지면서 피크 메모리가 1GB에서 200MB 아래로 내려갔고, 3만 건 이상에서 간헐적으로 발생하던 브라우저 멈춤도 완전히 사라졌습니다. 처리 시간 단축은 클라이언트-서버 간 데이터 이중 전송이 제거되고 서버에서 수집과 생성을 한 번에 처리하게 된 결과입니다.

  1. 클라이언트 부담 대폭 감소: 가장 큰 문제는 Client-Side에서 발생하는 메모리 과부하 및 CPU 사용량 증가였습니다. Server-Side 페이징과 Excel 파일 생성 로직을 서버로 이관함으로써, 클라이언트는 더 이상 대량의 데이터를 메모리에 유지하거나 무거운 파일 생성 작업을 수행하지 않게 되었습니다. 이는 브라우저 멈춤 현상과 OutOfMemory 에러를 효과적으로 해결했습니다.
  2. 응답성 및 사용자 경험 향상: SSE 스트리밍 도입으로 사용자는 다운로드 과정(데이터 수집, 파일 생성)을 실시간으로 확인할 수 있게 되었습니다. 진행률 바와 단계별 메시지는 불확실한 대기 시간을 의미 있는 피드백으로 바꾸어, 사용자가 다운로드 상태를 명확히 인지하고 더 이상 답답함을 느끼지 않도록 했습니다.
  3. 안정적인 대용량 데이터 처리: ExcelJS 라이브러리로의 전환과 청크 기반 처리, setImmediate를 활용한 이벤트 루프 양보 등의 최적화는 수십만 건 이상의 대용량 데이터도 안정적으로 Excel 파일로 생성할 수 있는 기반을 마련했습니다.
  4. 사용자 제어권 강화: AbortController를 활용한 다운로드 취소 기능은 사용자가 원할 때 언제든지 작업을 중단할 수 있게 하여, 불필요한 네트워크 트래픽과 리소스 낭비를 방지하고 사용자에게 더 나은 제어 경험을 제공했습니다.

회고

이번 프로젝트에서 핵심적인 판단 기준은 두 가지였습니다. 첫째, 데이터 처리 로직을 Client-Side와 Server-Side 중 어디에 둘 것인가. 둘째, 비동기 통신 방식을 어떻게 선택할 것인가.

SSEReadableStream을 활용한 실시간 스트리밍은, 사용자에게 "기다림"을 "인지 가능한 진행 과정"으로 전환하는 데 효과적이었습니다. Base64 인코딩의 33% 오버헤드, setImmediate를 활용한 이벤트 루프 양보 등 세부 최적화의 실제 효과도 검증할 수 있었습니다.

참고 자료

수정 이력

  • 2026-05-04
    • 클라이언트 SSE 수신 예시: EventSourcefetch + ReadableStream 정정 및 선택 이유 보강
    • API Route 경로 표기를 src/api/export-stream/route.ts로 통일
    • SSE 응답 헤더에 Connection: keep-alive 추가
    • 구현 포인트 순서 정리: 클라이언트 SSE 수신을 구현 포인트로 이동하고, 다운로드 취소 코드 예시 압축
    • 서버 측 호출부와 ExcelJS 정의부의 시그니처 정합성 보강: generateExcelContent 호출에 awaitsendProgress 반영
    • 상호 참조 표기를 번호 기반 대신 위치 기반("앞서 살펴본", "아래에서 살펴볼")으로 정리