SpreadJS 엑셀 로딩 성능 최적화

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

참고문헌