Devin.KR

JSON 자료형 유지하기

80분 안팎

학습 목표

문자열·숫자·참값·목록을 구분합니다.

개념

모양보다 자료형을 보존합니다

CSV의 쪽수 "12"를 그대로 JSON으로 쓰면 JSON에서도 문자열 "12"가 됩니다. JSON으로 바꾸었다는 이유만으로 숫자를 복원하지는 않습니다. 다음 실행에서 쪽수에 1을 더할 수 있어야 한다면 저장 전에 Python 정수 12로 변환해야 합니다. 변환 책임은 데이터를 읽는 코드에 있습니다. 파일 확장자나 출력 글자의 모양만 보고 자료형을 판단하지 않고 type과 실제 연산으로 확인합니다.

JSON은 문자열·수·불린·null·배열·객체를 표현하는 텍스트 형식입니다. Python의 str은 문자열, int는 정수 표기의 수, bool은 true 또는 false, None은 null, list는 배열, 문자열 키 dict는 객체로 대응합니다. JSON 수를 읽었을 때 정수 표기와 소수점 표기는 기본 Python 파서에서 int와 float로 다르게 복원될 수 있습니다. 이번 프로젝트는 쪽수·원 단위 금액을 정수로 저장합니다.

앞 모듈의 completed는 정수 0 또는 1입니다. 사람이 보기에는 불린 true와 1이 모두 완료를 뜻할 수 있지만 API 계약에서 같은 자료형은 아닙니다. 이번 미션은 기존 함수와 테스트를 유지하기 위해 completed를 정수로 남깁니다. 불린의 대응은 따라하기에서 별도로 관찰하고, 저장 형식을 바꾸는 개선은 다른 소비자까지 함께 수정할 때 진행합니다. 값의 의미와 값의 표현을 구분하는 연습입니다.

텍스트 입출력 함수와 파일 함수

json.dumps는 Python 값을 JSON 문자열로 만들고 json.loads는 JSON 문자열을 Python 값으로 읽습니다. 마지막 s는 문자열을 다루는 쪽을 구분하는 이름입니다. json.dump와 json.load는 열린 파일 핸들을 받습니다. Path 객체를 dump의 파일 인자에 직접 넣지 말고 with로 파일을 열어 전달합니다. 이 구분이 흐려지면 AttributeError처럼 예상하지 못한 속성 오류가 나타날 수 있습니다.

ensure_ascii=False는 한글을 사람이 읽는 글자로 출력하도록 하는 옵션입니다. 기본 설정에서 한글이 유니코드 escape로 보이더라도 다시 읽으면 같은 문자열일 수 있습니다. 이 옵션은 파일 인코딩을 바꾸는 기능이 아닙니다. 파일을 열 때의 encoding="utf-8"과 JSON 문자열 표현을 고르는 옵션을 함께 이해합니다. 한글이 화면에서 보인다는 사실만으로 쪽수의 자료형까지 검증한 것은 아닙니다.

indent=2는 사람이 읽기 좋게 줄과 들여쓰기를 추가합니다. 구분자 뒤 공백이나 객체 키 순서가 다르더라도 같은 의미의 JSON일 수 있습니다. 저장 후 내용 비교는 문자열 전체의 동일성보다 파싱한 기록의 필드와 자료형을 중심으로 합니다. 다만 실패 후 기존 파일을 지켰는지는 바이트 비교가 더 명확합니다. 비교 목적에 따라 검증 수준을 선택하며 모든 비교를 한 가지 방식으로 처리하지 않습니다.

CSV에서 기록 객체로 옮기는 경계

이 레슨의 브라우저 과제는 CSV를 한 번에 받아 ID 저장소를 구성하고 그 값 목록을 JSON 배열로 출력합니다. 헤더와 열 개수 검사를 먼저 진행한 다음 ID를 strip해 비어 있거나 이미 사용되었으면 거부합니다. 이어 제목, 쪽수, 금액, 완료를 순서대로 검사합니다. 정상 행만 후보 저장소에 추가하고 파일 전체 검증이 끝나기 전에는 출력하지 않습니다. 마지막 행 실패가 부분 성공으로 보이지 않게 하기 위한 순서입니다.

쪽수와 금액은 양끝 공백을 제거한 뒤 부호가 선택적으로 붙은 ASCII 숫자 문자열을 받습니다. 빈 문자열은 EMPTY, 숫자 형식이 아니면 NOT_INTEGER, 변환한 수가 음수이면 NEGATIVE 오류입니다. 0과 +0은 정수 0으로 저장합니다. 문자열 "3.0"이나 "1,000"은 정수 입력 계약에 없으므로 거부합니다. 금액 안의 쉼표를 자동 삭제하는 기능은 이번 프로젝트에 추가하지 않습니다.

