Devin.KR

CSV 행과 열 해석하기

75분 안팎

학습 목표

쉼표·한글·빈 값의 처리 규칙을 정합니다.

개념

표를 문자열 분리로 읽으면 생기는 문제

독서 목록을 표 계산 프로그램에서 내보내면 각 행이 한 기록이고 각 열이 ID·제목·쪽수·금액·완료를 뜻하는 CSV를 받을 수 있습니다. 제목에 쉼표가 없을 때만 split(",")로 읽어 보면 잘 되는 것처럼 보입니다. 그러나 책 제목이 책, 산책이면 쉼표가 제목 일부인지 열 경계인지 결정해야 합니다. 따옴표로 감싼 필드 안의 쉼표를 보존하는 CSV 파서를 쓰는 이유입니다.

CSV는 문자로 표현된 표이고 열 이름이나 업무 자료형을 스스로 보장하지 않습니다. 이번 계약의 열 이름은 id,title,pages,cost,completed이며 이 순서로 받습니다. pages의 8과 cost의 1200도 기본 DictReader로 읽으면 문자열입니다. 파서가 표의 경계를 복원한 뒤 별도의 입력 검사기가 숫자·필수값·중복 정책을 검사합니다. CSV를 성공적으로 열었다는 사실만으로 유효한 독서 목록이라고 판단하지 않습니다.

필드 안의 큰따옴표는 CSV에서 두 개의 큰따옴표로 표현합니다. 큰따옴표가 있는 제목과 쉼표가 있는 제목을 손으로 재조립하면 한 사례를 고치다가 다른 사례를 깨뜨리기 쉽습니다. 입력은 csv.DictReader, 출력은 csv.DictWriter에 맡기고 제목을 직접 escape하지 않습니다. 모든 값에 무작정 따옴표를 제거하는 replace를 적용하면 제목 자체의 큰따옴표까지 삭제되어 원래 데이터가 손실됩니다.

헤더와 행의 구조부터 검사합니다

DictReader는 첫 레코드를 열 이름으로 삼고 각 후속 레코드를 딕셔너리로 반환합니다. row["title"]처럼 의미 있는 이름으로 접근하면 열 위치 숫자를 외울 필요가 없습니다. 이번 미션은 헤더의 내용과 순서를 정확하게 제한합니다. 동일한 이름이 두 번 들어가는 헤더도 거부합니다. 다른 순서의 헤더를 허용하려면 순서와 이름 검사를 따로 설계해야 하며, 지금 구현에 우연히 들어온 동작을 정식 기능으로 안내하지 않습니다.

값이 열 이름보다 많으면 DictReader 결과의 None 키에 남는 값 목록이 들어갑니다. 값이 부족하면 해당 열 값이 None이 될 수 있습니다. 빈 칸인 문자열 ""과 열 자체가 빠진 None은 다른 상태입니다. 먼저 열 개수를 검사하고, 그 다음 필드값을 검사하면 열 부족을 제목 공백 오류로 잘못 보고하지 않습니다. 예상하지 못한 열을 조용히 버리는 방식은 파일을 만든 사람에게 수정 단서를 주지 못합니다.

헤더만 있는 파일은 데이터 0건을 나타낼 수 있습니다. 내용 자체가 없는 파일은 헤더도 확인할 수 없으므로 이 계약에서는 오류입니다. 완전히 빈 줄은 기본 CSV reader가 건너뛰는 반면 쉼표가 나열된 행은 여러 빈 필드를 가진 데이터 행입니다. 표의 빈 줄을 독서 기록 한 건이라고 가정하지 않습니다. 헤더만 있는 CSV를 전체 교체 가져오기에 넣으면 빈 저장소가 되므로 빈 결과를 처리하는 정책도 사용자에게 알려야 합니다.

빈 값과 0을 다른 의미로 다룹니다

금액이 0이면 무료 도서라는 명시적 값입니다. 금액이 빈 칸이면 금액을 입력하지 않았다는 뜻일 수 있습니다. 이번 프로젝트는 빈 금액을 오류로 거부하고 0은 받아들입니다. 빈 값을 int(row["cost"] or 0)으로 바꾸면 불완전한 자료가 무료 도서처럼 보입니다. 관대한 처리가 필요한 업무도 있지만 그때는 기본값을 넣었다는 사실과 근거를 별도로 기록해야 합니다.

