변환 왕복과 손실 검사
85분 안팎
학습 목표
누락·중복·인코딩 문제를 비교로 찾습니다.
개념
변환이 끝났다는 증거를 만듭니다
CSV를 JSON으로 바꾸고 오류 없이 저장했다고 해서 모든 정보가 남았다고 단정할 수는 없습니다. 중복 ID를 딕셔너리에 넣어 앞의 기록이 사라졌을 수도 있고, 제목의 쉼표를 지웠을 수도 있습니다. 이번 레슨은 원본 CSV의 의미와 변환 결과의 의미를 비교하는 검증기를 만듭니다. 파일 생성 성공이 아니라 ID·제목·쪽수·금액·완료·순서가 보존되었음을 수료 증거로 남깁니다.
왕복은 원본 표현에서 다른 표현으로 옮겼다가 다시 원래 종류의 표현으로 돌아오는 과정입니다. CSV → 기록 객체 → JSON → 기록 객체 → CSV를 거칩니다. 원본과 결과 CSV가 글자 단위로 같아야 한다는 뜻은 아닙니다. writer가 따옴표를 필요한 곳에만 붙이거나 줄 끝 표현을 바꿀 수 있기 때문입니다. CSV 파서로 다시 읽어 같은 업무 값을 나타내는지 확인하는 것이 이 프로젝트의 왕복 기준입니다.
데이터 보존은 변환 계약에 따른 약속입니다. 제목 양끝 공백을 정리하고 쪽수 +0을 정수 0으로 바꾸는 것은 미리 정한 정규화입니다. 제목 안의 쉼표와 줄바꿈을 지우는 것은 허용하지 않은 손실입니다. 공백을 제거한다는 규칙도 제목 내부 공백까지 없앤다는 뜻이 아닙니다. 어디까지 동일해야 하는지 정의하지 않으면 문자열이 다르다는 이유로 정상 변환을 반려하거나 손실을 정상으로 받아들일 수 있습니다.
개수·ID·자료형·값·순서로 나눕니다
전체 개수만 비교하면 한 기록이 빠지고 다른 기록이 두 번 들어온 상황을 놓칠 수 있습니다. 먼저 결과에 같은 ID가 중복되었는지 확인하고 개수와 ID 집합을 비교합니다. 개수는 전체 수를, ID 집합은 어떤 기록이 존재하는지를 뜻합니다. 두 검사가 통과한 다음 ID별 필드값과 자료형을 비교합니다. 이렇게 층을 나누면 오류 메시지가 수정할 위치를 더 정확히 가리킵니다.
Python에서 0과 False는 동등 비교에서 같게 취급될 수 있고 0과 0.0도 같다고 비교될 수 있습니다. 따라서 딕셔너리 전체의 ==만으로 정수 계약까지 확인했다고 말할 수 없습니다. pages·cost·completed는 정확한 int를 요구하고 id·title은 정확한 str을 요구합니다. 자료형 검사에서 불린이나 실수가 발견되면 값 비교 이전에 LOSS_TYPE으로 보고합니다. 같은 뜻처럼 보이는 표현이라도 저장 계약이 달라지면 이후 계산이 달라질 수 있습니다.
앞 모듈은 입력 순서를 보존한 목록 조회를 제공합니다. 두 JSON 결과가 같은 ID와 값을 가진다고 해도 순서가 바뀌면 화면의 목록 순서가 바뀝니다. 이번 검증기는 ID별 값이 모두 같을 때 마지막으로 ID 순서 목록을 비교합니다. 순서가 중요하지 않은 보고서를 만들 때는 다른 계약을 사용할 수 있지만 프로젝트의 기존 보장부터 바꾸지는 않습니다. 정렬이 편리하다는 이유로 먼저 원본을 정렬하면 순서 손실을 발견할 수 없습니다.
왕복 검증기 과제의 입력
브라우저 입력은 csv와 records 두 필드를 가진 JSON 객체 한 개입니다. csv는 이미 앞 레슨 규칙을 만족한 원본 CSV 문자열이며 records는 변환 도구가 만든 JSON 기록 목록입니다. 원본 제목은 정규화되어 있고 ID는 중복되지 않습니다. 검증 대상 records는 필드 누락·자료형 변화·값 변화·순서 변화가 있을 수 있습니다. 모든 대상 기록에는 문자열 id가 있다는 제한으로 비교 로직에 집중합니다.
과제는 records를 JSON 직렬화·역직렬화한 뒤 구조와 내용을 확인합니다. 정확히 id,title,pages,cost,completed 필드가 없으면 LOSS_FIELDS입니다. 이후 중복 ID, 개수 차이, ID 집합 차이, 자료형 차이, 값 차이, 순서 차이를 각각 LOSS_DUPLICATE_ID, LOSS_COUNT, LOSS_ID, LOSS_TYPE, LOSS_VALUE, LOSS_ORDER로 보고합니다. 여러 문제가 있으면 이 순서의 첫 오류만 출력하여 결과를 재현 가능하게 만듭니다.
모든 검사가 통과하면 csv.DictWriter로 records를 CSV 문자열로 되돌립니다. fieldnames와 lineterminator를 명시해 출력 형태를 통제합니다. 다시 DictReader로 읽고 정수 필드를 복원한 목록이 원본과 같으면 OK를 출력합니다. 다시 읽기가 다르면 LOSS_ROUNDTRIP입니다. 출력은 판정 한 줄이며 입력 자체가 잘못된 JSON인 경우는 과제 범위에서 제외합니다. 오류 메시지 수보다 무엇을 증명하는 검증인지 설명할 수 있는지가 중요합니다.
실패 시 기존 파일을 지키는 미션
미션은 m05 solution 전체를 기반으로 persistence.py를 추가합니다. 기존 store.py의 add_record를 가져와 필드 규칙을 재사용합니다. read_csv는 새 후보 저장소만 만들고 기존 메모리 저장소를 수정하지 않습니다. import_csv는 기존 JSON을 먼저 읽어 손상 여부를 확인하고 CSV 전체를 검증한 뒤 후보 전체 목록으로 저장 파일을 교체합니다. 같은 ID끼리 합치는 기능이 아니므로 가져오기 후 이전 목록 대신 새 목록이 남습니다.
저장 대상 파일을 w로 먼저 열면 마지막 CSV 행이 잘못되어도 이전 기록은 이미 사라집니다. 저장 순서를 검증 → 문자열 직렬화 → 같은 폴더의 임시 파일 쓰기 → 닫기 → replace로 정합니다. 임시 파일을 같은 폴더에 두는 이유는 다른 파일시스템 사이 이동 문제를 줄이기 위해서입니다. 교체 전 실패하면 기존 대상은 그대로 남습니다. 테스트는 잘못된 늦은 행과 교체 실패를 각각 만들어 대상 바이트를 비교합니다.
임시 파일은 실패 경로에서도 정리합니다. 하지만 임시 파일을 쓰고 교체한다고 동시 작성자 충돌을 막거나 정전까지 보장하는 것은 아닙니다. 지금 프로젝트는 한 사람이 로컬에서 실행하는 사용 범위입니다. 별도 파일 잠금·버전 검사·fsync 같은 내구성 장치는 후속 요구에 따라 설계합니다. 미션에서 검증한 범위를 README에 적으면 다음 작성자가 보장되지 않은 동작을 오해하지 않습니다.
첫 실행·손상·빈 목록을 구별합니다
존재하지 않는 JSON 파일은 첫 실행으로 간주해 빈 저장소를 반환합니다. 유효한 [] 파일도 빈 저장소지만 파일이 존재하며 정상적으로 저장한 결과입니다. 0바이트 파일은 JSON 문법을 읽을 수 없으므로 손상으로 취급합니다. 손상 파일을 빈 목록으로 읽고 저장하면 복구할 원본마저 없어질 수 있습니다. 테스트에서 세 상태의 차이를 보여 주고, 오류가 발생한 파일은 덮어쓰기 전에 별도로 보관합니다.
헤더만 있는 CSV를 가져오면 유효한 0건 목록으로 전체 교체합니다. 이 동작을 기존 기록을 유지하는 것으로 잘못 안내하지 않습니다. 실제 업무의 대량 가져오기라면 0건 교체에 대한 추가 확인이나 백업이 필요할 수 있습니다. 이번 교육 과제는 자동 확인 흐름을 늘리기보다 입력 계약과 교체 의미를 정확히 설명하고 작은 테스트 파일로 동작을 확인합니다.
테스트 결과로 인계합니다
미션의 명령은 python3 -m unittest discover -s tests -p "test_*.py"입니다. 기존 35개와 신규 파일 검사 14개를 실행합니다. 실패 메시지에서 예상 값과 실제 값을 보고 저장소 내용이 다른지, 예외가 발생하지 않았는지, 원본 바이트가 달라졌는지를 구분합니다. starter의 기존 테스트는 유지되며 새 파일 기능의 실패를 고치면 solution과 같은 계약을 만족합니다. 단순히 실패를 없애기 위해 테스트를 삭제하지 않습니다.
인계 문서에는 전체 교체 정책, UTF-8 CSV와 JSON의 차이, 물리적 줄 번호의 의미, 빈 금액 거부, completed의 정수 계약, 첫 실행과 손상의 구분을 적습니다. 한글·쉼표·0쪽·중복 ID·새 프로세스 읽기의 증거를 함께 남깁니다. 마지막에는 기존 파일이 유지된 실패 테스트 이름을 제시합니다. 검증한 사례와 남은 한계를 적는 방식은 뒤 모듈의 SQL 공개 보고서와 최종 인계에서도 계속 사용됩니다.
따라하기
표현이 달라도 값은 같습니다
따옴표와 줄 끝이 달라진 CSV를 파싱해서 비교합니다.
import csv, io
original = 'id,title\nb01,"산책"\n'
output = io.StringIO(newline="")
writer = csv.writer(output, lineterminator="\n")
writer.writerows(csv.reader(io.StringIO(original)))
restored = output.getvalue()
print(original == restored)
print(list(csv.reader(io.StringIO(original))) == list(csv.reader(io.StringIO(restored))))실행 결과
False True
동등 비교만으로 놓치는 자료형
정수 0과 불린 False는 동등하지만 저장 계약은 다를 수 있습니다.
before = {"pages": 0}
after = {"pages": False}
print(before == after)
print(type(before["pages"]).__name__, type(after["pages"]).__name__)
print(type(after["pages"]) is int)실행 결과
True int bool False
개수 검사와 중복 검사
개수가 같아도 서로 다른 기록 한 건이 사라질 수 있습니다.
before = ["b01", "b02"]
after = ["b01", "b01"]
print(len(before) == len(after))
print(set(before) == set(after))
print(len(set(after)) == len(after))실행 결과
True False False
CSV·JSON 실제 왕복
제목의 쉼표와 큰따옴표를 파서와 writer에 맡깁니다.
import csv, io, json
original = 'id,title,pages,cost,completed\n001,"책, ""산책""",0,0,1\n'
records = list(csv.DictReader(io.StringIO(original)))
for row in records:
for key in ("pages", "cost", "completed"):
row[key] = int(row[key])
loaded = json.loads(json.dumps(records, ensure_ascii=False))
buffer = io.StringIO(newline="")
writer = csv.DictWriter(buffer, fieldnames=["id", "title", "pages", "cost", "completed"], lineterminator="\n")
writer.writeheader()
writer.writerows(loaded)
print(buffer.getvalue(), end="")
print(list(csv.DictReader(io.StringIO(original))) == list(csv.DictReader(io.StringIO(buffer.getvalue()))))실행 결과
id,title,pages,cost,completed 001,"책, ""산책""",0,0,1 True
확인 문제
실습
입력은 {"csv": 원본CSV문자열, "records": 변환된기록목록} JSON 객체입니다. 원본 CSV는 id,title,pages,cost,completed 헤더, 정규화된 제목, 중복 없는 문자열 ID, 0 이상 정수 pages/cost, 정수 0·1 completed를 만족합니다. 대상 기록도 객체이며 문자열 id가 존재합니다. records를 JSON 왕복 후 비교합니다. 오류 우선순위는 필드 집합(LOSS_FIELDS)→중복 ID(LOSS_DUPLICATE_ID)→개수(LOSS_COUNT)→ID 집합(LOSS_ID)→정확한 자료형(LOSS_TYPE)→ID별 필드값(LOSS_VALUE)→ID 순서(LOSS_ORDER)입니다. 정상이라면 DictWriter로 CSV에 쓰고 다시 파싱·정수 복원하여 원본과 비교합니다. 일치하면 OK, 아니면 LOSS_ROUNDTRIP 한 줄을 출력합니다. 제목의 쉼표·큰따옴표·줄바꿈과 ID 앞의 0을 보존합니다.
모범 답안
import csv, io, json, sys
fields = ["id", "title", "pages", "cost", "completed"]
data = json.load(sys.stdin)
def parse_csv(text):
result = []
for row in csv.DictReader(io.StringIO(text, newline="")):
result.append({field: int(row[field]) if field in ("pages", "cost", "completed")
else row[field] for field in fields})
return result
original = parse_csv(data["csv"])
records = json.loads(json.dumps(data["records"], ensure_ascii=False))
status = None
if any(set(record) != set(fields) for record in records):
status = "LOSS_FIELDS"
else:
ids = [record["id"] for record in records]
old_ids = [record["id"] for record in original]
if len(set(ids)) != len(ids):
status = "LOSS_DUPLICATE_ID"
elif len(records) != len(original):
status = "LOSS_COUNT"
elif set(ids) != set(old_ids):
status = "LOSS_ID"
elif any(type(record[field]) is not (int if field in ("pages", "cost", "completed") else str)
for record in records for field in fields):
status = "LOSS_TYPE"
else:
expected = {record["id"]: record for record in original}
if any(record != expected[record["id"]] for record in records):
status = "LOSS_VALUE"
elif ids != old_ids:
status = "LOSS_ORDER"
else:
buffer = io.StringIO(newline="")
writer = csv.DictWriter(buffer, fieldnames=fields, lineterminator="\n")
writer.writeheader()
writer.writerows(records)
status = "OK" if parse_csv(buffer.getvalue()) == original else "LOSS_ROUNDTRIP"
print(status)
더 읽기
면접 질문
- 다른 사람이 프로그램을 실행하도록 준비한 내용을 설명합니다.
- 메모리와 디스크에 저장된 데이터의 차이를 설명합니다.