SpreadJS 엑셀 로딩 성능 최적화
1. 문제의 시작: 파일을 읽었는데, 왜 브라우저가 멈추지?
SpreadJS를 이용해 엑셀 파일을 임포트 하는 과정에서, 파일 크기는 약 680KB로 크지 않았음에도 불구하고 브라우저가 멈추는(Freezing) 현상이 발생했습니다. 초기에는 단순히 데이터 양이 많아서 발생하는 문제로 추측했지만, 실제 데이터는 100행 수준이었습니다.
문제의 원인은 엑셀의 사용 범위에 있었습니다.
- 실제 데이터: 약 100행
- 엑셀 내부 시트 크기: 최대 행 수 (1,048,576행)
엑셀은 값이 없더라도 스타일, 서식, 과거 입력 흔적이 존재하면 해당 셀을 “사용된 영역”으로 간주합니다. SpreadJS는 이 범위를 그대로 로딩하기 때문에, 결과적으로 작은 파일이지만 대용량 데이터처럼 동작하는 상황이 발생했습니다.
2. 초기 시도: 반복문 기반 탐색
문제를 해결하기 위해, 시트의 마지막 행부터 역순으로 탐색하며 실제 데이터가 존재하는 마지막 위치를 찾는 로직을 구현했습니다.
const findLastDataRowAndTrim = (sheet) => {
const totalRowCount = sheet.getRowCount();
const totalColumnCount = sheet.getColumnCount();
let lastDataRowIndex = -1;
// 하단에서부터 데이터가 있는 마지막 행 탐색
for (let rowIndex = totalRowCount - 1; rowIndex >= 0; rowIndex--) {
let hasDataInRow = false;
for (let columnIndex = 0; columnIndex < totalColumnCount; columnIndex++) {
const cellValue = sheet.getValue(rowIndex, columnIndex);
if (cellValue !== null && cellValue !== "") {
hasDataInRow = true;
break;
}
}
if (hasDataInRow) {
lastDataRowIndex = rowIndex;
break;
}
}
// 마지막 데이터 이후의 불필요한 행 제거
if (lastDataRowIndex >= 0) {
const rowsToRemove = totalRowCount - lastDataRowIndex - 1;
if (rowsToRemove > 0) {
sheet.deleteRows(lastDataRowIndex + 1, rowsToRemove);
}
}
};
[한계점]
- 과도한 반복 연산: 예를 들어 10,000행 × 100열 → 최대 100만 번의 getValue() 호출
- 메인 스레드 점유: 반복문 실행 동안 UI 렌더링이 차단되어 브라우저가 멈춘 것처럼 보이는 현상 발생
- API 호출 비용: 단순 배열 접근이 아닌 SpreadJS API 호출을 반복 수행 → 성능 저하
- 최적화 효과 제한적: suspendPaint() 적용에도 불구하고, 반복문 자체의 비용이 커 체감 성능 개선이 미미
3. 해결방법: 내장 API getUsedRange 활용
SpreadJS는 이미 메모리 상에 데이터가 어디까지 차 있는지 알고 있습니다. 내부적으로 데이터가 존재하는 영역(Used Range)을 이미 관리하고 있기 때문에 이를 활용하면 셀을 직접 순회할 필요가 없습니다.
const trimSheetToUsedRange = (sheet) => {
// 데이터, 수식, 스타일이 포함된 실제 사용 범위를 즉시 계산
const usedRange = sheet.getUsedRange(GC.Spread.Sheets.UsedRangeType.all);
if (!usedRange) return;
const rowCount = usedRange.row + usedRange.rowCount;
const columnCount = usedRange.col + usedRange.colCount;
// 행/열 삭제(deleteRows) 대신 크기 재설정(setRowCount)으로 더 빠르게 처리
sheet.setRowCount(rowCount);
sheet.setColumnCount(columnCount);
};
const trimWorkbookSheets = (workbook) => {
const sheetCount = workbook.getSheetCount();
for (let i = 0; i < sheetCount; i++) {
trimSheetToUsedRange(workbook.getSheet(i));
}
};
- deleteRows 대신 setRowCount 사용 → 내부 처리 비용 감소
- 코드 단순화 → 유지 보수성 향상
4. 개선 결과
동일한 데이터 기준으로 성능을 비교한 결과
- 기존 방식: 수 초 이상 소요되거나, 경우에 따라 브라우저 프리징 발생
- 개선 방식: 체감상 즉시 처리 (프리징 현상 해소)
5. 적용 시 주의사항
getUsedRange 사용 시 다음 사항을 고려해야 합니다.
- 스타일만 있는 셀도 포함됨: 값이 없어도 서식이 존재하면 사용 영역으로 인식됨
- 숨겨진 행/열 포함: UI에서 보이지 않더라도 범위 계산에 포함됨
→ 위와 같은 이유로 실제 데이터 범위보다 크게 계산될 수 있으며, 시트 크기를 줄이는 효과가 제한될 수 있습니다. - 완전히 빈 시트: 시트에 데이터나 서식이 전혀 없는 경우 getUsedRange 호출 시 null이 반환될 수 있으므로, 반환값 사용 전에 null 여부를 확인해야 합니다.
6. 마치며
직접 반복문을 구현하는 대신, 엔진이 제공하는 기능을 활용하는 것만으로도 코드는 더 간결해지고 성능은 크게 향상될 수 있습니다.
브라우저가 멈춘다면, 문제는 데이터의 크기가 아니라 처리 방식일 수 있습니다. 불필요한 반복 연산을 수행하고 있지는 않은지, 이미 더 효율적인 방법이 존재하지 않는지 확인해 보시기 바랍니다.
JY
참고문헌
- SpreadJS 공식 문서 (getUsedRange API)
https://developer.mescius.com/spreadjs/demos/features/worksheet/get-used-range/purejs