JSON 처리 — json 모듈, 커스텀 직렬화, 설정 파일 패턴
JSON(JavaScript Object Notation)은 웹 API와 설정 파일의 표준 데이터 형식입니다. Python의 json 모듈은 표준 라이브러리에 내장되어 있으며, 기본 타입부터 커스텀 객체까지 유연하게 직렬화할 수 있습니다.
기본 — dumps / loads
import json
# Python 객체 → JSON 문자열 (직렬화)
data = {
"name": "Alice",
"age": 30,
"active": True,
"scores": [95, 87, 92],
"address": None,
}
json_str = json.dumps(data)
# '{"name": "Alice", "age": 30, "active": true, "scores": [95, 87, 92], "address": null}'
# JSON 문자열 → Python 객체 (역직렬화)
parsed = json.loads(json_str)
print(parsed["name"]) # "Alice"
print(type(parsed)) # dict
Python ↔ JSON 타입 매핑
| Python | JSON |
|---|---|
dict | object {} |
list, tuple | array [] |
str | string |
int, float | number |
True / False | true / false |
None | null |
파일과 직접 처리 — dump / load
import json
from pathlib import Path
# 파일에 직렬화
data = {"key": "value", "count": 42}
with open("data.json", "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
# 파일에서 역직렬화
with open("data.json", "r", encoding="utf-8") as f:
loaded = json.load(f)
# pathlib.Path와 사용
path = Path("config.json")
with path.open("r", encoding="utf-8") as f:
config = json.load(f)
indent와 sort_keys 옵션
import json
data = {"z_key": 3, "a_key": 1, "m_key": 2}
# 들여쓰기 없음 (기본 — 네트워크 전송에 적합)
json.dumps(data)
# '{"z_key": 3, "a_key": 1, "m_key": 2}'
# 들여쓰기 4칸
json.dumps(data, indent=4)
# 키 알파벳 정렬
json.dumps(data, sort_keys=True)
# '{"a_key": 1, "m_key": 2, "z_key": 3}'
# 한국어 등 비ASCII 문자 유지 (기본은 \uXXXX 이스케이프)
json.dumps({"name": "홍길동"}, ensure_ascii=False)
# '{"name": "홍길동"}'
# 구분자 커스터마이징 (공백 최소화)
json.dumps(data, separators=(",", ":"))
# '{"z_key":3,"a_key":1,"m_key":2}'
커스텀 직렬화 — default 함수
json.dumps()는 기본적으로 dict, list, str, int, float, bool, None만 직렬화합니다. 다른 타입은 default 함수나 JSONEncoder로 처리합니다.
default 함수 방식
import json
from datetime import datetime, date
from decimal import Decimal
from pathlib import Path
from uuid import UUID
def json_default(obj):
"""json.dumps의 default 파라미터에 전달할 함수"""
if isinstance(obj, datetime):
return obj.isoformat() # "2024-01-15T10:30:00"
if isinstance(obj, date):
return obj.isoformat() # "2024-01-15"
if isinstance(obj, Decimal):
return float(obj) # 또는 str(obj)로 정밀도 유지
if isinstance(obj, Path):
return str(obj)
if isinstance(obj, UUID):
return str(obj)
if isinstance(obj, set):
return sorted(obj) # set → 정렬된 list
if hasattr(obj, "__dict__"):
return obj.__dict__ # 일반 객체의 속성 딕셔너리
raise TypeError(f"직렬화 불가: {type(obj).__name__}")
# 사용
data = {
"created_at": datetime.now(),
"price": Decimal("19.99"),
"tags": {"python", "tutorial"},
"path": Path("/home/user/data.txt"),
}
print(json.dumps(data, default=json_default, ensure_ascii=False, indent=2))
커스텀 JSONEncoder 클래스
import json
from datetime import datetime
from decimal import Decimal
from uuid import UUID
class AppJSONEncoder(json.JSONEncoder):
"""애플리케이션 전용 JSON 인코더"""
def default(self, obj):
if isinstance(obj, datetime):
return {"__type__": "datetime", "value": obj.isoformat()}
if isinstance(obj, Decimal):
return {"__type__": "decimal", "value": str(obj)}
if isinstance(obj, UUID):
return {"__type__": "uuid", "value": str(obj)}
return super().default(obj) # 처리 불가 타입은 부모에게 위임
class AppJSONDecoder(json.JSONDecoder):
"""AppJSONEncoder와 쌍을 이루는 디코더"""
def __init__(self, **kwargs):
super().__init__(object_hook=self._object_hook, **kwargs)
@staticmethod
def _object_hook(d: dict):
type_tag = d.get("__type__")
if type_tag == "datetime":
return datetime.fromisoformat(d["value"])
if type_tag == "decimal":
return Decimal(d["value"])
if type_tag == "uuid":
return UUID(d["value"])
return d
# 사용
from decimal import Decimal
from uuid import uuid4
original = {
"id": uuid4(),
"price": Decimal("99.99"),
"created": datetime.now(),
}
encoded = json.dumps(original, cls=AppJSONEncoder, ensure_ascii=False)
decoded = json.loads(encoded, cls=AppJSONDecoder)
print(type(decoded["price"])) # <class 'decimal.Decimal'>
print(type(decoded["created"])) # <class 'datetime.datetime'>
datetime과 Decimal 직렬화 패턴
실무에서 가장 자주 마주치는 두 타입의 권장 패턴입니다.
import json
from datetime import datetime
from decimal import Decimal
# Decimal → str (정밀도 유지, 권장)
def decimal_to_str(obj):
if isinstance(obj, Decimal):
return str(obj) # "19.99" — float으로 변환 시 정밀도 손실
raise TypeError
# datetime → ISO 8601
def datetime_to_iso(obj):
if isinstance(obj, datetime):
return obj.isoformat()
raise TypeError
# 통합 encoder
def encode(obj):
if isinstance(obj, datetime):
return obj.isoformat()
if isinstance(obj, Decimal):
return str(obj)
raise TypeError(f"{type(obj)}")
# 역직렬화: ISO 8601 → datetime
from datetime import datetime
dt = datetime.fromisoformat("2024-01-15T10:30:00")
설정 파일 패턴
import json
from pathlib import Path
from typing import Any
import copy
class ConfigManager:
"""
JSON 기반 설정 파일 관리자.
기본값과 사용자 설정을 병합합니다.
"""
DEFAULTS: dict[str, Any] = {
"server": {
"host": "0.0.0.0",
"port": 8000,
"debug": False,
"workers": 4,
},
"database": {
"url": "sqlite:///app.db",
"pool_size": 5,
"timeout": 30,
},
"cache": {
"backend": "memory",
"ttl": 300,
},
"logging": {
"level": "INFO",
"format": "json",
},
}
def __init__(self, config_path: str | Path) -> None:
self.config_path = Path(config_path)
self._config = copy.deepcopy(self.DEFAULTS)
self._load()
def _load(self) -> None:
if not self.config_path.exists():
return
with self.config_path.open("r", encoding="utf-8") as f:
user_config = json.load(f)
self._deep_merge(self._config, user_config)
@staticmethod
def _deep_merge(base: dict, override: dict) -> None:
"""override의 값을 base에 재귀적으로 병합합니다."""
for key, value in override.items():
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
ConfigManager._deep_merge(base[key], value)
else:
base[key] = value
def get(self, *keys: str, default: Any = None) -> Any:
"""점 표기법 경로로 값을 가져옵니다: get("server", "port")"""
node = self._config
for key in keys:
if not isinstance(node, dict) or key not in node:
return default
node = node[key]
return node
def save(self) -> None:
with self.config_path.open("w", encoding="utf-8") as f:
json.dump(self._config, f, ensure_ascii=False, indent=2)
# 사용
config = ConfigManager("config.json")
port = config.get("server", "port") # 8000 (기본값)
debug = config.get("server", "debug") # False
실전 예제 — API 응답 처리
import json
from dataclasses import dataclass, asdict
from datetime import datetime
@dataclass
class User:
id: int
name: str
email: str
created_at: datetime
def to_json(self) -> str:
d = asdict(self)
d["created_at"] = self.created_at.isoformat()
return json.dumps(d, ensure_ascii=False)
@classmethod
def from_json(cls, json_str: str) -> "User":
d = json.loads(json_str)
d["created_at"] = datetime.fromisoformat(d["created_at"])
return cls(**d)
def parse_api_response(response_body: str) -> list[User]:
"""API JSON 응답을 파싱합니다."""
try:
data = json.loads(response_body)
except json.JSONDecodeError as e:
raise ValueError(f"JSON 파싱 실패 (위치 {e.pos}): {e.msg}") from e
if not isinstance(data, list):
raise ValueError(f"배열 응답이 필요합니다. 받은 타입: {type(data).__name__}")
users = []
for i, item in enumerate(data):
try:
users.append(User(
id=int(item["id"]),
name=str(item["name"]),
email=str(item["email"]),
created_at=datetime.fromisoformat(item["created_at"]),
))
except (KeyError, ValueError) as e:
print(f"[경고] {i}번째 항목 건너뜀: {e}")
return users
고수 팁
팁 1 — json.JSONDecodeError의 위치 정보
import json
bad_json = '{"key": "value",}' # 후행 쉼표
try:
json.loads(bad_json)
except json.JSONDecodeError as e:
print(e.msg) # Expecting property name enclosed in double quotes
print(e.lineno) # 오류 발생 줄
print(e.colno) # 오류 발생 열
print(e.pos) # 문자 위치
팁 2 — 스트리밍 JSON 파싱
큰 JSON 파일을 다룰 때는 ijson 패키지(pip install ijson)로 스트리밍 파싱을 사용합니다.
# pip install ijson
import ijson
with open("large.json", "rb") as f:
for item in ijson.items(f, "item"): # 최상위 배열의 각 요소
process(item)
팁 3 — JSON5 / JSONC (주석 있는 설정 파일)
JSON은 주석을 지원하지 않습니다. 설정 파일에 주석이 필요하다면 json5 또는 tomllib(Python 3.11+)을 사용합니다.
# pip install json5
import json5
with open("config.jsonc", "r") as f:
config = json5.load(f) # // 주석 지원
팁 4 — __slots__ 클래스를 json 직렬화
__slots__를 사용하는 클래스는 __dict__가 없으므로 별도 처리가 필요합니다.
def encode_slots(obj):
if hasattr(obj, "__slots__"):
return {slot: getattr(obj, slot) for slot in obj.__slots__}
raise TypeError
json.dumps(obj, default=encode_slots)