응답 자료를 기록으로 연결
65분 안팎
학습 목표
전달 형식과 업무 규칙을 분리합니다.
개념
형식이 맞아도 독서 기록이 맞지는 않습니다
HTTP 응답을 받은 뒤에는 전달된 자료가 프로젝트의 계산 계약에 맞는지 확인합니다. Content-Type이 JSON이라고 해도 본문이 손상되었거나 오류 자료일 수 있습니다. JSON 문법이 정상이어도 쪽수가 문자열이거나 합계가 행과 다를 수 있습니다. 이 레슨은 네트워크 요청 없이 제공된 응답 문자열을 읽고 공개 보고서의 업무 규칙을 검증하는 브라우저 실습입니다.
입력은 한 JSON 객체이며 rows, completed, total_pages 세 키만 허용합니다. rows는 전체 공개 기록 목록이고 각 기록은 id, title, pages, completed 네 키만 가집니다. id와 title은 공백만으로 이루어지지 않은 문자열입니다. ID는 고유해야 합니다. pages는 0 이상 정수 또는 null이고 completed는 0, 1 또는 null입니다. 정렬된 ID 순서의 rows와 completed를 요구하여 기존 SQL 보고서 계약을 유지합니다.
completed 목록은 rows 중 완료 표시가 정수 1인 행을 순서대로 선택한 결과와 정확히 같아야 합니다. total_pages는 그 행의 알려진 쪽수 합이며 쪽수가 null인 행은 합산에서 제외합니다. 알려진 값이 없거나 목록이 비면 합계는 정수 0입니다. 성공 출력은 전체 건수, 완료 건수, 합계를 REPORT|전체|완료|합계 형식으로 나타냅니다. 검증 실패는 ERR_REPORT 한 줄로 반환합니다.
파싱과 규칙 검증을 나눕니다
json.loads는 문자열을 Python 값으로 변환합니다. JSON의 null은 None, 배열은 리스트, 객체는 딕셔너리로 읽힙니다. 파싱에 성공했다는 것은 문법과 표현을 해석했다는 뜻입니다. 반환값이 객체인지, 필드가 무엇인지, 값이 도메인 규칙에 맞는지는 직접 확인해야 합니다. 문자열인 7을 정수 7로 자동 변환하면 응답 계약 위반을 숨기므로 이 과제는 거부합니다.
Python에서 bool은 int의 하위 유형이어서 isinstance(True, int)는 참입니다. 하지만 이 프로젝트는 completed의 true와 쪽수의 false를 정수로 허용하지 않습니다. type(value) is int처럼 정확한 유형을 검사해야 합니다. JSON의 0과 false는 다른 표현이며 업무에서 쓰는 완료 표시는 정수 0 또는 1입니다. 동일하게 total_pages에도 불린과 7.0 같은 실수는 허용하지 않습니다.
키 목록을 정확히 검사하는 이유는 개인정보 필드가 추가된 응답을 조용히 받아들이지 않기 위해서입니다. owner_email이 있다면 화면 출력에서 생략하는 대신 응답 계약 위반으로 처리합니다. 이 규칙은 이 실습의 엄격한 공개 보고서 버전에 대한 정책입니다. 모든 실제 API가 새 필드를 거부해야 한다는 일반 규칙으로 확대하지 않습니다. 확장 허용 여부도 API 계약의 일부입니다.
합계를 독립적으로 대조합니다
응답의 total_pages를 그대로 출력하면 전송 값이 틀려도 과제를 통과한 것처럼 보입니다. rows에서 완료 행을 새로 선택하고 pages가 None이 아닌 값만 더해 total_pages와 대조합니다. 예를 들어 완료 행의 쪽수가 7과 null이면 완료 건수는 2, 합계는 7입니다. null을 이유로 완료 행 자체를 지우면 completed 목록의 의미가 바뀝니다.
완료 목록을 별도로 검증하지 않으면 total_pages만 맞는 불일치 보고서가 통과할 수 있습니다. rows에 완료 1인 행이 있지만 completed가 빈 목록이면 실패해야 합니다. 반대로 완료 0인 행이 completed에 들어 있어도 실패입니다. 목록의 순서와 내용이 같은지 비교하고 전체 ID 정렬도 확인합니다. 중복 제목은 허용하지만 중복 ID는 동일 기록 구분을 깨뜨리므로 거부합니다.
빈 보고서는 예외가 아니라 정상 결과입니다. rows와 completed가 모두 빈 목록이고 total_pages가 0이면 REPORT|0|0|0을 출력합니다. 빈 문자열은 JSON이 아니므로 ERR_REPORT입니다. 쪽수 0인 완료 기록 한 건은 REPORT|1|1|0이 됩니다. 출력 합계의 0과 null 쪽수의 의미를 혼동하지 않도록 경계 테스트에서 두 경우를 따로 확인합니다.
실패를 작고 일관되게 반환합니다
JSONDecodeError는 JSON 문법 오류이고 키 누락 또는 잘못된 자료형은 보고서 규칙 위반입니다. 내부적으로 다른 단계에서 발견해도 이 실습의 표준 출력은 ERR_REPORT로 통일합니다. 다만 모든 Exception을 잡아 개발자의 오타까지 숨기지는 않습니다. 명시적인 검증 함수에서 ValueError로 거부하고 JSONDecodeError와 함께 예상한 실패만 처리하면 구현 오류를 디버깅할 수 있습니다.
중복 객체 키도 살펴봅니다. 일반 json.loads는 같은 이름의 키가 여러 번 나오면 뒤 값을 채택할 수 있습니다. 이 과제는 object_pairs_hook으로 중복 키를 찾아 거부합니다. 값이 같더라도 중복은 허용하지 않습니다. total_pages가 두 번 나온 입력을 성공으로 처리하면 송신자의 모호한 표현이 가려집니다. 검증 단계는 전송 자료를 임의로 고치는 단계와 구분합니다.
starter는 파싱된 total_pages만 읽어 임시 결과를 출력합니다. 정상 사례 하나에만 맞춘 코드를 늘리는 대신 최상위 구조, 행 구조, 값 유형, 고유 ID, 정렬, 완료 목록, 합계 순서로 검사합니다. 테스트가 실패하면 입력을 바꾸거나 정답 출력을 하드코딩하지 않고 어느 규칙이 빠졌는지 찾습니다. 출력에는 소유자나 전체 입력을 덧붙이지 않으며 오류 메시지도 정해진 한 줄을 유지합니다.
실습을 마치면 전송 성공, JSON 해석 성공, 업무 계약 만족을 각각 설명합니다. 200 본문에 오류 객체가 온 경우에는 형식 검증이 실패할 수 있고, 정상 공개 보고서라도 HTTP가 어떤 상태로 전달했는지는 이 문자열 실습만으로 알 수 없습니다. 앞 레슨의 관찰표와 이번 계산 결과를 함께 두어 HTTP 책임과 보고서 책임의 경계를 보여 줍니다. 파일 입출력의 자세한 사용법은 더 읽기로 이어갑니다.
따라하기
null과 정수 읽기
JSON을 해석한 뒤에도 업무 검증은 필요합니다.
import json
r = json.loads('{"pages": null, "completed": 1}')
print(r["pages"] is None, type(r["completed"]).__name__)실행 결과
True int
불린 경계 확인
불린을 쪽수나 완료 정수로 허용하지 않을 검사 방식을 고릅니다.
print(isinstance(True, int), type(True) is int)실행 결과
True False
완료 합계 계산
측정되지 않은 쪽수는 합에서 제외하되 완료 건수에는 포함합니다.
rows = [{"pages": 7, "completed": 1}, {"pages": None, "completed": 1}, {"pages": 20, "completed": 0}]
completed = [r for r in rows if r["completed"] == 1]
print(len(completed), sum(r["pages"] for r in completed if r["pages"] is not None))실행 결과
2 7
확인 문제
실습
표준 입력의 JSON 공개 보고서를 검증합니다. 본문의 키·유형·정렬·고유 ID·완료 목록·합계 계약을 모두 검사합니다. 성공은 REPORT|전체건수|완료건수|합계, 실패는 ERR_REPORT 한 줄을 출력합니다. 중복 객체 키도 실패이며 입력을 자동 보정하지 않습니다.
모범 답안
import json, sys
def unique(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError("DUPLICATE_KEY")
result[key] = value
return result
def validate(r):
if type(r) is not dict or set(r) != {"rows", "completed", "total_pages"}:
raise ValueError("SHAPE")
if type(r["rows"]) is not list or type(r["completed"]) is not list:
raise ValueError("LIST")
ids = []
for row in r["rows"]:
if type(row) is not dict or set(row) != {"id", "title", "pages", "completed"}:
raise ValueError("COLUMNS")
for key in ["id", "title"]:
if type(row[key]) is not str or not row[key].strip():
raise ValueError("TEXT")
p, c = row["pages"], row["completed"]
if p is not None and (type(p) is not int or p < 0):
raise ValueError("PAGES")
if c is not None and (type(c) is not int or c not in (0, 1)):
raise ValueError("COMPLETED")
ids.append(row["id"])
if ids != sorted(ids) or len(set(ids)) != len(ids):
raise ValueError("IDS")
done = [row for row in r["rows"] if row["completed"] == 1]
# 비교만으로 bool과 int를 구분하지 못하므로 완료 행의 유형도 확인합니다.
for row in r["completed"]:
if type(row) is not dict or set(row) != {"id", "title", "pages", "completed"}:
raise ValueError("DONE_COLUMNS")
if type(row["completed"]) is not int or row["completed"] != 1:
raise ValueError("DONE_FLAG")
if row["pages"] is not None and type(row["pages"]) is not int:
raise ValueError("DONE_PAGES")
if done != r["completed"]:
raise ValueError("DONE")
total = sum(row["pages"] for row in done if row["pages"] is not None)
if type(r["total_pages"]) is not int or total != r["total_pages"]:
raise ValueError("TOTAL")
return f"REPORT|{len(ids)}|{len(done)}|{total}"
try:
print(validate(json.loads(sys.stdin.read(), object_pairs_hook=unique)))
except (ValueError, json.JSONDecodeError):
print("ERR_REPORT")
더 읽기
면접 질문
- JSON 해석 성공과 독서 보고서 규칙 검증 성공을 어떻게 구분합니까?