브라우저에서 대용량 CSV를 안 멈추고 파싱하기
CSV → JSON 변환기에 "대용량 파일 모드"를 추가하면서 실제로 겪은 문제와, 그 과정에서 랜덤 테스트로 잡아낸 버그 하나를 기록으로 남깁니다.
왜 큰 CSV를 열면 탭이 멈추는가
가장 단순한 CSV → JSON 변환기는 이렇게 동작합니다: FileReader.readAsText()로 파일 전체를 문자열 하나로 읽고, 그 문자열을 <textarea>에 넣고, 파싱해서 결과를 다시 문자열로 만들어 또 다른 <textarea>에 넣습니다. 수 KB짜리 CSV라면 전혀 문제가 없습니다. 하지만 파일이 수십~수백 MB가 되면 세 지점에서 브라우저가 멈춥니다.
- 수백 MB짜리 문자열을
textarea.value에 대입하는 순간 — 브라우저의 텍스트 렌더링/레이아웃 엔진이 그 정도 크기의 편집 가능한 텍스트를 감당하도록 설계되어 있지 않습니다. - 수백만 행을 한 번에 순회하며 파싱하는 동안 — 이 자체는 보통 수백 ms~수 초 수준이지만, 그동안 메인 스레드가 완전히 막혀 있으면 "페이지가 응답하지 않습니다" 경고가 뜨기 쉽습니다.
- 결과 배열 전체를
JSON.stringify로 한 번에 문자열화해서 다시textarea.value에 넣는 순간 — 첫 번째 문제가 출력 쪽에서 한 번 더 반복됩니다.
세 지점 모두 근본 원인은 같습니다: 파일 전체를 한 덩어리로 다루는 것.
접근: 상태를 유지하는 증분 파서
해결책은 File.stream()으로 파일을 청크 단위(보통 수십~수백 KB)로 읽으면서, 청크가 들어올 때마다 바로 처리하는 것입니다. 문제는 CSV 파싱이 상태를 가진다는 점입니다 — 지금 따옴표 안에 있는지, 직전 문자가 이스케이프된 큰따옴표의 앞부분이었는지 같은 정보가 청크 경계에서 끊기지 않고 이어져야 합니다.
그래서 한 글자씩 훑는 배치 파서의 상태 머신을 그대로 유지하되, 함수가 끝날 때 지역 변수로 사라지는 대신 클로저에 남아있게 만들었습니다.
function createStreamingCsvParser(delimiter, onRow) {
let field = "";
let row = [];
let inQuotes = false; // false | true | "maybe-close"
let pendingCR = false;
function push(chunk) {
for (let i = 0; i < chunk.length; i++) {
// ... 문자 하나씩, 이전 push() 호출이 남긴 상태에서 이어서 처리
}
}
function finish() { /* 마지막 남은 필드/행 마무리 */ }
return { push, finish };
}
까다로운 부분은 청크가 하필 애매한 지점에서 끊길 때입니다. 예를 들어 필드 안에 이스케이프된 큰따옴표("")가 있는데, 그 두 글자 중 첫 번째 "가 청크의 마지막 글자라면? 지금 당장은 "닫는 따옴표"인지 "이스케이프의 시작"인지 알 수 없습니다. 다음 청크의 첫 글자를 봐야 압니다. 이걸 위해 inQuotes에 true/false 외에 "maybe-close"라는 세 번째 상태를 추가해서, 다음 push() 호출의 첫 글자로 판단을 미뤘습니다.
버그: truthy 체크 하나가 전체를 깨뜨렸다
처음 구현했을 때 이렇게 썼습니다.
if (inQuotes) {
// 따옴표 안에서의 처리
}
if (inQuotes === "maybe-close") {
// 청크 경계 판단 보류 상태 처리
}
겉보기엔 멀쩡해 보이지만, JavaScript에서 "maybe-close"는 truthy입니다. 그러니 inQuotes === "maybe-close"인 상태에서 첫 번째 if (inQuotes)가 먼저 걸려버려서, 원래 의도한 "경계 판단" 분기는 절대 실행되지 않았습니다. 코드는 에러 없이 조용히 잘못된 결과를 만들어냈습니다 — 이런 종류의 버그가 가장 위험합니다.
이 버그를 발견한 방법은 손으로 만든 몇 가지 테스트 케이스가 아니라, 같은 텍스트를 무작위 위치에서 쪼개 스트리밍 파서에 넣고, 매번 배치 파서(한 번에 전체를 파싱하는 원래 버전)의 결과와 정확히 일치하는지 비교하는 퍼징 테스트였습니다.
for (let trial = 0; trial < 20; trial++) {
const boundaries = randomChunkBoundaries(text.length, avgChunkSize);
const streamed = streamingParseAll(text, ",", boundaries);
assert.deepEqual(streamed, parseCSV(text, ",")); // 배치 파서와 대조
}
청크 크기를 1글자로 좁혔더니(극단적으로 잘게 쪼갠 경우) 거의 모든 따옴표 문자가 "이 청크의 마지막 글자"가 되어 "maybe-close" 분기를 강제로 반복 실행시켰고, 그 결과 대부분의 테스트 케이스가 즉시 실패로 드러났습니다. 수정은 한 줄이었습니다 — if (inQuotes)를 if (inQuotes === true)로 바꾸는 것.
얻은 교훈
- 상태 머신에 "판단 보류" 상태를 추가할 때는 그 상태가 다른 체크에 실수로 걸리지 않는지 반드시 확인하세요. 문자열이든 숫자든, 진짜 값(true/false)과 "보류 중" 마커를 같은 타입으로 섞으면 truthy/falsy 체크가 조용히 잘못된 분기를 탈 수 있습니다.
- 청크 경계를 다루는 코드는 반드시 무작위 경계로 테스트하세요. 손으로 고른 테스트 케이스는 "이 정도면 되겠지" 하는 경계에서만 자릅니다. 실제 네트워크나 파일 스트림은 그런 배려를 하지 않습니다.
- 기존에 검증된 로직(배치 파서)이 있다면, 새 구현(스트리밍 파서)의 정답지로 그대로 재사용하세요. 처음부터 "정답이 무엇인지"를 다시 정의할 필요가 없었기 때문에, 퍼징 테스트를 몇 분 만에 만들 수 있었습니다.
이 스트리밍 파서는 실제로 CSV ↔ JSON 변환기의 대용량 파일 모드(2MB 초과 파일에서 자동 활성화)에 그대로 쓰이고 있습니다.
자주 묻는 질문
왜 Web Worker를 쓰지 않았나요?
Web Worker로 파싱을 별도 스레드로 옮기면 메인 스레드가 막히는 문제는 피할 수 있지만, 결과 전송(postMessage) 자체가 큰 데이터에서는 또 다른 복사 비용이 됩니다. 이 도구는 청크 단위로 처리하며 주기적으로 이벤트 루프에 양보(yield)하는 것만으로도 충분히 반응성을 유지할 수 있어 Worker 없이 구현했습니다.
모든 CSV 파싱 문제에 이런 스트리밍 파서가 필요한가요?
아니요. 수 MB 이하의 파일이라면 파일 전체를 문자열로 읽어 한 번에 파싱해도 체감상 즉시 끝납니다. 스트리밍은 파일이 커서 "문자열 하나로 들고 있는 것 자체"가 부담이 될 때만 필요한 최적화입니다.
왜 JSON → CSV 방향은 스트리밍하지 않나요?
CSV → JSON은 한 행을 읽으면 바로 그 행의 결과를 확정할 수 있는 반면, JSON → CSV는 헤더(모든 객체에 등장하는 키의 합집합)를 먼저 확정해야 각 행을 출력할 수 있어 최소 2단계 처리가 필요합니다. 현재는 이 방향까지는 스트리밍을 구현하지 않았습니다.