이 레슨의 브라우저 과제는 CSV 구조와 빈 값 구별에 집중합니다. 제목은 양끝 공백을 정리한 뒤 비어 있으면 ERR_TITLE을 반환합니다. cost는 양끝 공백을 정리하되 문자열로 유지하고 빈 값이면 ERR_COST_EMPTY를 반환합니다. 숫자 검증과 JSON 정수 변환은 다음 레슨에서 연결합니다. 아직 숫자 검사를 하지 않는다는 범위를 이해하면 과제 결과를 완성된 업무 검증기라고 오해하지 않습니다.

과제의 성공 출력은 [[제목, 금액문자열], ...] 형태의 JSON 배열입니다. 쉼표와 줄바꿈이 있는 제목을 손상 없이 출력하기 위한 표현이며 저장 파일을 만드는 동작은 아닙니다. 한글은 사람이 읽기 쉽게 ensure_ascii=False로 출력합니다. 여러 행 중 오류가 하나라도 있으면 첫 오류 한 줄만 출력합니다. 검증 완료 전까지 성공 결과를 print하지 않아 부분 목록이 정상 결과처럼 보이는 상황을 피합니다.

파일 경계와 레코드 경계

CSV 파일을 열 때 encoding="utf-8-sig"와 newline=""을 사용합니다. utf-8-sig는 시작 부분의 UTF-8 BOM이 있으면 제거하고 없으면 UTF-8로 읽습니다. 인코딩을 자동 추측하는 옵션은 아니므로 다른 문자셋의 파일까지 읽는다는 의미는 아닙니다. newline=""은 파일 객체의 줄바꿈 변환을 제한해 CSV 파서가 필드 안의 줄바꿈과 레코드 끝을 처리하도록 합니다.

따옴표 안의 제목이 두 줄이면 CSV 한 레코드가 파일의 두 물리적 줄을 차지합니다. enumerate(reader,start=2)는 레코드 순번에 가까운 수이고 물리적 줄 번호가 아닐 수 있습니다. 이번 오류 메시지의 ROW N은 reader.line_num으로 얻은 해당 레코드의 마지막 물리적 줄 번호입니다. 사용자에게 시작 줄 번호라고 설명하지 않습니다. 제목의 줄바꿈을 허용하는 순간 행 번호의 의미도 계약에 포함해야 합니다.

브라우저에서는 파일 경로 대신 sys.stdin.read()로 전체 문자열을 받습니다. io.StringIO(text,newline="")는 그 문자열을 읽을 수 있는 텍스트 스트림으로 감쌉니다. 이미 문자로 전달된 표준 입력에서 BOM은 text.removeprefix("\ufeff")로 한 번 제거합니다. 로컬 파일 인코딩과 표준 입력 문자열의 처리를 구분하면 문자열에 encoding 옵션을 붙여야 한다는 혼동을 피할 수 있습니다.

오류 메시지를 입력 위치와 연결합니다

KeyError: title은 제목 값이 틀렸다는 뜻보다 요청한 열 이름이 딕셔너리에 없다는 단서입니다. 헤더를 repr로 출력해 철자·대소문자·숨은 BOM·뒤 공백을 살펴봅니다. ROW 3: ERR_COLUMNS는 그 레코드의 열 개수가 계약과 어긋났다는 뜻입니다. 제목의 쉼표를 따옴표 없이 넣은 경우 뒤쪽 값이 한 칸씩 밀릴 수 있으므로 값 변환을 시도하기 전에 원본 레코드를 확인합니다.

strict=True는 닫히지 않은 따옴표 같은 일부 CSV 문법 문제를 csv.Error로 보고하도록 돕습니다. 업무 규칙까지 검사해 주는 옵션은 아닙니다. CSV_SYNTAX와 ERR_COST_EMPTY를 나누어 표시하면 작성자가 따옴표 구조를 고칠지 값을 채울지 판단할 수 있습니다. 모든 오류를 CSV가 잘못되었습니다 한 문장으로 바꾸면 학습자도 어디를 고쳐야 할지 알기 어렵습니다.

