npm install 없이 xlsx 파일 읽기 — ZIP+XML 직접 파싱한 함정 5가지 (2026)

·

네이버 블로그 통계 화면은 로그인이 필요해서 API로 못 가져온다. 대신 화면에서 엑셀 다운로드 버튼을 누르면 파일이 받아진다. 이 파일 하나를 읽으려고 xlsx 패키지를 넣을지 고민하다가, 직접 파고들어 확인해보니 xlsx는 생각보다 단순한 구조였다.

xlsx의 정체 — ZIP 안에 XML

xlsx 확장자를 .zip으로 바꿔서 열어보면 그대로 압축 해제된다. 실제로 해보면 xl/worksheets/sheet1.xml(셀 데이터), xl/sharedStrings.xml(문자열 테이블), xl/workbook.xml(시트 목록) 같은 파일들이 나온다. 즉 xlsx를 읽는 문제는 두 단계로 쪼개진다.

  1. ZIP을 풀어서 파일 목록을 얻는다
  2. 그 안의 XML을 정규식으로 파싱한다

Node.js 표준 라이브러리의 zlib.inflateRawSync만 있으면 ZIP의 압축 해제(DEFLATE)는 끝난다. 별도 패키지가 필요 없다.

함정 1 — 파일 목록은 끝에서부터 찾는다

ZIP 파일의 구조는 직관과 반대다. 각 파일의 목록(Central Directory)이 어디 있는지 알려주는 정보(End Of Central Directory, EOCD)가 파일 맨 끝에 있다. 그런데 EOCD 뒤에는 가변 길이의 코멘트가 붙을 수 있어서, 파일 끝에서 정확히 22바이트 앞이 EOCD라고 단정할 수 없다.

let eocd = -1;
for (let i = buf.length - 22; i >= 0 && i >= buf.length - 22 - 65535; i--) {
  if (buf.readUInt32LE(i) === SIG_EOCD) {
    eocd = i;
    break;
  }
}

그래서 파일 끝에서부터 최대 65,535바이트(코멘트 필드의 최대 길이)를 거꾸로 훑으며 시그니처(0x06054b50)를 찾는다. 앞에서부터 순서대로 읽는 접근으로는 이 구조 자체가 성립하지 않는다.

함정 2 — 로컬 헤더와 중앙 디렉터리의 길이가 다를 수 있다

각 파일의 실제 데이터 위치는 로컬 헤더(Local File Header)에 있는데, 이 헤더의 extra 필드 길이가 중앙 디렉터리에 적힌 값과 다를 수 있다. 중앙 디렉터리 값만 믿고 오프셋을 계산하면 데이터 시작 위치가 어긋난다.

if (buf.readUInt32LE(localOff) === SIG_LOC) {
  const lNameLen = buf.readUInt16LE(localOff + 26);
  const lExtraLen = buf.readUInt16LE(localOff + 28);
  const dataStart = localOff + 30 + lNameLen + lExtraLen;
  ...
}

그래서 실제 데이터 시작 위치는 로컬 헤더에서 다시 읽은 길이로 계산한다. 이 부분은 처음엔 중앙 디렉터리 값만 쓰다가, 실제 네이버 파일에서 압축 해제 결과가 깨져서 원인을 찾다가 고친 부분이다.

함정 3 — 빈 셀은 XML에서 통째로 생략된다

가장 먼저 발견한, 그리고 가장 위험한 함정이다. 네이버 통계 엑셀은 값이 0이 아니라 셀 자체가 비어 있으면 <c> 태그를 아예 안 쓴다.

<row r="1"><c r="A1"><v>1</v></c><c r="D1"><v>4</v></c></row>

B열과 C열이 존재하지 않는다. 이 행을 앞에서부터 순서대로 배열에 밀어넣으면 [1, 4]가 되어 D열 값이 B열 자리로 당겨진다. 이 상태로 통계를 집계하면 유입경로별 숫자가 조용히 뒤섞인 채로 나온다 — 에러 없이 틀린 숫자가 나오는 게 제일 위험하다.

const ref = /r="([A-Z]+\d+)"/.exec(attrs)?.[1];
...
if (ref) cells[colIndex(ref)] = value;
else cells.push(value);

해결은 각 셀의 r="A1" 같은 좌표 속성으로 실제 열 위치를 복원하는 것이다. 저 예시라면 [1, null, null, 4]가 정확한 결과다.

