JSON 중복 키 검사: dict로 합치기 전에 같은 이름 잡기

이 카테고리 글 목록

전체글 보기

같은 JSON 객체에 이름이 두 번 나오면, 일반 dict로 바뀌기 전에 중복을 검출해야 한다. Python의 기본 json.loads는 같은 이름의 마지막 값만 남긴다. 이미 만들어진 dict를 검사하는 단계에서는 앞에 어떤 값이 있었는지 복구할 수 없다. 입력 단계에서 중복 이름을 거부할 계약이라면 object_pairs_hook를 사용한다.

여기서 말하는 ‘중복 키’는 한 객체 안에서 이름이 반복되는 경우다. 배열에 든 서로 다른 객체가 각각 role 필드를 갖는 것은 같은 문제가 아니다. 아래 역할값과 JSON은 설명용 합성 입력이며 실제 계정이나 권한 시스템의 관측값이 아니다.

‘파싱 성공’이 중복 이름의 안전한 처리를 뜻하지 않는다

RFC 8259 §4는 객체 이름의 유일성을 SHOULD로 권고하고, 중복이 있을 때 구현별 동작이 달라질 수 있다고 설명한다. 따라서 중복 이름을 모든 JSON 파서가 반드시 문법 오류로 거부한다고 단정할 수 없다. Python json의 중복 이름 설명에서 Python 기본 디코더가 마지막 값만 사용함을 확인할 수 있다.

예를 들어 {"role":"reader","role":"admin"}을 기본 설정으로 읽으면 {'role': 'admin'}만 남는다. 그 뒤 role이 허용된 문자열인지 확인해도 원문에 두 번 있었다는 사실은 알 수 없다. 스키마 검사를 더 촘촘하게 하는 것만으로 앞서 버린 정보를 되살릴 수는 없다.

이름·값 쌍을 받는 시점에 거부한다

object_pairs_hook 공식 계약는 객체를 곧바로 dict로 만들기 전에 순서 있는 이름·값 쌍 목록을 callback에 전달한다. 아래 함수는 객체마다 새 dict를 만들고, 값을 넣기 전에 이름의 존재 여부를 확인한다. 값이 null·0·false여도 중복을 놓치지 않도록 result.get(name)의 참/거짓으로 판단하지 않는다.

코드는 CPython 3.14.3·Windows 11에서 실행했다. 설명은 Python 3.14 계열 공식 문서와 대조했으며, 문서 사이트의 현재 패치 판본은 실행한 인터프리터와 다를 수 있다.

import json

class DuplicateName(ValueError):
    pass

def unique_object(pairs):
    result = {}
    for name, value in pairs:
        if name in result:  # null, 0, false 값도 존재 여부로 검사한다.
            raise DuplicateName(f"duplicate name: {name!r}")
        result[name] = value
    return result

def reject_constant(name):
    raise ValueError(f"non-JSON constant: {name}")

def loads_unique(text):
    return json.loads(
        text,
        object_pairs_hook=unique_object,
        parse_constant=reject_constant,
    )

print("default:", json.loads('{"role":"reader","role":"admin"}'))
cases = [
    ("flat", '{"role":"reader","role":"admin"}'),
    ("nested", '{"user":{"role":"reader","role":"admin"}}'),
    ("separate", '[{"role":"reader"},{"role":"admin"}]'),
    ("escaped", r'{"x":1,"\u0078":2}'),
    ("null-first", '{"x":null,"x":2}'),
]
for label, text in cases:
    try:
        print(label + ": OK", loads_unique(text))
    except DuplicateName as error:
        print(label + ": REJECT", str(error))

# JSON escape 해제와 Unicode 정규화는 다른 정책이다.
different = loads_unique(r'{"\u00e9":1,"e\u0301":2}')
print("normalization-not-applied:", len(different))
for text in ["NaN", '{"x":1,}']:
    try:
        loads_unique(text)
    except ValueError as error:
        print("extra-rejection:", type(error).__name__)

주요 출력은 다음과 같다. 기본 파싱의 마지막 값 채택과, 같은 원문의 중복 거부를 나란히 볼 수 있다.

default: {'role': 'admin'}
flat: REJECT duplicate name: 'role'
nested: REJECT duplicate name: 'role'
separate: OK [{'role': 'reader'}, {'role': 'admin'}]
escaped: REJECT duplicate name: 'x'
null-first: REJECT duplicate name: 'x'
normalization-not-applied: 2
extra-rejection: ValueError
extra-rejection: JSONDecodeError

어느 이름을 ‘같다’고 보는가

입력 상황 이 코드의 결과 판단 기준
한 객체에 role 두 번 거부 그 객체의 이름이 반복됨
중첩된 user 안에 role 두 번 거부 중첩 객체에도 hook 적용
배열의 두 객체에 role 하나씩 허용 서로 다른 객체의 필드
x와 \u0078 거부 JSON escape 해제 뒤 같은 문자열
é와 e+\u0301 이름 두 개로 허용 Unicode 정규화는 별도 정책

마지막 두 행을 혼동하면 안 된다. JSON escape를 해제하는 일과, 서로 다른 Unicode 문자열을 정규화해 같은 이름으로 취급하는 일은 다르다. 예제는 디코더가 넘긴 문자열 그대로 비교한다. 정규화나 대소문자 무시가 필요한 업무라면 원문 보존과 충돌 처리 규칙을 별도로 정해야 한다.

이 검사가 끝난 뒤에도 남는 검증

예제는 기본 디코더가 받아들이는 비표준 상수 NaN·Infinity·-Infinity도 parse_constant로 거부한다. 그러나 입력 크기·중첩 한도·숫자 범위·필수 필드·원문 근거·실행 권한 전체를 검증하는 완성형 입력 장벽은 아니다. 특히 이 hook의 중복 오류는 이름을 알려주지만 전체 객체 경로를 제공하지 않는다.

중복 거부가 필요한 원문은 이 loader로 처음 읽고, 그 결과에 스키마와 업무 검증을 적용한다. 다른 곳에서 이미 dict로 바꾼 데이터를 다시 JSON으로 저장한 뒤 이 함수를 호출해도 원래의 중복 이름은 찾을 수 없다. 다음 단계의 필수값·원문·권한 구분은 JSON 결과의 형식·근거·권한 검증에서 이어서 볼 수 있다.

이 카테고리의 다른 글

전체글 보기

“JSON 중복 키 검사: dict로 합치기 전에 같은 이름 잡기”에 대한 1개의 생각

댓글 남기기