Python 실무: CSV 매출 합계에서 잘못된 행을 숨기지 않기
문제에서 시작하기
월말 합계 스크립트가 예외를 무시하고 다음 행으로 넘어간다. 실행은 성공했지만 수량이 깨진 한 행이 빠져 실제 합계보다 작다. 데이터 파이프라인에서 중요한 것은 끝까지 돌았다는 사실보다 어떤 입력을 합계에 포함했는지 설명할 수 있는 것이다. 작은 파일 전체를 실패시키는 명시적인 정책부터 구현한다.
그림. 이 예제의 정책은 잘못된 행이 있으면 전체 실패다. 부분 처리 정책은 오류 목록과 성공·실패 건수를 함께 설계해야 한다. Devin.KR이 직접 제작한 학습용 SVG 개념도입니다. 이미지를 선택하면 크게 볼 수 있습니다.
학습 목표와 선수지식
반복문, 함수, 예외를 알고 있다면 따라갈 수 있다. CSV 형식 처리와 값 검증을 분리하고 float 없이 십진 금액을 계산하며 원인이 있는 행 번호를 남긴다.
실행 환경
Python 3.12 이상, 표준 라이브러리만 사용. python3 csv-total.py. StringIO의 자체 가상 데이터를 처리하며 운영 파일이나 개인정보를 읽지 않는다.
직접 실행하기
csv-total.py에 저장한다.
import csv
import io
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
def total_sales(text):
reader = csv.DictReader(io.StringIO(text, newline=""))
if reader.fieldnames != ["item", "quantity", "price"]:
raise ValueError("header must be item,quantity,price")
total = Decimal("0")
for row in reader:
try:
if None in row or any(value is None for value in row.values()):
raise ValueError("column count")
if not row["item"].strip():
raise ValueError("blank item")
quantity = int(row["quantity"])
price = Decimal(row["price"])
if not 1 <= quantity <= 1000:
raise ValueError("quantity range")
if not price.is_finite() or not Decimal("0") <= price <= Decimal("1000000"):
raise ValueError("price range")
if price != price.quantize(Decimal("0.01")):
raise ValueError("price scale")
except (ValueError, InvalidOperation) as exc:
raise ValueError(f"invalid record ending at line {reader.line_num}") from exc
total += price * quantity
return total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
valid = 'item,quantity,price\n"pen, blue",2,0.10\nbook,1,3.25\n'
assert total_sales(valid) == Decimal("3.45")
assert total_sales('item,quantity,price\n') == Decimal("0.00")
for bad in ['item,quantity,price\npen,0,1.00\n',
'item,quantity,price\npen,1,NaN\n',
'item,quantity,price\npen,1,0.001\n',
'item,quantity,price\npen,1\n']:
try:
total_sales(bad)
except ValueError:
pass
else:
raise AssertionError("bad row accepted")
print(total_sales(valid))
print("invalid rows rejected")
예상 결과
3.45
invalid rows rejected
코드를 읽는 순서
CSV 안에는 쉼표가 포함된 상품명도 있다. split(",")는 따옴표 규칙을 이해하지 못하므로 csv 모듈에 형식 처리를 맡긴다. 실제 파일이라면 open(path, encoding="utf-8", newline="")로 열고 파일의 인코딩 계약을 확인한다.
헤더를 정확한 순서로 검사하는 것은 이번 입력 계약이다. 열 순서 변경을 허용하려면 이름 집합과 중복 여부를 따로 확인해도 된다. DictReader는 열 부족과 초과를 특별한 값으로 표시할 수 있으므로 그 상태도 정상 행으로 통과시키지 않는다.
Decimal은 원문 문자열에서 만든다. 금액은 유한한 값인지 먼저 확인한 뒤 범위를 검사한다. NaN은 파싱 가능한 십진 객체라는 점이 함정이다. 타입을 만들 수 있다는 것과 업무 금액으로 쓸 수 있다는 것은 다르다.
소수 둘째 자리보다 세밀한 금액은 거절한다. 1.000처럼 같은 수치를 추가 0으로 표현한 값은 허용하지만 0.001처럼 수치가 바뀌는 값은 허용하지 않는다. 표현 자릿수 자체를 정확히 두 자리로 제한하고 싶다면 별도 문자열 형식 계약이 필요하다.
잘못된 행 하나가 있으면 함수는 합계를 반환하지 않는다. 운영에서 오류 행을 격리하고 정상 행을 계속 처리하는 요구가 생기면 합계와 오류 목록, 성공·실패 행 수를 함께 반환해야 한다. except:continue 한 줄로 오류를 지우면 정산 완전성을 확인할 수 없다.
흔한 오류와 반례
- 숫자가 아니면 0으로 바꾸는 보정은 누락을 정상 판매처럼 만든다.
- reader.line_num은 레코드 순번이 아니라 읽은 물리적 줄 수다. 따옴표 안 줄바꿈이 있으면 하나의 레코드가 여러 줄을 차지할 수 있다.
- 이 예제는 메모리 문자열과 작은 금액·행 수를 가정한다. 실제 큰 파일은 스트리밍 입력과 전체 행 수·금액 상한, Decimal 문맥 정밀도까지 정해야 한다.
연습문제
음수 가격과 Infinity가 거절되는지 확인하라. 빈 파일과 헤더만 있는 파일의 차이를 설명하고, 전체 실패 대신 오류 행을 따로 보고하려면 반환 구조를 제안하라.
정답과 해설
음수는 범위 검사, Infinity는 is_finite 검사에서 거절된다. 빈 파일은 필수 헤더가 없어 실패하며 헤더만 있는 파일은 판매가 0건이므로 0.00이다. 부분 처리 정책은 total, accepted_count, rejected_count, errors를 가진 결과를 반환하고, 호출자는 rejected_count가 0이 아닐 때 정산 확정을 멈출지 명시적으로 결정해야 한다.
참고자료와 작성 정보
Devin.KR AI 작성 · 2026-09-12. 독립적으로 작성한 설명과 가상 데이터 예제다. 실행 결과와 경계 조건을 검증했으며, 공개 전 편집 검토 대상이다. 특정 서적의 번역·발췌·요약본이 아니다.