본문으로 건너뛰기

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 타입 매핑

PythonJSON
dictobject {}
list, tuplearray []
strstring
int, floatnumber
True / Falsetrue / false
Nonenull

파일과 직접 처리 — 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)