🚀 Overview

서버에서 처리하면 되는데 왜 클라이언트에서 하려고 하죠?
프로젝트를 시작하면서 동료 개발자에게 받았던 첫 질문이었습니다. 사실 저도 처음엔 같은 생각이었죠. 프로젝트를 진행하면서 엑셀 파일로 된 데이터를 분석하고 처리해야 하는 요구사항들을 마주쳤는데, 엑셀 파일 처리는 서버의 영역이라고 당연하게 생각했습니다. 하지만 실제로 개발을 하다 보니 이런 고정관념이 조금씩 깨지기 시작했습니다.
🤔 왜 클라이언트에서 처리하기로 했나
기존 방식의 한계
처음 마주친 문제는 파일 크기였습니다. 수백 MB에 달하는 엑셀 파일을 서버로 업로드하는 과정에서 여러 제약이 있었습니다. 대용량 엑셀 파일을 서버로 업로드하고, 이를 처리한 뒤 다시 결과를 클라이언트로 내려받는 과정에서 네트워크 대역폭이 낭비되다보니 사용자 입장에선 적지 않은 시간을 대기해야 했습니다. 게다가 여러 사용자가 동시에 이런 처리를 요청한다면 서버에 꽤 큰 부담이 될 확률이 많습니다.
네트워크 목업 테스트
그러면 얼마나 큰 부담이 될까요? 세부적인 구현에 들어가기 전에 실제로 얼마나 오래 걸릴지 네트워크 테스트를 해보았습니다.
구체적인 구현 방식은 크게 다음과 같습니다.
- 클라이언트 → 서버 엑셀 파일 전송
- 서버에서 유효성 검사 및 클라이언트에 필요한 데이터 파싱
- 서버 → 클라이언트 엑셀 분석 성공 여부 및 데이터 전송
이 중에 1단계만 목 데이터를 통해 간이 테스트를 해보았습니다. 테스트 코드를 짤 땐 굳이 제가 만들기 보단 요즘 부려먹기 딱 좋은 똑똑이 Claude를 사용했습니다. 직접 테스트하기 위해 몇 시간 동안 노력할 부분을 단 몇 분만에 구현해주니 효자가 따로 없답니다 🤣
요청할 엑셀 데이터는 약 58MB입니다. 이 배열은 1,000개의 row를 가지며 각각의 데이터마다 1000자의 랜덤 배열값을 넣도록 만들었습니다. 그러면 총 1,000 * 1,000 = 1,000,000자의 글자를 전송하게 됩니다. 이를 총 5번 반복해보았습니다.
엑셀 테스트 컴포넌트 코드
import React, { useState } from 'react';
import { Line } from 'react-chartjs-2';
import {
CategoryScale,
Chart as ChartJS,
Legend,
LinearScale,
LineElement,
PointElement,
Title,
Tooltip,
} from 'chart.js';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
export default function ExcelUploadTest() {
const [loading, setLoading] = useState(false);
const [timings, setTimings] = useState([]);
const [fileSize, setFileSize] = useState(0);
const styles = {
container: {
padding: '24px',
maxWidth: '64rem',
margin: '0 auto'
},
header: {
marginBottom: '24px'
},
title: {
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '16px'
},
description: {
color: '#666',
marginBottom: '16px'
},
fileInput: {
marginBottom: '16px'
},
button: {
padding: '8px 16px',
backgroundColor: '#3B82F6',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
marginBottom: '24px'
},
buttonDisabled: {
opacity: 0.5,
cursor: 'not-allowed'
},
chartContainer: {
height: '320px',
marginBottom: '24px'
},
resultsContainer: {
backgroundColor: 'white',
padding: '16px',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
},
resultsTitle: {
fontWeight: 'bold',
marginBottom: '8px'
},
resultsList: {
display: 'flex',
flexDirection: 'column',
gap: '8px'
}
};
const testFileUpload = async (file) => {
setLoading(true);
const newTimings = [];
const iterations = 5;
for (let i = 0; i < iterations; i++) {
const startTime = performance.now();
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('https://httpbin.org/post', {
method: 'POST',
body: formData
});
await response.json();
const endTime = performance.now();
const duration = endTime - startTime;
newTimings.push({
iteration: i + 1,
duration: duration / 1000 // 초 단위로 변환
});
setTimings([...newTimings]);
// 다음 요청 전 1초 대기
await new Promise(resolve => setTimeout(resolve, 1000));
} catch (error) {
console.error('File upload failed:', error);
}
}
setLoading(false);
};
const handleFileChange = (e) => {
const file = e.target.files[0];
if (file) {
setFileSize(file.size);
}
};
const handleUploadTest = async () => {
const fileInput = document.querySelector('input[type="file"]');
const file = fileInput.files[0];
if (file) {
await testFileUpload(file);
}
};
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const chartData = {
labels: timings.map(t => `요청 ${t.iteration}`),
datasets: [
{
label: '파일 업로드 시간 (초)',
data: timings.map(t => t.duration),
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.5)',
}
]
};
const chartOptions = {
responsive: true,
plugins: {
legend: {
position: 'top',
},
title: {
display: true,
text: '엑셀 파일 업로드 시간'
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: '소요 시간 (초)'
}
}
}
};
return (
<div style={styles.container}>
<div style={styles.header}>
<h1 style={styles.title}>엑셀 파일 업로드 테스트</h1>
<p style={styles.description}>
동일한 엑셀 파일을 5번 연속으로 업로드하여 네트워크 성능을 테스트합니다.
</p>
</div>
<input
type="file"
accept=".xlsx,.xls"
onChange={handleFileChange}
style={styles.fileInput}
/>
{fileSize > 0 && (
<p style={{ marginBottom: '16px' }}>
파일 크기: {formatFileSize(fileSize)}
</p>
)}
<button
onClick={handleUploadTest}
disabled={loading || fileSize === 0}
style={{
...styles.button,
...(loading || fileSize === 0 ? styles.buttonDisabled : {})
}}
>
{loading ? '테스트 진행 중...' : '테스트 시작'}
</button>
{timings.length > 0 && (
<div>
<div style={styles.chartContainer}>
<Line options={chartOptions} data={chartData} />
</div>
<div style={styles.resultsContainer}>
<h3 style={styles.resultsTitle}>테스트 결과</h3>
<div style={styles.resultsList}>
<p>파일 크기: {formatFileSize(fileSize)}</p>
<p>평균 업로드 시간: {(timings.reduce((acc, t) => acc + t.duration, 0) / timings.length).toFixed(2)}초</p>
<p>최대 업로드 시간: {Math.max(...timings.map(t => t.duration)).toFixed(2)}초</p>
<p>최소 업로드 시간: {Math.min(...timings.map(t => t.duration)).toFixed(2)}초</p>
</div>
</div>
</div>
)}
</div>
);
}

