파일 형식은 확장자가 아니라 첫 바이트가 말합니다 — 매직 넘버와 인코딩

정기창·

Go 로 짜인 서버를 TypeScript 로 다시 짜며 다시 공부한 정석을 적는 연재의 일곱 번째 글입니다. 이번 요구는 다른 시스템에서 내려받은 파일을 이름이 아니라 실물로 판별해서 읽어야 한다는 것입니다.

관리 화면에서 내려받은 엑셀이나 CSV 파일을 업로드받아 읽는 기능은 흔합니다. 그런데 막상 실물 파일을 모아 보니, 이름과 내용이 다른 파일이 적지 않았습니다. ".csv" 라는 이름의 파일이 실제로는 엑셀 파일이었고, ".xls" 라는 이름 아래에 서로 다른 형식이 섞여 있었습니다. 여기서는 그 실물을 흉내 낸 예제 파일로 판별 과정을 재현했습니다.

확장자는 파일이 달고 있는 이름표일 뿐입니다

확장자는 파일 이름 끝에 붙은 ".csv", ".xlsx" 같은 꼬리표입니다. 운영체제는 이 꼬리표를 보고 어떤 프로그램으로 열지 정하지만, 꼬리표는 내용과 상관없이 누구나 바꿀 수 있습니다. 파일을 내보내는 쪽 시스템이 이름을 잘못 붙이는 일도 있습니다.

그래서 확장자만 믿고 CSV 로 읽으면, 실제로는 엑셀인 파일에서 알아볼 수 없는 글자만 읽어 오게 됩니다. 반대로 "CSV 만 받는다"고 확장자로 막으면, 이름만 틀린 정상 파일을 전부 거절하게 됩니다.

파일의 맨 앞 몇 바이트에는 형식의 서명이 들어 있습니다

많은 파일 형식은 파일 맨 앞에 "나는 이 형식이다"라는 고정된 값을 두기로 약속합니다. 이 값을 매직 넘버 또는 파일 시그니처라고 부릅니다. 바이트는 컴퓨터가 저장하는 가장 작은 단위 묶음이고, 아래의 D0, CF 같은 두 글자는 바이트 하나의 값을 16진수로 적은 것입니다.

형식맨 앞 바이트근거
옛 엑셀(.xls, Excel 97~2003)D0 CF 11 E0 A1 B1 1A E1Microsoft 복합 파일 형식 명세
요즘 엑셀(.xlsx)50 4B 03 04ZIP 압축 파일의 시작(ZIP 명세)
UTF-8 텍스트(BOM 있음)EF BB BF유니코드 FAQ
XML 스프레드시트 20033C 3F 78 6D 6C (<?xml)텍스트 XML, 뒤에 스프레드시트 네임스페이스

요즘 엑셀 파일(.xlsx)은 사실 여러 XML 파일을 ZIP 으로 묶은 것이라 ZIP 의 서명으로 시작합니다. 그래서 ZIP 이라는 것을 확인한 뒤에는, 안에 엑셀이 쓰는 폴더 이름(xl/)이 있는지까지 봐야 엑셀이라고 말할 수 있습니다.

이름과 실물이 다른 파일 여섯 개를 판별해 봤습니다

맨 앞 8바이트를 읽어 형식을 가리는 작은 판별기를 만들고, 예제 파일 여섯 개에 돌려 봤습니다. old-binary.xls 는 판별 로직을 보여 주기 위해 서명 8바이트만 맞춘 합성 파일이라, 실제 엑셀로는 열리지 않습니다.

파일              확장자  첫 8바이트                 판별 결과
report.csv        .csv   50 4B 03 04 14 00 00 00  -> ZIP 기반 엑셀(.xlsx)
legacy.xls        .xls   3C 3F 78 6D 6C 20 76 65  -> XML 스프레드시트 2003
old-binary.xls    .xls   D0 CF 11 E0 A1 B1 1A E1  -> 옛 엑셀(OLE2 복합 문서)
utf8-bom.csv      .csv   EF BB BF EC BD 94 EB 93  -> 텍스트 / UTF-8(BOM)
cp949.csv         .csv   C4 DA B5 E5 2C BC F6 B7  -> 텍스트 / EUC-KR 계열(추정)
text-guard.csv    .csv   EC BD 94 EB 93 9C 2C EC  -> 텍스트 / UTF-8

같은 ".csv" 네 개가 실제로는 엑셀 하나와 서로 다른 인코딩의 텍스트 셋이었고, 같은 ".xls" 둘은 전혀 다른 형식이었습니다. "엑셀 파일"이라는 한 단어 안에 옛 바이너리 형식, XML 텍스트 형식, ZIP 형식이 모두 들어 있다는 것을 이번에 제대로 알게 됐습니다.

function detect(buf: Buffer) {
  const startsWith = (hex: string) => buf.subarray(0, hex.length / 2).equals(Buffer.from(hex, 'hex'));
  if (startsWith('D0CF11E0A1B11AE1')) return '옛 엑셀(OLE2)';
  if (startsWith('504B0304')) return buf.includes(Buffer.from('xl/')) ? 'xlsx' : 'ZIP';
  const head = buf.subarray(0, 512).toString('latin1');
  if (head.startsWith('<?xml') && buf.includes(Buffer.from('urn:schemas-microsoft-com:office:spreadsheet'))) {
    return 'XML 스프레드시트 2003';
  }
  return '텍스트'; // 여기부터는 인코딩을 가린다
}

