파일·예외와 오류 행 기록
120분 안팎
학습 목표
JSONL 센서 파일을 읽고 잘못된 행 번호를 별도 기록합니다.
개념
한 행의 실패가 전체 기록을 숨기지 않도록
센서 파일을 재생하다 한 줄이 깨졌다고 해서 이전의 정상 기록까지 없어진 것으로 처리하면 장애 원인을 찾기 어렵습니다. 반대로 잘못된 행을 아무 흔적 없이 버리면 입력 손실이 보이지 않습니다. 이번 레슨에서는 JSONL 파일을 물리 행 단위로 읽고 유효 메시지 목록과 오류 목록을 분리합니다. 유효 메시지는 뒤의 좌표 변환 단계로 전달하고 오류는 원본 행 번호와 이유로 추적합니다. 파일 전체를 못 읽는 실패와 한 메시지가 잘못된 실패는 다르게 다룹니다.
JSONL과 프로젝트 메시지
JSONL은 각 줄에 JSON 값 하나를 적는 기록 형식입니다. 파일 전체를 대괄호로 묶은 JSON 배열과는 다릅니다. 한 메시지는 한 줄의 객체로 두고 json.loads(line)으로 그 줄만 파싱합니다. 여러 줄로 보기 좋게 들여쓴 객체는 이 입력 형식에 맞지 않습니다. 빈 파일은 기록이 없는 경우이고 빈 줄은 메시지 자리에 내용이 없는 경우로 구분합니다. 빈 파일의 결과는 유효 0건·오류 0건이며, 빈 줄이 들어 있는 파일은 해당 물리 행을 오류로 남깁니다.
메시지의 필수 키는 seq, stamp_s, frame_id, range_m입니다. seq는 음수가 아닌 정수 순번이며 bool은 허용하지 않습니다. stamp_s는 기록 시작 기준 초로 표시한 음수가 아닌 유한 숫자입니다. frame_id는 공백만으로 이루어지지 않은 문자열이며 range_m는 0.05~8.0m의 유한 숫자입니다. 추가 필드는 그대로 보존합니다. 이번 단계는 seq 연속성이나 시각 증가를 검사하지 않습니다. 범위와 형식의 계약을 시간 순서 계약과 섞지 않아야 후속 모듈에서 지연과 재생을 따로 시험할 수 있습니다.
파싱 성공과 유효 메시지는 다릅니다
json.loads가 성공해도 결과는 배열, 숫자, null일 수 있습니다. 먼저 dict인지 확인한 뒤 필수 키를 검사합니다. 이어서 키의 값이 허용 형식인지 보고 마지막으로 수치 범위를 확인합니다. 문자열 "1.2"를 숫자로 자동 변환하지 않습니다. 파일을 만드는 쪽이 잘못된 형식을 보내고 있는데 수신자가 조용히 보정하면 생산자 오류가 숨겨집니다. 검증 함수는 ValueError로 계약 위반을 알리고, 파일을 읽는 함수는 그 실패를 오류 목록으로 바꿉니다.
숫자 검사에 math.isfinite를 넣어 NaN과 무한대를 제외합니다. Python의 기본 JSON 파서는 NaN 같은 비표준 상수를 받아들일 수 있으므로 파싱 성공만으로 유한성이 보장되지 않습니다. 타입 확인을 먼저 수행하면 문자열이나 None을 isfinite에 넣어 TypeError를 일으키지 않습니다. 범위 비교만으로 모든 비정상 수를 분명히 설명하려 하지 않고 유한 숫자 여부를 독립된 계약으로 둡니다. 오류 이유에는 해당 필드 이름을 넣어 어느 값을 고쳐야 하는지 알 수 있게 합니다.
물리 행 번호를 보존하기
enumerate(stream, 1)은 파일 첫 줄을 1로 셉니다. 잘못된 행이나 빈 줄도 번호를 차지합니다. 오류 번호를 유효 개수에서 계산하면 첫 오류 뒤부터 원본 위치와 어긋납니다. 오류 항목은 {"line": 번호, "reason": 이유}이며 오류 원문 전체를 복제하지 않습니다. 원본 파일을 보존한 상태에서 line으로 찾아볼 수 있습니다. 실무 기록에 민감한 필드가 추가되는 경우를 고려해 진단에 필요한 위치와 요약 이유만 남기는 습관을 익힙니다.
실습 fixture의 첫째·셋째·다섯째 행은 유효하고 둘째 행은 JSON 문법이 잘못되었으며 넷째 행은 음수 거리입니다. 따라서 유효 메시지 seq는 0, 1, 2로 남고 오류 행 번호는 2와 4입니다. 오류가 있었다고 다음 정상 행을 읽지 않으면 유효 개수가 줄어듭니다. 입력 순서를 유지하며 유효 목록에 append하고 각 반복에서 실패를 처리한 다음 다음 행으로 진행합니다. 반환값은 두 목록의 튜플이므로 호출부는 valid, errors = read_records(path)로 받습니다.
예외를 잡는 범위를 정하기
with Path(path).open(encoding="utf-8")은 파일을 열고 블록이 끝나면 닫습니다. 파일 열기는 행 처리 try 바깥에 둡니다. FileNotFoundError나 PermissionError는 파일 전체를 확보하지 못한 문제이므로 한 행의 오류로 바꿀 수 없습니다. read_records는 이런 OSError를 호출자에게 전달합니다. 파일을 못 열었는데 빈 목록 두 개를 반환하면 정상 빈 파일과 구별되지 않습니다. 이후 실행 스크립트에서는 치명적인 파일 실패를 표준 오류와 실패 종료 코드로 전달합니다.
행 처리에서는 json.JSONDecodeError와 검증 함수의 ValueError를 잡습니다. 예상하지 않은 NameError나 AttributeError까지 except Exception으로 잡으면 코드의 오타가 센서 데이터 불량처럼 기록됩니다. 테스트에서 traceback이 나오면 마지막 줄의 예외 종류부터 확인하고 그 위에서 내 파일의 호출 위치를 찾습니다. 테스트의 AssertionError는 계산 결과가 기대와 다르다는 뜻이고, ImportError는 테스트가 처리 함수에 도달하기 전 모듈을 찾지 못했다는 뜻입니다. 두 실패를 같은 수정으로 해결하려 하지 않습니다.
오류 메시지에서 수정 대상을 찾기
Expecting value라는 JSONDecodeError 메시지는 값이 와야 하는 자리에 JSON이 아닌 문자가 있었다는 신호입니다. 오류 항목의 line은 원본 파일 행이며 파서 메시지 속 line과 column은 이번에 넘긴 한 줄 문자열 내부 위치입니다. 둘을 혼동해 원본 첫째 행만 반복해서 고치지 않습니다. missing range_m은 필수 키 누락이고 range_m out of bounds는 형식은 숫자이지만 계약 범위가 맞지 않았다는 뜻입니다. 오류 이유의 문구를 외우기보다 처리 단계와 필드 이름으로 원인을 분류합니다.
원본을 고쳐 테스트를 통과시키는 것은 구현 검증이 아닙니다. starter에서는 오류 목록을 채우는 부분이 비어 있으므로 read_records 안의 TODO를 수정합니다. 테스트는 빈 파일, 빈 행, 경계, 키 누락, 잘못된 형식, bool, 유한하지 않은 숫자, 파일 누락을 다룹니다. unittest의 failures와 errors를 읽고 기능 기대와 실행 장애를 분리합니다. 해답에서는 모든 검사가 통과해야 하며, 몇 행이 유효한지만 보지 않고 어느 원본 행이 거부됐는지도 확인합니다.
기록 처리의 적용 범위
유효 메시지는 입력 객체의 복사본으로 반환합니다. 나중에 좌표 변환 코드가 반환값에 필드를 추가해도 검증에 넣었던 원본 dict는 바뀌지 않도록 합니다. 이번 객체의 필수 값은 숫자와 문자열이어서 얕은 복사면 충분하지만 중첩 목록 같은 추가 필드를 변경할 때는 별도 복사 정책이 필요합니다. 파일 자체는 읽기만 하며 자동 보정하지 않습니다. 같은 파일을 다시 처리했을 때 동일한 유효·오류 분류가 나오는지 확인해 후속 재생 실험의 기준을 만듭니다.
이 구현은 파일을 한 줄씩 읽지만 결과는 목록에 모두 모읍니다. 따라서 파일 크기에 비례해 결과 메모리가 늘어나며 장시간 실시간 센서 스트림을 그대로 연결하는 용도는 아닙니다. 작은 고정 기록에서 계약과 오류 경로를 배우는 도구입니다. 실습 완료 증거는 유효 3건·오류 2건, 오류 행 [2, 4], 빈 파일 결과와 테스트 통과입니다. 실물 시험에 적용할 때의 저장 주기나 폐기 정책은 후속 통합에서 정하며 여기서는 입력을 잃지 않고 설명하는 책임에 집중합니다.
따라하기
JSON 값과 메시지 객체 구분
파싱 결과의 형식부터 확인합니다. JSON 배열은 파싱돼도 메시지 객체는 아닙니다.
import json
for line in ['{"seq":0}', "[]", "not-json"]:
try:
raw = json.loads(line)
print(type(raw).__name__)
except json.JSONDecodeError as error:
print(type(error).__name__)
실행 결과
dict list JSONDecodeError
빈 행도 번호 유지
임시 파일에 빈 행과 정상 행을 적습니다. 파일을 닫은 뒤 물리 행 번호를 그대로 세어 오류를 기록합니다.
import json
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as root:
path = Path(root) / "records.jsonl"
path.write_text('\n{"seq":0}\n', encoding="utf-8")
errors = []
with path.open(encoding="utf-8") as stream:
for number, line in enumerate(stream, 1):
try:
json.loads(line)
except json.JSONDecodeError:
errors.append(number)
print(errors)
실행 결과
[1]
유한 숫자와 bool 구별
Python에서 bool이 정수 검사에 통과하는 경우와 유한 수 검사 결과를 확인합니다.
import math
print(isinstance(True, int), type(True) is int)
for value in [1.0, float("nan"), float("inf")]:
print(math.isfinite(value))
실행 결과
True False True False False
제공된 기록을 실제로 처리하기
solution ZIP을 별도 폴더에 풀고 그 폴더에서 실행합니다. 제공된 fixture를 읽은 실제 결과로 유효 개수와 오류 행을 확인합니다. starter에서는 TODO를 완성하기 전 결과가 다릅니다.
python3 - <<'PYCODE'
from sensor import read_records
valid, errors = read_records('fixtures/sensors.jsonl')
print('valid', len(valid), 'errors', len(errors))
print([item['line'] for item in errors])
PYCODE실행 결과
valid 3 errors 2 [2, 4]
확인 문제
실습
starter ZIP을 풀고 sensor.py의 read_records에서 오류 항목을 기록하는 TODO를 채웁니다. 물리 행 번호는 1부터 세고 빈 행도 오류입니다. 원본과 테스트는 고치지 않습니다. 유효 3행·오류 2행 fixture의 오류 번호는 [2, 4]이며 빈 파일, 경계, 타입과 파일 누락 검사를 포함한 12개 테스트를 통과합니다.
실행 명령
bash check.sh
기대 결과
12개 테스트 통과(OK).
모범 답안
모범 답안 내려받기더 읽기
면접 질문
- 손상된 센서 기록의 행 오류와 파일 전체 오류를 어떻게 분리합니까?