따라하기에서는 따옴표가 있는 제목을 파싱하고, 빈 금액과 0을 비교하고, 열 부족·초과의 표현을 관찰합니다. 이어지는 과제에서는 이 관찰을 헤더 → 구조 → 필수값의 검사 순서로 묶습니다. 정상 한 건만 통과시킨 뒤 끝내지 말고 제목에 큰따옴표가 있는 행, 빈 목록, 빈 제목, 마지막 행 오류를 실행합니다. 실패해도 앞에서 읽은 행을 성공 출력하지 않는지를 함께 확인합니다.

따라하기

제목 속 쉼표 보존

같은 행을 split과 csv.reader로 읽어 차이를 확인합니다.

import csv, io
line = 'b01,"책, 산책",8,0,1\n'
print(len(line.strip().split(",")))
row = next(csv.reader(io.StringIO(line)))
print(len(row))
print(row[1])

실행 결과

6
5
책, 산책

빈 금액과 무료 도서

빈 칸을 자동으로 0으로 바꾸지 않고 각각 판단합니다.

import csv, io
text = "title,cost\n무료 책,0\n금액 미입력,\n"
for row in csv.DictReader(io.StringIO(text)):
    cost = row["cost"].strip()
    print(row["title"], repr(cost), "ERR_COST_EMPTY" if not cost else "입력됨")

실행 결과

무료 책 '0' 입력됨
금액 미입력 '' ERR_COST_EMPTY

빠진 열과 남는 열

None 값과 None 키가 각각 어느 상황에 생기는지 확인합니다.

import csv, io
for text in ("id,title,cost\nb01,책\n", "id,title,cost\nb01,책,0,extra\n"):
    row = next(csv.DictReader(io.StringIO(text)))
    print(row)

실행 결과

{'id': 'b01', 'title': '책', 'cost': None}
{'id': 'b01', 'title': '책', 'cost': '0', None: ['extra']}

다중 줄 레코드 번호

line_num은 레코드의 끝 물리적 줄 번호입니다.

import csv, io
reader = csv.DictReader(io.StringIO('id,title\nb01,"첫 줄\n둘째 줄"\nb02,짧은 책\n', newline=""))
for row in reader:
    print(reader.line_num, repr(row["title"]))

실행 결과

3 '첫 줄\n둘째 줄'
4 '짧은 책'

확인 문제

실습

표준 입력 CSV를 읽습니다. 헤더는 id,title,pages,cost,completed 순서이며 BOM을 허용합니다. 제목·금액은 strip 후 비어 있으면 오류입니다. 이 과제는 숫자 검사 없이 금액을 문자열로 유지합니다. 정상은 [[제목,금액문자열],...]를 JSON 한 줄로 출력합니다. 첫 오류만 CSV_HEADER, ROW N: ERR_COLUMNS, ROW N: ERR_TITLE, ROW N: ERR_COST_EMPTY, ROW N: CSV_SYNTAX 중 하나로 출력하며 부분 결과는 출력하지 않습니다. N은 오류 레코드가 끝나는 물리적 줄 번호입니다. 헤더만 있으면 []입니다.

모범 답안
import csv, io, sys, json
text = sys.stdin.read().removeprefix("\ufeff")
reader = csv.DictReader(io.StringIO(text, newline=""), strict=True)
fields = ["id", "title", "pages", "cost", "completed"]
result = []
error = None
if reader.fieldnames != fields:
    error = "CSV_HEADER"
else:
    try:
        for row in reader:
            if None in row or any(v is None for v in row.values()):
                error = f"ROW {reader.line_num}: ERR_COLUMNS"
                break
            title, cost = row["title"].strip(), row["cost"].strip()
            if not title:
                error = f"ROW {reader.line_num}: ERR_TITLE"
                break
            if not cost:
                error = f"ROW {reader.line_num}: ERR_COST_EMPTY"
                break
            result.append([title, cost])
    except csv.Error:
        error = f"ROW {reader.line_num}: CSV_SYNTAX"
print(error if error else json.dumps(result, ensure_ascii=False))

더 읽기

면접 질문

  • 다른 사람이 프로그램을 실행하도록 준비한 내용을 설명합니다.