완료 입력은 CSV의 "0" 또는 "1"을 받아 int로 변환합니다. bool("0")을 사용하면 비어 있지 않은 문자열이라 True가 되어 미완료 기록을 완료로 바꿉니다. 같은 이유로 bool("false")도 원하는 해석이 아닙니다. 문자열의 업무 값을 명시적으로 비교한 뒤 변환하면 조용한 오분류를 줄일 수 있습니다. 모든 변환을 편의 함수 한 줄로 끝내려 하기보다 계약을 드러내는 분기문을 작성합니다.

파싱 성공과 스키마 성공은 다릅니다

json.loads("[]")는 빈 목록을, json.loads("{}")는 빈 딕셔너리를 반환합니다. 둘 다 문법에 맞지만 이번 저장 파일의 최상위 계약은 기록 목록이므로 {}는 거부합니다. 기록에는 id,title,pages,cost,completed 다섯 필드가 있고 추가되거나 빠진 필드는 오류입니다. 객체 키가 여러 번 나오면 기본 파서는 뒤의 값으로 덮을 수 있으므로 미션은 object_pairs_hook으로 중복 키를 거부합니다. 원본 정보가 조용히 사라지는 일을 막기 위한 검사입니다.

Python에서 bool은 int의 하위 자료형이므로 isinstance(True,int)는 참입니다. 그러나 JSON의 true를 쪽수 1로 받아들이면 자료 계약이 느슨해집니다. 정수를 엄격하게 요구하는 저장 검사에서는 type(value) is int를 사용합니다. 자료형 검사를 한 뒤 0 이상인지, completed가 0 또는 1인지도 별도로 확인합니다. 자료형만 맞아도 업무상 허용하지 않은 수가 들어올 수 있습니다.

ID는 문자열로 유지합니다. "001"을 정수 1로 바꾸면 앞의 0이 사라지고 "1"과 구분할 수 없습니다. JSON 객체의 키를 숫자로 사용해도 텍스트 JSON을 거치면 문자열 키로 돌아올 수 있습니다. 이번 저장 형태는 키와 기록을 각각 두 번 저장하는 객체 대신 ID 필드를 가진 기록 목록을 사용하고, 읽은 다음 ID 저장소를 재구성합니다. 기록 목록 안에서도 중복 ID는 거부합니다.

오류를 읽고 데이터 손상을 피합니다

JSONDecodeError는 따옴표·쉼표·괄호 같은 문법이 맞지 않거나 문서가 비었다는 뜻입니다. 메시지의 line과 column은 JSON 텍스트 안의 위치이고 CSV 행 번호와 다릅니다. 파일을 {}로 바꿔 오류를 없애려 하지 말고 원본을 별도로 보관해 마지막 정상 백업과 비교합니다. 읽기 실패를 곧바로 첫 실행의 빈 목록으로 바꾸면 다음 저장에서 기존 기록이 사라질 수 있습니다.

TypeError: Object of type set is not JSON serializable은 입력이 JSON에서 지원하지 않는 Python 집합이라는 단서입니다. 날짜나 사용자 정의 객체도 표현 규칙이 필요할 수 있습니다. 이 모듈의 기록은 문자열과 정수만 사용하므로 지원하지 않는 객체가 들어오면 계약 위반을 먼저 조사합니다. 임의로 str(value)로 바꾸면 출력은 되더라도 복원할 수 있는 구조를 잃어버릴 수 있습니다.

JSON 문서 전체를 두 번 이어 쓰면 load에서 Extra data 오류를 만날 수 있습니다. 한 파일에 한 문서를 저장하는 방식과 줄마다 독립 JSON을 저장하는 방식은 다른 계약입니다. 이번 저장 함수는 전체 목록 하나를 새 파일로 만든 다음 교체합니다. 형식 선택을 README에 적고, 상대가 어떤 읽기 함수를 쓸지까지 생각해야 전달 가능한 자료가 됩니다.

완료 확인의 범위

과제 결과에서 pages와 cost에 큰따옴표가 없는지, completed가 true가 아니라 0 또는 1인지, 제목에 한글과 쉼표가 남아 있는지 살펴봅니다. 정상 행 두 개를 변환한 뒤 원래 순서가 유지되는지도 확인합니다. 오류 테스트에서는 ERR_DUPLICATE_ID와 ERR_PAGES_NOT_INTEGER처럼 이유가 식별되는지 봅니다. 문자 표현 검사·구조 검사·업무값 검사를 나누어 설명하면 변환 로직을 다른 파일 형식에도 적용할 수 있습니다.

따라하기

JSON에서 자료형 관찰

문자열·정수·불린·목록을 JSON 왕복 후 확인합니다. 기존 completed 계약과 비교합니다.

