Python CSV 구분자 직접 지정하기 쉼표 탭 세미콜론을 같은 표로 읽기
설명용 도해: 구분자를 명시한 세 입력은 같은 두 레코드와 세 필드로 읽습니다. 실제 앱 화면이 아닙니다.
같은 두 레코드를 쉼표·탭·세미콜론으로 저장했다면, 파일마다 구분자를 직접 지정해서 같은 2×3 표가 나오는지 확인하세요. 메모에 들어 있는 쉼표나 세미콜론까지 나누면 열이 밀립니다. 큰따옴표 처리는 csv.reader에 맡기고, 사용할 구분자는 파일 제공 규칙에 맞춰 선택합니다.
이번 실습에서는 --delimiter comma, tab, semicolon만 허용합니다. 옵션을 빼면 중단하고, 추정 결과로 조용히 다시 읽지 않습니다. 가상 입력의 메모 두 개, 수량 문자열 02, 레코드 순서를 보존한 JSON을 새 파일로 만듭니다. 수량 합계 5는 내용 대조를 돕는 보조 검산입니다.
1 구분자와 인용 규칙을 먼저 정합니다
이 예제의 입력 계약은 다음과 같습니다.
- 머리글은 소문자
id,qty,memo의 세 필드이며 순서도 같아야 합니다 - 인코딩은 UTF-8입니다. 시작 BOM은 있어도 되고, 해석 오류를 무시하지 않습니다
- 구분자는 명시한 쉼표·탭·세미콜론 중 하나입니다. 확장자로 추측하지 않습니다
- 인용 문자는 큰따옴표입니다. 필드 내용에 들어가는 큰따옴표는 두 번 씁니다
- 모든 데이터 레코드는 세 필드입니다.
id는 빈 문자열이면 거부하고memo는 빈 문자열을 허용합니다 qty는 ASCII 숫자 1~6자리입니다. 앞자리 0을 허용하지만 음수·소수·공백·전각 숫자는 받지 않습니다
Python의 기본 CSV reader는 필드를 문자열 목록으로 돌려줍니다. 여기서는 원문 02를 결과에도 02로 남기고, 보조 합계를 계산할 때만 정수 2로 사용합니다. 인용 처리와 값의 자료형 변환을 분리합니다. Python 3.12 csv.reader
이 계약에서 쉼표 입력의 "연필, 파랑; 확인"은 메모 한 필드입니다. 바깥 큰따옴표는 CSV 문법이고 값에는 포함되지 않습니다. """승인"" 표시"를 읽으면 내부 큰따옴표 한 쌍을 담은 "승인" 표시가 됩니다. delimiter와 doublequote 설명

2 같은 두 레코드의 세 가지 입력을 확인합니다
ZIP의 fixtures/comma.csv는 다음 내용입니다.
id,qty,memo
K01,02,"연필, 파랑; 확인"
K02,3,"""승인"" 표시"
fixtures/tab.tsv는 필드 사이에 실제 탭 문자 U+0009가 들어 있습니다. 아래 간격을 공백 여러 개로 다시 저장하지 말고, 제공 파일로 먼저 실행하세요.
id qty memo
K01 02 "연필, 파랑; 확인"
K02 3 """승인"" 표시"
fixtures/semicolon.csv는 세미콜론으로 구분합니다. 첫 메모에도 세미콜론이 있으므로 메모 전체를 큰따옴표로 감쌌습니다.
id;qty;memo
K01;02;"연필, 파랑; 확인"
K02;3;"""승인"" 표시"
읽은 뒤 세 파일에서 같아야 하는 표는 다음과 같습니다. 머리글은 데이터 레코드 수에 포함하지 않습니다.
| id | qty 원문 | memo 값 |
|---|---|---|
| K01 | 02 | 연필, 파랑; 확인 |
| K02 | 3 | "승인" 표시 |
기대값은 데이터 레코드 2개, 레코드마다 필드 3개, 수량 검산 2+3=5입니다. 세 파일은 같은 자료를 다르게 표현한 것이므로 이어 붙여 합산하지 않습니다. 합계가 같아도 메모가 손상될 수 있으므로 표의 여섯 필드와 순서까지 대조합니다.