Macbook M3를 기준으로 Wi-fi 환경에서 테스트 해보았습니다. 테스트는 평균 약 25초 정도 소요됐고, 한 요청은 40초까지 오래 걸리기도 했습니다.

개발자 모드에서 확인해보면, 네트워크 전송 폭은 80MB 정도 웃돌았습니다.
"아니 그러면 1번 단계에서 파일을 보내는 데만 약 25초 정도 소요되는데 서버에서 처리하는 과정은 얼마나 걸릴 것이며, 그걸 다시 클라이언트에 전송하기까지 계속 사용자는 기다려야 하나?", “서버에 파일을 요청하는 타이밍을 사용자 모르게 앞당길까?”, “서버에서 여러 사용자의 파일들을 받아 분석하다보면 서버 메모리가 남아나지 않을 거 같은데?” 등 등 개발자 도구를 보면서 생각이 많아졌습니다. 그러던 도중 이런 고민 속에서 문득 든 생각이 있었습니다. "요즘 브라우저도 엄청나게 발전했는데, 클라이언트에서 처리할 수는 없을까?"
오늘날의 브라우저

2000년대 초반의 브라우저라면 상상도 할 수 없었던 일들이 지금은 가능해졌습니다. 그때의 브라우저는 단순히 HTML을 표시하고 간단한 JavaScript를 실행하는 정도였죠. 파일을 다루는 건 정말 제한적이었고, 대용량 데이터는 꿈도 꾸지 못했습니다.
초기 브라우저의 파일 업로드는 <input type="file">
을 통해 가능했지만, 파일의 내용을 직접 읽거나 조작하는 건 불가능했죠. 웹 브라우저의 능력이 크게 향상된 건 2010년대 들어서입니다. Chrome의 V8, Firefox의 SpiderMonkey와 같은 JavaScript 엔진들의 발전으로 복잡한 연산 처리가 가능해졌고, 2009년 Web Workers의 등장으로 백그라운드 처리의 길이 열렸습니다.
특히 2011-2013년 사이는 브라우저의 파일 처리 능력이 비약적으로 발전한 시기였습니다. ArrayBuffer의 등장으로 바이너리 데이터를 효율적으로 다룰 수 있게 되었고, FileReader API와 Blob의 도입으로 파일 데이터를 직접 읽고 조작할 수 있게 되었죠
메모리 관리 측면에서도 큰 발전이 있었습니다. 가비지 컬렉션이 더욱 똑똑해졌고, 개발자들은 성능 모니터링 도구들을 통해 메모리 사용량을 실시간으로 확인하고 최적화할 수 있게 되었죠. 예전에는 수 MB의 데이터만 다뤄도 브라우저가 뻗어버렸는데, 이제는 수백 MB의 데이터도 적절한 처리만 해주면 무리 없이 다룰 수 있습니다.
이러한 브라우저의 발전을 확인하면서 문제를 다시 들여다보았습니다. xlsx
파일의 구조를 분석해보니 생각보다 훨씬 단순했습니다. 알고 보니 그저 ZIP으로 압축된 XML 파일들의 모음이었던 것이죠. 이걸 깨닫는 순간 "이 정도는 브라우저에서 충분히 처리할 수 있겠다"라는 생각이 들었습니다.
라이브러리는 있을까?
처음에는 당연히 이미 만들어진 라이브러리가 있지 않을까 생각했습니다. 웬만해선 오픈소스로 나와있기 때문이죠. 역시 찾아보니 JavaScript 환경에서 가장 널리 사용되는 xlsx
라이브러리를 발견했습니다. 이 라이브러리는 Excel 파일을 읽고 쓸 수 있게 해주는데, 특히 JSON 형태로 데이터를 쉽게 변환할 수 있다는 점이 특징입니다.
const handleFileUpload = async (event) => {
const file = event.target.files[0];
const buffer = await file.arrayBuffer();
const workbook = XLSX.read(buffer, { type: 'array' });
const data = XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]]);
console.log('변환된 데이터:', data);
};
코드를 보시면 아시겠지만, 정말 간단하게 엑셀을 다룰 수 있습니다. 파일을 ArrayBuffer로 읽어서 xlsx 라이브러리에 넘겨주기만 하면 됩니다. 이렇게 변환된 데이터는 JavaScript 객체로 다루기도 쉽고요.
그럼에도 불구하고