import json
value = {"title": "책", "pages": 0, "flag": True, "tags": ["독서"], "memo": None}
text = json.dumps(value, ensure_ascii=False)
print(text)
restored = json.loads(text)
for key in value:
    print(key, type(restored[key]).__name__)

실행 결과

{"title": "책", "pages": 0, "flag": true, "tags": ["독서"], "memo": null}
title str
pages int
flag bool
tags list
memo NoneType

문자열 0의 함정

bool은 문자열 내용을 숫자로 해석하지 않습니다.

import json
raw = "0"
print(bool(raw))
print(json.dumps({"completed": int(raw)}))
print(json.dumps({"completed": bool(raw)}))

실행 결과

True
{"completed": 0}
{"completed": true}

정수 계약과 불린 구별

isinstance와 정확한 자료형 검사가 서로 다른 답을 주는 이유를 확인합니다.

import json
for text in ("0", "0.0", "true", '"0"'):
    value = json.loads(text)
    print(text, type(value).__name__, isinstance(value, int), type(value) is int)

실행 결과

0 int True True
0.0 float False False
true bool True False
"0" str False False

JSON 문법 오류 위치 읽기

문법에 맞지 않는 문서와 두 문서를 붙인 입력을 분리해 관찰합니다.

import json
for text in ('[{"id": "b01",}]', '[] []'):
    try:
        json.loads(text)
    except json.JSONDecodeError as e:
        print(e.msg, e.lineno, e.colno)

실행 결과

Illegal trailing comma before end of object 1 14
Extra data 1 4

확인 문제

실습

표준 입력 CSV의 헤더는 id,title,pages,cost,completed 순서입니다. BOM은 제거하고 엄격 CSV 파싱을 사용합니다. 열 개수→빈 ID→중복 ID→빈 제목→쪽수→금액→완료 순서로 검사합니다. 텍스트는 strip합니다. 쪽수·금액은 선택적 부호와 ASCII 숫자만 허용하며 0 이상 정수, 완료는 0 또는 1 정수로 변환합니다. 정상은 입력 순서대로 id,title,pages,cost,completed 필드를 가진 객체 목록을 JSON 한 줄로 출력합니다. 헤더만 있으면 []입니다. 첫 오류는 CSV_HEADER 또는 ROW N: ERR_COLUMNS/ERR_ID/ERR_DUPLICATE_ID/ERR_TITLE/ERR_PAGES_EMPTY/ERR_PAGES_NOT_INTEGER/ERR_PAGES_NEGATIVE/ERR_COST_EMPTY/ERR_COST_NOT_INTEGER/ERR_COST_NEGATIVE/ERR_COMPLETED/CSV_SYNTAX로 출력합니다. N은 해당 레코드 끝 물리적 줄 번호입니다. 성공 부분 결과는 출력하지 않습니다.

모범 답안
import csv, io, json, re, sys
fields = ["id", "title", "pages", "cost", "completed"]
reader = csv.DictReader(io.StringIO(sys.stdin.read().removeprefix("\ufeff"), newline=""), strict=True)
store = {}
error = None

def number(raw, field):
    text = raw.strip()
    if not text:
        return None, "ERR_" + field + "_EMPTY"
    if not re.fullmatch(r"[+-]?[0-9]+", text):
        return None, "ERR_" + field + "_NOT_INTEGER"
    try:
        value = int(text)
    except ValueError:
        return None, "ERR_" + field + "_NOT_INTEGER"
    if value < 0:
        return None, "ERR_" + field + "_NEGATIVE"
    return value, None

if reader.fieldnames != fields:
    error = "CSV_HEADER"
else:
    try:
        for row in reader:
            status = None
            if None in row or any(v is None for v in row.values()):
                status = "ERR_COLUMNS"
            else:
                record_id, title = row["id"].strip(), row["title"].strip()
                if not record_id:
                    status = "ERR_ID"
                elif record_id in store:
                    status = "ERR_DUPLICATE_ID"
                elif not title:
                    status = "ERR_TITLE"
                else:
                    pages, status = number(row["pages"], "PAGES")
                    if status is None:
                        cost, status = number(row["cost"], "COST")
                    if status is None and row["completed"].strip() not in ("0", "1"):
                        status = "ERR_COMPLETED"
                    if status is None:
                        store[record_id] = dict(id=record_id, title=title, pages=pages,
                                                cost=cost, completed=int(row["completed"].strip()))
            if status:
                error = f"ROW {reader.line_num}: {status}"
                break
    except csv.Error:
        error = f"ROW {reader.line_num}: CSV_SYNTAX"
print(error if error else json.dumps(list(store.values()), ensure_ascii=False))

더 읽기

면접 질문

  • 메모리와 디스크에 저장된 데이터의 차이를 설명합니다.