2026년 7월 21일 | 개발 깨알 상식_Tips
orders.csv는 터미널에서 4줄로 보였지만 Python의 csv.reader로 읽으면 헤더를 포함해 3개 레코드였다. 메모 컬럼 안의 줄바꿈이 물리 줄을 하나 늘렸기 때문이다. 파일이 깨진 것도 아니고 파서가 한 행을 버린 것도 아니었다. 내가 서로 다른 단위를 같은 ‘행 수’라고 부른 것이 문제였다.
물리 줄과 레코드가 갈리는 지점
CSV에서 레코드는 무조건 줄바꿈 하나로 끝나지 않는다. 필드가 따옴표로 감싸져 있으면 그 안에 쉼표와 줄바꿈이 들어갈 수 있다. 배송 메모, 상담 기록, 사용자 서술, 주소처럼 사람이 여러 줄을 입력하는 컬럼에서 흔히 생기는 모양이다. 화면에서는 두 줄로 보이지만 CSV 문법에서는 여전히 한 필드이고 한 레코드다.
물리 줄 수는 파일 안의 줄바꿈 경계를 센 값이고, 논리 레코드 수는 CSV 파서가 문법을 해석한 뒤 반환한 행의 개수다. 여기에 헤더를 제외한 데이터 행 수가 또 따로 있다. 세 값을 구분하지 않으면 수집 로그에는 4건이라고 쓰고, 적재 테이블에는 2건이 들어간 것처럼 보여 불필요한 누락 조사를 시작하게 된다.
네 줄짜리 입력에서 확인한 차이
order_id,note
A-101,"배송 전
문 앞 연락"
A-102,"부재 시 경비실"
이 샘플은 물리적으로 4줄이다. 하지만 CSV 파서가 반환하는 것은 order_id,note 헤더, A-101 레코드, A-102 레코드까지 3개다. 실제 데이터는 헤더를 빼면 2건이다. A-101의 note 값에는 줄바꿈 문자가 보존된다. 따라서 wc -l, splitlines(), 에디터의 줄 번호는 파일 전송량이나 대략적인 형태를 볼 때는 쓸 수 있어도 적재 건수의 정답이 아니다.
나는 이런 차이를 만나면 먼저 파일 끝의 개행 유무까지 확인한다. wc -l은 레코드가 아니라 줄바꿈 문자를 세므로 마지막 개행이 없으면 사람이 보는 물리 줄 수와도 하나 차이 날 수 있다. 즉 줄 수 하나에는 CSV 문법과 파일 끝 처리라는 두 변수가 이미 섞여 있다.
줄 단위 전처리가 더 위험한 이유
집계만 어긋나는 것으로 끝나면 그나마 낫다. 파일을 한 줄씩 읽어 쉼표로 나누거나, 줄마다 strip()을 적용한 뒤 다시 합치면 따옴표 안의 원래 줄바꿈을 레코드 경계로 오해한다. A-101은 컬럼 수가 부족한 행과 정체를 알 수 없는 다음 행으로 쪼개진다. 에러가 나면 빨리 발견되지만, 부족한 컬럼을 빈 값으로 채우는 코드가 있으면 잘못된 두 건이 정상 데이터처럼 남을 수 있다.
대용량 파일을 병렬 처리할 때 임의의 바이트 위치나 줄 번호로 청크를 나누는 방식도 같은 함정에 빠진다. 청크 경계가 따옴표 필드 한가운데에 걸리면 앞 조각과 뒤 조각 어느 쪽도 독립된 CSV가 아니다. 단순히 따옴표 개수가 짝수인지 보는 휴리스틱도 이스케이프된 따옴표, dialect 차이, CRLF를 만나면 쉽게 흔들린다. 문법 해석은 CSV 파서에 맡기고, 분할은 완성된 레코드 뒤에서 해야 한다.
파서 기준으로 세는 가장 작은 방법
import csv
with open("orders.csv", newline="", encoding="utf-8") as f:
reader = csv.reader(f)
header = next(reader)
data_count = sum(1 for _ in reader)
Python 공식 문서가 파일을 newline=""로 열도록 안내하는 이유는 개행 처리를 텍스트 계층에서 먼저 바꾸지 않고 CSV 모듈이 맡게 하기 위해서다. 여기서 얻은 data_count는 파서가 성공적으로 반환한 데이터 레코드 수다. 헤더 유무가 파일마다 다르면 무조건 next()를 호출하지 말고 스키마 계약이나 명시적 옵션으로 분리해야 한다.
reader.line_num도 이름만 보고 레코드 번호로 쓰면 안 된다. 이 값은 소스에서 읽은 물리 줄의 누적 위치에 가깝다. 오류 위치를 로그에 남길 때는 유용하지만, 처리한 데이터 건수는 별도 카운터로 올리는 편이 안전하다. 나는 로그에 physical_line과 record_index를 다른 필드로 남긴다.
운영 로그에서 따로 남길 네 숫자
- 파일 크기와 물리 줄 수: 전송 중 잘림이나 이전 파일 대비 급격한 형태 변화를 보는 보조 신호
- 파싱된 논리 레코드 수: 헤더를 포함해 파서가 반환한 전체 행 수
- 적재 대상 데이터 수: 헤더, 주석, 필터 제외 규칙을 적용한 뒤 실제로 넣을 건수
- 거절된 레코드 수: 컬럼 수, 타입, 필수값, 스키마 검증에서 탈락한 건수와 대표 오류
이 네 숫자를 한 줄의 ‘행 수’로 합치지 않으면 어디서 줄었는지 바로 보인다. 파싱 수와 적재 수가 다르면 필터나 검증 규칙을 보고, 물리 줄만 갑자기 늘었으면 멀티라인 필드 비율이나 개행 형식을 본다. 반대로 모든 숫자가 이전 파일과 같아도 내용 중복이나 식별자 충돌은 별도 문제이므로 건수 일치만 성공 조건으로 삼지는 않는다.
파서 계약도 로그와 함께 고정하는 편이 좋다. 구분자, quote 문자, 이스케이프 방식, 인코딩, 헤더 유무를 실행마다 추측하게 두면 같은 파일이 설정에 따라 다른 레코드 집합이 된다. 나는 입력 스키마 버전과 dialect 이름을 적고, 파싱 오류가 난 물리 줄 위치와 직전까지 확정된 레코드 인덱스를 함께 남긴다. 그러면 “437번째 행 오류”가 파일 줄 번호인지 데이터 순번인지 다시 해석하느라 시간을 쓰지 않는다.
부분 성공을 허용하는 배치에서는 성공 수, 거절 수, 미처리 수의 합이 입력 논리 레코드 수와 맞는지도 확인한다. 이 등식이 맞지 않으면 파서 뒤의 필터가 조용히 버렸거나 재시도 중 같은 레코드를 두 번 셌을 가능성이 있다. 원본 파일 해시와 파서 설정까지 같이 남겨야 다음 실행에서 같은 입력을 재현할 수 있다.
내가 재현 파일에 꼭 넣는 경계값
CSV 입력 문제를 고칠 때는 정상 한 줄만 남기지 않는다. 따옴표 안 줄바꿈, 쉼표가 든 문자열, 이스케이프된 따옴표, 빈 문자열과 결측 표현, CRLF, 마지막 개행이 없는 파일을 작은 fixture에 넣는다. 그리고 writer로 쓴 결과를 reader로 다시 읽어 레코드 수와 필드 값이 같은지 확인한다. 이 테스트가 있으면 성능 개선을 위해 스트리밍이나 청크 처리를 바꿔도 레코드 경계를 망가뜨렸는지 금방 잡힌다.
핵심 판단은 단순하다. 파일이 몇 줄인지 묻기 전에 그 숫자가 전송 단위인지, 파서 단위인지, 업무 데이터 단위인지부터 적는다. 카운터의 이름과 단위를 로그 스키마에 함께 넣어야 대시보드에서도 같은 뜻으로 읽힌다. CSV에서는 눈에 보이는 줄과 데이터 한 건이 같다는 가정이 가장 먼저 버려야 할 가정이었다.
'[개발 깨알 상식_Tips]' 카테고리의 다른 글
| 제한 시간 뒤에도 남는 subprocess 자식 프로세스 (0) | 2026.07.30 |
|---|---|
| SQLite에서 PRAGMA foreign_keys가 연결마다 꺼지는 문제 (1) | 2026.07.27 |
| Docker Compose에서 env_file 값이 빈 문자열로 덮이는 원인 (0) | 2026.07.18 |
| 이어 붙인 gzip에서 뒤쪽 레코드가 사라지는 원인 (0) | 2026.07.17 |
| 한글 파일명의 NFC·NFD 불일치 (0) | 2026.07.16 |