하지만 실제로 사용해보니 큰 문제가 있었습니다. 우리가 처리해야 할 데이터의 Worst Case는 약 10,000행인데, xlsx 라이브러리는 1,000행만 넘어가도 STATUS_BREAKPOINT
예외를 던집니다. 게다가 라이브러리의 구조가 꽤 복잡했습니다. WTF
나 !ref
같은 독특한 속성들을 이해해야 했고, 워크북과 워크시트라는 계층 구조도 강제되어 있었죠.
특히 시트 간 참조를 할 때는 Sheet1'!A1
같은 특정 형식만 사용할 수 있어서, 우리가 원하는 방식으로 데이터를 자유롭게 다루기가 어려웠습니다. 결국 요구사항을 구현하기엔 러닝커브도 필요하고 한계가 너무 명확했습니다.
이런 한계들을 마주하면서 "그렇다면 직접 구현하는 게 어떨까?"라는 생각을 하게 되었습니다.
⚒️ 직접 구현해보자

1. xlsx 파일 구조 분석
직접 구현에 들어가기 위해 xlsx
파일의 구조를 세부적으로 분석해보았습니다. 압축 해제를 해서 뜯어보면 다음과 같은 구조를 가집니다.
📁 [Content_Types].xml
📁 _rels
📁 docProps
📁 xl
├── 📁 _rels
├── 📁 theme
├── 📁 worksheets
│ └── sheet1.xml
│ └── sheet2.xml
├── sharedStrings.xml
├── styles.xml
└── workbook.xml
여기서 데이터를 다루려면 크게 세 가지의 xml
를 들여다보면 알 수 있습니다.
xl/workbook.xml
- 엑셀 워크북의 전체적인 구조 정보를 담고 있습니다.
- 정보는 시트 이름과 순서 정보를 포함합니다.
<sheets>
<sheet name="Sheet1" sheetId="1" r:id="rId1"/>
<sheet name="Sheet2" sheetId="2" r:id="rId2"/>
</sheets>
xl/worksheets/sheet1.xml
- 실제 엑셀 시트의 데이터를 볼 수 있습니다.
- 셀의 값이나 위치 정보를 포함하고 있습니다.
<worksheet>
<sheetData>
<row r="1">
<c r="A1" t="s">
<v>0</v>
</c>
</row>
</sheetData>
</worksheet>
xl/sharedStrings.xml
- 엑셀 내부에서는 메모리 효율성을 위해 반복되는 문자열을 캐싱합니다. 그 캐싱한 값들을 볼 수 있습니다.
<sst>
<si>
<t>제목</t>
</si>
<si>
<t>이름</t>
</si>
</sst>
여기서 셀 데이터 형식을 보면 다음과 같습니다.
<c r="A1" t="s">
<v>0</v>
</c>
r
: 셀의 위치를 알 수 있습니다. (A1, B2 …)t
: 셀의 타입을 알 수 있습니다.s
: 캐싱된 문자열 (sharedStrings.xml
의 인덱스 정보)n
: 숫자b
: true/falsestr
: 직접 입력된 문자열
<v>
: 실제 값
이러한 정보들을 활용해 셀의 위치를 파악하고 2차원 배열로 구성한 뒤, 각 셀의 타입에 맞게 데이터를 변환하면 훨씬 더 다루기 쉬울 것 같았습니다. A1
데이터를 읽는 것보단, [0,0]
2차원 배열의 데이터를 읽는 게 훨씬 더 쉽다고 생각했습니다.
2. xml 데이터 다루기
앞서 말씀드렸듯이 xlsx
파일은 ZIP으로 압축된 XML 파일들의 모음이기에 압축을 풀어야 했습니다. 이는 jszip
라이브러리를 사용하면 쉽게 해결할 수 있습니다.
const zip = new JSZip();
const zipData = await zip.loadAsync(file);
이제 본격적으로 엑셀 파일을 사용합니다. 여러 xml
파일이 있지만 그 중에 중요한 두 개의 파일을 선택하여 압축 해제합니다.
xl/sharedStrings.xml
: 문자열 데이터가 모여있는 파일xl/worksheets/sheet1.xml
: 실제 시트의 데이터가 있는 파일
이 XML 파일들을 먼저 문자열로 변환했습니다. 시트가 여러 개라면 sheet2
, sheet3
이렇게 늘어나겠지만 우선 요구사항에선 하나의 시트만 다루므로 이는 생략했습니다.
const sharedStringsXMLString = await zipData.file('xl/sharedStrings.xml')?.async('string');
const workSheetXMLString = await zipData.file('xl/worksheets/sheet1.xml')?.async('string');
이제 이 상태로는 문자열로 데이터를 읽을 수 있습니다. 하지만 문자열이기에 여전히 데이터를 가공하거나 수정할 때 제약이 많습니다.
이제 이 XML 파일들을 문자열 형태로 데이터를 읽을 수 있습니다. 하지만 여기서 또 다른 문제가 있었죠. 문자열 상태로는 데이터를 가공하거나 수정하기가 여전히 까다로웠습니다. 예를 들어 특정 셀의 값을 찾으려면 매번 문자열을 파싱하고 정규식으로 찾아야 하는데, 너무 비효율적입니다.
이러한 문제를 브라우저의 DOMParser
를 활용하여 해결했습니다. 이 도구를 사용하면 XML 문자열을 DOM 트리 구조로 변환할 수 있습니다. DOM 트리 구조로 변환하면 getElementsByTagName
과 같은 DOM API를 사용할 수 있어서, 데이터에 쉽게 접근하고 수정할 수 있습니다.
const parseXML = (xmlString) => {
const parser = new DOMParser();
return parser.parseFromString(xmlString, 'text/xml');
};
해당 함수를 통해 xml
문자열을 DOM
객체로 파싱합니다.
const workSheetsXML = parseXML(worksheetXMLString);
const sharedStringsXML = parseXML(sharedStringXMLString);
이제 본격적으로 데이터를 자유자재로 다룰 수 있게 됩니다.
3. 셀 데이터 계산하기
이제 데이터를 다룰 수 있게 되었지만, 실제 엑셀의 데이터를 추출하기 위해서는 두 가지 파일을 함께 살펴봐야 합니다.
SharedStrings
sharedStrings.xml
에는 캐싱된 문자열 데이터들이 모여있습니다. 엑셀에서 똑같은 문자열이 여러 번 사용될 때마다 매번 저장하는 대신, 이 파일에 한 번만 저장해두고 인덱스 값으로 참조하는 방식을 사용합니다.
이러한 구조를 참고하여 1차원 배열을 만들고 똑같이 인덱스 값으로 데이터를 참조할 수 있는 식으로 구현해야 합니다. 앞서 살펴봤던 shraredStrings.xml
구조를 기반으로 다음과 같이 구현하였니다.
const parseSharedStrings = (xmlDoc) => {
// 문자열을 캐싱하는 sharedStrings 배열
const strings = [];
// <si> 태그로 구분된 캐싱 문자열 묶음을 불러옵니다
const siTags = xmlDoc.getElementsByTagName('si');
for (let i = 0; i < siTags.length; i++) {
// <si> 내부에 캐싱 문자열들을 읽어 strings 배열에 저장합니다
const tTags = siTags[i].getElementsByTagName('t');
strings.push(
Array.from(tTags)
.map((t) => t.textContent)
.join(''),
);
}
return strings;
};
그리고 공유 문자열이 없는 경우도 고려하여 구현합니다.
const sharedStrings = sharedStringsXMLString ? parseSharedStrings(parseXML(sharedStringsXML)) : [];
WortSheets
실제 엑셀 시트의 모든 정보는 worksheet.xml
에 담겨 있습니다. 앞서 살펴본 구조로는 각 행마다 <row>
태그로 묶고, 각각의 셀 데이터를 <c>
태그로 묶어서 데이터를 분류하고 있습니다. 특히 셀의 위치가 A1
, B2
같은 엑셀 좌표로 저장되어 있기에 이 부분을 숫자로 치환하고 2차원 배열 내에 값을 모아두어야 합니다.
const rows = [];
const rowTags = xmlDoc.getElementsByTagName('row');
let maxCol = 0;
우선 각 행의 정보를 담는 배열인 rows
배열을 만들어둔 후, <row>
태그를 읽습니다.
for (let i = 0; i < rowTags.length; i++) {
// 특정 행
const rowTag = rowTags[i];
// 행 인덱스
const rowIndex = parseInt(rowTag.getAttribute('r'), 10) - 1;
// 특정 행의 데이터
const rowData = [];
// 특정 행에 있는 셀 모음
const cells = rowTag.getElementsByTagName('c');
...
}
행마다의 값을 계산합니다. row
태그에는 해당 행이 몇 번째인지 나타내는 값이 있기에 이를 인덱스로 명시합니다.
...
for (let j = 0; j < cells.length; j++) {
const cell = cells[j];
const cellRef = cell.getAttribute('r');
const cellPos = decodeCellRef(cellRef);
if (!cellPos) continue;
...
}
각각의 셀들의 위치를 먼저 계산합니다. 위치값은 r
값으로 확인할 수 있으며, 엑셀 기반의 위치입니다. 이를 숫자 형태로 치환해야 합니다.
const decodeCellRef = (ref) => {
// r 데이터에서 각각 행과 열의 데이터를 파싱합니다.
// 만약 'AA1' 일 경우, match 데이터는 다음과 같습니다.
// ['AA1', 'AA', 1]
const match = ref.match(/([A-Z]+)([0-9]+)/);
if (!match) return null;
// 파싱한 데이터를 통해 row와 col를 계산합니다.
const col =
match[1].split('').reduce((acc, char) => acc * 26 + (char.charCodeAt(0) - 64), 0) - 1;
const row = parseInt(match[2], 10) - 1;
return { row, col };
};
해당 함수를 통해 엑셀 위치값을 행과 열 인덱스로 바라볼 수 있도록 구현합니다.
...
maxCol = Math.max(maxCol, cellPos.col + 1);
const vTag = cell.getElementsByTagName('v')[0];
let value = '';
if (vTag) {
// <v> 태그의 데이터를 추출합니다.
value = vTag.textContent;
const cellType = cell.getAttribute('t');
// 데이터 타입이 sharedString이라면, 추출한 sharedString을 참고하여 데이터를 가져옵니다.
if (cellType === 's') {
value = sharedStrings[parseInt(value, 10)] || '';
// 데이터 타입이 boolean이라면, true/false 값으로 변환합니다.
} else if (cellType === 'b') {
value = value === '1';
// 데이터 타입이 number라면, number 값으로 처리하고 이외의 값은 그대로 출력합니다.
} else if (!cellType || cellType === 'n') {
value = isNaN(parseFloat(value)) ? value : parseFloat(value);
}
}
rowData[cellPos.col] = value;
<v>
태그 내 여러 Attribute
를 통해 엑셀 값을 JS 데이터로 변환합니다.
for (let j = 0; j < maxCol; j++) {
if (rowData[j] === undefined) {
rowData[j] = '';
}
}
혹시 비어있는 셀이 있다면, 빈 string 값으로 치환합니다.
const totalRows = Math.max(...Object.keys(rows).map(Number)) + 1;
for (let i = 0; i < totalRows; i++) {
if (!rows[i]) {
rows[i] = Array(maxCol).fill('');
}
}
return { rows: Object.values(rows), maxCol };
만들어진 rows
2차원 배열을 토대로 비어있는 행을 찾아 빈 string 배열로 치환합니다. 그리고 최종적으로 전체 행의 갯수와 2차원 배열 데이터를 return
합니다.
전체 셀 데이터 계산 코드
const decodeCellRef = (ref) => {
const match = ref.match(/([A-Z]+)([0-9]+)/);
if (!match) return null;
const col =
match[1].split('').reduce((acc, char) => acc * 26 + (char.charCodeAt(0) - 64), 0) - 1;
const row = parseInt(match[2], 10) - 1;
return { row, col };
};
const parseWorksheet = async (xmlDoc, sharedStrings, chunkSize = 1000) => {
const rows = [];
const rowTags = xmlDoc.getElementsByTagName('row');
let maxCol = 0;
const processChunk = async (startIdx, endIdx) => {
for (let i = startIdx; i < Math.min(endIdx, rowTags.length); i++) {
const rowTag = rowTags[i];
const rowIndex = parseInt(rowTag.getAttribute('r'), 10) - 1;
const rowData = [];
const cells = rowTag.getElementsByTagName('c');
for (let j = 0; j < cells.length; j++) {
const cell = cells[j];
const cellRef = cell.getAttribute('r');
const cellPos = decodeCellRef(cellRef);
if (!cellPos) continue;
maxCol = Math.max(maxCol, cellPos.col + 1);
const vTag = cell.getElementsByTagName('v')[0];
let value = '';
if (vTag) {
value = vTag.textContent;
const cellType = cell.getAttribute('t');
if (cellType === 's') {
value = sharedStrings[parseInt(value, 10)] || '';
} else if (cellType === 'b') {
value = value === '1';
} else if (!cellType || cellType === 'n') {
value = isNaN(parseFloat(value)) ? value : parseFloat(value);
}
}
rowData[cellPos.col] = value;
}
for (let j = 0; j < maxCol; j++) {
if (rowData[j] === undefined) {
rowData[j] = '';
}
}
rows[rowIndex] = rowData;
}
};
const chunks = Math.ceil(rowTags.length / chunkSize);
for (let chunk = 0; chunk < chunks; chunk++) {
const startIdx = chunk * chunkSize;
const endIdx = startIdx + chunkSize;
await processChunk(startIdx, endIdx);
await new Promise((resolve) => setTimeout(resolve, 0));
}
const totalRows = Math.max(...Object.keys(rows).map(Number)) + 1;
for (let i = 0; i < totalRows; i++) {
if (!rows[i]) {
rows[i] = Array(maxCol).fill('');
}
}
return { rows: Object.values(rows), maxCol };
};
4. 결과
const { rows } = await parseWorksheet(parseXML(worksheetXML), sharedStrings);
이제 구현한 함수를 활용하여 엑셀 데이터를 추출할 수 있습니다. rows
값을 콘솔에 찍어보면 다음과 같이 출력됩니다.