함정 4 — 서식이 바뀌면 문자열이 조각난다

엑셀에서 셀 하나의 텍스트 중간에 굵게·색상 같은 서식을 다르게 주면, 저장될 때 그 문자열이 여러 개의 <r><t> 조각으로 쪼개진다.

<si><r><t>임대차</t></r><r><t>분쟁조정위원회</t></r></si>

첫 조각(<t>)만 읽으면 “임대차”까지만 얻고 뒤가 잘린다. 실제로 이 문제는 블로그 글 제목을 통계 파일에서 대조하는 과정에서 걸렸다 — 제목 일부에만 강조 서식이 들어간 셀이 있었다. 고친 방법은 <si> 안의 모든 <t> 조각을 순서대로 이어붙이는 것이다.

for (const [, si] of xml.matchAll(/<si>([\s\S]*?)<\/si>/g)) {
  let text = "";
  for (const [, t] of si.matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)) text += t;
  out.push(unescapeXml(text));
}

함정 5 — 재현 가능한 실제 파일이 없다

네이버 통계 페이지는 로그인이 필요해서, 이 파서를 CI에서 실제 파일로 테스트할 방법이 없다. 사용자가 받아온 파일을 저장소에 커밋할 수도 없다 — 실제 블로그 방문자 데이터가 들어 있기 때문이다.

그래서 테스트 파일 안에 최소 ZIP 라이터를 직접 만들어, 위 함정 하나하나를 재현하는 가짜 xlsx를 그 자리에서 생성한다.

function makeZip(entries) {
  // Local File Header + Central Directory + EOCD를 직접 조립한다
  // CRC32는 이 파서가 검사하지 않으므로 0으로 둬도 된다
}

이렇게 5개 테스트(공유 문자열, 빈 셀 생략, 인라인 문자열, 서식 쪼개진 문자열, xlsx가 아닌 파일)를 각각의 최소 픽스처로 검증한다. 파일 형식이 바뀌어도 눈으로 확인하지 않고 숫자로 확인할 수 있게 됐다.

정리

xlsx를 다루는 데 필요한 건 패키지가 아니라 ZIP 구조에 대한 이해였다. 실제로 걸린 함정은 전부 “명세를 안 읽어서”가 아니라 “네이버가 실제로 내보내는 파일이 명세의 가장 단순한 경우가 아니어서” 생겼다 — 빈 셀 생략, extra 필드 길이 불일치, 서식 쪼개짐 전부 스펙상 합법이지만 순진하게 짜면 걸리는 부분들이다.

자주 묻는 질문

그냥 xlsx나 exceljs 같은 npm 패키지를 쓰면 안 되나요?

쓸 수 있습니다. 다만 이 저장소는 스크립트에 외부 의존성을 거의 안 붙이는 원칙이 있고(canvas·sharp 정도가 예외), 네이버 통계 엑셀 하나 읽으려고 수 MB짜리 패키지를 넣는 건 과하다고 판단했습니다. 필요한 기능이 '셀 값 읽기'뿐이라 직접 짜는 쪽이 더 가벼웠습니다.

이 파서로 서식이나 수식도 읽을 수 있나요?

아니요. 셀 값 읽기 전용입니다. 서식·수식·차트는 전부 무시합니다. 네이버 통계 다운로드 파일에는 이런 게 없어서 지원 범위를 처음부터 좁게 잡았습니다.

빈 셀이 생략된다는 걸 어떻게 알았나요?

실제 네이버 통계 파일의 XML을 직접 열어봤습니다. 값이 0이 아니라 셀 자체가 없으면 <c> 태그가 통째로 빠져 있었습니다. 순서대로 배열에 밀어넣는 방식으로 짰다면 이후 모든 열이 한 칸씩 당겨져 조회수와 유입경로 숫자가 뒤바뀌었을 겁니다.

테스트는 실제 네이버 파일로 돌리나요?

아닙니다. 사용자가 받아온 통계 파일에는 실제 블로그 데이터가 들어 있어 저장소에 커밋할 것이 못 됩니다. 대신 최소 ZIP 라이터를 테스트 코드 안에 직접 만들어서, 빈 셀 생략·서식 쪼개진 문자열 같은 함정 각각을 재현하는 픽스처를 그때그때 생성합니다.

#Node.js#xlsx#ZIP#파서#무의존성