CSV의 빈칸 0 미정 값을 Python JSON에 구분해 남기기
가상 CSV 입력의 의미를 요약한 설명용 도해입니다. 실제 앱 실행 화면이 아니며 빈칸과 미정을 실제 수량 0으로 대체하지 않습니다.
CSV 수량 칸이 비었거나 N/A, 미정이라면 실제 수량 0으로 바꾸지 마세요. 원래 표기와 결측 사유를 따로 남긴 JSON을 만들면, 숫자가 없는 이유를 형식 변환 뒤에도 확인할 수 있습니다. 이번 가상 다섯 행의 기대값은 missing 3·zero 1·value 1, 확인된 수량의 부분 소계 5, 수치 미확정 3입니다.
이 실습에서 N/A를 받는 것은 제공자가 “해당 없음”으로 정한 고정 입력 계약 때문입니다. 다른 CSV에서도 N/A가 언제나 결측이라는 뜻은 아닙니다. 로컬 파일만 읽고 새 JSON 하나를 만드는 표준 라이브러리 예제이며, 외부 서비스나 실제 재고·주문 자료를 사용하지 않습니다.
1 결측 표기 세 개의 뜻부터 정합니다
이 예제의 계약 이름은 fixed-v1입니다. 코드의 MISSING_TOKENS에 정확히 다음 세 문자열이 들어 있습니다. 사용자가 실행 옵션으로 목록을 추가하는 설정 도구는 아닙니다.
| CSV에서 읽은 qty | state | numeric_qty | missing_reason | 이 계약의 의미 |
|---|---|---|---|---|
빈 문자열 "" |
missing | null | empty | 수량 값이 비어 있음 |
N/A |
missing | null | not_applicable | 제공자가 해당 없음으로 지정 |
미정 |
missing | null | pending | 아직 수량을 정하지 않음 |
0 |
zero | 0 | null | 명시된 실제 정수 0 |
5 |
value | 5 | null | 명시된 실제 정수 5 |