3 시험을 먼저 실행하고 새 결과 이름을 지정합니다
새 빈 로컬 연습 폴더에 ZIP을 직접 풀고 README.txt가 있는 폴더에서 터미널을 엽니다. 표준 라이브러리만 사용하므로 추가 패키지나 API 키는 필요하지 않습니다. Linux의 CPython 3.12.14에서 실제 실행했으며 Windows와 Microsoft Excel 앱은 시험하지 않았습니다.
python --version
python -m unittest discover -s tests -v
검수 환경의 버전 표시는 Python 3.12.14, 시험 마지막은 Ran 43 tests와 OK입니다. python3가 같은 인터프리터를 가리킨다면 명령 첫 단어만 바꿉니다. 다른 버전이나 운영체제는 전체 시험을 먼저 실행하고, 실패하면 자신의 자료를 넣기 전에 환경과 오류를 확인하세요.
다음 세 출력 이름은 아직 없어야 합니다.
python code/read_delimited.py --input fixtures/comma.csv --delimiter comma --out comma_result.json
python code/read_delimited.py --input fixtures/tab.tsv --delimiter tab --out tab_result.json
python code/read_delimited.py --input fixtures/semicolon.csv --delimiter semicolon --out semicolon_result.json
각 명령의 종료코드는 0이고 콘솔은 다음과 같습니다.
구분자 comma / 레코드 2 / 필드 3 / 수량 검산 5 / 원본 쓰기 0
구분자 tab / 레코드 2 / 필드 3 / 수량 검산 5 / 원본 쓰기 0
구분자 semicolon / 레코드 2 / 필드 3 / 수량 검산 5 / 원본 쓰기 0
텍스트 편집기로 각 JSON을 열고 expected/comma.json, expected/tab.json, expected/semicolon.json과 대조합니다. 세 결과의 공통 부분은 다음과 같습니다.
{
"header": ["id", "qty", "memo"],
"record_count": 2,
"field_count": 3,
"quantity_total": 5,
"records": [
["K01", "02", "연필, 파랑; 확인"],
["K02", "3", "\"승인\" 표시"]
]
}
실제 JSON에는 선택한 delimiter와 읽은 원본 바이트의 source_sha256도 있습니다. 세 입력의 파일 바이트는 다르므로 해시는 서로 다릅니다. 같은 표라는 판정은 records의 값과 순서가 같다는 뜻입니다. 해시가 같아야 한다는 뜻이 아닙니다.
4 잘못된 구분자를 지정하고 옵션만 고쳐 복구합니다
fixtures/wrong_delimiter.csv는 세미콜론 파일과 바이트가 같습니다. 파일이 손상된 것이 아니라, 아래 명령의 쉼표 설정이 입력 계약과 맞지 않는 반례입니다.
python code/read_delimited.py --input fixtures/wrong_delimiter.csv --delimiter comma --out recovery_result.json
머리글 id;qty;memo를 쉼표로 읽으면 한 필드가 됩니다. 기대하는 실패 신호는 다음 메시지와 종료코드 2입니다. 새 recovery_result.json은 없어야 합니다.
실행 실패: 머리글 필드 수: 기대 3, 실제 1; 구분자 계약을 확인하세요
파일 제공 규칙과 텍스트 편집기의 머리글을 확인한 뒤 구분자 옵션만 semicolon으로 바꿉니다. 메모 안 문자까지 바뀔 수 있으므로 파일 전체의 세미콜론을 쉼표로 일괄 치환하지 않습니다.
python code/read_delimited.py --input fixtures/wrong_delimiter.csv --delimiter semicolon --out recovery_result.json
복구 후에는 종료코드 0, 레코드 2·필드 3·수량 검산 5가 나와야 합니다. 결과는 expected/semicolon.json과 같습니다. 제작 시 이 실패와 복구를 같은 원본으로 실행하고 원본 SHA-256이 바뀌지 않았음을 확인했습니다.
--delimiter를 생략하거나 auto를 지정해도 종료코드 2로 멈춥니다. argparse의 필수 옵션과 선택값 제한을 사용합니다.
5 ZIP과 동일한 전체 코드
다음은 code/read_delimited.py의 전체 내용입니다. DELIMITERS에서 이름을 실제 한 문자로 바꾼 뒤 reader에 전달합니다. skipinitialspace=False로 공백을 지우지 않고, 인용문은 reader가 처리합니다. strict=True는 reader가 감지한 잘못된 CSV 입력을 중단시킵니다. 모든 CSV 방언이나 업무 규칙을 검증하는 독립 문법 검증기는 아닙니다.
"""Read a small synthetic table with an explicitly approved delimiter."""
import argparse
import csv
import hashlib
import io
import json
import os
from pathlib import Path
import re
import stat
import sys
import tempfile
DELIMITERS = {'comma': ',', 'tab': '\t', 'semicolon': ';'}
HEADER = ['id', 'qty', 'memo']
MAX_BYTES = 1_000_000
MAX_RECORDS = 10_000
def plain_path(value, directory=False):
path = Path(value)
if '..' in path.parts:
raise ValueError('.. 경로 별칭은 사용하지 마세요')
path = path.absolute()
for part in [*reversed(path.parents), path]:
info = part.lstat()
if stat.S_ISLNK(info.st_mode) or getattr(info, 'st_file_attributes', 0) & 0x400:
raise ValueError(f'링크·reparse 경로는 사용하지 마세요: {part}')
info = path.lstat()
valid = stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode)
if not valid:
raise ValueError('입력은 일반 폴더여야 합니다' if directory else '입력은 일반 파일이어야 합니다')
if not directory and info.st_nlink != 1:
raise ValueError('하드 링크 입력은 직접 복사한 일반 파일로 바꾸세요')
return path
def read_table(source, delimiter_name):
if delimiter_name not in DELIMITERS:
raise ValueError('구분자는 comma, tab, semicolon 중 명시하세요')
source = plain_path(source)
with source.open('rb') as handle:
raw = handle.read(MAX_BYTES + 1)
if len(raw) > MAX_BYTES:
raise ValueError(f'입력 바이트 한도 {MAX_BYTES} 초과')
try:
text = raw.decode('utf-8-sig', errors='strict')
except UnicodeError as error:
raise ValueError('UTF-8 해석 실패: 제공자의 인코딩을 확인하세요') from error
reader = csv.reader(io.StringIO(text, newline=''),
delimiter=DELIMITERS[delimiter_name], quotechar='"',
doublequote=True, skipinitialspace=False, strict=True)
previous_limit = csv.field_size_limit(MAX_BYTES)
try:
header = next(reader, None)
width = len(header) if header is not None else 0
if width != len(HEADER):
raise ValueError(f'머리글 필드 수: 기대 3, 실제 {width}; 구분자 계약을 확인하세요')
if header != HEADER:
raise ValueError('머리글 이름·순서는 정확히 id,qty,memo여야 합니다')
rows, quantity_total = [], 0
for number, row in enumerate(reader, 1):
if number > MAX_RECORDS:
raise ValueError(f'데이터 레코드 한도 {MAX_RECORDS} 초과')
if len(row) != len(HEADER):
raise ValueError(f'데이터 {number} 필드 수: 기대 3, 실제 {len(row)}')
if row[0] == '':
raise ValueError(f'데이터 {number}: ID가 비었습니다')
if re.fullmatch(r'[0-9]{1,6}', row[1]) is None:
raise ValueError(f'데이터 {number}: 수량은 ASCII 숫자 1~6자리여야 합니다')
quantity_total += int(row[1])
rows.append(row)
if not rows:
raise ValueError('데이터 레코드가 없습니다')
except csv.Error as error:
raise ValueError('CSV 인용 규칙 해석 실패: ' + str(error)) from error
finally:
csv.field_size_limit(previous_limit)
return {'delimiter': delimiter_name, 'header': HEADER.copy(),
'record_count': len(rows), 'field_count': len(HEADER),
'quantity_total': quantity_total, 'records': rows,
'source_sha256': hashlib.sha256(raw).hexdigest()}
def convert(source, target, delimiter_name):
source = plain_path(source)
target = Path(target)
if '..' in target.parts:
raise ValueError('.. 경로 별칭은 사용하지 마세요')
target = target.absolute()
if source == target:
raise ValueError('원본과 출력은 서로 다른 경로여야 합니다')
if os.path.lexists(target):
raise ValueError('기존 출력 보존: 새 --out 이름을 지정하세요')
if target.suffix.lower() != '.json':
raise ValueError('출력은 새 .json 파일이어야 합니다')
parent = plain_path(target.parent, directory=True)
report = read_table(source, delimiter_name)
rendered = json.dumps(report, ensure_ascii=False, indent=2) + '\n'
stage, published = None, False
try:
with tempfile.NamedTemporaryFile('w', encoding='utf-8', newline='\n',
dir=parent, prefix='.p05-',
suffix='.tmp', delete=False) as handle:
stage = Path(handle.name)
handle.write(rendered)
handle.flush()
os.fsync(handle.fileno())
try:
os.link(stage, target)
except FileExistsError as error:
raise ValueError('기존 출력 보존: 확정 중 충돌, 새 이름을 지정하세요') from error
published = True
finally:
if stage is not None:
try:
stage.unlink(missing_ok=True)
except OSError:
if not published:
raise
print('알림: 완성 출력은 저장됐지만 임시파일 정리는 실패했습니다', file=sys.stderr)
return report
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--input', required=True)
parser.add_argument('--out', required=True)
parser.add_argument('--delimiter', choices=DELIMITERS, required=True)
args = parser.parse_args(argv)
try:
report = convert(args.input, args.out, args.delimiter)
except (ValueError, OSError) as error:
print('실행 실패: ' + str(error), file=sys.stderr)
return 2
print(f"구분자 {report['delimiter']} / 레코드 {report['record_count']} / "
f"필드 {report['field_count']} / 수량 검산 {report['quantity_total']} / 원본 쓰기 0")
return 0
if __name__ == '__main__':
raise SystemExit(main())
입력은 바이트 한도를 확인한 뒤 UTF-8로 엄격하게 해석합니다. utf-8-sig는 시작 BOM을 건너뛰는 방식입니다. Python UTF-8 BOM codec
최종 JSON은 모든 데이터 검사를 통과한 뒤에만 준비합니다. 같은 출력 폴더의 임시파일에 끝까지 쓴 다음, 새 이름으로 하드 링크를 만듭니다. 확정 순간 다른 파일이 같은 이름에 생겨도 덮어쓰지 않습니다. 하드 링크를 지원하지 않는 위치에서는 중단하며 덮어쓰기 방식으로 자동 전환하지 않습니다. os.link, NamedTemporaryFile
6 다른 오류에서도 원본과 이전 결과를 보존합니다
다음은 각각 머리글·후반 인용문·인코딩·누락 파일의 의도한 실패 명령입니다.
python code/read_delimited.py --input fixtures/failures/bad_header.csv --delimiter comma --out rejected_header.json
python code/read_delimited.py --input fixtures/failures/late_unclosed.csv --delimiter comma --out rejected_quote.json
python code/read_delimited.py --input fixtures/failures/invalid_utf8.csv --delimiter comma --out rejected_encoding.json
python code/read_delimited.py --input fixtures/missing.csv --delimiter comma --out rejected_missing.json
모두 종료코드 2이며 새 최종 JSON이 없어야 합니다. 앞의 정상 레코드를 읽었더라도 후반 인용문이 닫히지 않으면 부분 표를 완성 결과로 저장하지 않습니다.
| 실패 신호 | 확인할 내용 | 복구 후 판정 |
|---|---|---|
| 머리글 이름·순서 또는 필드 수 오류 | 제공 규칙, 구분자, 정확한 세 열 | 정정 사본의 모든 레코드가 세 필드이고 값·순서가 맞음 |
| CSV 인용 규칙 해석 실패 | 큰따옴표의 시작·닫힘과 내부 두 번 쓰기 | 새 사본이 끝까지 읽히고 기대 레코드 수와 일치 |
| UTF-8 해석 실패 | 제공자가 지정한 인코딩 | 확인된 UTF-8 사본에서 문자가 원자료와 일치 |
| 파일 경로 오류 | 작업 폴더와 실제 파일명 | 존재하는 일반 입력 파일로 실행됨 |
| 기존 출력 보존 | 같은 이름의 결과나 링크 존재 | 이전 결과는 그대로이고 새 이름에 완성 JSON 저장 |
원본 근거 없이 열을 버리거나 빠진 값을 채우지 않습니다. 인코딩 오류도 깨진 문자를 대체 저장해서 숨기지 않습니다.
첫 정상 실행 뒤 같은 이름으로 다시 실행하면 기존 출력 오류가 나오는 것이 정상입니다. 이전 결과를 지우지 말고 새 이름을 씁니다.
python code/read_delimited.py --input fixtures/comma.csv --delimiter comma --out comma_result.json
python code/read_delimited.py --input fixtures/comma.csv --delimiter comma --out comma_second.json
첫 명령은 종료코드 2이며 기존 결과를 보존합니다. 두 번째 명령은 새 이름일 때 종료코드 0이고 expected/comma.json과 같습니다.
7 확인한 범위와 실습의 한계를 구분합니다
제공 자동시험은 43개이며 별도 CLI fixture 검사는 21개 시나리오입니다. 세 구분자의 독립 기대값, 인용 내용·탭·공백 보존, 필수 옵션, 잘못된 머리글·열 수·인용문·UTF-8, 없는 파일, 원본·기존 출력·재실행·링크 거부를 확인했습니다. 저장 실패와 확정 시 충돌도 시험에서 만들어 새 최종 출력과 임시파일 상태를 대조했습니다. 모든 제공 입력은 실행 전후 SHA-256이 같았습니다.
python tests/run_fixture_checks.py
python tests/verify_checksums.py
첫 명령의 all_passed와 all_source_bytes_unchanged는 true여야 합니다. 두 번째 명령은 배포 파일을 체크섬 목록과 비교합니다. Python unittest
이 예제는 입력 1,000,000바이트와 데이터 10,000레코드까지를 실습 한도로 둡니다. 빈 파일, 머리글만 있는 파일, 빈 레코드는 거부합니다. 파일 전체와 결과를 메모리에 보관하므로 대용량 성능을 보장하지 않습니다. ID 중복, 수량의 업무상 진위, 파일 여러 개의 결합은 검사하지 않습니다.
원본과 출력이 같은 경로이면 거부합니다. 입력·상위 경로의 심볼릭 링크와 reparse 표식, .. 별칭, 하드 링크 입력도 거부합니다. 출력 상위 폴더는 미리 있어야 하며, 기존 출력에는 끊어진 심볼릭 링크도 포함합니다. 이런 경로 확인에는 Path.lstat를 사용합니다.
다른 프로그램이 파일을 동시에 바꾸지 않는 격리된 로컬 연습 폴더에서 한 번에 한 작업만 실행하세요. 경로 검사는 보안 격리기나 공격자의 경로 경쟁을 막는 기능이 아닙니다. 동기화·공유 폴더의 동시 변경을 보호하지 않습니다. source_sha256은 읽은 바이트의 지문이며 외부 변경을 계속 감시하는 값이 아닙니다.
강제 종료나 전원 장애는 시험하지 않았습니다. 임시파일이 남으면 완성 결과로 사용하지 마세요. 확정 후 임시파일 정리만 실패하면 완성 결과는 남고 경고가 나옵니다. 저장장치 복구를 보장하지 않습니다. 결과는 JSON 문자열로 확인하며, Windows·Excel의 가져오기나 화면 표시가 이 검수에 포함됐다고 해석하지 않습니다.
8 자주 묻는 질문
CSV 확장자면 comma를 지정하면 되나요
제공 규칙을 먼저 확인하세요. 이 실습의 semicolon.csv도 확장자는 CSV입니다. 파일명만으로 구분자를 결정하지 않습니다.
Sniffer로 자동 결정하면 더 편하지 않나요
Sniffer.sniff()는 주어진 표본을 분석해 방언을 반환합니다. 그 후보를 제공자가 승인한 형식 계약과 같다고 취급하지 않는 것이 이 실습의 정책입니다. 후보는 참고 정보로만 보고, 제공 규칙과 원문을 확인한 사람이 명시 옵션을 선택합니다. 코드에는 Sniffer나 추정 fallback이 없습니다. Sniffer.sniff 공식 설명
수량 합계가 5면 정상인가요
두 레코드·각 세 필드·메모 내용·순서가 모두 같아야 합니다. 합계만으로 메모 손상이나 레코드 누락을 판정하지 않습니다.
메모의 쉼표를 지워도 되나요
메모 내용이 달라집니다. 이 예제에서는 구분자와 인용 규칙을 맞추어 원래 내용을 읽습니다. 문자열을 정리해야 한다면 그 작업의 별도 규칙과 검산을 먼저 정하세요.
실습 파일과 이어서 볼 글
P05_Practice_Explicit_Delimiters.zip 다운로드
ZIP에는 본문과 같은 코드, 가상 입력, 독립 기대 JSON, 43개 unittest, CLI 대조 검사, README, 실제 검수 환경과 SHA-256 목록이 들어 있습니다. 새 결과 이름은 ZIP에 미리 만들어 두지 않았으므로 압축을 푼 뒤 안내한 명령으로 직접 생성할 수 있습니다.
쉼표 파일에서 열 수가 틀린 위치를 찾으려면 CSV 열 수와 원문 위치 검사를, 큰 파일을 일정한 레코드 수로 나누려면 CSV 분할과 원본 ID·내용 검산을 이어 볼 수 있습니다.
공식 문서 확인일은 2026-10-06 UTC입니다. 열람한 Python 3.12 문서의 현재 표제는 3.12.15이며, 실제 실행한 인터프리터는 CPython 3.12.14입니다. 문서 열람과 로컬 실행 결과를 구분했습니다.