🚨 과연 브라우저에서 하면 “정말” 괜찮을까

이제 엑셀 데이터를 2차원 배열로 추출할 수 있게 되었습니다. 하지만 여기서 가장 큰 고민이 하나 있었습니다. "정말 브라우저에서 이런 처리를 해도 괜찮을까?"라는 거였죠.
앞서 살펴봤던 브라우저의 발전으로 인해 가능하다는 점은 충분히 파악했습니다. 하지만 딱 하나 간과한 점은 바로 “사용자의 스펙”입니다. 브라우저를 실행하는 컴퓨터 성능에 따라 처리 속도가 달라질 수 있다는 점이었죠. 혹시라도 사용자의 컴퓨터 사양이 낮다면 Out of Memory
오류가 발생해서 서비스가 멈춰버릴 수도 있고, 가비지 컬렉터가 제대로 동작하지 않아 메모리 누수가 발생할 수도 있었죠. 이런 걱정들을 해소하기 위해 다양한 환경에서 테스트를 진행해보기로 했습니다.
Worst Case로 테스트하기

10000행을 가지고 있는 엑셀로 변환할 때 메모리 변화를 테스트해보았습니다. 테스트는 크롬 및 크로미움 기반에서만 동작하는 performance
API의 memory
를 활용하였습니다. deprecated
된 API지만 사용자마다 쉽게 사용량을 모니터링할 수 있기에 간단하게 구현해보았습니다. 물론 이를 구현하지 않아도 ‘개발자 모드 - 성능 모니터’에서 쉽게 확인하실 수 있습니다.
퍼포먼스 그래프 구현 코드
if (performance && performance.memory) {
const usedHeap =
Math.round((performance.memory.usedJSHeapSize / (1024 * 1024)) * 100) / 100;
const totalHeap =
Math.round((performance.memory.totalJSHeapSize / (1024 * 1024)) * 100) / 100;
const heapLimit =
Math.round((performance.memory.jsHeapSizeLimit / (1024 * 1024)) * 100) / 100;
const currentTime = new Date().toLocaleTimeString();
setCurrentMemory({
used: usedHeap,
total: totalHeap,
limit: heapLimit,
});

또한 엑셀을 분석하고 데이터 처리하는 과정을 단계별로 나누어 소요 시간을 확인하도록 구현해보았습니다. performance
API의 now
를 활용하여 시간을 계산합니다.
단계별 구현 코드
// 단계 1: 파일 업로드
const startTime1 = performance.now();
flushSync(() => updateProcessingStep(1, 'processing'));
await new Promise((resolve) => setTimeout(resolve, 0));
flushSync(() => updateProcessingStep(1, 'complete', performance.now() - startTime1));
// 단계 2: ZIP 파일 로드
const startTime2 = performance.now();
flushSync(() => updateProcessingStep(2, 'processing'));
const zip = new JSZip();
const zipData = await zip.loadAsync(file);
flushSync(() => updateProcessingStep(2, 'complete', performance.now() - startTime2));
// 단계 3: 공유 문자열 처리
const startTime3 = performance.now();
flushSync(() => updateProcessingStep(3, 'processing'));
const sharedStringsXML = await zipData.file('xl/sharedStrings.xml')?.async('string');
const sharedStrings = sharedStringsXML ? parseSharedStrings(parseXML(sharedStringsXML)) : [];
flushSync(() => updateProcessingStep(3, 'complete', performance.now() - startTime3));
// 단계 4: 워크시트 로드
const startTime4 = performance.now();
flushSync(() => updateProcessingStep(4, 'processing'));
const worksheetXML = await zipData.file('xl/worksheets/sheet1.xml')?.async('string');
if (!worksheetXML) throw new Error('워크시트를 찾을 수 없습니다.');
flushSync(() => updateProcessingStep(4, 'complete', performance.now() - startTime4));
// 단계 5: 데이터 처리
const startTime5 = performance.now();
flushSync(() => updateProcessingStep(5, 'processing'));
const { rows } = await parseWorksheet(parseXML(worksheetXML), sharedStrings);
flushSync(() => updateProcessingStep(5, 'complete', performance.now() - startTime5));
📜 테스트
테스트 데이터 정보
행 수 | 데이터 크기 | 문자 수 | 브라우저 |
---|---|---|---|
10,000행 | 600MB | 약 2억자 | Chromium |
테스트 기기
기기 | RAM |
---|---|
Macbook M3 | 18GB |
Intel i7-1360P | 32GB |
Intel i5-10세대 | 8GB |
엑셀은 약 600MB이며 약 2억자 + @가 담겨있는 어마무시한 걸로 준비해보았습니다. 이러한 파일을 네트워크에 담아서 보낸다고 하면… 상상하기도 하기 싫군요 😂

테스트 결과

Intel i7-1360P
총 약 8.8초가 소요 됐으며, 브라우저 메모리는 약 1기가를 할당 받았습니다.

Macbook M3
약 6초 정도 소요됐습니다. 윈도우에 비해 2.8초 정도 빠릅니다.

Intel i5-10세대
약 16초 정도 소요됐으며, 메모리 할당은 기존처럼 약 1기가가 할당됐습니다.
실제로 기능을 붙여 테스트를 해보니 메모리 변화를 실시간으로 확인할 수 있고, 단계별 소요 시간 및 엑셀 파싱 결과도 볼 수 있습니다. 놀랍게도 3개의 기기로 테스트 해본 결과 전부 성공하였답니다 😲
세부 과정


메모리 히스토리를 살펴보면 사용이 끝난 파일 메모리는 깔끔하게 해제되는 것을 확인할 수 있었습니다. 가비지 컬렉터(GC)에 의해 2단계를 거쳐 원래 메모리 상태로 돌아옵니다.

메모리 스냅샷을 찍어보면, Array State를 내부에서 가지고 있고 파일 메모리만 해제된 것을 확인하였습니다. 이러한 테스트 결과들을 통해 브라우저에서도 충분히 안정적으로 엑셀 파일을 처리할 수 있다는 확신을 얻게 되었습니다 😊
💡 배운 점
이 과정을 통해 몇 가지 중요한 교훈을 얻었습니다:
- 고정관념의 극복: "서버에서만 가능하다"는 생각은 단순한 편견이었습니다. 현대의 브라우저는 생각보다 훨씬 더 많은 기능들을 제공하고 있었고, 이를 적절히 활용하면 서버 부하도 줄이면서 더 나은 사용자 경험을 제공할 수 있다는 것을 깨달았습니다.
- 성능 테스트와 모니터링: 다양한 환경에서의 테스트를 통해, 600MB 크기의 파일도 클라이언트에서 무리 없이 처리할 수 있다는 것을 확인했습니다. 특히 메모리 모니터링을 통해 가비지 컬렉션이 제대로 동작하는 것을 검증할 수 있었습니다.
이러한 테스트 과정을 공유드려 클라이언트단에서 해결할 수 있다 말씀드렸고, 긍정적인 검토를 해주셨습니다. 물론 모든 상황에 이 방식이 적합한 것은 아닙니다. 파일의 특성, 사용자의 환경, 보안 요구 사항 등을 종합적으로 고려해야 하죠. 특히 보안 측면에서 브라우저에서 직접 파일을 다루는 게 적절한지 깊은 검토가 필요할 수 있습니다.
다음에는 이 경험을 바탕으로 더 다양한 최적화 방법들을 시도해보려 합니다. Web Workers를 활용한 병렬 처리라든가, SharedArrayBuffer를 이용한 메모리 최적화 같은 것들도 고려해 볼 수 있을 것 같습니다.