not_applicable과 pending은 업무상 같은 상태가 아닙니다. 해당 없음에 나중에 숫자를 반드시 채워야 한다고 판단하지도 않습니다. 이번 수치 소계에서는 둘 다 숫자가 없는 행으로 세지만 원래 사유를 서로 바꾸지 않습니다. unresolved_count는 숫자 수량이 없는 missing·rejected 행 수입니다. 모두 입력 실수라는 뜻이나 모두 처리 대기라는 뜻으로 읽지 마세요.
원표기 raw_qty, 분류 state, 숫자 numeric_qty, 결측 사유 missing_reason, 거부 사유 rejection_reason을 나눕니다. 숫자가 없다는 null만 저장하면 빈칸·해당 없음·미정을 다시 구분할 수 없으므로 사유 필드가 필요합니다.
Python의 기본 csv.reader는 필드를 문자열 목록으로 돌려줍니다. 이 코드는 자동 float 변환 옵션을 사용하지 않고 먼저 계약을 검사합니다. Python None은 JSON의 null, 정수는 JSON 숫자로 저장됩니다. "0"이라는 원표기와 숫자 0을 모두 남기는 이유입니다. Python JSON 자료형 변환
2 다섯 행과 독립 기대값을 확인합니다
ZIP을 새 빈 로컬 연습 폴더에 풀고 fixtures/main.csv를 텍스트 편집기로 엽니다. 입력은 모두 합성 자료입니다.
id,qty
M01,
M02,0
M03,N/A
M04,미정
M05,5
M01,은 두 번째 필드가 빈 문자열인 두 열 레코드입니다. M01처럼 두 번째 열이 아예 없거나 줄 전체가 비어 있는 경우는 구조 오류입니다. 공백 한 칸도 빈 문자열과 다르므로 missing으로 받지 않습니다.
기대 분류는 순서대로 missing·zero·missing·missing·value입니다. 수치가 있는 두 값만 더하면 0+5=5입니다. 나머지 세 행의 수량을 0으로 가정하지 않았으므로 5는 다섯 행 전체의 확정 합계가 아닙니다.
입력 계약은 다음과 같습니다.
- UTF-8 또는 시작 UTF-8 BOM, 쉼표 CSV, 머리글은 정확히
id,qty순서 - 모든 데이터 레코드가 정확히 두 열이며 1~10,000레코드, 파일은 1,000,000바이트 이하
- ID는 ASCII 영문으로 시작해 영문·숫자·밑줄·하이픈만 사용하고 1~32자, 대소문자 구분, 중복 금지
- 숫자 수량은 문자열 전체가
0또는[1-9][0-9]{0,5}인 ASCII 정수, 범위 0~999999 - 결측은 빈 문자열·
N/A·미정의 정확한 세 표기만 허용 - 공백 제거, 대소문자 변환, Unicode 정규화, 소수 절삭, 잘못된 값의 0 대체 없음
- CSV 파서 필드 한도는 131072자이며 한도를 넘으면 전체 중단
따라서 00, 01, +5, 1e2, 전각 숫자 1은 숫자 수량으로 받지 않습니다. n/a, N/A , 미 정, NULL도 승인된 결측 표기가 아닙니다. 숫자 문법은 이 실습의 계약이며 모든 수량 업무의 일반 규칙이 아닙니다.
re.fullmatch 문서처럼 문자열 전체의 일치를 검사합니다. 허용된 최대 여섯 자리 문자열에만 int()를 적용하므로 아주 큰 숫자를 먼저 변환하거나 앞부분만 잘라 받지 않습니다.
3 시험을 실행한 뒤 새 JSON을 만듭니다
README.txt가 있는 압축 해제 루트에서 터미널을 엽니다. 추가 패키지 설치나 API 키가 필요하지 않습니다. 코드의 요구 버전은 Python 3.12 이상이고, 실제 검수는 Linux CPython 3.12.14에서 했습니다. Windows·macOS와 Microsoft Excel 앱 실행은 시험하지 않았습니다.
python --version
python -m unittest discover -s tests -v
python tests/run_fixture_checks.py
python tests/verify_checksums.py
자동시험의 마지막은 Ran 46 tests와 OK입니다. fixture 검사는 cli_checks=21, fixture_count=16, all_passed=true, all_source_bytes_unchanged=true를 반환합니다. 마지막 명령은 배포 파일의 SHA-256을 대조합니다. python3가 같은 인터프리터를 가리킨다면 명령의 첫 단어만 바꿀 수 있습니다. 자신의 환경에서 시험이 실패하면 업무 파일로 넘어가지 말고 버전과 오류부터 확인하세요. unittest discover 안내
다음 결과 이름은 아직 없어야 합니다. 출력 부모 폴더는 이미 있어야 하며, 여기서는 현재 폴더에 만듭니다.
python code/missing_states.py --input fixtures/main.csv --out main_result.json
실제 확인한 콘솔 내용은 다음과 같으며 종료코드는 0입니다.
입력 5 / missing 3 / zero 1 / value 1 / rejected 0 / 확인된 수량 부분 소계 5 / 수치 미확정 3
main_result.json을 열고 expected/main.json의 정답과 대조합니다. 아래는 전체 보고서 중 집계 필드만 뽑은 내용입니다.
{
"input_rows": 5,
"missing_count": 3,
"zero_count": 1,
"value_count": 1,
"rejected_count": 0,
"missing_reason_counts": {"empty": 1, "not_applicable": 1, "pending": 1},
"known_numeric_subtotal": 5,
"subtotal_scope": "approved numeric zero/value rows only",
"subtotal_is_partial": true,
"unresolved_count": 3,
"all_rows_in_contract": true
}
행 목록의 numeric_qty는 순서대로 null, 0, null, null, 5이고 숫자 0과 5는 따옴표 없는 JSON 숫자입니다. M03의 raw_qty="N/A"와 missing_reason="not_applicable", M04의 raw_qty="미정"와 missing_reason="pending"도 각각 남아야 합니다.
all_rows_in_contract=true와 종료 0은 승인된 상태 표현을 보존한 변환이 끝났다는 뜻입니다. 모든 행에 확정 수량이 있다는 뜻이 아닙니다. 집계할 때는 부분 소계 표시와 수치 미확정 수를 함께 읽으세요.
4 ZIP과 동일한 전체 코드
아래는 code/missing_states.py 전체입니다. 직접 저장할 때는 일부 함수만 복사하지 말고 같은 파일 이름으로 저장하세요.
"""Preserve approved missing reasons separately from zero in a small CSV."""
import argparse
import csv
import hashlib
import io
import json
import os
from pathlib import Path
import re
import stat
import sys
import tempfile
HEADER = ['id', 'qty']
MISSING_TOKENS = {'': 'empty', 'N/A': 'not_applicable', '미정': 'pending'}
ID = re.compile(r'[A-Za-z][A-Za-z0-9_-]{0,31}')
INTEGER = re.compile(r'(?:0|[1-9][0-9]{0,5})')
MAX_BYTES, MAX_ROWS, MAX_FIELD = 1_000_000, 10_000, 131_072
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('링크·reparse 경로는 사용하지 않습니다')
info = path.stat()
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_source(value):
path = plain_path(value)
before = path.stat()
flags = os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_NONBLOCK', 0)
with os.fdopen(os.open(path, flags), 'rb') as handle:
opened = os.fstat(handle.fileno())
if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino):
raise ValueError('읽기 전 입력 파일이 바뀌었습니다')
if not stat.S_ISREG(opened.st_mode) or opened.st_nlink != 1:
raise ValueError('입력 파일 유형 또는 링크 수를 확인하세요')
raw = handle.read(MAX_BYTES + 1)
after = os.fstat(handle.fileno())
if (opened.st_size, opened.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
raise ValueError('읽는 중 입력 파일이 바뀌었습니다')
if len(raw) > MAX_BYTES:
raise ValueError('입력 한도 1,000,000바이트 초과')
return raw
def quantity(raw):
result = {'state': 'rejected', 'numeric_qty': None,
'missing_reason': None, 'rejection_reason': None}
if raw in MISSING_TOKENS:
result.update(state='missing', missing_reason=MISSING_TOKENS[raw])
elif INTEGER.fullmatch(raw):
value = int(raw)
result.update(state='zero' if value == 0 else 'value', numeric_qty=value)
else:
if any(char.isspace() for char in raw):
reason = 'whitespace'
elif re.fullmatch(r'-[0-9]+(?:\.[0-9]+)?', raw):
reason = 'negative_not_allowed'
elif re.fullmatch(r'[0-9]+\.[0-9]+', raw):
reason = 'decimal_not_allowed'
elif re.fullmatch(r'0[0-9]+', raw):
reason = 'leading_zero'
elif re.fullmatch(r'[1-9][0-9]*', raw):
reason = 'integer_out_of_range'
else:
reason = 'unapproved_token'
result['rejection_reason'] = reason
return result
def inspect(source):
raw = read_source(source)
try:
text = raw.decode('utf-8-sig', errors='strict')
except UnicodeDecodeError as error:
raise ValueError('UTF-8 해석 실패: 제공자의 인코딩을 확인하세요') from error
rows, seen = [], set()
previous_limit = csv.field_size_limit(MAX_FIELD)
try:
reader = csv.reader(io.StringIO(text, newline=''), delimiter=',',
quotechar='"', doublequote=True, skipinitialspace=False,
strict=True)
if next(reader, None) != HEADER:
raise ValueError('머리글은 정확히 id,qty 순서여야 합니다')
for record, fields in enumerate(reader, 2):
if record - 1 > MAX_ROWS:
raise ValueError('데이터 레코드 한도 10,000행 초과')
if len(fields) != 2:
raise ValueError(f'레코드 {record}: 정확히 두 열이 필요합니다')
ident, raw_qty = fields
if not ID.fullmatch(ident) or ident in seen:
raise ValueError(f'레코드 {record}: ID 형식 또는 중복을 확인하세요')
seen.add(ident)
rows.append({'source_record': record, 'id': ident, 'raw_qty': raw_qty,
**quantity(raw_qty)})
except csv.Error as error:
raise ValueError('CSV 구문 또는 필드 길이 오류: ' + str(error)) from error
finally:
csv.field_size_limit(previous_limit)
if not rows:
raise ValueError('데이터 레코드가 1개 이상 필요합니다')
counts = {state: sum(row['state'] == state for row in rows)
for state in ('missing', 'zero', 'value', 'rejected')}
unresolved = counts['missing'] + counts['rejected']
return {
'contract_version': 'fixed-v1', 'integer_grammar': '0|[1-9][0-9]{0,5}',
'missing_tokens': MISSING_TOKENS.copy(), 'input_rows': len(rows),
**{state + '_count': count for state, count in counts.items()},
'missing_reason_counts': {reason: sum(row['missing_reason'] == reason for row in rows)
for reason in MISSING_TOKENS.values()},
'known_numeric_subtotal': sum(row['numeric_qty'] for row in rows
if row['state'] in ('zero', 'value')),
'subtotal_scope': 'approved numeric zero/value rows only',
'subtotal_is_partial': unresolved > 0, 'unresolved_count': unresolved,
'all_rows_in_contract': counts['rejected'] == 0,
'source_changes_written': 0, 'rows': rows,
'source_sha256': hashlib.sha256(raw).hexdigest(),
}
def convert(source, target):
if sys.version_info < (3, 12):
raise ValueError('Python 3.12 이상이 필요합니다')
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 = inspect(source)
rendered = json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False) + '\n'
if hashlib.sha256(read_source(source)).hexdigest() != report['source_sha256']:
raise ValueError('입력이 변경됐습니다: 동시 수정 없는 사본에서 다시 실행하세요')
stage, published = None, False
try:
with tempfile.NamedTemporaryFile('w', encoding='utf-8', newline='\n',
dir=parent, prefix='.p12-', suffix='.tmp', delete=False) as handle:
stage = Path(handle.name)
handle.write(rendered)
handle.flush()
os.fsync(handle.fileno())
if json.loads(stage.read_text(encoding='utf-8')) != report:
raise ValueError('임시 JSON 재읽기 결과가 다릅니다')
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 as error:
if not published:
raise
print('경고: 완성 출력은 저장됐지만 임시 파일 정리에 실패했습니다: '
+ str(error), file=sys.stderr)
return report
def main(argv=None):
parser = argparse.ArgumentParser(description='고정 CSV 결측 계약과 실제 0을 JSON에 보존')
parser.add_argument('--input', required=True)
parser.add_argument('--out', required=True)
args = parser.parse_args(argv)
try:
report = convert(args.input, args.out)
except (ValueError, OSError) as error:
print('실행 실패: ' + str(error), file=sys.stderr)
return 2
partial = '부분 소계' if report['subtotal_is_partial'] else '모든 행 수치 소계'
print(f"입력 {report['input_rows']} / missing {report['missing_count']} / "
f"zero {report['zero_count']} / value {report['value_count']} / "
f"rejected {report['rejected_count']} / 확인된 수량 {partial} "
f"{report['known_numeric_subtotal']} / 수치 미확정 {report['unresolved_count']}")
return 0 if report['all_rows_in_contract'] else 1
if __name__ == '__main__':
raise SystemExit(main())
quantity()는 먼저 고정 결측 토큰을 정확히 비교하고, 그다음 허용 정수만 변환합니다. 승인 밖 표기는 숫자를 만들지 않고 rejected와 거부 이유를 남깁니다. 이유가 겹치면 위쪽 검사가 먼저 적용됩니다. 예를 들어 공백이 붙은 -1은 whitespace입니다. 한 이유가 문자열의 모든 문제를 열거한다는 뜻은 아닙니다.
raw_qty는 CSV 파서가 해석한 필드 문자열입니다. CSV의 구분용 따옴표까지 복사한 바이트 원문은 아닙니다. B04,""의 빈 인용 필드와 M01,의 비인용 빈 필드는 모두 빈 문자열로 읽힙니다. 원래 파일 바이트는 변경하지 않고 source_sha256으로 읽은 바이트의 지문을 남깁니다.
source_record는 머리글을 1로 센 CSV 논리 레코드 번호입니다. 인용 필드 안에 줄바꿈이 있으면 편집기의 물리 줄 번호와 다를 수 있습니다. strict=True는 파서가 감지한 잘못된 CSV 구문을 중단시키며, 열 수·ID·행 수는 별도 검사입니다. 모든 CSV 규격을 검증하는 독립 문법 검증기는 아닙니다. CSV 형식 옵션
시작 BOM 처리는 utf-8-sig codec을 사용합니다. UTF-8 해석 실패는 대체 문자나 무시 옵션으로 숨기지 않습니다.
5 승인되지 않은 표기는 거부 행으로 남깁니다
다음은 fixtures/rejected.csv의 첫 다섯 데이터 행입니다. 공백 한 칸과 후행 공백이 눈에 잘 보이지 않으므로 제공 파일을 그대로 사용하세요.
id,qty
X01,5
X02,
X03,n/a
X04,N/A
X05,미 정
전체 파일에는 이 밖에 NULL, -1, 1.5, 00, +5, 1e2, 1, 1000000, ?가 있어 총 14행입니다.
python code/missing_states.py --input fixtures/rejected.csv --out rejected_result.json
기대값은 missing 0·zero 0·value 1·rejected 13, 부분 소계 5, 수치 미확정 13, 종료코드 1입니다. 구조는 유효하므로 마지막 행까지 검사한 완성 보고서가 생깁니다. 거부한 13행은 numeric_qty=null이고 missing_reason=null이며, 원표기와 rejection_reason을 보존합니다.
| 원표기 | rejection_reason | 먼저 확인할 것 |
|---|---|---|
공백 한 칸, N/A , 미 정 |
whitespace | 공백도 실제 값인지 제공 규칙 확인 |
n/a, NULL, ?, +5, 1e2, 1 |
unapproved_token | 승인 목록·숫자 표기와 원자료 확인 |
-1 |
negative_not_allowed | 낱개 수량·음수 조정의 업무 의미 확인 |
1.5 |
decimal_not_allowed | 소수 단위인지 확인, 임의 절삭 금지 |
00 |
leading_zero | 표기 규칙 확인, 원문을 조용히 바꾸지 않기 |
1000000 |
integer_out_of_range | 범위·단위 확인, 잘라서 통과시키지 않기 |
가령 ?를 보았다고 곧바로 미정으로 바꾸면 제공하지 않은 의미를 만들게 됩니다. 담당자가 실제 상태를 확인해야 합니다. fixtures/recovery.csv는 R01이 실제로 미정이라고 제공자가 확인했다는 가상 전제로 따로 만든 사본이며 원본 수정 기능이 아닙니다.
id,qty
R01,미정
R02,5
python code/missing_states.py --input fixtures/recovery.csv --out recovery_result.json
이 사본의 기대값은 missing 1·value 1·rejected 0, pending 1, 부분 소계 5·수치 미확정 1, 종료코드 0입니다. 정상 숫자가 확인된 것처럼 0을 넣어 통과시키지 않았습니다. 업무 정책을 바꿔 NA 같은 새 토큰을 받으려면 계약 버전·코드·독립 기대값과 반례를 함께 새로 정해야 합니다. 실행 중 자동으로 허용 목록을 넓히는 옵션은 없습니다.
6 파일 오류와 정상 결측을 구분해 복구합니다
| 종료코드 | 최종 JSON | 의미와 다음 행동 |
|---|---|---|
| 0 | 새 완성 보고서 있음 | 모든 행이 고정 계약 안에 있음, missing 사유와 부분 소계 확인 |
| 1 | 새 완성 보고서 있음 | 승인 밖 업무 표기가 있음, raw_qty와 거부 이유 확인 |
| 2 | 이번 실행의 새 최종 보고서 없음 | 옵션·구조·인코딩·접근·저장 문제 복구 |
종료코드 1을 예상하는 명령 뒤에 &&로 다음 명령을 연결하면 다음 단계가 실행되지 않을 수 있습니다. 명령을 하나씩 실행하고 콘솔·종료코드·결과 파일을 함께 확인하세요.
다음 세 실패 명령은 모두 종료코드 2이고 지정한 새 JSON이 없어야 합니다.
python code/missing_states.py --input fixtures/failures/wrong_header.csv --out failed_header.json
python code/missing_states.py --input fixtures/failures/late_unclosed.csv --out failed_quote.json
python code/missing_states.py --input fixtures/failures/invalid_utf8.csv --out failed_encoding.json
- 머리글 오류는
id,qty의 이름·순서·구분자를 확인하고 올바른 별도 사본으로 전체 재실행합니다 - 후반의 닫히지 않은 따옴표는 CSV 구문을 확인합니다. 앞쪽 정상 행만 완성 보고서로 내보내지 않습니다
- UTF-8 오류는 제공자의 실제 인코딩을 확인해 UTF-8 사본을 만들고 내용이 원자료와 같은지 확인합니다
- 중복 ID나 잘못된 ID는 행 식별 근거를 확인합니다. 임의로 한 행을 삭제하거나 합치지 않습니다
- 입력이 없거나 출력 부모 폴더가 없으면 위치를 확인합니다. 파일 접근·쓰기 오류는 본인에게 허용된 로컬 연습 폴더에서 복구합니다
- 링크·하드 링크 또는 저장소의 하드 링크 지원 문제는 직접 복사한 일반 파일과 일반 로컬 폴더로 다시 시험합니다. 검사를 꺼서 통과시키지 않습니다
ZIP의 fixtures/failures에는 위 세 파일 외에도 추가 열·빠진 열·중복 ID·빈 ID·머리글만 있음·빈 레코드·빈 파일이 있습니다. 총 10개는 전체 중단 입력입니다. 옵션을 빠뜨리거나 지원하지 않는 --missing-token을 넣으면 argparse가 사용법을 stderr에 내고 종료 2로 끝납니다.
7 0과 결측만 있는 경계도 따로 확인합니다
다음 명령의 출력 이름도 미리 없어야 합니다.
python code/missing_states.py --input fixtures/normal.csv --out normal_result.json
python code/missing_states.py --input fixtures/boundary.csv --out boundary_result.json
python code/missing_states.py --input fixtures/missing_only.csv --out missing_only_result.json
| 입력 | missing / zero / value / rejected | 확인된 수량 소계 | 부분 표시 / 수치 미확정 | 종료 |
|---|---|---|---|---|
normal.csv의 0,2,3 |
0 / 1 / 2 / 0 | 5 | false / 0 | 0 |
boundary.csv의 0,1,999999,"" |
1 / 1 / 2 / 0 | 1000000 | true / 1 | 0 |
| missing_only.csv의 빈문자열·N/A·미정 | 3 / 0 / 0 / 0 | 0 | true / 3 | 0 |
마지막 파일의 소계 0은 수치가 있는 행을 하나도 더하지 않은 빈 합의 계산 결과입니다. zero_count=0, value_count=0을 같이 읽으면 실제 수량 0을 확인한 행이 없다는 것을 알 수 있습니다. 세 행의 전체 수량이 0이라는 뜻으로 옮기지 마세요. boundary의 소계 1000000은 두 허용 양수 1과 999999를 더한 값이므로 개별 입력 상한과 달라도 정상입니다.
expected의 여섯 JSON과 hand_check.txt는 변환 코드를 실행해 정답을 복사한 파일이 아닙니다. 합성 입력에서 직접 정한 상태·원표기·사유·숫자와 손계산을 고정했습니다. source_sha256만 기대 JSON에서 제외하며 현재 입력 바이트와 별도로 대조합니다. 기대값을 입력과 함께 고쳐 결과를 맞추면 독립 검산이 사라집니다.
8 원본과 이전 결과를 보존합니다
이미 main_result.json을 만들었다면 같은 명령을 다시 실행해 보세요. 종료코드 2로 기존 결과를 보존합니다. 새 비교 결과는 새 이름으로 만듭니다.
python code/missing_states.py --input fixtures/main.csv --out main_result.json
python code/missing_states.py --input fixtures/main.csv --out main_second.json
두 번째 새 이름의 보고서는 같은 상태·사유·수치와 원본 해시를 가져야 합니다. 입력과 출력에 같은 경로, 기존 파일·폴더·끊어진 심볼릭 링크, 입력·부모 경로의 링크와 검출 가능한 reparse 표식, 하드 링크 입력, ..를 넣은 경로는 거부합니다. 경로 확인은 Path.lstat을 사용합니다.
완성 JSON은 같은 출력 폴더의 NamedTemporaryFile에 모두 쓰고 동기화·재읽기 대조한 다음 os.link로 새 최종 이름을 만듭니다. 기존 파일을 replace하지 않으며 하드 링크 생성 실패 때 덮어쓰기 방식으로 바꾸지 않습니다. 임시 파일에는 .p12- 접두사가 붙습니다.
46개 자동시험에서 실제 0과 세 결측 사유, 거부 원표기, JSON 저장 후 자료형, 10,000/10,001행, 1,000,000바이트의 정확 경계와 초과, 필드 길이·ID 경계, BOM·CRLF, 기존 출력·재실행·링크 보존을 확인했습니다. 부분 쓰기·fsync·최종 링크 실패, 출력 이름 선점, 입력 변경은 시험에서 통제해 발생시킨 오류입니다. 이 처리된 실패에서 새 최종 출력 부재 또는 선점된 기존 내용 보존을 대조했습니다. 실제 저장 장치 장애를 경험한 사례로 제시한 것은 아닙니다.
제공 CSV 16개는 검수 실행 전후 SHA-256이 같았습니다. 새 ZIP을 별도 폴더에 풀어 같은 46개 시험과 21건 CLI fixture 대조를 다시 실행했고, 본문·HTML·ZIP의 전체 코드가 같음을 확인했습니다. 독자 ZIP에는 코드·가상 입력·독립 기대값·시험·README·체크섬만 있습니다.
적용 범위와 한계
이 코드는 숫자가 없는 원인과 실제 0을 형식 변환 뒤에도 구분하는 연습입니다. 수량×단가 금액 검산, 일반 CSV 스키마 변환기, DB NULL 내보내기, 실제 재고 승인 기능으로 확장하지 않았습니다. 해당 없음과 미정의 실제 업무상 처리를 확정하려면 담당자의 계약 확인이 필요합니다.
입력과 결과를 메모리에 보관하므로 대용량 속도는 측정하지 않았습니다. 제한을 늘리려면 자원 사용과 경계 시험을 다시 정해야 합니다. 다른 프로그램이 파일·폴더를 동시에 바꾸지 않는 별도 개인 로컬 연습 폴더에서 한 작업씩 실행하세요. 공유·동기화 폴더의 동시 변경, 공격자의 경로 경쟁, 강제 종료·정전까지 막는 보안 격리나 복구 보장 도구는 아닙니다. 경로 확인과 원본 해시는 잠금 기능이 아닙니다.
최종 저장 전까지 잡힌 쓰기 오류에서는 새 최종 보고서를 만들지 않습니다. 확정 뒤 임시 파일 정리만 실패하면 완성 보고서를 보존하고 경고를 냅니다. 이 경우와 강제 종료로 남은 임시 파일을 구분하고 .p12- 파일을 완료 결과로 사용하지 마세요. 제거 권한까지 실패하면 임시 파일이 남을 수 있습니다.
Windows의 잠금·reparse 경로, macOS, 네트워크 저장소와 Microsoft Excel 가져오기·표시 동작은 실제 시험하지 않았습니다. 결과는 우선 텍스트 편집기로 확인하세요. JSON의 null을 가져온 앱이 공란으로 보여도 missing_reason까지 보존했는지는 별도 대조가 필요합니다.
자주 묻는 질문
N/A를 무조건 결측으로 받아도 되나요
제공자의 문서화된 규칙을 확인해야 합니다. 이 예제는 정확한 N/A를 해당 없음으로 정한 고정 계약입니다. 다른 업무에서 N/A가 코드값이라면 이 계약을 그대로 적용할 수 없습니다.
missing이 세 건인데 왜 종료코드가 0인가요
세 결측 표기는 이 파일의 정상 상태 표현입니다. 종료 0은 그 상태와 사유를 완전히 보존한 변환 완료입니다. 전체 숫자 합계가 확정됐다는 표시가 아니므로 부분 소계 true·수치 미확정 3을 같이 읽습니다.
null을 모두 0으로 바꾸면 소계가 같지 않나요
주 입력에서는 숫자 5가 같아 보이더라도 실제 0 행 수와 결측 사유가 사라집니다. 이 실습의 완료 조건은 소계뿐 아니라 다섯 ID·원표기·상태·reason·숫자 자료형이 모두 기대값과 같은 것입니다.
공백을 제거하거나 소문자 n/a를 대문자로 바꾸면 안 되나요
이 계약은 자동 변환을 승인하지 않았습니다. 별도 정리 정책이 필요하면 원표기와 변환 이력, 허용 규칙과 독립 반례를 따로 정하세요. 여기서는 거부 이유를 남겨 원자료로 돌아갑니다.
이어서 볼 글과 공식 자료
원래 Excel 조회의 실제 0과 미등록·원본 오류 차이는 IFERROR·IFNA 원인 분리 실습에서 볼 수 있습니다. 승인한 결측 상태 없이 잘못된 정수 표기를 검토하는 별도 목적은 CSV 수량 열의 소수·음수·빈칸 분리와 구분됩니다. 이번에는 승인된 결측을 오류로 바꾸지 않고 JSON 안에 사유를 유지합니다.
공식 기술 자료 확인일은 2026-10-06 UTC입니다. Python 3.12 계열 페이지의 확인 당시 표제는 3.12.15였고, 실제 실행한 인터프리터는 CPython 3.12.14입니다. 공식 문서가 결측 토큰의 업무 의미를 정해 주는 것은 아닙니다. 그 의미는 이 글의 고정 계약입니다.
게시 전 재검수는 2026-10-07 KST에 했습니다. 기존 준비 원고·실습 ZIP을 보존하고 Linux CPython 3.12.14에서 46개 자동시험과 21건 CLI 대조를 다시 통과했습니다. 표시 게시일과 실제 자료 확인·재검수일은 구분합니다.