텍스트 파일은 인코딩까지 알아야 읽을 수 있습니다

형식이 텍스트라고 끝이 아닙니다. 인코딩은 글자를 바이트로 바꾸는 규칙인데, 같은 "코드"라는 글자도 규칙에 따라 전혀 다른 바이트가 됩니다. 요즘은 대부분 UTF-8 을 쓰지만, 오래된 시스템은 한국어를 CP949(EUC-KR 을 넓힌 규칙)로 저장하는 경우가 여전히 있습니다. 규칙을 잘못 고르면 글자가 깨집니다.

CP949 로 저장된 파일을
UTF-8 로 읽기  : "�ڵ�,����,�ݾ�"
EUC-KR 로 읽기 : "코드,수량,금액"

판별 순서는 이렇게 잡았습니다. BOM 이 있으면 UTF-8 로 봅니다. 없으면 먼저 "틀리면 에러를 내는" 엄격한 UTF-8 해석을 시도합니다. 옛 인코딩의 한글 바이트가 우연히 올바른 UTF-8 이 될 가능성은 낮기 때문입니다. 그래도 실패하면 EUC-KR 계열로 읽습니다. 웹 표준 인코딩 목록에서 euc-kr 은 이런 한국어 옛 인코딩을 가리키는 공식 이름이고, Node.js 의 TextDecoder 도 이 이름을 받습니다.

다만 이것은 판별이 아니라 추정입니다. 영문과 숫자만 있는 짧은 파일은 두 규칙 어느 쪽으로 읽어도 똑같아서 구분할 수 없습니다. 그래서 결과에 "추정"이라는 표시를 남기고, 다음 단계에서 첫 줄의 열 이름이 기대한 것과 맞는지로 한 번 더 확인했습니다.

숫자 앞의 0 을 지키려는 흔적도 벗겨 내야 합니다

내려받은 CSV 를 열어 보니 값이 ="00123" 처럼 감싸여 있는 경우가 있었습니다. 엑셀은 00123 을 숫자로 보고 앞의 0 을 지워 버리는데, 이렇게 수식 모양으로 감싸 두면 글자로 취급해 0 을 지켜 줍니다. 내보내는 쪽이 엑셀 사용자를 배려한 흔적입니다.

그대로 읽은 값        = ["=\"00123\"", "=\"00456\""]
감싼 부분을 벗긴 값    = ["00123", "00456"]
숫자로 바꿔 버렸다면   = [123, 456]   ← 앞의 0 이 사라진다

프로그램이 읽을 때는 이 포장을 벗겨야 하고, 벗긴 뒤에도 코드 값은 절대 숫자로 바꾸지 않아야 합니다. 00123 과 123 은 사람에게는 같아 보여도, 다른 표와 이어 붙이는 순간 다른 코드가 됩니다.

판별은 한 칸이 아니라 여러 겹으로 합니다

① 맨 앞 바이트로 형식 판별 (OLE2 / ZIP / XML / 텍스트)
② 형식 안쪽 구조 확인 (ZIP 이면 엑셀 폴더가 있는가)
③ 텍스트면 인코딩 판별 (BOM → 엄격한 UTF-8 → 옛 인코딩)
④ 첫 줄의 열 이름이 기대와 맞는가
⑤ 줄마다 값 검증 (포장 벗기기, 형식 검사)
   → 어느 단계든 실패하면 "왜 못 읽었는지"를 돌려주고 멈춘다

마지막 줄이 가장 중요했습니다. 형식을 잘못 짚은 채로 읽으면 에러 없이 엉뚱한 값을 만들어 내기 쉽습니다. 병합된 셀 때문에 데이터가 조용히 사라진 일을 적은 예전 글에서도 그랬듯, 파일 읽기의 사고는 대개 멈추지 않고 "성공"하는 쪽에서 납니다. 그래서 판별의 각 단계는 확신하지 못하면 이유를 붙여 멈추게 했습니다.

정리하며

  • 확장자는 믿지 않고 참고만 합니다. 형식은 파일 맨 앞의 매직 넘버로 판별합니다.
  • "엑셀 파일"은 한 형식이 아닙니다. 옛 바이너리, XML 텍스트, ZIP 형식을 각각 다르게 읽어야 합니다.
  • 인코딩 판별은 추정이라고 적어 둡니다. 엄격한 해석을 먼저 시도하고, 열 이름 같은 내용으로 한 번 더 확인합니다.

파일 하나를 읽는 일이 이렇게 여러 겹의 판단이라는 것을, 여러 형식이 섞인 실물을 모아 보고서야 알았다는 생각이 들었습니다. 명세를 읽는 것보다 실물 파일의 첫 8바이트를 들여다보는 쪽이 훨씬 많은 것을 알려 주었습니다.

실험 환경: Node.js v26.5.0(full ICU) · Python 3 + openpyxl 3.1.5(예제 파일 생성) · 2026-09-29. 예제 파일은 모두 이 글을 위해 만든 것이고, 옛 엑셀 파일은 서명만 맞춘 합성 파일입니다.

파일 형식매직 넘버인코딩CSV엑셀UTF-8Node.js