입력 스키마 검증 함수
100분 안팎
학습 목표
표준 입력 JSON에서 필수 열·타입·날짜·범위를 검사하고 오류 목록을 출력합니다.
개념
저장 전에 입력 계약을 코드로 확인합니다
새 교통 파일에 total_vehicles가 빠졌는데 평균 코드가 기본값 0을 넣으면 보고서는 오류 없이 생성되면서 사실과 다른 숫자를 보여 줄 수 있습니다. 필드 누락은 정상적인 관측 0과 다릅니다. 이번 레슨은 데이터를 계산하기 전에 필수 열, 값의 자료형, 날짜 표기와 허용 범위를 검사하는 함수를 작성합니다. 검사의 결과를 행 번호와 필드 이름으로 남겨 제공자와 작성자가 같은 문제를 찾게 합니다.
브라우저 실습의 입력은 JSON 배열이며 한 객체가 지역·날짜 한 관측입니다. 필수 필드는 region, date, total_vehicles, rain_mm입니다. region은 공백 제거 뒤 비어 있지 않은 문자열, date는 실제 달력 날짜를 나타내는 YYYY-MM-DD 문자열입니다. 통행량과 강수는 키가 존재해야 하지만 값 null은 허용합니다. 결측을 허용한다는 말과 필드 자체의 누락을 허용한다는 말은 구분합니다.
필드 존재와 타입을 서로 다른 검사로 둡니다
row.get으로 읽기만 하면 없는 키와 값이 null인 키가 모두 None으로 보일 수 있습니다. 먼저 key in row로 존재를 확인하고 없으면 missing을 기록합니다. 키가 있으면 그 값의 타입과 의미를 검사합니다. region과 date는 null을 허용하지 않습니다. 숫자 필드의 null은 결측으로 받아들이며 여기서 0으로 채우지 않습니다. 결측 처리의 영향을 비교한 앞 모듈의 판단을 유지합니다.
JSON 숫자 12는 Python int로, 12.0은 float로 읽힙니다. total_vehicles는 비음수 정수 관측이므로 int만 허용합니다. 문자열 "12"를 자동 변환하지 않고 integer 오류로 표시합니다. 수집 스키마에서 타입이 바뀌었다는 사실을 드러내는 것이 목적입니다. CSV는 원래 문자열을 제공하므로 미션에서는 m04 normalize로 명시적으로 변환한 뒤 같은 숫자 계약을 검사합니다.
Python의 bool은 int의 하위 타입입니다. isinstance(True, int)는 참이므로 이것만 사용하면 true를 통행량 1로 받아들일 수 있습니다. 실습에서는 type(value) is int로 정확한 타입을 확인합니다. rain_mm은 int 또는 float를 허용하지만 bool은 제외합니다. 통행량0과 강수0.0은 유효한 값입니다. if not value로 비어 있음과 0을 함께 처리하면 정상적인 영점 관측이 사라집니다.
날짜는 형식과 달력을 함께 검사합니다
문자열 길이만 확인하면 2026-02-30도 날짜처럼 보입니다. date.fromisoformat으로 실제 달력 날짜인지 검사하고 결과의 isoformat을 원래 문자열과 비교합니다. 이 비교는 표기를 YYYY-MM-DD로 제한하는 계약입니다. 일부 Python 버전에서 받아들이는 압축 날짜나 다른 ISO 표기가 있더라도 원래 문자열과 같지 않으면 오류로 남깁니다. 문자열의 모양과 날짜의 존재를 둘 다 확인합니다.
이 프로젝트의 date는 현지 관측 일자이며 시각이나 시간대 오프셋이 없는 값입니다. 이 레슨에서 UTC 변환을 추가하지 않습니다. 시간 단위 수집으로 바꾸려면 날짜만으로 키가 충분한지 먼저 재설계해야 합니다. 문자열 정렬이 날짜 순서처럼 보인다고 달력 검사를 생략하지 않습니다. 날짜 구문 오류는 date 코드로 기록하고 그 행의 숫자 필드 검사도 계속합니다.
숫자의 범위를 측정 의미와 연결합니다
통행량은 0 이상, 강수량도 0 이상이고 유한한 수여야 합니다. 음수 강수는 이 관측 계약에 맞지 않으므로 range 오류입니다. 무한대나 NaN은 평균 계산에 섞이면 요약값을 망칠 수 있어 math.isfinite로 차단합니다. 숫자처럼 보인다는 사실만으로 측정 가능하고 집계 가능한 값이라고 판단하지 않습니다. 실제 최대값 기준은 제공자의 단위와 장비 범위를 확인해 별도 품질 기준으로 정합니다.
JSON 표준에는 NaN과 Infinity가 없습니다. Python의 json.loads는 기본 설정에서 이 표기를 받아들일 수 있어 브라우저 진입점의 parse_constant에서 거부합니다. 잘못된 JSON 구문이나 이 비표준 상수는 JSON:invalid로 출력합니다. 라이브러리를 믿고 기본 설정을 그대로 사용하는 대신 실습의 입력 계약과 일치하는지를 점검합니다. 유효 JSON인 1e309는 유한하지 않은 float가 될 수 있으므로 값 검사에서 range로 잡습니다.
오류 목록을 일정한 순서로 출력합니다
최상위 값이 배열이 아니면 ROOT:array 한 줄을 출력합니다. 배열 안의 원소가 객체가 아니면 해당 행의 ROW:object를 출력하고 다음 원소로 이동합니다. 객체는 region, date, total_vehicles, rain_mm 순서로 검사하며 한 필드에는 가장 앞선 오류 하나만 기록합니다. 타입이 잘못된 값에 음수 비교를 하지 않으므로 검사 함수가 TypeError로 중단되는 일을 피합니다.
오류 출력 형식은 행번호:필드:이유입니다. 예를 들어 2:total_vehicles:integer는 두 번째 객체의 통행량이 정수 타입 계약에 맞지 않다는 뜻입니다. 여기의 번호는 JSON 배열의 1부터 시작하는 관측 순번이며 CSV의 헤더 포함 줄 번호가 아닙니다. 오류가 없으면 OK 한 줄만 출력합니다. 빈 배열도 형식상 정상이며 OK입니다. 빈 수집을 허용할지 여부는 다음 품질 모듈의 운영 판단으로 확장합니다.
추가 필드는 허용하며 검사하지 않습니다. 제공자가 설명 열을 더 붙였을 때 기존 필수 열 계약을 유지하기 위한 브라우저 정책입니다. 반면 local 미션의 결합 CSV는 m04의 아홉 열 이름과 순서를 엄격하게 검사합니다. 두 검사가 다르다는 사실을 README에서 설명해야 후속 작성자가 추가 열을 임의로 제거하지 않습니다. 검사의 허용 범위를 이름만 보고 같다고 가정하지 않습니다.
검사 함수는 저장을 수행하지 않습니다
errors 함수는 오류 문자열의 목록만 반환합니다. 입력 객체를 고치거나 파일을 만들지 않으므로 작은 경계 사례를 쉽게 테스트할 수 있습니다. 표준 입력을 읽고 출력하는 코드는 함수 밖의 진입점에 둡니다. 검사가 실패해도 모든 행의 오류를 보여 주는 브라우저 방식과 첫 치명적인 오류에서 ETL 실행을 멈추는 local 방식은 같은 계약을 다른 호출 목적에 사용하는 예입니다.
확인할 경계는 빈 배열, 결측 null, 관측0, 문자열 숫자, 불리언, 존재하지 않는 날짜, 필수 필드 누락과 객체가 아닌 행입니다. 성공 예제 한 개만으로 검사가 완성됐다고 판단하지 않습니다. 더 읽기의 예외 장은 오류 종류와 전달 방법을 확장합니다. 이 레슨은 어느 필드가 왜 잘못되었는지 설명하는 입력 계약을 만들며, 오류를 빈 정상 결과로 숨기지 않는 습관을 익힙니다.
따라하기
불리언을 숫자로 받지 않기
total_vehicles의 정수 검사는 bool을 제외해야 합니다.
for value in [0, True, '0', None]:
print(repr(value), 'is_int', isinstance(value, int), 'exact_int', type(value) is int)실행 결과
0 is_int True exact_int True True is_int True exact_int False '0' is_int False exact_int False None is_int False exact_int False
달력과 표기 검사
존재하지 않는 날짜와 계약에 없는 구분자를 각각 거부합니다.
from datetime import date
for value in ['2026-09-01', '2026-02-30', '2026/09/01']:
try:
valid = date.fromisoformat(value).isoformat() == value
except ValueError:
valid = False
print(value, valid)실행 결과
2026-09-01 True 2026-02-30 False 2026/09/01 False
누락과 결측 구분
0.0은 관측이며 null은 허용한 결측입니다. 키 누락은 별도 오류입니다.
rows = [{}, {'rain_mm':None}, {'rain_mm':0.0}]
for row in rows:
if 'rain_mm' not in row: print('missing')
elif row['rain_mm'] is None: print('nullable')
else: print('observed', row['rain_mm'])실행 결과
missing nullable observed 0.0
확인 문제
실습
표준 입력 JSON 배열을 검사합니다. region·date·total_vehicles·rain_mm 순서로 필수 키를 확인합니다. region은 공백 제거 후 비지 않는 문자열, date는 실제 YYYY-MM-DD 날짜, total_vehicles는 bool 제외 비음수 int 또는 null, rain_mm은 bool 제외 비음수 유한 int/float 또는 null입니다. 추가 열은 허용합니다. 한 필드당 첫 오류만 출력합니다. missing/text/date/integer/number/range 코드를 본문 규칙대로 행번호:필드:이유 형식으로 출력합니다. 최상위 배열이 아니면 ROOT:array, 행이 객체가 아니면 행번호:ROW:object, JSON 구문·비표준 상수 오류면 JSON:invalid입니다. 오류가 없거나 빈 배열이면 OK입니다.
모범 답안
"""브라우저와 같은 필수 열·타입·날짜·범위 검사입니다."""
import math
from datetime import date
FIELDS = ('region', 'date', 'total_vehicles', 'rain_mm')
def errors(rows):
if not isinstance(rows, list):
return ['ROOT:array']
found = []
for number, row in enumerate(rows, 1):
prefix = str(number) + ':'
if not isinstance(row, dict):
found.append(prefix + 'ROW:object')
continue
for key in FIELDS:
if key not in row:
found.append(prefix + key + ':missing')
continue
value = row[key]
if key == 'region':
if not isinstance(value, str) or not value.strip():
found.append(prefix + key + ':text')
elif key == 'date':
try:
if not isinstance(value, str) or date.fromisoformat(value).isoformat() != value:
raise ValueError()
except ValueError:
found.append(prefix + key + ':date')
elif value is None:
continue
elif key == 'total_vehicles':
if type(value) is not int:
found.append(prefix + key + ':integer')
elif value < 0:
found.append(prefix + key + ':range')
elif type(value) not in (int, float):
found.append(prefix + key + ':number')
elif (type(value) is float and not math.isfinite(value)) or value < 0:
found.append(prefix + key + ':range')
return found
import json
import sys
def reject_constant(value):
raise ValueError('nonstandard JSON number')
try:
rows = json.loads(sys.stdin.read(), parse_constant=reject_constant)
except (ValueError, RecursionError):
print('JSON:invalid')
else:
problems = errors(rows)
print('\n'.join(problems) if problems else 'OK')
더 읽기
면접 질문
- 결측값을 삭제할지 채울지 판단하는 과정을 설명해 주시면 됩니다.